diff --git a/config/sentry.php b/config/sentry.php index 2905b498..7884fa00 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -103,6 +103,9 @@ // Capture HTTP client requests as spans 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + // Capture Laravel cache events (hits, writes etc.) as spans + 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + // Capture Redis operations as spans (this enables Redis events in Laravel) 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), diff --git a/src/Sentry/Laravel/Features/CacheIntegration.php b/src/Sentry/Laravel/Features/CacheIntegration.php index 2cb2e5af..2e0d8dbe 100644 --- a/src/Sentry/Laravel/Features/CacheIntegration.php +++ b/src/Sentry/Laravel/Features/CacheIntegration.php @@ -9,17 +9,22 @@ use Illuminate\Support\Str; use Sentry\Breadcrumb; use Sentry\Laravel\Features\Concerns\ResolvesEventOrigin; +use Sentry\Laravel\Features\Concerns\TracksPushedScopesAndSpans; +use Sentry\Laravel\Features\Concerns\WorksWithSpans; use Sentry\Laravel\Integration; use Sentry\SentrySdk; +use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; +use Sentry\Tracing\SpanStatus; class CacheIntegration extends Feature { - use ResolvesEventOrigin; + use WorksWithSpans, TracksPushedScopesAndSpans, ResolvesEventOrigin; public function isApplicable(): bool { - return $this->isTracingFeatureEnabled('redis_commands') + return $this->isTracingFeatureEnabled('redis_commands', false) + || $this->isTracingFeatureEnabled('cache') || $this->isBreadcrumbFeatureEnabled('cache'); } @@ -31,11 +36,29 @@ public function onBoot(Dispatcher $events): void Events\CacheMissed::class, Events\KeyWritten::class, Events\KeyForgotten::class, - ], [$this, 'handleCacheEvent']); + ], [$this, 'handleCacheEventsForBreadcrumbs']); + } + + if ($this->isTracingFeatureEnabled('cache')) { + $events->listen([ + Events\RetrievingKey::class, + Events\RetrievingManyKeys::class, + Events\CacheHit::class, + Events\CacheMissed::class, + + Events\WritingKey::class, + Events\WritingManyKeys::class, + Events\KeyWritten::class, + Events\KeyWriteFailed::class, + + Events\ForgettingKey::class, + Events\KeyForgotten::class, + Events\KeyForgetFailed::class, + ], [$this, 'handleCacheEventsForTracing']); } if ($this->isTracingFeatureEnabled('redis_commands', false)) { - $events->listen(RedisEvents\CommandExecuted::class, [$this, 'handleRedisCommand']); + $events->listen(RedisEvents\CommandExecuted::class, [$this, 'handleRedisCommands']); $this->container()->afterResolving(RedisManager::class, static function (RedisManager $redis): void { $redis->enableEvents(); @@ -43,7 +66,7 @@ public function onBoot(Dispatcher $events): void } } - public function handleCacheEvent(Events\CacheEvent $event): void + public function handleCacheEventsForBreadcrumbs(Events\CacheEvent $event): void { switch (true) { case $event instanceof Events\KeyWritten: @@ -72,7 +95,64 @@ public function handleCacheEvent(Events\CacheEvent $event): void )); } - public function handleRedisCommand(RedisEvents\CommandExecuted $event): void + public function handleCacheEventsForTracing(Events\CacheEvent $event): void + { + if ($this->maybeHandleCacheEventAsEndOfSpan($event)) { + return; + } + + $this->withParentSpanIfSampled(function (Span $parentSpan) use ($event) { + if ($event instanceof Events\RetrievingKey || $event instanceof Events\RetrievingManyKeys) { + $keys = $event instanceof Events\RetrievingKey + ? [$event->key] + : $event->keys; + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('cache.get') + ->setData([ + 'cache.key' => $keys, + ]) + ->setDescription(implode(', ', $keys)) + ) + ); + } + + if ($event instanceof Events\WritingKey || $event instanceof Events\WritingManyKeys) { + $keys = $event instanceof Events\WritingKey + ? [$event->key] + : $event->keys; + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('cache.put') + ->setData([ + 'cache.key' => $keys, + 'cache.ttl' => $event->seconds, + ]) + ->setDescription(implode(', ', $keys)) + ) + ); + } + + if ($event instanceof Events\ForgettingKey) { + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('cache.remove') + ->setData([ + 'cache.key' => [$event->key], + ]) + ->setDescription($event->key) + ) + ); + } + }); + } + + public function handleRedisCommands(RedisEvents\CommandExecuted $event): void { $parentSpan = SentrySdk::getCurrentHub()->getSpan(); @@ -116,4 +196,44 @@ public function handleRedisCommand(RedisEvents\CommandExecuted $event): void $parentSpan->startChild($context); } + + private function maybeHandleCacheEventAsEndOfSpan(Events\CacheEvent $event): bool + { + // End of span for RetrievingKey and RetrievingManyKeys events + if ($event instanceof Events\CacheHit || $event instanceof Events\CacheMissed) { + $finishedSpan = $this->maybeFinishSpan(SpanStatus::ok()); + + if ($finishedSpan !== null && count($finishedSpan->getData()['cache.key'] ?? []) === 1) { + $finishedSpan->setData([ + 'cache.hit' => $event instanceof Events\CacheHit, + ]); + } + + return true; + } + + // End of span for WritingKey and WritingManyKeys events + if ($event instanceof Events\KeyWritten || $event instanceof Events\KeyWriteFailed) { + $finishedSpan = $this->maybeFinishSpan( + $event instanceof Events\KeyWritten ? SpanStatus::ok() : SpanStatus::internalError() + ); + + if ($finishedSpan !== null) { + $finishedSpan->setData([ + 'cache.success' => $event instanceof Events\KeyWritten, + ]); + } + + return true; + } + + // End of span for ForgettingKey event + if ($event instanceof Events\KeyForgotten || $event instanceof Events\KeyForgetFailed) { + $this->maybeFinishSpan(); + + return true; + } + + return false; + } } diff --git a/src/Sentry/Laravel/Features/Concerns/TracksPushedScopesAndSpans.php b/src/Sentry/Laravel/Features/Concerns/TracksPushedScopesAndSpans.php index 922458f7..103a5a1d 100644 --- a/src/Sentry/Laravel/Features/Concerns/TracksPushedScopesAndSpans.php +++ b/src/Sentry/Laravel/Features/Concerns/TracksPushedScopesAndSpans.php @@ -5,6 +5,7 @@ use Sentry\Laravel\Integration; use Sentry\SentrySdk; use Sentry\Tracing\Span; +use Sentry\Tracing\SpanStatus; trait TracksPushedScopesAndSpans { @@ -72,4 +73,21 @@ protected function maybePopScope(): void --$this->pushedScopeCount; } + + protected function maybeFinishSpan(?SpanStatus $status = null): ?Span + { + $span = $this->maybePopSpan(); + + if ($span === null) { + return null; + } + + if ($status !== null) { + $span->setStatus($status); + } + + $span->finish(); + + return $span; + } } diff --git a/src/Sentry/Laravel/Features/Concerns/WorksWithSpans.php b/src/Sentry/Laravel/Features/Concerns/WorksWithSpans.php new file mode 100644 index 00000000..47041640 --- /dev/null +++ b/src/Sentry/Laravel/Features/Concerns/WorksWithSpans.php @@ -0,0 +1,33 @@ +getSpan(); + + // If the span is not available or not sampled we don't need to do anything + if ($parentSpan === null || !$parentSpan->getSampled()) { + return null; + } + + return $parentSpan; + } + + /** @param callable(Span $parentSpan): void $callback */ + protected function withParentSpanIfSampled(callable $callback): void + { + $parentSpan = $this->getParentSpanIfSampled(); + + if ($parentSpan === null) { + return; + } + + $callback($parentSpan); + } +} diff --git a/src/Sentry/Laravel/Features/HttpClientIntegration.php b/src/Sentry/Laravel/Features/HttpClientIntegration.php index 7fa05035..ee34e564 100644 --- a/src/Sentry/Laravel/Features/HttpClientIntegration.php +++ b/src/Sentry/Laravel/Features/HttpClientIntegration.php @@ -108,12 +108,7 @@ public function handleResponseReceivedHandlerForTracing(ResponseReceived $event) public function handleConnectionFailedHandlerForTracing(ConnectionFailed $event): void { - $span = $this->maybePopSpan(); - - if ($span !== null) { - $span->setStatus(SpanStatus::internalError()); - $span->finish(); - } + $this->maybeFinishSpan(SpanStatus::internalError()); } public function handleResponseReceivedHandlerForBreadcrumb(ResponseReceived $event): void diff --git a/src/Sentry/Laravel/Features/LivewirePackageIntegration.php b/src/Sentry/Laravel/Features/LivewirePackageIntegration.php index a71da180..dfa29112 100644 --- a/src/Sentry/Laravel/Features/LivewirePackageIntegration.php +++ b/src/Sentry/Laravel/Features/LivewirePackageIntegration.php @@ -168,11 +168,7 @@ public function handleComponentHydrate(Component $component): void public function handleComponentDehydrate(Component $component): void { - $span = $this->maybePopSpan(); - - if ($span !== null) { - $span->finish(); - } + $span = $this->maybeFinishSpan(); } private function updateTransactionName(string $componentName): void diff --git a/src/Sentry/Laravel/Features/NotificationsIntegration.php b/src/Sentry/Laravel/Features/NotificationsIntegration.php index aeaf3134..5b948bee 100644 --- a/src/Sentry/Laravel/Features/NotificationsIntegration.php +++ b/src/Sentry/Laravel/Features/NotificationsIntegration.php @@ -58,7 +58,7 @@ public function handleNotificationSending(NotificationSending $event): void public function handleNotificationSent(NotificationSent $event): void { - $this->finishSpanWithStatus(SpanStatus::ok()); + $this->maybeFinishSpan(SpanStatus::ok()); if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { Integration::addBreadcrumb(new Breadcrumb( @@ -75,16 +75,6 @@ public function handleNotificationSent(NotificationSent $event): void } } - 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); diff --git a/src/Sentry/Laravel/Features/QueueIntegration.php b/src/Sentry/Laravel/Features/QueueIntegration.php index 8f7b836b..1f80233b 100644 --- a/src/Sentry/Laravel/Features/QueueIntegration.php +++ b/src/Sentry/Laravel/Features/QueueIntegration.php @@ -112,16 +112,12 @@ public function handleJobQueueingEvent(JobQueueing $event): void public function handleJobQueuedEvent(JobQueued $event): void { - $span = $this->maybePopSpan(); - - if ($span !== null) { - $span->finish(); - } + $this->maybeFinishSpan(); } public function handleJobProcessedQueueEvent(JobProcessed $event): void { - $this->finishJobWithStatus(SpanStatus::ok()); + $this->maybeFinishSpan(SpanStatus::ok()); $this->maybePopScope(); } @@ -225,21 +221,11 @@ public function handleWorkerStoppingQueueEvent(WorkerStopping $event): void public function handleJobExceptionOccurredQueueEvent(JobExceptionOccurred $event): void { - $this->finishJobWithStatus(SpanStatus::internalError()); + $this->maybeFinishSpan(SpanStatus::internalError()); Integration::flushEvents(); } - private function finishJobWithStatus(SpanStatus $status): void - { - $span = $this->maybePopSpan(); - - if ($span !== null) { - $span->setStatus($status); - $span->finish(); - } - } - private function normalizeQueueName(?string $queue): string { if ($queue === null) { diff --git a/test/Sentry/Features/CacheIntegrationTest.php b/test/Sentry/Features/CacheIntegrationTest.php index 2d5dcac3..991ba950 100644 --- a/test/Sentry/Features/CacheIntegrationTest.php +++ b/test/Sentry/Features/CacheIntegrationTest.php @@ -2,8 +2,10 @@ namespace Sentry\Laravel\Tests\Features; +use Illuminate\Cache\Events\RetrievingKey; use Illuminate\Support\Facades\Cache; use Sentry\Laravel\Tests\TestCase; +use Sentry\Tracing\Span; class CacheIntegrationTest extends TestCase { @@ -48,4 +50,109 @@ public function testCacheBreadcrumbIsNotRecordedWhenDisabled(): void $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); } + + public function testCacheGetSpanIsRecorded(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::get('foo'); + }); + + $this->assertEquals('cache.get', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + $this->assertFalse($span->getData()['cache.hit']); + } + + public function testCacheGetSpanIsRecordedForBatchOperation(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::get(['foo', 'bar']); + }); + + $this->assertEquals('cache.get', $span->getOp()); + $this->assertEquals('foo, bar', $span->getDescription()); + $this->assertEquals(['foo', 'bar'], $span->getData()['cache.key']); + } + + public function testCacheGetSpanIsRecordedWithCorrectHitData(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::put('foo', 'bar'); + Cache::get('foo'); + }); + + $this->assertEquals('cache.get', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + $this->assertTrue($span->getData()['cache.hit']); + } + + public function testCachePutSpanIsRecorded(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::put('foo', 'bar', 99); + }); + + $this->assertEquals('cache.put', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + $this->assertEquals(99, $span->getData()['cache.ttl']); + } + + public function testCachePutSpanIsRecordedForBatchOperation(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::putMany(['foo' => 'bar', 'baz' => 'qux'], 99); + }); + + $this->assertEquals('cache.put', $span->getOp()); + $this->assertEquals('foo, baz', $span->getDescription()); + $this->assertEquals(['foo', 'baz'], $span->getData()['cache.key']); + $this->assertEquals(99, $span->getData()['cache.ttl']); + } + + public function testCacheRemoveSpanIsRecorded(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::forget('foo'); + }); + + $this->assertEquals('cache.remove', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + } + + private function markSkippedIfTracingEventsNotAvailable(): void + { + if (class_exists(RetrievingKey::class)) { + return; + } + + $this->markTestSkipped('The required cache events are not available in this Laravel version'); + } + + private function executeAndReturnMostRecentSpan(callable $callable): Span + { + $transaction = $this->startTransaction(); + + $callable(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertTrue(count($spans) >= 2); + + return array_pop($spans); + } }