Skip to content

Commit

Permalink
Implement notifications tracing and breadcrumbs (#852)
Browse files Browse the repository at this point in the history
* Implement notifications tracing and breadcrumbs

* Disable view tracing for notification tests

* 🙈

---------

Co-authored-by: Michi Hoffmann <cleptric@users.noreply.github.com>
  • Loading branch information
stayallive and cleptric committed Apr 11, 2024
1 parent 7edd171 commit 1b045d5
Show file tree
Hide file tree
Showing 5 changed files with 212 additions and 0 deletions.
6 changes: 6 additions & 0 deletions config/sentry.php
Expand Up @@ -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
Expand Down Expand Up @@ -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),

Expand Down
97 changes: 97 additions & 0 deletions src/Sentry/Laravel/Features/NotificationsIntegration.php
@@ -0,0 +1,97 @@
<?php

namespace Sentry\Laravel\Features;

use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Events\NotificationSending;
use Illuminate\Notifications\Events\NotificationSent;
use Sentry\Breadcrumb;
use Sentry\Laravel\Features\Concerns\TracksPushedScopesAndSpans;
use Sentry\Laravel\Integration;
use Sentry\SentrySdk;
use Sentry\Tracing\SpanContext;
use Sentry\Tracing\SpanStatus;

class NotificationsIntegration extends Feature
{
use TracksPushedScopesAndSpans;

private const FEATURE_KEY = 'notifications';

public function isApplicable(): bool
{
return $this->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;
}
}
1 change: 1 addition & 0 deletions src/Sentry/Laravel/ServiceProvider.php
Expand Up @@ -61,6 +61,7 @@ class ServiceProvider extends BaseServiceProvider
Features\Storage\Integration::class,
Features\HttpClientIntegration::class,
Features\FolioPackageIntegration::class,
Features\NotificationsIntegration::class,
Features\LivewirePackageIntegration::class,
];

Expand Down
102 changes: 102 additions & 0 deletions test/Sentry/Features/NotificationsIntegrationTest.php
@@ -0,0 +1,102 @@
<?php

namespace Sentry\Features;

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Sentry\Laravel\Tests\TestCase;
use Sentry\Tracing\Span;
use Sentry\Tracing\SpanStatus;

class NotificationsIntegrationTest extends TestCase
{
protected $defaultSetupConfig = [
'sentry.tracing.views' => 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;
}
}
6 changes: 6 additions & 0 deletions test/Sentry/TestCase.php
Expand Up @@ -31,6 +31,8 @@ abstract class TestCase extends LaravelTestCase
// or use the `$this->resetApplicationWithConfig([ /* config */ ]);` helper method
];

protected $defaultSetupConfig = [];

/** @var array<int, array{0: Event, 1: EventHint|null}> */
protected static $lastSentryEvents = [];

Expand Down Expand Up @@ -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);
}
Expand Down

0 comments on commit 1b045d5

Please sign in to comment.