From 48f2a00564db6c5c48fca675236acf83a8d0f83c Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 6 May 2026 11:58:26 +0200 Subject: [PATCH] fix: route controller exceptions through sentry-laravel reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-2 follow-up. The PR-2 backend SDK install passed unit tests because they exercised the scrubber and the BindSentryContext scope writer in isolation, but live exceptions from controllers never reached GlitchTip — they were correctly logged to laravel.log but the report() call had no Sentry-aware reporter to invoke. Root cause: sentry-laravel 4.x does NOT auto-register an exception reporter. The host application is required to wire Integration::handles inside withExceptions in bootstrap/app.php (per the package README and Sentry docs). Without it, report and Laravels automatic report-before-render flow only hit the default log channel. Fix: add Integration::handles at the top of withExceptions so sentry-laravel registers a reportable callback that calls captureUnhandledException for every reported throwable. Filtering remains downstream: - ignore_exceptions in config/sentry.php drops Validation, Authentication, Authorization (RFC §3.10). - SentryEventScrubber::scrub returns null for sub-500 HttpException via the before_send hook (RFC §3.7). Regression coverage: tests/Feature/Observability/ExceptionReportingTest installs a real Sentry client with a recording before_send and exercises the full request to capture pipeline through the auth and sentry.context middleware. Five cases: RuntimeException IS captured (with §3.6 tags attached), ValidationException is not, NotFoundHttpException 404 is not, AuthorizationException 403 is not, request-context tags ride along on the captured event. Test count: 1532 to 1537. Larastan clean. Pint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/bootstrap/app.php | 10 ++ .../Observability/ExceptionReportingTest.php | 155 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 api/tests/Feature/Observability/ExceptionReportingTest.php 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); + } +}