diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 4f63a011..84e3489d 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -48,6 +48,16 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { + // RFC-WS-7 §3.10 — bridge Laravel's exception handler into + // sentry-laravel so report($e) and Laravel's automatic + // report-before-render flow reach GlitchTip. sentry-laravel 4.x + // does NOT auto-register this; the README installation snippet + // requires the host application to wire it explicitly. + // Filtering happens downstream of this hook: ignore_exceptions in + // config/sentry.php drops Validation/Auth/AuthZ; SentryEventScrubber + // drops sub-500 HttpExceptions via the before_send hook. + \Sentry\Laravel\Integration::handles($exceptions); + // Public Form Builder standardised error envelope (S2c D6). $exceptions->render(function (\App\Exceptions\FormBuilder\PublicFormApiException $e, Request $request) { $body = [ diff --git a/api/tests/Feature/Observability/ExceptionReportingTest.php b/api/tests/Feature/Observability/ExceptionReportingTest.php new file mode 100644 index 00000000..b2bfd8ef --- /dev/null +++ b/api/tests/Feature/Observability/ExceptionReportingTest.php @@ -0,0 +1,155 @@ + + */ + private static array $captured = []; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(RoleSeeder::class); + + self::$captured = []; + + // Wire a real Sentry client whose before_send records events into + // the static buffer and returns null (drops, never networked). + $clientBuilder = ClientBuilder::create([ + 'dsn' => 'https://test@localhost/1', + 'environment' => 'testing', + 'release' => 'crewli-api@test', + 'send_default_pii' => false, + 'traces_sample_rate' => 0.0, + 'profiles_sample_rate' => 0.0, + 'ignore_exceptions' => [ + ValidationException::class, + \Illuminate\Auth\AuthenticationException::class, + AuthorizationException::class, + ], + 'before_send' => static function (SentryEvent $event, ?EventHint $hint = null): ?SentryEvent { + self::$captured[] = ['event' => $event, 'hint' => $hint]; + + return null; + }, + ]); + + $hub = new Hub($clientBuilder->getClient()); + SentrySdk::setCurrentHub($hub); + + // Test-only routes that exercise each branch of the + // ignore_exceptions / before_send / capture pipeline. + Route::middleware(['auth:sanctum', 'sentry.context'])->group(function (): void { + Route::get('_obs_runtime', static fn () => throw new RuntimeException('boom')) + ->name('test.obs.runtime'); + Route::get('_obs_validation', static function (): never { + throw ValidationException::withMessages(['email' => 'required']); + })->name('test.obs.validation'); + Route::get('_obs_404', static fn () => throw new NotFoundHttpException('nope')) + ->name('test.obs.404'); + Route::get('_obs_403', static fn () => throw new AuthorizationException('denied')) + ->name('test.obs.403'); + }); + } + + private function actAsOrgAdmin(): void + { + $org = Organisation::factory()->create(); + $user = User::factory()->create(); + $org->users()->attach($user, ['role' => 'org_admin']); + $user->assignRole('org_admin'); + Sanctum::actingAs($user); + } + + public function test_runtime_exception_from_controller_is_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_runtime')->assertStatus(500); + + $this->assertCount(1, self::$captured, 'expected exactly one captured event'); + $event = self::$captured[0]['event']; + $exceptions = $event->getExceptions(); + $this->assertNotEmpty($exceptions); + $this->assertSame(RuntimeException::class, $exceptions[0]->getType()); + $this->assertSame('boom', $exceptions[0]->getValue()); + } + + public function test_validation_exception_is_not_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_validation')->assertStatus(422); + + $this->assertCount(0, self::$captured); + } + + public function test_not_found_http_exception_is_not_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_404')->assertStatus(404); + + $this->assertCount(0, self::$captured); + } + + public function test_authorization_exception_is_not_captured(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_403')->assertStatus(403); + + $this->assertCount(0, self::$captured); + } + + public function test_runtime_exception_carries_request_context(): void + { + $this->actAsOrgAdmin(); + + $this->getJson('/_obs_runtime')->assertStatus(500); + + $this->assertCount(1, self::$captured); + $tags = self::$captured[0]['event']->getTags(); + // BindSentryContext should have set these on the scope before + // the exception fired in the controller. + $this->assertSame('api', $tags['app'] ?? null); + $this->assertSame('GET', $tags['http.method'] ?? null); + } +}