Repository: getsentry/sentry-laravel (php)

Issue:
Cache performance spans
With the `Illuminate\Redis\Events\CommandExecuted` we could add spans for redis commands. We do this in the Symfony SDK where cache calls are profiled, maybe we could generelize since just Redis spans don't seem too interesting but cache performance spans could ne nice to see since they do take time but haven't looked to close if there are command where we can hook into the cache events.

Pull request description:
Cache performance spans & breadcrumbs
This adds:

- Cache breadcrumbs
- Redis command spans (optional)

We might want to extend this in the future to also trace the cache commands themselfs (like we do in Symfony) which requires decorating the cache drivers (not enough events to do it purely event based).

Fixes #627

Requires merging #657 first!

Patch:
diff --git a/config/sentry.php b/config/sentry.php
index e47aa8f..9b4a8be 100644
--- a/config/sentry.php
+++ b/config/sentry.php
@@ -15,6 +15,9 @@ return [
         // Capture Laravel logs in breadcrumbs
         'logs' => true,
 
+        // Capture Laravel cache events in breadcrumbs
+        'cache' => true,
+
         // Capture Livewire components in breadcrumbs
         'livewire' => true,
 
@@ -56,6 +59,12 @@ return [
         // Capture HTTP client requests as spans
         'http_client_requests' => true,
 
+        // Capture Redis operations as spans (this enables Redis events in Laravel)
+        'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
+
+        // Try to find out where the Redis command originated from and add it to the command spans
+        'redis_origin' => true,
+
         // Indicates if the tracing integrations supplied by Sentry should be loaded
         'default_integrations' => true,
 
diff --git a/src/Sentry/Laravel/Features/CacheIntegration.php b/src/Sentry/Laravel/Features/CacheIntegration.php
new file mode 100644
index 0000000..2addbdc
--- /dev/null
+++ b/src/Sentry/Laravel/Features/CacheIntegration.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Sentry\Laravel\Features;
+
+use Illuminate\Cache\Events;
+use Illuminate\Contracts\Events\Dispatcher;
+use Illuminate\Redis\Events as RedisEvents;
+use Illuminate\Redis\RedisManager;
+use Sentry\Breadcrumb;
+use Sentry\Laravel\Features\Concerns\ResolvesEventOrigin;
+use Sentry\Laravel\Integration;
+use Sentry\SentrySdk;
+use Sentry\Tracing\SpanContext;
+
+class CacheIntegration extends Feature
+{
+    use ResolvesEventOrigin;
+
+    public function isApplicable(): bool
+    {
+        return $this->isTracingFeatureEnabled('redis_commands') || $this->isBreadcrumbFeatureEnabled('cache');
+    }
+
+    public function setup(Dispatcher $events): void
+    {
+        if ($this->isBreadcrumbFeatureEnabled('cache')) {
+            $events->listen([
+                Events\CacheHit::class,
+                Events\CacheMissed::class,
+                Events\KeyWritten::class,
+                Events\KeyForgotten::class,
+            ], [$this, 'handleCacheEvent']);
+        }
+
+        if ($this->isTracingFeatureEnabled('redis_commands', false)) {
+            $events->listen(RedisEvents\CommandExecuted::class, [$this, 'handleRedisCommand']);
+
+            $this->container()->afterResolving(RedisManager::class, static function (RedisManager $redis): void {
+                $redis->enableEvents();
+            });
+        }
+    }
+
+    public function handleCacheEvent(Events\CacheEvent $event): void
+    {
+        switch (true) {
+            case $event instanceof Events\KeyWritten:
+                $message = 'Written';
+                break;
+            case $event instanceof Events\KeyForgotten:
+                $message = 'Forgotten';
+                break;
+            case $event instanceof Events\CacheMissed:
+                $message = 'Missed';
+                break;
+            case $event instanceof Events\CacheHit:
+                $message = 'Read';
+                break;
+            default:
+                // In case events are added in the future we do nothing when an unknown event is encountered
+                return;
+        }
+
+        Integration::addBreadcrumb(new Breadcrumb(
+            Breadcrumb::LEVEL_INFO,
+            Breadcrumb::TYPE_DEFAULT,
+            'cache',
+            "{$message}: {$event->key}",
+            $event->tags ? ['tags' => $event->tags] : []
+        ));
+    }
+
+    public function handleRedisCommand(RedisEvents\CommandExecuted $event): void
+    {
+        $parentSpan = SentrySdk::getCurrentHub()->getSpan();
+
+        // If there is no tracing span active there is no need to handle the event
+        if ($parentSpan === null) {
+            return;
+        }
+
+        $context = new SpanContext();
+        $context->setOp('db.redis');
+        $context->setDescription(strtoupper($event->command) . ' ' . ($event->parameters[0] ?? null));
+        $context->setStartTimestamp(microtime(true) - $event->time / 1000);
+        $context->setEndTimestamp($context->getStartTimestamp() + $event->time / 1000);
+
+        $data = [
+            'db.redis.connection' => $event->connectionName,
+        ];
+
+        if ($this->shouldSendDefaultPii()) {
+            $data['db.redis.parameters'] = $event->parameters;
+        }
+
+        if ($this->isTracingFeatureEnabled('redis_origin')) {
+            $commandOrigin = $this->resolveEventOrigin();
+
+            if ($commandOrigin !== null) {
+                $data['db.redis.origin'] = $commandOrigin;
+            }
+        }
+
+        $context->setData($data);
+
+        $parentSpan->startChild($context);
+    }
+}
diff --git a/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php b/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php
new file mode 100644
index 0000000..92aad22
--- /dev/null
+++ b/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Sentry\Laravel\Features\Concerns;
+
+use Sentry\Laravel\Tracing\BacktraceHelper;
+
+trait ResolvesEventOrigin
+{
+    protected function resolveEventOrigin(): ?string
+    {
+        $backtraceHelper = $this->makeBacktraceHelper();
+
+        $firstAppFrame = $backtraceHelper->findFirstInAppFrameForBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS));
+
+        if ($firstAppFrame === null) {
+            return null;
+        }
+
+        $filePath = $backtraceHelper->getOriginalViewPathForFrameOfCompiledViewPath($firstAppFrame) ?? $firstAppFrame->getFile();
+
+        return "{$filePath}:{$firstAppFrame->getLine()}";
+    }
+
+    private function makeBacktraceHelper(): BacktraceHelper
+    {
+        return $this->container()->make(BacktraceHelper::class);
+    }
+}
diff --git a/src/Sentry/Laravel/Features/Feature.php b/src/Sentry/Laravel/Features/Feature.php
index d89a504..97abd65 100644
--- a/src/Sentry/Laravel/Features/Feature.php
+++ b/src/Sentry/Laravel/Features/Feature.php
@@ -5,13 +5,14 @@ namespace Sentry\Laravel\Features;
 use Illuminate\Contracts\Container\Container;
 use Sentry\Integration\IntegrationInterface;
 use Sentry\Laravel\BaseServiceProvider;
+use Sentry\SentrySdk;
 
 /**
  * @method void setup() Setup the feature in the environment.
  *
  * @internal
  */
-abstract class Feature implements IntegrationInterface
+abstract class Feature
 {
     /**
      * @var Container The Laravel application container.
@@ -48,9 +49,9 @@ abstract class Feature implements IntegrationInterface
     abstract public function isApplicable(): bool;
 
     /**
-     * Initializes the current integration by registering it once.
+     * Initializes the feature.
      */
-    public function setupOnce(): void
+    public function boot(): void
     {
         if (method_exists($this, 'setup') && $this->isApplicable()) {
             try {
@@ -83,6 +84,20 @@ abstract class Feature implements IntegrationInterface
         return empty($config) ? [] : $config;
     }
 
+    /**
+     * Should default PII be sent by default.
+     */
+    protected function shouldSendDefaultPii(): bool
+    {
+        $client = SentrySdk::getCurrentHub()->getClient();
+
+        if ($client === null) {
+            return false;
+        }
+
+        return $client->getOptions()->shouldSendDefaultPii();
+    }
+
     /**
      * Indicates if the given feature is enabled for tracing.
      */
diff --git a/src/Sentry/Laravel/ServiceProvider.php b/src/Sentry/Laravel/ServiceProvider.php
index 9a1c235..c24aaeb 100644
--- a/src/Sentry/Laravel/ServiceProvider.php
+++ b/src/Sentry/Laravel/ServiceProvider.php
@@ -41,9 +41,10 @@ class ServiceProvider extends BaseServiceProvider
     ];
 
     /**
-     * List of default feature integrations that are enabled by default.
+     * List of features that are provided by the SDK.
      */
-    protected const DEFAULT_FEATURES = [
+    protected const FEATURES = [
+        Features\CacheIntegration::class,
         Features\LivewirePackageIntegration::class,
     ];
 
@@ -57,6 +58,8 @@ class ServiceProvider extends BaseServiceProvider
         if ($this->hasDsnSet()) {
             $this->bindEvents();
 
+            $this->setupFeatures();
+
             if ($this->app->bound(HttpKernelInterface::class)) {
                 /** @var \Illuminate\Foundation\Http\Kernel $httpKernel */
                 $httpKernel = $this->app->make(HttpKernelInterface::class);
@@ -126,6 +129,20 @@ class ServiceProvider extends BaseServiceProvider
         }
     }
 
+    /**
+     * Setup the default SDK features.
+     */
+    protected function setupFeatures(): void
+    {
+        foreach (self::FEATURES as $feature) {
+            try {
+                $this->app->make($feature)->boot();
+            } catch (\Throwable $e) {
+                // Ensure that features do not break the whole application
+            }
+        }
+    }
+
     /**
      * Register the artisan commands.
      */
@@ -241,19 +258,12 @@ class ServiceProvider extends BaseServiceProvider
 
         $userConfig = $this->getUserConfig();
 
-        $integrationsToResolve = array_merge(
-            $userConfig['integrations'] ?? [],
-            // These features are enabled by default and can be configured using the `tracing` and `breadcrumbs` config
-            self::DEFAULT_FEATURES
-        );
+        $integrationsToResolve = array_merge($userConfig['integrations'] ?? []);
 
         $enableDefaultTracingIntegrations = $userConfig['tracing']['default_integrations'] ?? true;
 
         if ($enableDefaultTracingIntegrations) {
-            $integrationsToResolve = array_merge(
-                $integrationsToResolve,
-                TracingServiceProvider::DEFAULT_INTEGRATIONS
-            );
+            $integrationsToResolve = array_merge($integrationsToResolve, TracingServiceProvider::DEFAULT_INTEGRATIONS);
         }
 
         foreach ($integrationsToResolve as $userIntegration) {
diff --git a/src/Sentry/Laravel/Tracing/EventHandler.php b/src/Sentry/Laravel/Tracing/EventHandler.php
index 0cf3f84..aa61588 100644
--- a/src/Sentry/Laravel/Tracing/EventHandler.php
+++ b/src/Sentry/Laravel/Tracing/EventHandler.php
@@ -233,7 +233,7 @@ class EventHandler
             $queryOrigin = $this->resolveQueryOriginFromBacktrace();
 
             if ($queryOrigin !== null) {
-                $context->setData(['sql.origin' => $queryOrigin]);
+                $context->setData(['db.sql.origin' => $queryOrigin]);
             }
         }
 


Test patch:
diff --git a/test/Sentry/Features/CacheIntegrationTest.php b/test/Sentry/Features/CacheIntegrationTest.php
new file mode 100644
index 0000000..421ab3b
--- /dev/null
+++ b/test/Sentry/Features/CacheIntegrationTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Sentry\Features;
+
+use Illuminate\Support\Facades\Cache;
+use Sentry\Laravel\Tests\TestCase;
+
+class CacheIntegrationTest extends TestCase
+{
+    public function testCacheBreadcrumbForWriteAndHitIsRecorded(): void
+    {
+        Cache::put($key = 'foo', 'bar');
+
+        $this->assertEquals("Written: {$key}", $this->getLastBreadcrumb()->getMessage());
+
+        Cache::get('foo');
+
+        $this->assertEquals("Read: {$key}", $this->getLastBreadcrumb()->getMessage());
+    }
+
+    public function testCacheBreadcrumbForWriteAndForgetIsRecorded(): void
+    {
+        Cache::put($key = 'foo', 'bar');
+
+        $this->assertEquals("Written: {$key}", $this->getLastBreadcrumb()->getMessage());
+
+        Cache::forget($key);
+
+        $this->assertEquals("Forgotten: {$key}", $this->getLastBreadcrumb()->getMessage());
+    }
+
+    public function testCacheBreadcrumbForMissIsRecorded(): void
+    {
+        Cache::get($key = 'foo');
+
+        $this->assertEquals("Missed: {$key}", $this->getLastBreadcrumb()->getMessage());
+    }
+
+    public function testCacheBreadcrumIsNotRecordedWhenDisabled(): void
+    {
+        $this->resetApplicationWithConfig([
+            'sentry.breadcrumbs.cache' => false,
+        ]);
+
+        $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.cache'));
+
+        Cache::get('foo');
+
+        $this->assertEmpty($this->getCurrentBreadcrumbs());
+    }
+}
diff --git a/test/Sentry/TestCase.php b/test/Sentry/TestCase.php
index 8b9a7f7..1449c2e 100644
--- a/test/Sentry/TestCase.php
+++ b/test/Sentry/TestCase.php
@@ -8,14 +8,12 @@ use Sentry\Breadcrumb;
 use Sentry\ClientInterface;
 use Sentry\Event;
 use Sentry\EventHint;
-use Sentry\Laravel\Integration;
 use Sentry\State\Scope;
 use ReflectionProperty;
 use Sentry\Laravel\Tracing;
 use Sentry\State\HubInterface;
 use Sentry\Laravel\ServiceProvider;
 use Orchestra\Testbench\TestCase as LaravelTestCase;
-use Throwable;
 
 abstract class TestCase extends LaravelTestCase
 {