diff --git a/src/Tracing/Cache/TraceableCacheAdapterForV3WithNamespace.php b/src/Tracing/Cache/TraceableCacheAdapterForV3WithNamespace.php index 843d8d2a..74417394 100644 --- a/src/Tracing/Cache/TraceableCacheAdapterForV3WithNamespace.php +++ b/src/Tracing/Cache/TraceableCacheAdapterForV3WithNamespace.php @@ -54,6 +54,9 @@ public function withSubNamespace(string $namespace): static $clone = clone $this; $clone->decoratedAdapter = $this->decoratedAdapter->withSubNamespace($namespace); + $clone->namespace = null === $this->namespace + ? $namespace + : \sprintf('%s.%s', $this->namespace, $namespace); return $clone; } diff --git a/src/Tracing/Cache/TraceableCacheAdapterTrait.php b/src/Tracing/Cache/TraceableCacheAdapterTrait.php index 42a80292..43620daa 100644 --- a/src/Tracing/Cache/TraceableCacheAdapterTrait.php +++ b/src/Tracing/Cache/TraceableCacheAdapterTrait.php @@ -34,6 +34,11 @@ trait TraceableCacheAdapterTrait */ private $decoratedAdapter; + /** + * @var string|null + */ + protected $namespace; + /** * {@inheritdoc} */ @@ -200,16 +205,23 @@ private function traceFunction(string $spanOperation, \Closure $callback, ?strin try { $result = $callback(); - // Necessary for static analysis. Otherwise, the TResult type is assumed to be CacheItemInterface. - if (!$result instanceof CacheItemInterface) { - return $result; + $data = []; + + if ($result instanceof CacheItemInterface) { + $data['cache.hit'] = $result->isHit(); + if ($result->isHit()) { + $data['cache.item_size'] = static::getCacheItemSize($result->get()); + } + } + + $namespace = $this->getCacheNamespace(); + if (null !== $namespace) { + $data['cache.namespace'] = $namespace; } - $data = ['cache.hit' => $result->isHit()]; - if ($result->isHit()) { - $data['cache.item_size'] = static::getCacheItemSize($result->get()); + if ([] !== $data) { + $span->setData($data); } - $span->setData($data); return $result; } finally { @@ -282,10 +294,15 @@ private function traceGet(string $key, callable $callback, ?float $beta = null, $now = microtime(true); - $getSpan->setData([ + $getData = [ 'cache.hit' => !$wasMiss, 'cache.item_size' => self::getCacheItemSize($value), - ]); + ]; + $namespace = $this->getCacheNamespace(); + if (null !== $namespace) { + $getData['cache.namespace'] = $namespace; + } + $getSpan->setData($getData); // If we got a timestamp here we know that we missed if (null !== $saveStartTimestamp) { @@ -296,9 +313,13 @@ private function traceGet(string $key, callable $callback, ?float $beta = null, ->setDescription(urldecode($key)); $saveSpan = $parentSpan->startChild($saveContext); $saveSpan->setStartTimestamp($saveStartTimestamp); - $saveSpan->setData([ + $saveData = [ 'cache.item_size' => self::getCacheItemSize($value), - ]); + ]; + if (null !== $namespace) { + $saveData['cache.namespace'] = $namespace; + } + $saveSpan->setData($saveData); $saveSpan->finish($now); } else { $getSpan->finish(); @@ -343,4 +364,12 @@ private function setCallbackWrapper(callable $callback, string $key): callable return $callback($this->decoratedAdapter->getItem($key)); }; } + + /** + * @return string|null + */ + protected function getCacheNamespace(): ?string + { + return $this->namespace; + } } diff --git a/src/Tracing/Cache/TraceableTagAwareCacheAdapterForV3WithNamespace.php b/src/Tracing/Cache/TraceableTagAwareCacheAdapterForV3WithNamespace.php new file mode 100644 index 00000000..62535279 --- /dev/null +++ b/src/Tracing/Cache/TraceableTagAwareCacheAdapterForV3WithNamespace.php @@ -0,0 +1,71 @@ + + */ + use TraceableCacheAdapterTrait; + + /** + * @param HubInterface $hub The current hub + * @param TagAwareAdapterInterface $decoratedAdapter The decorated cache adapter + */ + public function __construct(HubInterface $hub, TagAwareAdapterInterface $decoratedAdapter) + { + $this->hub = $hub; + $this->decoratedAdapter = $decoratedAdapter; + } + + /** + * {@inheritdoc} + * + * @param mixed[] $metadata + */ + public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed + { + return $this->traceGet($key, $callback, $beta, $metadata); + } + + /** + * {@inheritdoc} + */ + public function invalidateTags(array $tags): bool + { + return $this->traceFunction('cache.invalidate_tags', function () use ($tags): bool { + return $this->decoratedAdapter->invalidateTags($tags); + }); + } + + public function withSubNamespace(string $namespace): static + { + if (!$this->decoratedAdapter instanceof NamespacedPoolInterface) { + throw new \BadMethodCallException(\sprintf('The %s::withSubNamespace() method is not supported because the decorated adapter does not implement the "%s" interface.', self::class, NamespacedPoolInterface::class)); + } + + $clone = clone $this; + $clone->decoratedAdapter = $this->decoratedAdapter->withSubNamespace($namespace); + $clone->namespace = null === $this->namespace + ? $namespace + : \sprintf('%s.%s', $this->namespace, $namespace); + + return $clone; + } +} diff --git a/src/aliases.php b/src/aliases.php index e0433bcb..2c98f103 100644 --- a/src/aliases.php +++ b/src/aliases.php @@ -9,7 +9,6 @@ use Sentry\SentryBundle\Tracing\Cache\TraceableCacheAdapter; use Sentry\SentryBundle\Tracing\Cache\TraceableCacheAdapterForV2; use Sentry\SentryBundle\Tracing\Cache\TraceableCacheAdapterForV3; -use Sentry\SentryBundle\Tracing\Cache\TraceableCacheAdapterForV3WithNamespace; use Sentry\SentryBundle\Tracing\Cache\TraceableTagAwareCacheAdapter; use Sentry\SentryBundle\Tracing\Cache\TraceableTagAwareCacheAdapterForV2; use Sentry\SentryBundle\Tracing\Cache\TraceableTagAwareCacheAdapterForV3; @@ -39,21 +38,24 @@ use Symfony\Component\Cache\DoctrineProvider; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Response\StreamableInterface; -use Symfony\Contracts\Cache\NamespacedPoolInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; if (interface_exists(AdapterInterface::class)) { if (!class_exists(DoctrineProvider::class, false) && version_compare(\PHP_VERSION, '8.0.0', '>=')) { if (!class_exists(TraceableCacheAdapter::class, false)) { - if (interface_exists(NamespacedPoolInterface::class)) { - class_alias(TraceableCacheAdapterForV3WithNamespace::class, TraceableCacheAdapter::class); + if (interface_exists('Symfony\\Contracts\\Cache\\NamespacedPoolInterface')) { + class_alias('Sentry\\SentryBundle\\Tracing\\Cache\\TraceableCacheAdapterForV3WithNamespace', TraceableCacheAdapter::class); } else { class_alias(TraceableCacheAdapterForV3::class, TraceableCacheAdapter::class); } } if (!class_exists(TraceableTagAwareCacheAdapter::class, false)) { - class_alias(TraceableTagAwareCacheAdapterForV3::class, TraceableTagAwareCacheAdapter::class); + if (interface_exists('Symfony\\Contracts\\Cache\\NamespacedPoolInterface')) { + class_alias('Sentry\\SentryBundle\\Tracing\\Cache\\TraceableTagAwareCacheAdapterForV3WithNamespace', TraceableTagAwareCacheAdapter::class); + } else { + class_alias(TraceableTagAwareCacheAdapterForV3::class, TraceableTagAwareCacheAdapter::class); + } } } else { if (!class_exists(TraceableCacheAdapter::class, false)) { diff --git a/tests/End2End/App/Controller/NamespacedCacheController.php b/tests/End2End/App/Controller/NamespacedCacheController.php new file mode 100644 index 00000000..2a09d85a --- /dev/null +++ b/tests/End2End/App/Controller/NamespacedCacheController.php @@ -0,0 +1,35 @@ +cache = $cache; + } + + public function populateNamespacedCache(): Response + { + $namespaced = $this->cache->withSubNamespace('tests'); + + $namespaced->get('namespaced-key', function (ItemInterface $item) { + $item->tag(['a tag name']); + + return 'namespaced-value'; + }); + + return new Response(); + } +} diff --git a/tests/End2End/App/routing.yml b/tests/End2End/App/routing.yml index caaa9f5d..0303d467 100644 --- a/tests/End2End/App/routing.yml +++ b/tests/End2End/App/routing.yml @@ -70,6 +70,10 @@ psr_tracing_cache_delete: path: /tracing/cache/psr/delete defaults: { _controller: 'Sentry\SentryBundle\Tests\End2End\App\Controller\PsrTracingCacheController::testDelete' } +namespaced_tracing_cache_populate: + path: /tracing/cache/namespaced/populate + defaults: { _controller: 'Sentry\SentryBundle\Tests\End2End\App\Controller\NamespacedCacheController::populateNamespacedCache' } + just_logging: path: /just-logging defaults: { _controller: 'Sentry\SentryBundle\Tests\End2End\App\Controller\LoggingController::justLogging' } diff --git a/tests/End2End/App/tracing.yml b/tests/End2End/App/tracing.yml index f54e14e5..e38dbf95 100644 --- a/tests/End2End/App/tracing.yml +++ b/tests/End2End/App/tracing.yml @@ -23,3 +23,9 @@ services: autowire: true tags: - { name: controller.service_arguments } + + Sentry\SentryBundle\Tests\End2End\App\Controller\NamespacedCacheController: + arguments: + $cache: '@cache.app.taggable' + tags: + - { name: controller.service_arguments } diff --git a/tests/End2End/TracingCacheEnd2EndTest.php b/tests/End2End/TracingCacheEnd2EndTest.php index 064abf98..e6e1f738 100644 --- a/tests/End2End/TracingCacheEnd2EndTest.php +++ b/tests/End2End/TracingCacheEnd2EndTest.php @@ -223,4 +223,35 @@ public function testPsrCacheDelete(): void $this->assertEquals('cache.remove', $span->getOp()); $this->assertNull($span->getData('cache.item_size')); } + + public function testNamespacedTagAwareCache(): void + { + if (!interface_exists(\Symfony\Contracts\Cache\NamespacedPoolInterface::class)) { + $this->markTestSkipped('Namespaced caches are not supported by this Symfony version.'); + } + + $client = static::createClient(['debug' => false]); + $cache = static::getContainer()->get('cache.app.taggable'); + + // make sure that the configured taggable cache supports namespaces before running this test + if (!$cache instanceof \Symfony\Contracts\Cache\NamespacedPoolInterface) { + $this->markTestSkipped('The configured tag-aware cache pool does not support namespaces.'); + } + + $client->request('GET', '/tracing/cache/namespaced/populate'); + $this->assertSame(200, $client->getResponse()->getStatusCode()); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + + $cacheGetSpans = array_values(array_filter($event->getSpans(), static function ($span) { + return 'cache.get' === $span->getOp(); + })); + $this->assertNotEmpty($cacheGetSpans); + + $cachePutSpans = array_filter($event->getSpans(), static function ($span) { + return 'cache.put' === $span->getOp(); + }); + $this->assertNotEmpty($cachePutSpans); + } } diff --git a/tests/Tracing/Cache/TraceableTagAwareCacheAdapterTest.php b/tests/Tracing/Cache/TraceableTagAwareCacheAdapterTest.php index d856601e..f15b66eb 100644 --- a/tests/Tracing/Cache/TraceableTagAwareCacheAdapterTest.php +++ b/tests/Tracing/Cache/TraceableTagAwareCacheAdapterTest.php @@ -5,10 +5,14 @@ namespace Sentry\SentryBundle\Tests\Tracing\Cache; use Sentry\SentryBundle\Tracing\Cache\TraceableTagAwareCacheAdapter; +use Sentry\SentryBundle\Tracing\Cache\TraceableTagAwareCacheAdapterForV3WithNamespace; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; /** * @phpstan-extends AbstractTraceableCacheAdapterTest @@ -42,6 +46,108 @@ public function testInvalidateTags(): void $this->assertNotNull($spans[1]->getEndTimestamp()); } + public function testWithSubNamespaceThrowsWhenNotNamespaced(): void + { + if (!interface_exists(NamespacedPoolInterface::class)) { + $this->markTestSkipped('Namespaced caches are not supported by this Symfony version.'); + } + + $decoratedAdapter = $this->createMock(TagAwareAdapterInterface::class); + $adapter = new TraceableTagAwareCacheAdapterForV3WithNamespace($this->hub, $decoratedAdapter); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('withSubNamespace() method is not supported'); + + $adapter->withSubNamespace('foo'); + } + + public function testWithSubNamespaceReturnsNamespacedAdapter(): void + { + if (!interface_exists(NamespacedPoolInterface::class)) { + $this->markTestSkipped('Namespaced caches are not supported by this Symfony version.'); + } + + $decoratedAdapter = new TagAwareAdapter(new ArrayAdapter(), new ArrayAdapter()); + if (!method_exists($decoratedAdapter, 'withSubNamespace')) { + $this->markTestSkipped('TagAwareAdapter::withSubNamespace() is not available in this Symfony version.'); + } + $namespacedAdapter = $decoratedAdapter->withSubNamespace('foo'); + + $adapter = new TraceableTagAwareCacheAdapterForV3WithNamespace($this->hub, $decoratedAdapter); + + $result = $adapter->withSubNamespace('foo'); + + $this->assertInstanceOf(NamespacedPoolInterface::class, $result); + $this->assertNotSame($adapter, $result); + + $ref = new \ReflectionProperty($result, 'decoratedAdapter'); + $ref->setAccessible(true); + + $this->assertEquals($namespacedAdapter, $ref->getValue($result)); + } + + public function testNamespaceIsAddedToSpanData(): void + { + if (!interface_exists(NamespacedPoolInterface::class)) { + $this->markTestSkipped('Namespaced caches are not supported by this Symfony version.'); + } + + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->any()) + ->method('getSpan') + ->willReturn($transaction); + + $decoratedAdapter = new TagAwareAdapter(new ArrayAdapter(), new ArrayAdapter()); + if (!method_exists($decoratedAdapter, 'withSubNamespace')) { + $this->markTestSkipped('TagAwareAdapter::withSubNamespace() is not available in this Symfony version.'); + } + $adapter = new TraceableTagAwareCacheAdapterForV3WithNamespace($this->hub, $decoratedAdapter); + + $namespaced = $adapter->withSubNamespace('foo')->withSubNamespace('bar'); + + $namespaced->delete('example'); + $this->assertNotNull($transaction->getSpanRecorder()); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame('cache.remove', $spans[1]->getOp()); + $this->assertSame('foo.bar', $spans[1]->getData()['cache.namespace']); + } + + public function testSingleNamespaceIsAddedToSpanData(): void + { + if (!interface_exists(NamespacedPoolInterface::class)) { + $this->markTestSkipped('Namespaced caches are not supported by this Symfony version.'); + } + + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->any()) + ->method('getSpan') + ->willReturn($transaction); + + $decoratedAdapter = new TagAwareAdapter(new ArrayAdapter(), new ArrayAdapter()); + if (!method_exists($decoratedAdapter, 'withSubNamespace')) { + $this->markTestSkipped('TagAwareAdapter::withSubNamespace() is not available in this Symfony version.'); + } + $adapter = new TraceableTagAwareCacheAdapterForV3WithNamespace($this->hub, $decoratedAdapter); + + $namespaced = $adapter->withSubNamespace('foo'); + + $namespaced->delete('example'); + $this->assertNotNull($transaction->getSpanRecorder()); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame('cache.remove', $spans[1]->getOp()); + $this->assertSame('foo', $spans[1]->getData()['cache.namespace']); + } + /** * {@inheritdoc} */