Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement notifications tracing and breadcrumbs #852

Merged
merged 5 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/sentry.php
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Loading