diff --git a/config/sentry.php b/config/sentry.php index ee5fce26..7e9cf969 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -63,6 +63,9 @@ // Capture HTTP client request information as breadcrumbs 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), ], // Performance monitoring specific configuration @@ -97,6 +100,9 @@ // Capture where the Redis command originated from on the Redis command spans 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + // Enable tracing for requests without a matching route (404's) 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), diff --git a/src/Sentry/Laravel/Features/NotificationsIntegration.php b/src/Sentry/Laravel/Features/NotificationsIntegration.php new file mode 100644 index 00000000..dc1f5502 --- /dev/null +++ b/src/Sentry/Laravel/Features/NotificationsIntegration.php @@ -0,0 +1,97 @@ +isTracingFeatureEnabled(self::FEATURE_KEY) + || $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY); + } + + public function onBoot(Dispatcher $events): void + { + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $events->listen(NotificationSending::class, [$this, 'handleNotificationSending']); + } + + $events->listen(NotificationSent::class, [$this, 'handleNotificationSent']); + } + + public function handleNotificationSending(NotificationSending $event): void + { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + if ($parentSpan === null) { + return; + } + + $context = (new SpanContext) + ->setOp('notification.send') + ->setData([ + 'id' => $event->notification->id, + 'channel' => $event->channel, + 'notifiable' => $this->formatNotifiable($event->notifiable), + 'notification' => get_class($event->notification), + ]) + ->setDescription($event->channel); + + $this->pushSpan($parentSpan->startChild($context)); + } + + public function handleNotificationSent(NotificationSent $event): void + { + $this->finishSpanWithStatus(SpanStatus::ok()); + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'notification.sent', + 'Sent notification', + [ + 'channel' => $event->channel, + 'notifiable' => $this->formatNotifiable($event->notifiable), + 'notification' => get_class($event->notification), + ] + )); + } + } + + private function finishSpanWithStatus(SpanStatus $status): void + { + $span = $this->maybePopSpan(); + + if ($span !== null) { + $span->setStatus($status); + $span->finish(); + } + } + + private function formatNotifiable(object $notifiable): string + { + $notifiable = get_class($notifiable); + + if ($notifiable instanceof Model) { + $notifiable .= "({$notifiable->getKey()})"; + } + + return $notifiable; + } +} diff --git a/src/Sentry/Laravel/ServiceProvider.php b/src/Sentry/Laravel/ServiceProvider.php index c000ac4f..424cd74d 100644 --- a/src/Sentry/Laravel/ServiceProvider.php +++ b/src/Sentry/Laravel/ServiceProvider.php @@ -61,6 +61,7 @@ class ServiceProvider extends BaseServiceProvider Features\Storage\Integration::class, Features\HttpClientIntegration::class, Features\FolioPackageIntegration::class, + Features\NotificationsIntegration::class, Features\LivewirePackageIntegration::class, ]; diff --git a/test/Sentry/Features/NotificationsIntegrationTest.php b/test/Sentry/Features/NotificationsIntegrationTest.php new file mode 100644 index 00000000..dfee35d5 --- /dev/null +++ b/test/Sentry/Features/NotificationsIntegrationTest.php @@ -0,0 +1,102 @@ + false, + ]; + + public function testSpanIsRecorded(): void + { + $span = $this->sendNotificationAndRetrieveSpan(); + + $this->assertEquals('mail', $span->getDescription()); + $this->assertEquals('mail', $span->getData()['channel']); + $this->assertEquals('notification.send', $span->getOp()); + $this->assertEquals(SpanStatus::ok(), $span->getStatus()); + } + + public function testSpanIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.notifications.enabled' => false, + ]); + + $this->sendNotificationAndExpectNoSpan(); + } + + public function testBreadcrumbIsRecorded(): void + { + $this->sendTestNotification(); + + $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); + + $breadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals('notification.sent', $breadcrumb->getCategory()); + } + + public function testBreadcrumbIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.notifications.enabled' => false, + ]); + + $this->sendTestNotification(); + + $this->assertCount(0, $this->getCurrentSentryBreadcrumbs()); + } + + private function sendTestNotification(): void + { + // We fake the mail so that no actual email is sent but the notification is still sent with all it's events + Mail::fake(); + + Notification::route('mail', 'sentry@example.com')->notifyNow(new NotificationsIntegrationTestNotification); + } + + private function sendNotificationAndRetrieveSpan(): Span + { + $transaction = $this->startTransaction(); + + $this->sendTestNotification(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + + return $spans[1]; + } + + private function sendNotificationAndExpectNoSpan(): void + { + $transaction = $this->startTransaction(); + + $this->sendTestNotification(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(1, $spans); + } +} + +class NotificationsIntegrationTestNotification extends \Illuminate\Notifications\Notification +{ + public function via($notifiable) + { + return ['mail']; + } + + public function toMail($notifiable) + { + return new \Illuminate\Notifications\Messages\MailMessage; + } +} diff --git a/test/Sentry/TestCase.php b/test/Sentry/TestCase.php index b6766421..dd3fe916 100644 --- a/test/Sentry/TestCase.php +++ b/test/Sentry/TestCase.php @@ -31,6 +31,8 @@ abstract class TestCase extends LaravelTestCase // or use the `$this->resetApplicationWithConfig([ /* config */ ]);` helper method ]; + protected $defaultSetupConfig = []; + /** @var array */ protected static $lastSentryEvents = []; @@ -61,6 +63,10 @@ protected function defineEnvironment($app): void $config->set('sentry.dsn', 'https://publickey@sentry.dev/123'); } + foreach ($this->defaultSetupConfig as $key => $value) { + $config->set($key, $value); + } + foreach ($this->setupConfig as $key => $value) { $config->set($key, $value); }