diff --git a/backend/app/Console/Commands/SyncScheduledPlugins.php b/backend/app/Console/Commands/SyncScheduledPlugins.php index 8e75b52..cbff507 100644 --- a/backend/app/Console/Commands/SyncScheduledPlugins.php +++ b/backend/app/Console/Commands/SyncScheduledPlugins.php @@ -5,6 +5,7 @@ use App\Jobs\RunPluginVersionIngestion; use App\Models\Plugin; use App\Services\Ingestion\IngestionService; +use App\Support\CorrelationId; use Illuminate\Console\Command; class SyncScheduledPlugins extends Command @@ -27,7 +28,10 @@ public function handle(IngestionService $service): int return; } - $run = $service->start($pluginVersion, 'scheduled'); + // Each scheduled run is its own logical operation, not part + // of an inbound request, so it gets its own fresh + // correlation ID rather than sharing one across the batch. + $run = $service->start($pluginVersion, 'scheduled', correlationId: CorrelationId::generate()); if ($run->wasRecentlyCreated) { RunPluginVersionIngestion::dispatch($pluginVersion->id, $run->id); diff --git a/backend/app/Http/Controllers/Api/V1/GitHubWebhookController.php b/backend/app/Http/Controllers/Api/V1/GitHubWebhookController.php index ae0f716..0a7e287 100644 --- a/backend/app/Http/Controllers/Api/V1/GitHubWebhookController.php +++ b/backend/app/Http/Controllers/Api/V1/GitHubWebhookController.php @@ -6,6 +6,8 @@ use App\Jobs\RunPluginVersionIngestion; use App\Models\Plugin; use App\Services\Ingestion\IngestionService; +use App\Support\CorrelationId; +use App\Support\Telemetry; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -15,7 +17,14 @@ class GitHubWebhookController extends Controller { public function __invoke(Request $request, IngestionService $service): JsonResponse { + $correlationId = CorrelationId::fromRequest($request); + if (! $this->hasValidSignature($request)) { + Telemetry::event('webhook.github.rejected', [ + 'correlation_id' => $correlationId, + 'reason' => 'invalid_signature', + ]); + return response()->json([ 'message' => 'Invalid GitHub webhook signature.', 'code' => 'webhook.invalid_signature', @@ -26,6 +35,13 @@ public function __invoke(Request $request, IngestionService $service): JsonRespo $branch = Str::after($request->string('ref')->toString(), 'refs/heads/'); if ($repository === '' || $branch === '') { + Telemetry::event('webhook.github.accepted', [ + 'correlation_id' => $correlationId, + 'repository' => $repository !== '' ? $repository : null, + 'branch' => $branch !== '' ? $branch : null, + 'queued' => 0, + ]); + return response()->json([ 'message' => 'Ignored GitHub webhook payload.', 'code' => 'webhook.ignored', @@ -39,14 +55,14 @@ public function __invoke(Request $request, IngestionService $service): JsonRespo ->with(['versions' => fn ($query) => $query->orderByDesc('is_latest')->orderByDesc('id')]) ->get() ->filter(fn (Plugin $plugin): bool => $this->matchesPlugin($plugin, $repository, $branch)) - ->each(function (Plugin $plugin) use ($service, &$queued): void { + ->each(function (Plugin $plugin) use ($service, $correlationId, &$queued): void { $pluginVersion = $plugin->versions->first(); if ($pluginVersion === null) { return; } - $run = $service->start($pluginVersion, 'webhook'); + $run = $service->start($pluginVersion, 'webhook', correlationId: $correlationId); if ($run->wasRecentlyCreated) { RunPluginVersionIngestion::dispatch($pluginVersion->id, $run->id); @@ -54,6 +70,13 @@ public function __invoke(Request $request, IngestionService $service): JsonRespo } }); + Telemetry::event('webhook.github.accepted', [ + 'correlation_id' => $correlationId, + 'repository' => $repository, + 'branch' => $branch, + 'queued' => $queued, + ]); + return response()->json([ 'message' => 'GitHub webhook processed.', 'code' => 'webhook.processed', diff --git a/backend/app/Http/Controllers/Api/V1/IngestionController.php b/backend/app/Http/Controllers/Api/V1/IngestionController.php index 54dd2b5..3cc518c 100644 --- a/backend/app/Http/Controllers/Api/V1/IngestionController.php +++ b/backend/app/Http/Controllers/Api/V1/IngestionController.php @@ -94,6 +94,7 @@ private function payload(IngestionRun $run): array 'id' => $run->id, 'plugin_version_id' => $run->plugin_version_id, 'source' => $run->source, + 'correlation_id' => $run->correlation_id, 'status' => $run->status, 'stats' => $run->stats, 'log' => $run->log, diff --git a/backend/app/Http/Controllers/Api/V1/PluginSyncController.php b/backend/app/Http/Controllers/Api/V1/PluginSyncController.php index c0c491f..13f068b 100644 --- a/backend/app/Http/Controllers/Api/V1/PluginSyncController.php +++ b/backend/app/Http/Controllers/Api/V1/PluginSyncController.php @@ -6,11 +6,13 @@ use App\Jobs\RunPluginVersionIngestion; use App\Models\Plugin; use App\Services\Ingestion\IngestionService; +use App\Support\CorrelationId; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class PluginSyncController extends Controller { - public function __invoke(Plugin $plugin, IngestionService $service): JsonResponse + public function __invoke(Request $request, Plugin $plugin, IngestionService $service): JsonResponse { $plugin->loadMissing('versions'); @@ -20,7 +22,7 @@ public function __invoke(Plugin $plugin, IngestionService $service): JsonRespons ->first() ?? $plugin->versions()->latest('id')->firstOrFail(); - $run = $service->start($pluginVersion, 'manual'); + $run = $service->start($pluginVersion, 'manual', correlationId: CorrelationId::fromRequest($request)); if ($run->wasRecentlyCreated) { RunPluginVersionIngestion::dispatch($pluginVersion->id, $run->id); @@ -31,6 +33,7 @@ public function __invoke(Plugin $plugin, IngestionService $service): JsonRespons 'id' => $run->id, 'plugin_version_id' => $run->plugin_version_id, 'source' => $run->source, + 'correlation_id' => $run->correlation_id, 'status' => $run->status, 'stats' => $run->stats, 'log' => $run->log, diff --git a/backend/app/Http/Middleware/AssignCorrelationId.php b/backend/app/Http/Middleware/AssignCorrelationId.php new file mode 100644 index 0000000..61af5cc --- /dev/null +++ b/backend/app/Http/Middleware/AssignCorrelationId.php @@ -0,0 +1,31 @@ +header(CorrelationId::HEADER); + $correlationId = CorrelationId::isValid($incoming) ? $incoming : CorrelationId::generate(); + + $request->attributes->set(CorrelationId::REQUEST_ATTRIBUTE, $correlationId); + + $response = $next($request); + $response->headers->set(CorrelationId::HEADER, $correlationId); + + return $response; + } +} diff --git a/backend/app/Models/IngestionRun.php b/backend/app/Models/IngestionRun.php index 7b0187b..4e4b667 100644 --- a/backend/app/Models/IngestionRun.php +++ b/backend/app/Models/IngestionRun.php @@ -18,6 +18,7 @@ class IngestionRun extends Model protected $fillable = [ 'plugin_version_id', 'source', + 'correlation_id', 'status', 'stats', 'log', diff --git a/backend/app/Services/Ingestion/IngestionService.php b/backend/app/Services/Ingestion/IngestionService.php index 9948e4d..f1974bb 100644 --- a/backend/app/Services/Ingestion/IngestionService.php +++ b/backend/app/Services/Ingestion/IngestionService.php @@ -15,6 +15,9 @@ use App\Models\PluginVersion; use App\Services\Discovery\DiscoveryCache; use App\Services\Markdown\MarkdownParser; +use App\Support\CorrelationId; +use App\Support\Telemetry; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Throwable; @@ -27,9 +30,20 @@ public function __construct( private readonly DiscoveryCache $cache, ) {} - public function start(PluginVersion $pluginVersion, string $source = 'manual', bool $force = false): IngestionRun + /** + * Start (or reuse) an ingestion run. + * + * The correlation ID identifies the caller's request, not the run: if + * an active run is reused, the run keeps the correlation ID of whoever + * created it, and this call's own ID is only recorded on the + * `ingestion.enqueued` telemetry event, referencing the reused run. + * Run history is never rewritten. + */ + public function start(PluginVersion $pluginVersion, string $source = 'manual', bool $force = false, ?string $correlationId = null): IngestionRun { - $run = DB::transaction(function () use ($pluginVersion, $source, $force): IngestionRun { + $requestCorrelationId = $correlationId ?? CorrelationId::generate(); + + $run = DB::transaction(function () use ($pluginVersion, $source, $force, $requestCorrelationId): IngestionRun { if (! $force) { $existingRun = IngestionRun::query() ->where('plugin_version_id', $pluginVersion->id) @@ -46,6 +60,7 @@ public function start(PluginVersion $pluginVersion, string $source = 'manual', b return IngestionRun::query()->create([ 'plugin_version_id' => $pluginVersion->id, 'source' => $source, + 'correlation_id' => $requestCorrelationId, 'status' => 'queued', 'stats' => [ 'docs_parsed' => 0, @@ -60,6 +75,17 @@ public function start(PluginVersion $pluginVersion, string $source = 'manual', b IngestionRunStatusChanged::dispatch($run); } + Telemetry::event('ingestion.enqueued', [ + 'correlation_id' => $requestCorrelationId, + 'ingestion_run_id' => $run->id, + 'plugin_version_id' => $pluginVersion->id, + 'plugin_id' => $pluginVersion->plugin_id, + 'community_id' => $pluginVersion->plugin?->community_id, + 'source' => $source, + 'reused' => ! $run->wasRecentlyCreated, + 'user_id' => Auth::id(), + ]); + return $run; } @@ -67,6 +93,7 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In { $pluginVersion->loadMissing('plugin'); $run ??= $this->start($pluginVersion); + $correlationId = $run->correlation_id ?? CorrelationId::generate(); $run->update([ 'status' => 'running', 'started_at' => now(), @@ -74,6 +101,15 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In ]); IngestionRunStatusChanged::dispatch($run->refresh()); + Telemetry::event('ingestion.started', [ + 'correlation_id' => $correlationId, + 'ingestion_run_id' => $run->id, + 'plugin_version_id' => $pluginVersion->id, + 'plugin_id' => $pluginVersion->plugin_id, + 'community_id' => $pluginVersion->plugin?->community_id, + 'source' => $run->source, + ]); + $stats = [ 'docs_parsed' => 0, 'commands_extracted' => 0, @@ -88,7 +124,8 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In 'level' => 'error', 'code' => $exception->failureCode(), 'message' => $exception->getMessage(), - ]]); + 'correlation_id' => $correlationId, + ]], $correlationId); } foreach ($files as $file) { @@ -98,6 +135,7 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In 'code' => 'document_not_modified', 'path' => $file->path, 'message' => "Skipped unchanged markdown file [{$file->path}].", + 'correlation_id' => $correlationId, ]; continue; @@ -117,6 +155,7 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In 'code' => 'markdown_warning', 'path' => $parsedDocument->path, 'message' => $warning, + 'correlation_id' => $correlationId, ]; } } catch (Throwable $exception) { @@ -126,6 +165,7 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In 'code' => 'document_parse_failed', 'path' => $file->path, 'message' => $exception->getMessage(), + 'correlation_id' => $correlationId, ]; } } @@ -139,6 +179,18 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In 'finished_at' => now(), ]); + Telemetry::event('ingestion.completed', [ + 'correlation_id' => $correlationId, + 'ingestion_run_id' => $run->id, + 'plugin_version_id' => $pluginVersion->id, + 'plugin_id' => $pluginVersion->plugin_id, + 'community_id' => $pluginVersion->plugin?->community_id, + 'status' => $status, + 'docs_parsed' => $stats['docs_parsed'], + 'commands_extracted' => $stats['commands_extracted'], + 'warnings' => $stats['warnings'], + ]); + if ($stats['docs_parsed'] > 0) { $pluginVersion->commands()->with(['category', 'pluginVersion.plugin.community'])->get()->searchable(); $this->cache->invalidate(); @@ -226,7 +278,7 @@ private function categoryId(ParsedCommand $command): ?int * @param array $stats * @param list> $log */ - private function fail(IngestionRun $run, array $stats, array $log): IngestionRun + private function fail(IngestionRun $run, array $stats, array $log, ?string $correlationId = null): IngestionRun { $run->update([ 'status' => 'failed', @@ -238,6 +290,21 @@ private function fail(IngestionRun $run, array $stats, array $log): IngestionRun $run = $run->refresh(); IngestionRunStatusChanged::dispatch($run); + // The failure code is consumed from the `code` key already + // persisted in the log entry, per ADR-27/BRAIN-007: never + // recomputed, never renamed. + $run->loadMissing('pluginVersion.plugin'); + $pluginVersion = $run->pluginVersion; + + Telemetry::event('ingestion.failed', [ + 'correlation_id' => $correlationId ?? $run->correlation_id, + 'ingestion_run_id' => $run->id, + 'plugin_version_id' => $run->plugin_version_id, + 'plugin_id' => $pluginVersion?->plugin_id, + 'community_id' => $pluginVersion?->plugin?->community_id, + 'code' => $log[0]['code'] ?? null, + ]); + return $run; } } diff --git a/backend/app/Support/CorrelationId.php b/backend/app/Support/CorrelationId.php new file mode 100644 index 0000000..c85c7ba --- /dev/null +++ b/backend/app/Support/CorrelationId.php @@ -0,0 +1,62 @@ +attributes->get(self::REQUEST_ATTRIBUTE); + + if (is_string($assigned) && $assigned !== '') { + return $assigned; + } + + return self::generate(); + } +} diff --git a/backend/app/Support/Telemetry.php b/backend/app/Support/Telemetry.php new file mode 100644 index 0000000..7e4e155 --- /dev/null +++ b/backend/app/Support/Telemetry.php @@ -0,0 +1,29 @@ + $context + */ + public static function event(string $name, array $context = []): void + { + Log::channel('telemetry')->info($name, array_filter( + $context, + static fn (mixed $value): bool => $value !== null, + )); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index e93ea80..fdef9b0 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -1,5 +1,6 @@ append(SecurityHeaders::class); $middleware->api(prepend: [ + AssignCorrelationId::class, ForceJsonResponse::class, ]); diff --git a/backend/config/logging.php b/backend/config/logging.php index 8d94292..07cbf2c 100644 --- a/backend/config/logging.php +++ b/backend/config/logging.php @@ -1,5 +1,6 @@ NullHandler::class, ], + // Dedicated structured-event channel for App\Support\Telemetry + // (F-011 correlation ID / domain event taxonomy). Additive: does + // not participate in the default "stack"/"single" channels, so it + // never changes the format or destination of framework logging. + 'telemetry' => [ + 'driver' => 'monolog', + 'name' => 'telemetry', + 'handler' => StreamHandler::class, + 'with' => [ + 'stream' => storage_path('logs/telemetry.log'), + ], + 'formatter' => JsonFormatter::class, + 'level' => env('LOG_LEVEL', 'debug'), + ], + 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], diff --git a/backend/database/migrations/2026_09_20_000001_add_correlation_id_to_ingestion_runs_table.php b/backend/database/migrations/2026_09_20_000001_add_correlation_id_to_ingestion_runs_table.php new file mode 100644 index 0000000..8e692ee --- /dev/null +++ b/backend/database/migrations/2026_09_20_000001_add_correlation_id_to_ingestion_runs_table.php @@ -0,0 +1,24 @@ +string('correlation_id')->nullable()->after('source'); + $table->index('correlation_id'); + }); + } + + public function down(): void + { + Schema::table('ingestion_runs', function (Blueprint $table): void { + $table->dropIndex(['correlation_id']); + $table->dropColumn('correlation_id'); + }); + } +}; diff --git a/backend/tests/Feature/IngestionTelemetryTest.php b/backend/tests/Feature/IngestionTelemetryTest.php new file mode 100644 index 0000000..6961ddc --- /dev/null +++ b/backend/tests/Feature/IngestionTelemetryTest.php @@ -0,0 +1,379 @@ + 'sync', + 'scout.driver' => 'null', + ]); + + Event::fake([ + CommandIndexUpdated::class, + IngestionRunStatusChanged::class, + ]); + + // Start every test from a clean, real telemetry log so assertions read + // only the lines this test produced. + @unlink(storage_path('logs/telemetry.log')); +}); + +/** + * @return list> + */ +function readTelemetryEvents(): array +{ + $path = storage_path('logs/telemetry.log'); + + if (! file_exists($path)) { + return []; + } + + $lines = array_filter(explode("\n", trim(file_get_contents($path) ?: ''))); + + return array_map(fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), $lines); +} + +/** + * @return list> + */ +function telemetryEventsNamed(string $name): array +{ + return array_values(array_filter( + readTelemetryEvents(), + fn (array $entry): bool => ($entry['message'] ?? null) === $name, + )); +} + +function telemetryPluginVersion(): PluginVersion +{ + $community = Community::factory()->create(); + $plugin = Plugin::factory()->create([ + 'community_id' => $community->id, + 'name' => 'Telemetry Plugin', + 'slug' => 'telemetry-plugin', + 'repository_url' => 'commandsphere/telemetry-plugin', + 'documentation_path' => 'docs', + 'default_branch' => 'main', + ]); + + return PluginVersion::factory()->latest()->create([ + 'plugin_id' => $plugin->id, + 'version' => '1.0.0', + 'git_ref' => 'main', + ]); +} + +function telemetryMarkdownFile(): GitHubMarkdownFile +{ + return new GitHubMarkdownFile( + path: 'docs/commands.md', + content: <<<'MARKDOWN' +--- +title: Telemetry Commands +commands: + - name: Telemetry Status + slug: telemetry-status + syntax: /telemetry status + description: Shows telemetry status. + category: diagnostics +--- + +# Telemetry Commands +MARKDOWN, + etag: '"telemetry-etag"', + ); +} + +function telemetryNotModifiedMarkdownFile(): GitHubMarkdownFile +{ + return new GitHubMarkdownFile( + path: 'docs/unchanged.md', + content: null, + etag: '"telemetry-unchanged-etag"', + notModified: true, + ); +} + +/** + * @param array $payload + * @return array{json: string, signature: string} + */ +function signedTelemetryPayload(array $payload, string $secret): array +{ + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + return [ + 'json' => $json, + 'signature' => 'sha256='.hash_hmac('sha256', $json, $secret), + ]; +} + +it('propagates a valid X-Request-Id from a webhook to the run, log entries, and response header', function (): void { + telemetryPluginVersion(); + config(['commandsphere.github.webhook_secret' => 'telemetry-secret']); + app()->bind(GitHubClient::class, fn () => new class implements GitHubClient + { + public function markdownFiles(Plugin $plugin, ?string $branch = null): array + { + return [telemetryMarkdownFile(), telemetryNotModifiedMarkdownFile()]; + } + }); + + $payload = signedTelemetryPayload([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'commandsphere/telemetry-plugin'], + ], 'telemetry-secret'); + $requestId = 'webhook-correlation-12345'; + + $response = $this->call( + 'POST', + '/api/v1/webhooks/github', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_HUB_SIGNATURE_256' => $payload['signature'], + 'HTTP_X_REQUEST_ID' => $requestId, + ], + content: $payload['json'], + ); + + $response->assertAccepted() + ->assertHeader('X-Request-Id', $requestId); + + $run = IngestionRun::query()->firstOrFail(); + + expect($run->correlation_id)->toBe($requestId) + ->and($run->status)->toBe('success') + ->and($run->log)->not->toBeEmpty() + ->and($run->log[0]['code'])->toBe('document_not_modified') + ->and(collect($run->log)->pluck('correlation_id')->unique()->all())->toBe([$requestId]); + + $enqueued = telemetryEventsNamed('ingestion.enqueued'); + $started = telemetryEventsNamed('ingestion.started'); + $completed = telemetryEventsNamed('ingestion.completed'); + $accepted = telemetryEventsNamed('webhook.github.accepted'); + + expect($enqueued)->toHaveCount(1) + ->and($enqueued[0]['context']['correlation_id'])->toBe($requestId) + ->and($enqueued[0]['context']['ingestion_run_id'])->toBe($run->id) + ->and($started)->toHaveCount(1) + ->and($started[0]['context']['correlation_id'])->toBe($requestId) + ->and($completed)->toHaveCount(1) + ->and($completed[0]['context']['correlation_id'])->toBe($requestId) + ->and($completed[0]['context']['status'])->toBe('success') + ->and($accepted)->toHaveCount(1) + ->and($accepted[0]['context']['correlation_id'])->toBe($requestId) + ->and($accepted[0]['context']['queued'])->toBe(1); +}); + +it('discards an invalid X-Request-Id and substitutes a generated one everywhere downstream', function (): void { + telemetryPluginVersion(); + config(['commandsphere.github.webhook_secret' => 'telemetry-secret']); + app()->bind(GitHubClient::class, fn () => new class implements GitHubClient + { + public function markdownFiles(Plugin $plugin, ?string $branch = null): array + { + return [telemetryMarkdownFile()]; + } + }); + + $payload = signedTelemetryPayload([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'commandsphere/telemetry-plugin'], + ], 'telemetry-secret'); + $unsafeRequestId = str_repeat('a', 300); + + $response = $this->call( + 'POST', + '/api/v1/webhooks/github', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_HUB_SIGNATURE_256' => $payload['signature'], + 'HTTP_X_REQUEST_ID' => $unsafeRequestId, + ], + content: $payload['json'], + ); + + $response->assertAccepted(); + $issuedId = $response->headers->get('X-Request-Id'); + + expect($issuedId)->not->toBeNull() + ->and($issuedId)->not->toBe($unsafeRequestId) + ->and(CorrelationId::isValid($issuedId))->toBeTrue(); + + $run = IngestionRun::query()->firstOrFail(); + + expect($run->correlation_id)->toBe($issuedId); +}); + +it('rejects a trailing-terminator X-Request-Id and substitutes a UUID in the header and the run column', function (string $terminator) { + telemetryPluginVersion(); + config(['commandsphere.github.webhook_secret' => 'telemetry-secret']); + app()->bind(GitHubClient::class, fn () => new class implements GitHubClient + { + public function markdownFiles(Plugin $plugin, ?string $branch = null): array + { + return [telemetryMarkdownFile()]; + } + }); + + $payload = signedTelemetryPayload([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'commandsphere/telemetry-plugin'], + ], 'telemetry-secret'); + $injectedRequestId = 'injected-id'.$terminator; + + $response = $this->call( + 'POST', + '/api/v1/webhooks/github', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_HUB_SIGNATURE_256' => $payload['signature'], + 'HTTP_X_REQUEST_ID' => $injectedRequestId, + ], + content: $payload['json'], + ); + + $response->assertAccepted(); + $issuedId = $response->headers->get('X-Request-Id'); + + expect($issuedId)->not->toBeNull() + ->and($issuedId)->not->toBe($injectedRequestId) + ->and(CorrelationId::isValid($issuedId))->toBeTrue() + ->and(preg_match('/\A[0-9a-f-]{36}\z/i', $issuedId))->toBe(1); + + $run = IngestionRun::query()->firstOrFail(); + + expect($run->correlation_id)->toBe($issuedId) + ->and($run->correlation_id)->not->toContain("\n") + ->and($run->correlation_id)->not->toContain("\r"); +})->with([ + 'trailing LF' => ["\n"], + 'trailing CR' => ["\r"], + 'trailing CRLF' => ["\r\n"], +]); + +it('rejects an unsigned webhook and logs webhook.github.rejected with a correlation id', function (): void { + telemetryPluginVersion(); + config(['commandsphere.github.webhook_secret' => 'telemetry-secret']); + + $this->call( + 'POST', + '/api/v1/webhooks/github', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_HUB_SIGNATURE_256' => 'sha256=invalid', + ], + content: json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'commandsphere/telemetry-plugin'], + ], JSON_THROW_ON_ERROR), + )->assertUnauthorized(); + + $rejected = telemetryEventsNamed('webhook.github.rejected'); + + expect($rejected)->toHaveCount(1) + ->and($rejected[0]['context']['correlation_id'])->not->toBeEmpty() + ->and($rejected[0]['context']['reason'])->toBe('invalid_signature'); +}); + +it('records ingestion.failed with the ADR-27 taxonomy code and the run correlation id', function (): void { + $version = telemetryPluginVersion(); + app()->bind(GitHubClient::class, fn () => new class implements GitHubClient + { + public function markdownFiles(Plugin $plugin, ?string $branch = null): array + { + throw GitHubRateLimitException::forRepository($plugin->repository_url ?? 'fixture'); + } + }); + + $service = app(IngestionService::class); + $run = $service->start($version, 'manual', correlationId: 'failure-correlation-id'); + $run = $service->run($version, $run); + + expect($run->status)->toBe('failed') + ->and($run->correlation_id)->toBe('failure-correlation-id') + ->and($run->log[0]['code'])->toBe('git_hub_rate_limit_exception') + ->and($run->log[0]['correlation_id'])->toBe('failure-correlation-id'); + + $failed = telemetryEventsNamed('ingestion.failed'); + + expect($failed)->toHaveCount(1) + ->and($failed[0]['context']['correlation_id'])->toBe('failure-correlation-id') + ->and($failed[0]['context']['code'])->toBe('git_hub_rate_limit_exception'); +}); + +it('keeps a reused run correlation id stable while the new request logs its own enqueue event', function (): void { + $version = telemetryPluginVersion(); + $service = app(IngestionService::class); + + $firstRun = $service->start($version, 'manual', correlationId: 'first-caller-id'); + $secondRun = $service->start($version, 'manual', correlationId: 'second-caller-id'); + + expect($secondRun->id)->toBe($firstRun->id) + ->and($secondRun->correlation_id)->toBe('first-caller-id'); + + $enqueued = telemetryEventsNamed('ingestion.enqueued'); + + expect($enqueued)->toHaveCount(2) + ->and($enqueued[0]['context']['correlation_id'])->toBe('first-caller-id') + ->and($enqueued[0]['context']['reused'])->toBeFalse() + ->and($enqueued[1]['context']['correlation_id'])->toBe('second-caller-id') + ->and($enqueued[1]['context']['reused'])->toBeTrue() + ->and($enqueued[1]['context']['ingestion_run_id'])->toBe($firstRun->id); +}); + +it('gives every scheduled plugin sync run its own correlation id', function (): void { + telemetryPluginVersion(); + + $secondCommunity = Community::factory()->create(); + $secondPlugin = Plugin::factory()->create([ + 'community_id' => $secondCommunity->id, + 'name' => 'Telemetry Plugin Two', + 'slug' => 'telemetry-plugin-two', + 'repository_url' => 'commandsphere/telemetry-plugin-two', + 'documentation_path' => 'docs', + 'default_branch' => 'main', + ]); + PluginVersion::factory()->latest()->create([ + 'plugin_id' => $secondPlugin->id, + 'version' => '1.0.0', + 'git_ref' => 'main', + ]); + + app()->bind(GitHubClient::class, fn () => new class implements GitHubClient + { + public function markdownFiles(Plugin $plugin, ?string $branch = null): array + { + return [telemetryMarkdownFile()]; + } + }); + + Artisan::call('commandsphere:sync-scheduled'); + + $runs = IngestionRun::query()->orderBy('id')->get(); + + expect($runs)->toHaveCount(2) + ->and($runs[0]->correlation_id)->not->toBeNull() + ->and($runs[1]->correlation_id)->not->toBeNull() + ->and($runs[0]->correlation_id)->not->toBe($runs[1]->correlation_id); +}); diff --git a/backend/tests/Unit/CorrelationIdTest.php b/backend/tests/Unit/CorrelationIdTest.php new file mode 100644 index 0000000..624011d --- /dev/null +++ b/backend/tests/Unit/CorrelationIdTest.php @@ -0,0 +1,45 @@ +not->toBeEmpty() + ->and(CorrelationId::isValid($id))->toBeTrue(); +}); + +it('accepts safe, bounded correlation id formats', function (): void { + expect(CorrelationId::isValid('abc-123_ok.value:1'))->toBeTrue() + ->and(CorrelationId::isValid(str_repeat('a', 128)))->toBeTrue(); +}); + +it('rejects null, empty, oversized, and unsafe correlation id values', function (): void { + expect(CorrelationId::isValid(null))->toBeFalse() + ->and(CorrelationId::isValid(''))->toBeFalse() + ->and(CorrelationId::isValid(str_repeat('a', 129)))->toBeFalse() + ->and(CorrelationId::isValid("abc\r\nX-Injected: 1"))->toBeFalse() + ->and(CorrelationId::isValid("line1\nline2"))->toBeFalse() + ->and(CorrelationId::isValid('has space'))->toBeFalse() + ->and(CorrelationId::isValid('