From 61a21f7cfa8712b56c83428d9563266c29a189d8 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Fri, 26 Jun 2026 10:49:35 +0200 Subject: [PATCH 01/10] fix(flow-php/symfony-telemetry-bundle): leaked context scopes in HTTP and messenger instrumentation - complete sub-request spans on kernel.finish_request - detach extracted request propagation scope so it no longer leaks into the next request - detach messenger continuation/baggage scope after handling --- .../HttpKernel/HttpKernelSpanSubscriber.php | 31 +++- .../Messenger/TracingMiddleware.php | 9 +- .../Middleware/BaggageCapturingMiddleware.php | 27 +++ .../HttpKernelSpanSubscriberTest.php | 161 ++++++++++++++++++ .../Messenger/TracingMiddlewareTest.php | 68 +++++++- 5 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Middleware/BaggageCapturingMiddleware.php diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php index 202f9312fd..b8cd3a1c4c 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php @@ -9,6 +9,7 @@ use Flow\Bridge\Symfony\HttpFoundationTelemetry\ResponseCarrier; use Flow\Telemetry\Context\Context; use Flow\Telemetry\Context\ContextStorage; +use Flow\Telemetry\Context\Scope; use Flow\Telemetry\PackageVersion; use Flow\Telemetry\Propagation\PropagationContext; use Flow\Telemetry\Propagation\Propagator; @@ -22,6 +23,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Event\FinishRequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\Event\TerminateEvent; @@ -36,6 +38,8 @@ private const string TRACER_ATTRIBUTE = '_flow_telemetry_tracer'; + private const string PROPAGATION_SCOPE_ATTRIBUTE = '_flow_telemetry_propagation_scope'; + /** @var array */ private array $excludePathRules; @@ -62,6 +66,7 @@ public static function getSubscribedEvents(): array KernelEvents::CONTROLLER => ['onController', 0], KernelEvents::RESPONSE => ['onResponse', -10000], KernelEvents::EXCEPTION => ['onException', 0], + KernelEvents::FINISH_REQUEST => ['onFinishRequest', -10000], KernelEvents::TERMINATE => ['onTerminate', -10000], ]; } @@ -153,10 +158,26 @@ public function onResponse(ResponseEvent $event): void } } + /** + * Sub-requests never reach kernel.terminate (it fires only for the main request), so their span is + * completed here, where kernel.finish_request fires once for every request, main and sub alike. + */ + public function onFinishRequest(FinishRequestEvent $event): void + { + if ($event->isMainRequest()) { + return; + } + + $this->completeSpan($event->getRequest()); + } + public function onTerminate(TerminateEvent $event): void { - $request = $event->getRequest(); + $this->completeSpan($event->getRequest()); + } + private function completeSpan(Request $request): void + { // @mago-expect analysis:mixed-assignment if (!($span = $request->attributes->get(self::SPAN_ATTRIBUTE)) instanceof Span) { return; @@ -169,6 +190,12 @@ public function onTerminate(TerminateEvent $event): void $tracer->complete($span); + // @mago-expect analysis:mixed-assignment + if (($scope = $request->attributes->get(self::PROPAGATION_SCOPE_ATTRIBUTE)) instanceof Scope) { + $scope->detach(); + $request->attributes->remove(self::PROPAGATION_SCOPE_ATTRIBUTE); + } + $request->attributes->remove(self::SPAN_ATTRIBUTE); $request->attributes->remove(self::TRACER_ATTRIBUTE); } @@ -186,7 +213,7 @@ private function extractContextFromRequest(Request $request): void $context = $context->withBaggage($propagationContext->baggage); } - $this->contextStorage->attach($context); + $request->attributes->set(self::PROPAGATION_SCOPE_ATTRIBUTE, $this->contextStorage->attach($context)); } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php index a17499fdde..b93612f67b 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Flow\Telemetry\Context\Context; use Flow\Telemetry\Context\ContextStorage; +use Flow\Telemetry\Context\Scope; use Flow\Telemetry\PackageVersion; use Flow\Telemetry\Propagation\PropagationContext; use Flow\Telemetry\Propagation\Propagator; @@ -77,6 +78,7 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope $workerSpan = $isReceived ? $this->contextStorage?->current()->activeSpan() : null; $links = []; $continuingRemoteTrace = false; + $propagationScope = null; if ($remote !== null && $remote->spanContext !== null) { $remoteSpanContext = $remote->spanContext; @@ -89,7 +91,7 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope $context = $context->withBaggage($remoteBaggage); } - $this->contextStorage?->attach($context); + $propagationScope = $this->contextStorage?->attach($context); $continuingRemoteTrace = true; } else { $links[] = SpanLink::create( @@ -98,7 +100,9 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope ); if ($remoteBaggage !== null && $this->contextStorage !== null) { - $this->contextStorage->attach($this->contextStorage->current()->withBaggage($remoteBaggage)); + $propagationScope = $this->contextStorage->attach( + $this->contextStorage->current()->withBaggage($remoteBaggage), + ); } } } @@ -129,6 +133,7 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope throw $e; } finally { $tracer->complete($span); + $propagationScope?->detach(); } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Middleware/BaggageCapturingMiddleware.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Middleware/BaggageCapturingMiddleware.php new file mode 100644 index 0000000000..b345271c33 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Middleware/BaggageCapturingMiddleware.php @@ -0,0 +1,27 @@ +capturedDuringHandling = $this->contextStorage->current()->baggage; + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php index 1d3c3c6ff5..d2cd94da40 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Router; @@ -407,6 +408,87 @@ public function test_extracts_context_from_traceparent_header(): void static::assertSame($incomingSpanId, $span->context()->parentSpanId?->toHex()); } + public function test_extracted_context_does_not_leak_into_the_next_request(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => [ + 'utf8' => true, + 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php', + ], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => [ + 'type' => 'memory', + 'exporter' => 'memory', + ], + ], + 'instrumentation' => [ + 'http_kernel' => [ + 'enabled' => true, + 'context_propagation' => true, + ], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $incomingTraceId = '0af7651916cd43dd8448eb211c80319c'; + + $firstRequest = Request::create('/test', 'GET'); + $firstRequest->headers->set('traceparent', "00-{$incomingTraceId}-b7ad6b7169203331-01"); + $kernel->terminate($firstRequest, $kernel->handle($firstRequest)); + + $secondRequest = Request::create('/test', 'GET'); + $kernel->terminate($secondRequest, $kernel->handle($secondRequest)); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $serverSpans = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + )); + + static::assertCount(2, $serverSpans); + + $continuingRemoteTrace = array_values(array_filter( + $serverSpans, + static fn(Span $s): bool => $s->context()->traceId->toHex() === $incomingTraceId, + )); + $freshTrace = array_values(array_filter( + $serverSpans, + static fn(Span $s): bool => $s->context()->traceId->toHex() !== $incomingTraceId, + )); + + static::assertCount( + 1, + $continuingRemoteTrace, + 'only the request carrying traceparent continues the remote trace', + ); + static::assertCount(1, $freshTrace); + static::assertNull( + $freshTrace[0]->context()->parentSpanId, + 'a request without traceparent must start a fresh root trace, not inherit the previous request remote parent', + ); + } + public function test_handles_missing_trace_headers_gracefully(): void { $kernel = $this->bootKernel([ @@ -586,6 +668,85 @@ public function test_traces_successful_http_request(): void static::assertSame(TestController::class . '::index', $attributes['controller']); } + public function test_completes_sub_request_span_nested_under_main_request(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => [ + 'utf8' => true, + 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php', + ], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => [ + 'type' => 'memory', + 'exporter' => 'memory', + ], + ], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $mainRequest = Request::create('/test', 'GET'); + $response = $kernel->handle($mainRequest); + + $subRequest = Request::create('/test', 'GET'); + $kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST); + + $kernel->terminate($mainRequest, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $spans = $processor->endedSpans(); + + $serverSpan = array_values(array_filter( + $spans, + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER && $s->name() === 'GET /test', + )); + $subRequestSpan = array_values(array_filter( + $spans, + static fn(Span $s): bool => $s->kind() === SpanKind::INTERNAL && $s->name() === 'GET /test', + )); + + static::assertCount(1, $serverSpan); + static::assertCount( + 1, + $subRequestSpan, + 'sub-request request span must be completed and exported on kernel.finish_request', + ); + + static::assertSame('GET', $subRequestSpan[0]->attributes()['http.request.method']); + static::assertTrue( + $subRequestSpan[0]->context()->traceId->equals($serverSpan[0]->context()->traceId), + 'sub-request span must share the main request trace', + ); + static::assertSame( + $serverSpan[0]->context()->spanId->toHex(), + $subRequestSpan[0]->context()->parentSpanId?->toHex(), + 'sub-request span must be a child of the main request span', + ); + } + public function test_injects_context_into_response_when_propagation_enabled(): void { $kernel = $this->bootKernel([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php index a393dcdf67..609a0dfed8 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php @@ -9,6 +9,7 @@ use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Messenger\TracingMiddleware; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Message\TestMessage; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\MessageHandler\TestMessageHandler; +use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Middleware\BaggageCapturingMiddleware; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Middleware\CapturingMiddleware; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\TestKernel; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Integration\KernelTestCase; @@ -111,6 +112,56 @@ public function test_context_is_extracted_on_consume(): void static::assertSame($originalTraceId, $span->context()->traceId->toHex()); } + public function test_continuation_context_does_not_leak_after_consume(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => [ + 'type' => 'memory', + 'exporter' => 'memory', + ], + ], + 'propagator' => ['type' => 'w3c'], + 'instrumentation' => [ + 'http_kernel' => false, + 'console' => false, + 'messenger' => [ + 'enabled' => true, + 'context_propagation' => true, + ], + ], + ]); + }, + ]); + + $this->getContainer(); + + $telemetry = $this->symfonyContext()->getService(Telemetry::class, Telemetry::class); + $contextStorage = $this->symfonyContext()->getService('flow.telemetry.context_storage', ContextStorage::class); + $propagator = $this->symfonyContext()->getService('flow.telemetry.propagator', Propagator::class); + + $bus = new MessageBus([ + new TracingMiddleware($telemetry, $contextStorage, $propagator, MessengerTracePropagation::Continuation), + new HandleMessageMiddleware(new HandlersLocator([ + TestMessage::class => [new TestMessageHandler()], + ])), + ]); + + $bus->dispatch(new Envelope(new TestMessage('test'), [ + new ReceivedStamp('async'), + new TelemetryStamp(['traceparent' => '00-abcdef0123456789abcdef0123456789-0123456789abcdef-01']), + ])); + + static::assertNull( + $contextStorage->current()->activeSpan(), + 'the continued remote span must not remain active on the worker context after the message is handled', + ); + } + public function test_consumer_span_has_no_links_in_continue_mode(): void { $this->bootKernel([ @@ -421,8 +472,11 @@ public function test_consumer_attaches_producer_baggage_in_link_mode(): void $workerTracer = $telemetry->tracer('worker'); $workerSpan = $workerTracer->span('messenger:consume'); + $baggageSpy = new BaggageCapturingMiddleware($contextStorage); + $bus = new MessageBus([ new TracingMiddleware($telemetry, $contextStorage, $propagator, MessengerTracePropagation::Link), + $baggageSpy, new HandleMessageMiddleware(new HandlersLocator([ TestMessage::class => [new TestMessageHandler()], ])), @@ -433,9 +487,17 @@ public function test_consumer_attaches_producer_baggage_in_link_mode(): void new TelemetryStamp(['traceparent' => $traceparent, 'baggage' => 'user.id=42']), ])); - $baggage = $contextStorage->current()->baggage; - static::assertFalse($baggage->isEmpty()); - static::assertSame('42', $baggage->get('user.id')); + static::assertNotNull($baggageSpy->capturedDuringHandling); + static::assertSame( + '42', + $baggageSpy->capturedDuringHandling->get('user.id'), + 'producer baggage must be active while the message is handled', + ); + + static::assertTrue( + $contextStorage->current()->baggage->isEmpty(), + 'producer baggage must not leak onto the worker context after the message is handled', + ); $workerTracer->complete($workerSpan); } From 978f2469fd209b426b041ca1ca9e83d17ac28e5a Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Fri, 26 Jun 2026 11:57:58 +0200 Subject: [PATCH 02/10] feat(flow-php/symfony-telemetry-bundle): security instrumentation - decorate the request span with the authenticated user (configurable user.id/roles/email) - capture the user at kernel.controller and on LoginSuccessEvent - add UserSpanAttributeProvider extension point for custom attributes --- composer.json | 2 + composer.lock | 515 +++++++++++++++++- .../bridges/symfony-telemetry-bundle.md | 47 ++ .../symfony/telemetry-bundle/composer.json | 4 + .../TelemetryBundle/FlowTelemetryBundle.php | 76 +++ .../Messenger/TracingMiddleware.php | 1 - .../Security/SecuritySpanSubscriber.php | 67 +++ .../Security/UserAttributeResolver.php | 66 +++ .../Security/UserSpanAttributeProvider.php | 19 + .../config/instrumentation/security.php | 30 + .../Fixtures/Security/FakeAuthenticator.php | 49 ++ .../Security/StaticUserAttributeProvider.php | 24 + .../Fixtures/Security/TestSecurityUser.php | 48 ++ .../FlowTelemetryExtensionTest.php | 45 ++ .../Security/SecuritySpanSubscriberTest.php | 169 ++++++ .../DependencyInjection/ConfigurationTest.php | 46 ++ .../Security/SecuritySpanSubscriberTest.php | 75 +++ .../Security/UserAttributeResolverTest.php | 138 +++++ 18 files changed, 1419 insertions(+), 2 deletions(-) create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/SecuritySpanSubscriber.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserAttributeResolver.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserSpanAttributeProvider.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/security.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/FakeAuthenticator.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/StaticUserAttributeProvider.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/TestSecurityUser.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Security/SecuritySpanSubscriberTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/SecuritySpanSubscriberTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/UserAttributeResolverTest.php diff --git a/composer.json b/composer.json index e6df0c04b2..301a840a7e 100644 --- a/composer.json +++ b/composer.json @@ -73,6 +73,8 @@ "symfony/messenger": "^6.4 || ^7.4 || ^8.0", "symfony/process": "^7.4 || ^8.0", "symfony/routing": "^6.4 || ^7.4 || ^8.0", + "symfony/security-core": "^6.4 || ^7.4 || ^8.0", + "symfony/security-http": "^6.4 || ^7.4 || ^8.0", "symfony/web-profiler-bundle": "^6.4 || ^7.4 || ^8.0", "twig/twig": "^3.0" }, diff --git a/composer.lock b/composer.lock index 222461adba..6aaef33c96 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5decb886648567a12839423c61e5391b", + "content-hash": "1d149f2c5614aa4c43ff1a84fdd8c0a4", "packages": [ { "name": "async-aws/core", @@ -6574,6 +6574,82 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/password-hasher", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/password-hasher.git", + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/18a7d92126c95962f7efbcc9e421ba710a366847", + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/security-core": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PasswordHasher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides password hashing utilities", + "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], + "support": { + "source": "https://github.com/symfony/password-hasher/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/polyfill-php82", "version": "v1.38.1", @@ -6719,6 +6795,177 @@ ], "time": "2026-05-23T16:05:06+00:00" }, + { + "name": "symfony/property-access", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/routing", "version": "v7.4.13", @@ -6804,6 +7051,189 @@ ], "time": "2026-05-24T11:20:33+00:00" }, + { + "name": "symfony/security-core", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-core.git", + "reference": "25db686fcf2a3fe00e1cf6dcab1fcb7aac71ba9b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-core/zipball/25db686fcf2a3fe00e1cf6dcab1fcb7aac71ba9b", + "reference": "25db686fcf2a3fe00e1cf6dcab1fcb7aac71ba9b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/ldap": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/validator": "<6.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Core\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - Core Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-core/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/security-http", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "da3c28025a664e6a88e1af104a74457d99301161" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/da3c28025a664e6a88e1af104a74457d99301161", + "reference": "da3c28025a664e6a88e1af104a74457d99301161", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.3|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/clock": "<6.4", + "symfony/http-client-contracts": "<3.0", + "symfony/security-bundle": "<6.4", + "symfony/security-csrf": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "web-token/jwt-library": "^3.3.2|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Http\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - HTTP Integration", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-http/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:06:12+00:00" + }, { "name": "symfony/twig-bridge", "version": "v7.4.12", @@ -7009,6 +7439,89 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/type-info", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-22T15:21:55+00:00" + }, { "name": "symfony/web-profiler-bundle", "version": "v7.4.13", diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index b4a3ab8e49..c27883af39 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -1136,6 +1136,53 @@ exists, so disabling `http_kernel` or excluding the path produces none. The resolution and argument toggles install service decorators only when enabled, so they add zero overhead when off. +#### Security + +Decorates the request span with the authenticated user. Requires `symfony/security-core` (and +`symfony/security-http` to capture logins that happen during the request). + +```yaml +flow_telemetry: + instrumentation: + security: + enabled: true + fields: + id: + enabled: true # getUserIdentifier() (default ON) + attribute: user.id + roles: + enabled: false # getRoleNames() (default OFF) + attribute: user.roles + email: + enabled: false # read from a getter on the user object (default OFF) + attribute: user.email + getter: getEmail +``` + +The user is read from the token storage at `kernel.controller` and again on `LoginSuccessEvent`, so +both already-authenticated requests and mid-request logins are covered. Each field is independent and +its attribute key is configurable; `email` is taken from the configured getter and skipped when the +method is missing or returns a non-scalar. Anonymous requests are left untouched. + +To attach application-specific attributes, implement `UserSpanAttributeProvider` and tag the service +(the tag is autoconfigured). Returned attributes are merged onto the request span and win on key +collision: + +```php +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Security\UserSpanAttributeProvider; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; + +final class TenantAttributes implements UserSpanAttributeProvider +{ + public function attributes(TokenInterface $token): array + { + $user = $token->getUser(); + + return $user instanceof AppUser ? ['app.tenant_id' => $user->getTenantId()] : []; + } +} +``` + #### Console Traces console commands. diff --git a/src/bridge/symfony/telemetry-bundle/composer.json b/src/bridge/symfony/telemetry-bundle/composer.json index 7d3114c02c..d56a7f5fcd 100644 --- a/src/bridge/symfony/telemetry-bundle/composer.json +++ b/src/bridge/symfony/telemetry-bundle/composer.json @@ -37,6 +37,8 @@ "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", "symfony/messenger": "^6.4 || ^7.4 || ^8.0", "symfony/routing": "^6.4 || ^7.4 || ^8.0", + "symfony/security-core": "^6.4 || ^7.4 || ^8.0", + "symfony/security-http": "^6.4 || ^7.4 || ^8.0", "symfony/var-dumper": "^6.4 || ^7.4 || ^8.0", "symfony/web-profiler-bundle": "^6.4 || ^7.4 || ^8.0", "twig/twig": "^3.0" @@ -47,6 +49,8 @@ "flow-php/symfony-http-foundation-telemetry-bridge": "Required for HTTP trace context propagation (extract incoming / inject outgoing W3C trace headers)", "flow-php/telemetry-otlp-bridge": "Required for OTLP exporter support", "symfony/messenger": "Required for Messenger tracing middleware", + "symfony/security-core": "Required for authenticated-user span decoration (security instrumentation)", + "symfony/security-http": "Required to capture mid-request/programmatic logins (LoginSuccessEvent) in security instrumentation", "symfony/web-profiler-bundle": "Required for the dev-only Flow Telemetry Web Profiler panel", "twig/twig": "Required for Twig template tracing" }, diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php index efd926b859..329ee802be 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php @@ -21,6 +21,7 @@ use Flow\Bridge\Symfony\TelemetryBundle\Exception\RuntimeException; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Console\ConsoleLogOutputSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Messenger\MessengerTracePropagation; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Security\UserSpanAttributeProvider; use Flow\Bridge\Symfony\TelemetryBundle\Logger\ConsoleOutputLogProcessor; use Flow\Bridge\Symfony\TelemetryBundle\Logger\ConsoleVerbosityLevels; use Flow\Bridge\Symfony\TelemetryBundle\Resource\Detector\SymfonyDeploymentDetector; @@ -150,6 +151,8 @@ final class FlowTelemetryBundle extends AbstractBundle private const string PSR18_TRACEABLE_CLIENT = 'Flow\\Bridge\\Psr18\\Telemetry\\PSR18TraceableClient'; + private const string SECURITY_TOKEN_STORAGE_INTERFACE = 'Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface'; + private const string WEB_PROFILER_BUNDLE = 'Symfony\\Bundle\\WebProfilerBundle\\WebProfilerBundle'; #[Override] @@ -178,6 +181,9 @@ static function (ChildDefinition $definition, WithTelemetryChannel $attribute): ); $container->addCompilerPass(new ChannelLoggerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION); + $container->registerForAutoconfiguration(UserSpanAttributeProvider::class) + ->addTag('flow.telemetry.security.user_attribute_provider'); + if (interface_exists(self::HTTP_CLIENT_INTERFACE)) { $container->addCompilerPass(new HttpClientTelemetryPass()); } @@ -605,6 +611,42 @@ public function configure(DefinitionConfigurator $definition): void ->end() ->end() ->end() + ->arrayNode('security') + ->info('Decorate the request span with the authenticated user (requires symfony/security-core; login capture requires symfony/security-http)') + ->canBeEnabled() + ->children() + ->arrayNode('fields') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('id') + ->info('User identifier (TokenInterface::getUserIdentifier())') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->scalarNode('attribute')->info('Span attribute key')->defaultValue('user.id')->cannotBeEmpty()->end() + ->end() + ->end() + ->arrayNode('roles') + ->info('Token role names (TokenInterface::getRoleNames())') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->scalarNode('attribute')->info('Span attribute key')->defaultValue('user.roles')->cannotBeEmpty()->end() + ->end() + ->end() + ->arrayNode('email') + ->info('User email, read from a getter on the user object') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->scalarNode('attribute')->info('Span attribute key')->defaultValue('user.email')->cannotBeEmpty()->end() + ->scalarNode('getter')->info('User method to read the email from')->defaultValue('getEmail')->cannotBeEmpty()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() ->arrayNode('http_client') ->info('HTTP client request tracing configuration') ->canBeEnabled() @@ -2894,6 +2936,40 @@ private function registerInstrumentation(array $config, ContainerConfigurator $c $container->import(__DIR__ . '/Resources/config/instrumentation/twig.php'); } + $securityConfig = $config['security'] ?? []; + + if ((bool) ($securityConfig['enabled'] ?? false)) { + if (!interface_exists(self::SECURITY_TOKEN_STORAGE_INTERFACE)) { + throw new RuntimeException( + 'Security instrumentation requires symfony/security-core package. Install it via composer: composer require symfony/security-core', + ); + } + + $fields = is_array($securityConfig['fields'] ?? null) ? $securityConfig['fields'] : []; + $idField = is_array($fields['id'] ?? null) ? $fields['id'] : []; + $rolesField = is_array($fields['roles'] ?? null) ? $fields['roles'] : []; + $emailField = is_array($fields['email'] ?? null) ? $fields['email'] : []; + + $builder->setParameter( + 'flow.telemetry.security.field.id_attribute', + ($idField['enabled'] ?? true) === true ? ($idField['attribute'] ?? 'user.id') : null, + ); + $builder->setParameter( + 'flow.telemetry.security.field.roles_attribute', + ($rolesField['enabled'] ?? false) === true ? ($rolesField['attribute'] ?? 'user.roles') : null, + ); + $builder->setParameter( + 'flow.telemetry.security.field.email_attribute', + ($emailField['enabled'] ?? false) === true ? ($emailField['attribute'] ?? 'user.email') : null, + ); + $builder->setParameter( + 'flow.telemetry.security.field.email_getter', + $emailField['getter'] ?? 'getEmail', + ); + + $container->import(__DIR__ . '/Resources/config/instrumentation/security.php'); + } + $this->registerParameterOnlyInstrumentation($config, $builder); } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php index b93612f67b..7f5ce7d595 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php @@ -7,7 +7,6 @@ use DateTimeImmutable; use Flow\Telemetry\Context\Context; use Flow\Telemetry\Context\ContextStorage; -use Flow\Telemetry\Context\Scope; use Flow\Telemetry\PackageVersion; use Flow\Telemetry\Propagation\PropagationContext; use Flow\Telemetry\Propagation\Propagator; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/SecuritySpanSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/SecuritySpanSubscriber.php new file mode 100644 index 0000000000..e407574199 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/SecuritySpanSubscriber.php @@ -0,0 +1,67 @@ + ['onController', 0], + ]; + + if (class_exists(LoginSuccessEvent::class)) { + $events[LoginSuccessEvent::class] = ['onLoginSuccess', 0]; + } + + return $events; + } + + public function onController(ControllerEvent $event): void + { + $token = $this->tokenStorage->getToken(); + + if ($token === null) { + return; + } + + $this->decorate($event->getRequest(), $token); + } + + public function onLoginSuccess(LoginSuccessEvent $event): void + { + $this->decorate($event->getRequest(), $event->getAuthenticatedToken()); + } + + private function decorate(Request $request, #[SensitiveParameter] TokenInterface $token): void + { + // @mago-expect analysis:mixed-assignment + if (!($span = $request->attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE)) instanceof Span) { + return; + } + + foreach ($this->resolver->resolve($token) as $key => $value) { + $span->setAttribute($key, $value); + } + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserAttributeResolver.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserAttributeResolver.php new file mode 100644 index 0000000000..a5ded772dd --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserAttributeResolver.php @@ -0,0 +1,66 @@ + $providers + */ + public function __construct( + private iterable $providers, + private ?string $idAttribute, + private ?string $rolesAttribute, + private ?string $emailAttribute, + private string $emailGetter, + ) {} + + /** + * @return array> + */ + public function resolve(#[SensitiveParameter] TokenInterface $token): array + { + $attributes = []; + + if ($this->idAttribute !== null && ($identifier = $token->getUserIdentifier()) !== '') { + $attributes[$this->idAttribute] = $identifier; + } + + if ($this->rolesAttribute !== null && ($roles = $token->getRoleNames()) !== []) { + $attributes[$this->rolesAttribute] = $roles; + } + + if ($this->emailAttribute !== null) { + $user = $token->getUser(); + + if ($user !== null && method_exists($user, $this->emailGetter)) { + // @mago-expect analysis:mixed-assignment + // @mago-expect analysis:string-member-selector + $value = $user->{$this->emailGetter}(); + + if (is_scalar($value)) { + $attributes[$this->emailAttribute] = $value; + } + } + } + + foreach ($this->providers as $provider) { + foreach ($provider->attributes($token) as $key => $value) { + $attributes[$key] = $value; + } + } + + return $attributes; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserSpanAttributeProvider.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserSpanAttributeProvider.php new file mode 100644 index 0000000000..754fc165a7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Security/UserSpanAttributeProvider.php @@ -0,0 +1,19 @@ +> + */ + public function attributes(#[SensitiveParameter] TokenInterface $token): array; +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/security.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/security.php new file mode 100644 index 0000000000..08d65813d7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/security.php @@ -0,0 +1,30 @@ +services(); + + $services->set('flow.telemetry.security.user_attribute_resolver', UserAttributeResolver::class)->args([ + tagged_iterator('flow.telemetry.security.user_attribute_provider'), + '%flow.telemetry.security.field.id_attribute%', + '%flow.telemetry.security.field.roles_attribute%', + '%flow.telemetry.security.field.email_attribute%', + '%flow.telemetry.security.field.email_getter%', + ]); + + $services + ->set('flow.telemetry.security.span_subscriber', SecuritySpanSubscriber::class) + ->args([ + service('security.token_storage'), + service('flow.telemetry.security.user_attribute_resolver'), + ]) + ->tag('kernel.event_subscriber'); +}; diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/FakeAuthenticator.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/FakeAuthenticator.php new file mode 100644 index 0000000000..25c6f71f53 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/FakeAuthenticator.php @@ -0,0 +1,49 @@ +> $attributes + */ + public function __construct( + private readonly array $attributes, + ) {} + + public function attributes(#[SensitiveParameter] TokenInterface $token): array + { + return $this->attributes; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/TestSecurityUser.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/TestSecurityUser.php new file mode 100644 index 0000000000..5742261323 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Security/TestSecurityUser.php @@ -0,0 +1,48 @@ + $roles + */ + public function __construct( + private readonly string $identifier, + private readonly ?string $email = null, + private readonly array $roles = [], + ) {} + + public function eraseCredentials(): void {} + + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @return array + */ + public function getProfile(): array + { + return ['email' => $this->email]; + } + + public function getRoles(): array + { + return $this->roles; + } + + /** + * @return non-empty-string + */ + public function getUserIdentifier(): string + { + return $this->identifier; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php index ec099ac966..d0a830198a 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php @@ -532,6 +532,51 @@ public function test_async_curl_transport_tick_subscriber_is_registered_when_mes static::assertTrue($definition->hasTag('kernel.event_subscriber')); } + public function test_security_instrumentation_registers_subscriber_and_field_parameters(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.build_dir', sys_get_temp_dir()); + $extension = (new FlowTelemetryBundle())->getContainerExtension(); + assert($extension !== null); + $extension->load([[ + 'resource' => [], + 'instrumentation' => [ + 'security' => [ + 'enabled' => true, + 'fields' => [ + 'roles' => ['enabled' => true], + 'email' => ['enabled' => false], + ], + ], + ], + ]], $container); + + static::assertTrue($container->hasDefinition('flow.telemetry.security.user_attribute_resolver')); + static::assertTrue( + $container->getDefinition('flow.telemetry.security.span_subscriber')->hasTag('kernel.event_subscriber'), + ); + + static::assertSame('user.id', $container->getParameter('flow.telemetry.security.field.id_attribute')); + static::assertSame('user.roles', $container->getParameter('flow.telemetry.security.field.roles_attribute')); + static::assertNull($container->getParameter('flow.telemetry.security.field.email_attribute')); + static::assertSame('getEmail', $container->getParameter('flow.telemetry.security.field.email_getter')); + } + + public function test_security_instrumentation_is_not_registered_by_default(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.build_dir', sys_get_temp_dir()); + $extension = (new FlowTelemetryBundle())->getContainerExtension(); + assert($extension !== null); + $extension->load([['resource' => []]], $container); + + static::assertFalse($container->hasDefinition('flow.telemetry.security.span_subscriber')); + } + public function test_custom_exporter_via_service(): void { $this->bootKernel([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Security/SecuritySpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Security/SecuritySpanSubscriberTest.php new file mode 100644 index 0000000000..b0caf61f30 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Security/SecuritySpanSubscriberTest.php @@ -0,0 +1,169 @@ +bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => [ + 'utf8' => true, + 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php', + ], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + 'security' => [ + 'enabled' => true, + 'fields' => [ + 'roles' => ['enabled' => true], + 'email' => ['enabled' => true], + ], + ], + ], + ]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container + ->setDefinition('security.token_storage', new Definition(TokenStorage::class)) + ->setPublic(true); + $container + ->setDefinition('test.user_attributes', new Definition(StaticUserAttributeProvider::class)) + ->setArgument(0, ['app.tenant_id' => 'acme']) + ->addTag('flow.telemetry.security.user_attribute_provider'); + }); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + /** @var TokenStorageInterface $tokenStorage */ + $tokenStorage = $container->get('security.token_storage'); + $user = new TestSecurityUser('alice', 'alice@example.com', ['ROLE_USER', 'ROLE_ADMIN']); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + + $request = Request::create('/test', 'GET'); + $kernel->terminate($request, $kernel->handle($request)); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $serverSpan = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + + $attributes = $serverSpan->attributes(); + static::assertSame('alice', $attributes['user.id']); + static::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $attributes['user.roles']); + static::assertSame('alice@example.com', $attributes['user.email']); + static::assertSame('acme', $attributes['app.tenant_id']); + } + + public function test_decorates_request_span_on_login_success(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => false], + 'console' => ['enabled' => false], + 'messenger' => false, + 'security' => ['enabled' => true], + ], + ]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container + ->setDefinition('security.token_storage', new Definition(TokenStorage::class)) + ->setPublic(true); + }); + }, + ]); + + $this->getContainer(); + + $telemetry = $this->symfonyContext()->getService(Telemetry::class, Telemetry::class); + $subscriber = $this->symfonyContext()->getService( + 'flow.telemetry.security.span_subscriber', + SecuritySpanSubscriber::class, + ); + + $span = $telemetry->tracer('test')->span('GET /test', SpanKind::SERVER); + + $request = Request::create('/test', 'GET'); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $span); + + $user = new TestSecurityUser('bob'); + + $subscriber->onLoginSuccess( + new LoginSuccessEvent( + new FakeAuthenticator(), + new SelfValidatingPassport(new UserBadge('bob', static fn(): TestSecurityUser => $user)), + new UsernamePasswordToken($user, 'main'), + $request, + null, + 'main', + ), + ); + + static::assertSame('bob', $span->attributes()['user.id']); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php index af4b3a53c3..2ad361efa7 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php @@ -775,6 +775,52 @@ public function test_messenger_link_to_worker_defaults_to_true_and_is_configurab static::assertFalse($disabled['instrumentation']['messenger']['link_to_worker']); } + public function test_security_fields_default_to_semconv_keys_and_are_configurable(): void + { + $default = $this->context->processConfig([ + 'resource' => [], + 'instrumentation' => ['security' => ['enabled' => true]], + ]); + $fields = $default['instrumentation']['security']['fields']; + + static::assertTrue($fields['id']['enabled']); + static::assertSame('user.id', $fields['id']['attribute']); + static::assertFalse($fields['roles']['enabled']); + static::assertSame('user.roles', $fields['roles']['attribute']); + static::assertFalse($fields['email']['enabled']); + static::assertSame('user.email', $fields['email']['attribute']); + static::assertSame('getEmail', $fields['email']['getter']); + + $custom = $this->context->processConfig([ + 'resource' => [], + 'instrumentation' => [ + 'security' => [ + 'enabled' => true, + 'fields' => [ + 'id' => ['enabled' => false, 'attribute' => 'app.actor'], + 'roles' => ['enabled' => true, 'attribute' => 'app.actor_roles'], + 'email' => ['enabled' => true, 'getter' => 'getEmailAddress'], + ], + ], + ], + ]); + $customFields = $custom['instrumentation']['security']['fields']; + + static::assertFalse($customFields['id']['enabled']); + static::assertSame('app.actor', $customFields['id']['attribute']); + static::assertTrue($customFields['roles']['enabled']); + static::assertSame('app.actor_roles', $customFields['roles']['attribute']); + static::assertTrue($customFields['email']['enabled']); + static::assertSame('getEmailAddress', $customFields['email']['getter']); + } + + public function test_security_is_disabled_by_default(): void + { + $config = $this->context->processConfig(['resource' => []]); + + static::assertFalse($config['instrumentation']['security']['enabled']); + } + public function test_max_batch_age_is_parsed_for_span_metric_and_log_processors(): void { $config = $this->context->processConfig([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/SecuritySpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/SecuritySpanSubscriberTest.php new file mode 100644 index 0000000000..b0ea6ae269 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/SecuritySpanSubscriberTest.php @@ -0,0 +1,75 @@ +tracer('test')->span('GET /test', SpanKind::SERVER); + + $request = Request::create('/test', 'GET'); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $span); + + $subscriber = new SecuritySpanSubscriber( + new TokenStorage(), + new UserAttributeResolver([], 'user.id', null, null, 'getEmail'), + ); + + $subscriber->onController( + new ControllerEvent( + $this->createMock(HttpKernelInterface::class), + static fn(): null => null, + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + + static::assertArrayNotHasKey('user.id', $span->attributes()); + } + + public function test_does_nothing_when_request_has_no_span(): void + { + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken(new UsernamePasswordToken(new TestSecurityUser('alice'), 'main')); + + $request = Request::create('/test', 'GET'); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, 'not-a-span'); + + $subscriber = new SecuritySpanSubscriber( + $tokenStorage, + new UserAttributeResolver([], 'user.id', null, null, 'getEmail'), + ); + + $subscriber->onController( + new ControllerEvent( + $this->createMock(HttpKernelInterface::class), + static fn(): null => null, + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + + static::assertSame('not-a-span', $request->attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE)); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/UserAttributeResolverTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/UserAttributeResolverTest.php new file mode 100644 index 0000000000..1bbd20a0cf --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Security/UserAttributeResolverTest.php @@ -0,0 +1,138 @@ + 'alice'], $resolver->resolve($token)); + } + + public function test_omits_user_id_when_disabled(): void + { + $resolver = new UserAttributeResolver([], null, null, null, 'getEmail'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice'), 'main'); + + static::assertSame([], $resolver->resolve($token)); + } + + public function test_omits_empty_user_identifier(): void + { + $resolver = new UserAttributeResolver([], 'user.id', null, null, 'getEmail'); + + static::assertSame([], $resolver->resolve(new NullToken())); + } + + public function test_includes_roles_when_enabled(): void + { + $resolver = new UserAttributeResolver([], null, 'user.roles', null, 'getEmail'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice'), 'main', ['ROLE_USER', 'ROLE_ADMIN']); + + static::assertSame(['user.roles' => ['ROLE_USER', 'ROLE_ADMIN']], $resolver->resolve($token)); + } + + public function test_omits_roles_when_empty(): void + { + $resolver = new UserAttributeResolver([], null, 'user.roles', null, 'getEmail'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice'), 'main', []); + + static::assertSame([], $resolver->resolve($token)); + } + + public function test_includes_email_from_getter(): void + { + $resolver = new UserAttributeResolver([], null, null, 'user.email', 'getEmail'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice', 'alice@example.com'), 'main'); + + static::assertSame(['user.email' => 'alice@example.com'], $resolver->resolve($token)); + } + + public function test_omits_email_when_getter_missing(): void + { + $resolver = new UserAttributeResolver([], null, null, 'user.email', 'getNonExistentField'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice', 'alice@example.com'), 'main'); + + static::assertSame([], $resolver->resolve($token)); + } + + public function test_omits_email_when_getter_returns_non_scalar(): void + { + $resolver = new UserAttributeResolver([], null, null, 'user.email', 'getProfile'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice', 'alice@example.com'), 'main'); + + static::assertSame([], $resolver->resolve($token)); + } + + public function test_omits_email_when_value_is_null(): void + { + $resolver = new UserAttributeResolver([], null, null, 'user.email', 'getEmail'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice'), 'main'); + + static::assertSame([], $resolver->resolve($token)); + } + + public function test_uses_custom_attribute_keys(): void + { + $resolver = new UserAttributeResolver([], 'app.actor', 'app.actor_roles', null, 'getEmail'); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice'), 'main', ['ROLE_USER']); + + static::assertSame(['app.actor' => 'alice', 'app.actor_roles' => ['ROLE_USER']], $resolver->resolve($token)); + } + + public function test_merges_provider_attributes(): void + { + $resolver = new UserAttributeResolver( + [new StaticUserAttributeProvider(['app.tenant_id' => 'acme', 'app.plan' => 'pro'])], + 'user.id', + null, + null, + 'getEmail', + ); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice'), 'main'); + + static::assertSame( + ['user.id' => 'alice', 'app.tenant_id' => 'acme', 'app.plan' => 'pro'], + $resolver->resolve($token), + ); + } + + public function test_provider_attributes_override_builtin_on_key_collision(): void + { + $resolver = new UserAttributeResolver( + [new StaticUserAttributeProvider(['user.id' => 'overridden'])], + 'user.id', + null, + null, + 'getEmail', + ); + + $token = new UsernamePasswordToken(new TestSecurityUser('alice'), 'main'); + + static::assertSame(['user.id' => 'overridden'], $resolver->resolve($token)); + } +} From ca3bb03d6d658c3017c2e156ddca13401486a658 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sat, 27 Jun 2026 10:06:39 +0200 Subject: [PATCH 03/10] feat(flow-php/symfony-telemetry-bundle): enrich the web profiler panel - show resource attributes, instrumentation scopes, and configured instruments (collapsible) - capture the in-flight HTTP server span so the root request span appears in the panel --- .../bridges/symfony-telemetry-bundle.md | 8 +- .../TelemetryBundle/FlowTelemetryBundle.php | 43 +++++ .../Profiler/FlowTelemetryDataCollector.php | 152 +++++++++++++++- .../Resources/config/profiler.php | 1 + .../views/Collector/telemetry.html.twig | 78 +++++++++ .../Profiler/FlowTelemetryProfilerTest.php | 18 +- .../FlowTelemetryDataCollectorTest.php | 162 ++++++++++++++++++ 7 files changed, 451 insertions(+), 11 deletions(-) diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index c27883af39..bc701bcf20 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -1326,9 +1326,11 @@ flow_telemetry: ### Web Profiler Adds a **Flow Telemetry** panel to the Symfony Web Profiler showing the signals captured during the -current request — spans as a timeline waterfall, metrics, and (when `capture_logs` is enabled) logs — -so telemetry is visible locally without an external OTLP backend. The toolbar shows the total signal -count; the panel breaks it down per signal type. +current request — the resource attributes, spans as a timeline waterfall, the instrumentation scopes +that produced them, metrics, and (when `capture_logs` is enabled) logs — so telemetry is visible +locally without an external OTLP backend. The toolbar shows the total signal count; the panel breaks +it down per signal type. It also lists the **configured instruments** (named tracers/meters/loggers +and their scope attributes) so those are visible even when an instrument did not emit this request. ```yaml flow_telemetry: diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php index 329ee802be..480b541bef 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php @@ -3022,10 +3022,53 @@ private function registerProfiler(array $config, ContainerConfigurator $containe $builder->setParameter('flow.telemetry.profiler.captured_exporters', array_values($capturedIds)); $builder->setParameter('flow.telemetry.profiler.capture_logs', $captureLogs); + $builder->setParameter('flow.telemetry.profiler.configured_instruments', $this->configuredInstruments($config)); $container->import(__DIR__ . '/Resources/config/profiler.php'); } + /** + * Flatten the named tracer/meter/logger config into rows for the profiler panel, so configured + * scope attributes stay visible even when an instrument did not emit a signal during the request. + * + * @param array $config + * + * @return list}> + */ + private function configuredInstruments(array $config): array + { + $rows = []; + + $groups = [ + 'tracer' => $config['tracers'] ?? null, + 'meter' => $config['meters'] ?? null, + 'logger' => $config['loggers'] ?? null, + ]; + + foreach ($groups as $type => $instruments) { + if (!is_array($instruments)) { + continue; + } + + foreach ($instruments as $name => $instrumentConfig) { + if (!is_array($instrumentConfig)) { + continue; + } + + $attributes = is_array($instrumentConfig['attributes'] ?? null) ? $instrumentConfig['attributes'] : []; + + $rows[] = [ + 'type' => $type, + 'name' => (string) $name, + 'version' => is_string($instrumentConfig['version'] ?? null) ? $instrumentConfig['version'] : 'unknown', + 'attributes' => is_array($attributes['scope'] ?? null) ? $attributes['scope'] : [], + ]; + } + } + + return $rows; + } + /** * @param array $providerConfig * diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php index 11c3cc71b4..c04f2c7698 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Profiler/FlowTelemetryDataCollector.php @@ -4,6 +4,8 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Profiler; +use DateTimeImmutable; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Telemetry\Logger\LogEntry; use Flow\Telemetry\Logger\Severity; use Flow\Telemetry\Meter\Metric; @@ -26,15 +28,37 @@ * @phpstan-type SpanRow array{name: string, kind: string, durationMs: null|float, startMs: float, offsetMs: float, startTime: string, scope: string, spanId: string, parentSpanId: null|string, depth: int, statusCode: int, statusDescription: null|string, attributes: array, eventCount: int} * @phpstan-type MetricRow array{name: string, type: string, value: float|int, unit: null|string, scope: string, attributes: array} * @phpstan-type LogRow array{severity: string, severityCode: int, message: string, scope: string, time: string, attributes: \Symfony\Component\VarDumper\Cloner\Data, hasAttributes: bool} + * @phpstan-type ScopeRow array{name: string, version: string, attributes: array} + * @phpstan-type InstrumentRow array{type: string, name: string, version: string, attributes: array} */ final class FlowTelemetryDataCollector extends DataCollector implements LateDataCollectorInterface { + private ?Span $requestSpan = null; + + private ?DateTimeImmutable $requestSpanEnd = null; + + /** + * @param list $configuredInstruments + */ public function __construct( private readonly Telemetry $telemetry, private readonly MemoryExporter $exporter, + private readonly array $configuredInstruments = [], ) {} - public function collect(Request $request, Response $response, ?Throwable $exception = null): void {} + public function collect(Request $request, Response $response, ?Throwable $exception = null): void + { + // @mago-expect analysis:mixed-assignment + $span = $request->attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE); + + // The HTTP server span completes on kernel.terminate, after the profiler saves the profile, so it + // never reaches the store. Capture it here while still open and snapshot the response moment as its + // display end; the real span keeps ending (and exporting) on terminate untouched. + if ($span instanceof Span) { + $this->requestSpan = $span; + $this->requestSpanEnd = new DateTimeImmutable(); + } + } public function lateCollect(): void { @@ -42,7 +66,7 @@ public function lateCollect(): void // profiler saves the profile. $this->telemetry->flush(); - $spans = $this->normalizeSpans($this->exporter->spans()); + $spans = $this->normalizeSpans($this->exporter->spans(), $this->requestSpan, $this->requestSpanEnd); $timelineDurationMs = 0.0; @@ -55,12 +79,17 @@ public function lateCollect(): void 'metrics' => $this->normalizeMetrics($this->exporter->metrics()), 'logs' => $this->normalizeLogs($this->exporter->logs()), 'timelineDurationMs' => $timelineDurationMs, + 'resourceAttributes' => $this->resourceAttributes(), + 'scopes' => $this->normalizeScopes(), + 'configuredInstruments' => $this->configuredInstruments, ]; } public function reset(): void { $this->data = []; + $this->requestSpan = null; + $this->requestSpanEnd = null; $this->exporter->reset(); } @@ -116,6 +145,35 @@ public function getSignalCount(): int return $this->getSpanCount() + $this->getMetricCount() + $this->getLogCount(); } + /** + * @return array + */ + public function getResourceAttributes(): array + { + // @mago-expect analysis:mixed-return-statement + return $this->data['resourceAttributes'] ?? []; + } + + /** + * @return list + */ + public function getScopes(): array + { + // @mago-expect analysis:mixed-return-statement + return $this->data['scopes'] ?? []; + } + + /** + * Named tracers/meters/loggers from configuration, shown regardless of whether they emitted a signal. + * + * @return list + */ + public function getConfiguredInstruments(): array + { + // @mago-expect analysis:mixed-return-statement + return $this->data['configuredInstruments'] ?? []; + } + public function getTimelineDurationMs(): float { // @mago-expect analysis:mixed-assignment @@ -129,8 +187,12 @@ public function getTimelineDurationMs(): float * * @return list */ - private function normalizeSpans(array $spans): array + private function normalizeSpans(array $spans, ?Span $inFlight = null, ?DateTimeImmutable $inFlightEnd = null): array { + if ($inFlight !== null && $inFlightEnd !== null && !$this->isCaptured($inFlight, $spans)) { + $spans[] = $inFlight; + } + $parents = []; foreach ($spans as $span) { @@ -145,12 +207,18 @@ private function normalizeSpans(array $spans): array $status = $span->status()?->normalize(); $spanId = $context['spanId']['hex']; $start = $span->startTime(); + $startMs = ($start->getTimestamp() * 1000.0) + ((int) $start->format('u') / 1000.0); + + $durationMs = + $span === $inFlight && $inFlightEnd !== null + ? ($inFlightEnd->getTimestamp() * 1000.0) + ((int) $inFlightEnd->format('u') / 1000.0) - $startMs + : $span->duration(); $rows[] = [ 'name' => $span->name(), 'kind' => $span->kind()->value, - 'durationMs' => $span->duration(), - 'startMs' => ($start->getTimestamp() * 1000.0) + ((int) $start->format('u') / 1000.0), + 'durationMs' => $durationMs, + 'startMs' => $startMs, 'offsetMs' => 0.0, 'startTime' => $start->format('H:i:s.v'), 'scope' => $span->scope()->name, @@ -178,6 +246,22 @@ private function normalizeSpans(array $spans): array return $rows; } + /** + * @param array $spans + */ + private function isCaptured(Span $span, array $spans): bool + { + $spanId = $span->context()->normalize()['spanId']['hex']; + + foreach ($spans as $captured) { + if ($captured->context()->normalize()['spanId']['hex'] === $spanId) { + return true; + } + } + + return false; + } + /** * @param array $parents */ @@ -244,4 +328,62 @@ private function normalizeLogs(array $logs): array return $rows; } + + /** + * The resource is shared by every signal from the provider, so the first available one describes the request. + * + * @return array + */ + private function resourceAttributes(): array + { + foreach ($this->exporter->spans() as $span) { + return $span->resource()->normalize()['attributes']; + } + + foreach ($this->exporter->metrics() as $metric) { + return $metric->resource->normalize()['attributes']; + } + + foreach ($this->exporter->logs() as $log) { + return $log->resource->normalize()['attributes']; + } + + return []; + } + + /** + * Distinct instrumentation scopes across all signals, identified by name + version. + * + * @return list + */ + private function normalizeScopes(): array + { + $instrumentationScopes = []; + + foreach ($this->exporter->spans() as $span) { + $instrumentationScopes[] = $span->scope(); + } + + foreach ($this->exporter->metrics() as $metric) { + $instrumentationScopes[] = $metric->scope; + } + + foreach ($this->exporter->logs() as $log) { + $instrumentationScopes[] = $log->scope; + } + + $scopes = []; + + foreach ($instrumentationScopes as $scope) { + $scopes[$scope->name . '@' . $scope->version] ??= [ + 'name' => $scope->name, + 'version' => $scope->version, + 'attributes' => $scope->attributes->normalize(), + ]; + } + + usort($scopes, static fn(array $a, array $b): int => $a['name'] <=> $b['name']); + + return $scopes; + } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/profiler.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/profiler.php index f1b3b41ecc..40392b06f5 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/profiler.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/profiler.php @@ -17,6 +17,7 @@ $services->set('flow.telemetry.profiler.collector', FlowTelemetryDataCollector::class)->args([ service(Telemetry::class), service('flow.telemetry.profiler.store'), + '%flow.telemetry.profiler.configured_instruments%', ])->tag('data_collector', [ 'id' => 'flow_telemetry', 'template' => '@FlowTelemetry/Collector/telemetry.html.twig', diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig index 9c6610e825..652ef47990 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/views/Collector/telemetry.html.twig @@ -80,6 +80,84 @@ {% endif %} + {% if collector.resourceAttributes is not empty %} +

Resource

+ Show resource attributes ({{ collector.resourceAttributes|length }}) + + {% endif %} + + {% if collector.scopes is not empty %} +

Scopes

+ Show scopes ({{ collector.scopes|length }}) + + {% endif %} + + {% if collector.configuredInstruments is not empty %} +

Configured instruments

+ Show configured instruments ({{ collector.configuredInstruments|length }}) + + {% endif %} +

Timeline

{% if collector.spans is empty %} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Profiler/FlowTelemetryProfilerTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Profiler/FlowTelemetryProfilerTest.php index c865be3051..16377ad9de 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Profiler/FlowTelemetryProfilerTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Profiler/FlowTelemetryProfilerTest.php @@ -109,8 +109,6 @@ public function test_panel_renders_captured_spans_in_the_profiler(): void '_controller' => TestController::class . '::index', ])); - // Emit a span that ends before the request handles, so it is flushed into the store at - // profiler-save time (the http_kernel root span ends later and is intentionally not captured). /** @var Telemetry $telemetry */ $telemetry = $container->get(Telemetry::class); $telemetry->tracer('app')->trace('controller_work', static fn(): ?string => null); @@ -129,6 +127,14 @@ public function test_panel_renders_captured_spans_in_the_profiler(): void static::assertStringContainsString('Flow Telemetry', $html); static::assertStringContainsString('controller_work', $html); static::assertStringContainsString('Timeline', $html); + // The HTTP server span only completes on kernel.terminate (after the profiler saves), so it is + // captured as an in-flight snapshot instead of being missing from the panel. + static::assertStringContainsString('GET /test', $html); + static::assertStringContainsString('Resource', $html); + static::assertStringContainsString('panel-test-service', $html); + static::assertStringContainsString('Scopes', $html); + static::assertStringContainsString('Configured instruments', $html); + static::assertStringContainsString('flow.component', $html); } public function test_spans_are_captured_after_a_request(): void @@ -279,12 +285,18 @@ private function frameworkConfig(): array private function flowConfig(array $profilerConfig): array { return [ - 'resource' => [], + 'resource' => ['custom' => ['service.name' => 'panel-test-service']], 'exporters' => ['memory' => ['memory' => null]], 'tracer_provider' => [ 'processor' => ['type' => 'batching', 'batch_size' => 100, 'exporter' => 'memory'], ], 'profiler' => $profilerConfig, + 'tracers' => [ + 'checkout' => [ + 'version' => '2.0.0', + 'attributes' => ['scope' => ['flow.component' => 'checkout']], + ], + ], 'instrumentation' => [ 'http_kernel' => ['enabled' => true], 'console' => ['enabled' => false], diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php index 076d96739b..742b9f56e0 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Profiler/FlowTelemetryDataCollectorTest.php @@ -5,6 +5,7 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Tests\Unit\Profiler; use DateTimeImmutable; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Profiler\FlowTelemetryDataCollector; use Flow\Telemetry\Context\SpanId; use Flow\Telemetry\Context\TraceId; @@ -19,8 +20,12 @@ use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use function array_filter; use function array_map; +use function array_values; use function Flow\Telemetry\DSL\telemetry; use function sprintf; @@ -188,6 +193,163 @@ public function test_empty_store_yields_no_rows(): void static::assertSame(0.0, $collector->getTimelineDurationMs()); } + public function test_resource_attributes_are_read_from_spans(): void + { + $collector = $this->collector($this->storeWithSpanTree()); + + $collector->lateCollect(); + + static::assertSame( + ['service.name' => 'test-service', 'service.version' => '1.0.0'], + $collector->getResourceAttributes(), + ); + } + + public function test_resource_attributes_fall_back_to_metrics(): void + { + $store = new MemoryExporter(); + $store->export(Signals::metrics([MetricMother::counter('app.requests', 1)])); + $collector = $this->collector($store); + + $collector->lateCollect(); + + static::assertSame('test-service', $collector->getResourceAttributes()['service.name']); + } + + public function test_resource_attributes_fall_back_to_logs(): void + { + $store = new MemoryExporter(); + $store->export(Signals::logs([LogEntryMother::create('hello', Severity::INFO)])); + $collector = $this->collector($store); + + $collector->lateCollect(); + + static::assertSame('test-service', $collector->getResourceAttributes()['service.name']); + } + + public function test_resource_attributes_are_empty_without_signals(): void + { + $collector = $this->collector(new MemoryExporter()); + + $collector->lateCollect(); + + static::assertSame([], $collector->getResourceAttributes()); + } + + public function test_late_collect_collects_distinct_scopes_sorted_by_name(): void + { + $store = $this->storeWithSpanTree(); + $store->export(Signals::metrics([MetricMother::counter('app.requests', 1)])); + $store->export(Signals::logs([LogEntryMother::create('hello', Severity::INFO)])); + $collector = $this->collector($store); + + $collector->lateCollect(); + + static::assertSame( + [ + ['name' => 'test', 'version' => '1.0.0', 'attributes' => []], + ['name' => 'test-instrumentation', 'version' => '1.0.0', 'attributes' => []], + ], + $collector->getScopes(), + ); + } + + public function test_scopes_are_empty_without_signals(): void + { + $collector = $this->collector(new MemoryExporter()); + + $collector->lateCollect(); + + static::assertSame([], $collector->getScopes()); + } + + public function test_configured_instruments_are_exposed(): void + { + $instruments = [ + [ + 'type' => 'tracer', + 'name' => 'checkout', + 'version' => '1.0.0', + 'attributes' => ['flow.component' => 'checkout'], + ], + ['type' => 'logger', 'name' => 'app', 'version' => 'unknown', 'attributes' => []], + ]; + $collector = new FlowTelemetryDataCollector( + telemetry(ResourceMother::default()), + new MemoryExporter(), + $instruments, + ); + + $collector->lateCollect(); + + static::assertSame($instruments, $collector->getConfiguredInstruments()); + } + + public function test_configured_instruments_default_to_empty(): void + { + $collector = $this->collector(new MemoryExporter()); + + $collector->lateCollect(); + + static::assertSame([], $collector->getConfiguredInstruments()); + } + + public function test_in_flight_request_span_is_captured_for_the_panel(): void + { + $collector = $this->collector(new MemoryExporter()); + + $request = Request::create('/orders', 'GET'); + $span = SpanMother::create( + 'GET /orders', + null, + null, + null, + SpanKind::SERVER, + new DateTimeImmutable('-100 milliseconds'), + ); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $span); + + $collector->collect($request, new Response()); + $collector->lateCollect(); + + $row = array_values(array_filter( + $collector->getSpans(), + static fn(array $r): bool => $r['name'] === 'GET /orders', + )); + + static::assertCount(1, $row); + static::assertNotNull($row[0]['durationMs']); + static::assertGreaterThan(0.0, $row[0]['durationMs']); + } + + public function test_request_span_is_not_duplicated_when_already_exported(): void + { + $start = new DateTimeImmutable('2024-01-01T00:00:00.000000+00:00'); + $span = SpanMother::create( + 'GET /dup', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::ROOT_ID), + null, + SpanKind::SERVER, + $start, + )->end($start->modify('+10000 microseconds')); + + $store = new MemoryExporter(); + $store->export(Signals::traces([$span])); + + $collector = $this->collector($store); + + $request = Request::create('/dup', 'GET'); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $span); + + $collector->collect($request, new Response()); + $collector->lateCollect(); + + $matching = array_filter($collector->getSpans(), static fn(array $r): bool => $r['name'] === 'GET /dup'); + + static::assertCount(1, $matching); + } + private function collector(MemoryExporter $store): FlowTelemetryDataCollector { return new FlowTelemetryDataCollector(telemetry(ResourceMother::default()), $store); From 1d8628bde45843af92af0d2d490bcf0c64f84160 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sun, 28 Jun 2026 13:05:04 +0200 Subject: [PATCH 04/10] feat(flow-php/symfony-telemetry-bundle): trace context propagation and route-aware span namin - outgoing trace context propagation via TraceContextProvider, Twig helpers and trace-context URL generator - extract incoming context from the URL query string (context_propagation_query, off by default) - name request spans by route path template or route name (route_naming), with sub-request/controller fallbacks --- .../bridges/symfony-telemetry-bundle.md | 94 +++++- .../HttpFoundationTelemetry/QueryCarrier.php | 42 +++ .../Tests/Unit/QueryCarrierTest.php | 57 ++++ .../Compiler/TraceContextUrlGeneratorPass.php | 31 ++ .../TelemetryBundle/FlowTelemetryBundle.php | 36 +++ .../HttpKernel/HttpKernelSpanSubscriber.php | 39 ++- .../HttpKernel/RouteNaming.php | 22 ++ .../Propagation/TraceContextProvider.php | 68 +++++ .../config/instrumentation/http_kernel.php | 3 + .../Resources/config/propagation.php | 19 ++ .../Resources/config/twig_propagation.php | 16 + .../Resources/config/url_propagation.php | 19 ++ .../Routing/TraceContextUrlGenerator.php | 37 +++ .../Twig/TelemetryPropagationExtension.php | 51 ++++ .../Fixtures/Routing/FakeUrlGenerator.php | 34 +++ .../FlowTelemetryExtensionTest.php | 18 ++ .../HttpKernelSpanSubscriberTest.php | 282 +++++++++++++++++- .../Routing/TraceContextUrlGeneratorTest.php | 60 ++++ .../DependencyInjection/ConfigurationTest.php | 25 ++ .../Propagation/TraceContextProviderTest.php | 73 +++++ .../Routing/TraceContextUrlGeneratorTest.php | 69 +++++ .../TelemetryPropagationExtensionTest.php | 65 ++++ 22 files changed, 1153 insertions(+), 7 deletions(-) create mode 100644 src/bridge/symfony/http-foundation-telemetry/src/Flow/Bridge/Symfony/HttpFoundationTelemetry/QueryCarrier.php create mode 100644 src/bridge/symfony/http-foundation-telemetry/tests/Flow/Bridge/Symfony/HttpFoundationTelemetry/Tests/Unit/QueryCarrierTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/TraceContextUrlGeneratorPass.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/RouteNaming.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Propagation/TraceContextProvider.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/twig_propagation.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/url_propagation.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Routing/TraceContextUrlGenerator.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Twig/TelemetryPropagationExtension.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Routing/FakeUrlGenerator.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index bc701bcf20..1f720c63a3 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -1105,6 +1105,8 @@ flow_telemetry: http_kernel: enabled: true context_propagation: true # Extract context from incoming headers + context_propagation_query: false # Also extract from the URL query string (off by default; see note below) + route_naming: path # Span name / http.route source: 'path' (template, e.g. /orders/{id}; default) or 'name' trace_controller: true # Controller body span (default ON) trace_controller_resolution: false # controller.get_callable span (default OFF) trace_controller_arguments: false # controller.get_arguments aggregate span (default OFF) @@ -1117,7 +1119,17 @@ flow_telemetry: - path: '/^\/api\/internal\/.*/' # Regex pattern ``` -In addition to the request (SERVER) span, the bundle can trace the controller lifecycle as child spans of +The request (SERVER) span follows the OpenTelemetry HTTP semantic conventions for its name and `http.route`, +controlled by `route_naming`: + +- `path` (default) — the route **path template**, e.g. `GET /orders/{id}` (low cardinality, semconv value + for `http.route`); resolved from the router. +- `name` — the Symfony **route name**, e.g. `GET order_show`. +- Sub-requests (`render(controller(...))`) have no route, so they are named after the **controller** + (`GET App\Controller\NavigationController::top`); a request that matches no route at all uses the method + only (`GET`). + +In addition to the request span, the bundle can trace the controller lifecycle as child spans of the request span (same instrumentation scope, kind `INTERNAL`). They are emitted only while the request span exists, so disabling `http_kernel` or excluding the path produces none. @@ -1639,6 +1651,86 @@ final class OrderService } ``` +### Propagating Trace Context to the Browser + +When `twig/twig` is installed the bundle registers Twig helpers that expose the current trace context, so +you can continue a trace into AJAX requests or multi-step flows. + +| Helper | Returns | +|-------------------------------|----------------------------------------------------------------------| +| `flow_traceparent()` | the W3C `traceparent` string (empty when there is no active span) | +| `flow_trace_context()` | array of propagation fields (`traceparent`, `tracestate`, `baggage`) | +| `flow_trace_context_meta()` | one `` tag per field (HTML-safe) | +| `flow_trace_context_url(url)` | the URL with the context appended to its query string | + +**AJAX (headers).** Render the context as meta tags and send them back as request headers — the next +request continues the trace automatically (`context_propagation` extracts them): + +```twig +{# templates/base.html.twig #} + + {{ flow_trace_context_meta() }} + +``` + +```js +// Forward every propagation field (traceparent, tracestate, baggage), not just traceparent. +const headers = {}; +document + .querySelectorAll('meta[name="traceparent"], meta[name="tracestate"], meta[name="baggage"]') + .forEach((meta) => { headers[meta.name] = meta.content; }); + +// Only send to your own API origins so trace IDs do not leak to third parties. +fetch('/api/orders', { headers }); +``` + +**Links / multi-step flows (query string).** A normal navigation cannot send headers, so carry the +context in the URL and enable query extraction: + +```twig +Continue +``` + +```yaml +flow_telemetry: + instrumentation: + http_kernel: + context_propagation: true + context_propagation_query: true +``` + +**From a controller / service (PHP).** The same helpers are available without Twig. Inject +`TraceContextProvider` for the raw context, or `TraceContextUrlGenerator` (an opt-in wrapper around the +router — it does not replace it, so `path()`/`url()` stay untouched): + +```php +use Flow\Bridge\Symfony\TelemetryBundle\Propagation\TraceContextProvider; +use Flow\Bridge\Symfony\TelemetryBundle\Routing\TraceContextUrlGenerator; + +final class CheckoutController extends AbstractController +{ + public function __construct( + private readonly TraceContextProvider $traceContext, + private readonly TraceContextUrlGenerator $traceContextUrls, + ) { + } + + public function step1(): Response + { + // append to a URL you generated yourself… + $next = $this->traceContext->appendToUrl($this->generateUrl('checkout_step_2')); + + // …or let the wrapper generate + append in one call + $next = $this->traceContextUrls->generate('checkout_step_2'); + + // raw fields, e.g. to forward as headers on an outgoing call + $headers = $this->traceContext->current(); // ['traceparent' => …, 'baggage' => …] + + return $this->redirect($next); + } +} +``` + ### Creating Custom Spans in Controllers ```php diff --git a/src/bridge/symfony/http-foundation-telemetry/src/Flow/Bridge/Symfony/HttpFoundationTelemetry/QueryCarrier.php b/src/bridge/symfony/http-foundation-telemetry/src/Flow/Bridge/Symfony/HttpFoundationTelemetry/QueryCarrier.php new file mode 100644 index 0000000000..f30e419a0d --- /dev/null +++ b/src/bridge/symfony/http-foundation-telemetry/src/Flow/Bridge/Symfony/HttpFoundationTelemetry/QueryCarrier.php @@ -0,0 +1,42 @@ + + */ +final readonly class QueryCarrier implements Carrier +{ + public function __construct( + private Request $request, + ) {} + + public function get(string $key): ?string + { + // query->get() throws on array values; read the raw bag so array params degrade to null instead. + // @mago-expect analysis:mixed-assignment + $value = $this->request->query->all()[$key] ?? null; + + return is_string($value) ? $value : null; + } + + public function set(string $key, string $value): static + { + throw new RuntimeException('QueryCarrier is read-only'); + } + + public function unwrap(): Request + { + return $this->request; + } +} diff --git a/src/bridge/symfony/http-foundation-telemetry/tests/Flow/Bridge/Symfony/HttpFoundationTelemetry/Tests/Unit/QueryCarrierTest.php b/src/bridge/symfony/http-foundation-telemetry/tests/Flow/Bridge/Symfony/HttpFoundationTelemetry/Tests/Unit/QueryCarrierTest.php new file mode 100644 index 0000000000..4c94a0d3ff --- /dev/null +++ b/src/bridge/symfony/http-foundation-telemetry/tests/Flow/Bridge/Symfony/HttpFoundationTelemetry/Tests/Unit/QueryCarrierTest.php @@ -0,0 +1,57 @@ +get('traceparent')); + } + + public function test_get_returns_null_for_missing_parameter(): void + { + $carrier = new QueryCarrier(Request::create('/next')); + + static::assertNull($carrier->get('traceparent')); + } + + public function test_get_returns_null_for_non_string_parameter(): void + { + $carrier = new QueryCarrier(Request::create('/next?traceparent[]=a&traceparent[]=b')); + + static::assertNull($carrier->get('traceparent')); + } + + public function test_set_throws_runtime_exception(): void + { + $carrier = new QueryCarrier(Request::create('/next')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('QueryCarrier is read-only'); + + $carrier->set('key', 'value'); + } + + public function test_unwrap_returns_original_request(): void + { + $request = Request::create('/next?traceparent=abc'); + + $carrier = new QueryCarrier($request); + + static::assertSame($request, $carrier->unwrap()); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/TraceContextUrlGeneratorPass.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/TraceContextUrlGeneratorPass.php new file mode 100644 index 0000000000..a23650d7c8 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/TraceContextUrlGeneratorPass.php @@ -0,0 +1,31 @@ +hasDefinition('flow.telemetry.trace_context_url_generator')) { + return; + } + + if ($container->has('router')) { + return; + } + + $container->removeDefinition('flow.telemetry.trace_context_url_generator'); + $container->removeAlias(TraceContextUrlGenerator::class); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php index 480b541bef..9fe4b47f96 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php @@ -18,8 +18,10 @@ use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\OTLPAvailabilityPass; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\ProfilerSignalCapturePass; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\Psr18ClientTelemetryPass; +use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\TraceContextUrlGeneratorPass; use Flow\Bridge\Symfony\TelemetryBundle\Exception\RuntimeException; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Console\ConsoleLogOutputSubscriber; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\RouteNaming; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Messenger\MessengerTracePropagation; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Security\UserSpanAttributeProvider; use Flow\Bridge\Symfony\TelemetryBundle\Logger\ConsoleOutputLogProcessor; @@ -153,6 +155,8 @@ final class FlowTelemetryBundle extends AbstractBundle private const string SECURITY_TOKEN_STORAGE_INTERFACE = 'Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface'; + private const string URL_GENERATOR_INTERFACE = 'Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface'; + private const string WEB_PROFILER_BUNDLE = 'Symfony\\Bundle\\WebProfilerBundle\\WebProfilerBundle'; #[Override] @@ -184,6 +188,8 @@ static function (ChildDefinition $definition, WithTelemetryChannel $attribute): $container->registerForAutoconfiguration(UserSpanAttributeProvider::class) ->addTag('flow.telemetry.security.user_attribute_provider'); + $container->addCompilerPass(new TraceContextUrlGeneratorPass()); + if (interface_exists(self::HTTP_CLIENT_INTERFACE)) { $container->addCompilerPass(new HttpClientTelemetryPass()); } @@ -538,6 +544,15 @@ public function configure(DefinitionConfigurator $definition): void ->info('Extract trace context from incoming request headers and inject it into outgoing response headers (requires flow-php/symfony-http-foundation-telemetry-bridge; silently disabled when absent)') ->defaultTrue() ->end() + ->booleanNode('context_propagation_query') + ->info('Also extract trace context from the URL query string (for links / full-page navigations that cannot send headers); headers take precedence. Security: lets callers inject a traceparent, so keep off unless needed. Requires context_propagation.') + ->defaultFalse() + ->end() + ->enumNode('route_naming') + ->info('What routed request spans use for their name and the http.route attribute: "path" (route path template, e.g. /orders/{id}; OTEL semconv default) or "name" (Symfony route name). Sub-requests fall back to the controller; unrouted requests use the method only.') + ->values(['path', 'name']) + ->defaultValue('path') + ->end() ->booleanNode('trace_controller') ->info('Trace controller body execution as a child of the request span (span name = resolved controller)') ->defaultTrue() @@ -2866,6 +2881,10 @@ private function registerInstrumentation(array $config, ContainerConfigurator $c 'flow.telemetry.http_kernel.context_propagation', ($httpKernelConfig['context_propagation'] ?? true) && class_exists(self::HTTP_FOUNDATION_REQUEST_CARRIER), ); + $builder->setParameter( + 'flow.telemetry.http_kernel.context_propagation_query', + ($httpKernelConfig['context_propagation_query'] ?? false) && class_exists(self::HTTP_FOUNDATION_REQUEST_CARRIER), + ); $builder->setParameter( 'flow.telemetry.http_kernel.trace_controller', $httpKernelConfig['trace_controller'] ?? true, @@ -2883,6 +2902,12 @@ private function registerInstrumentation(array $config, ContainerConfigurator $c $httpKernelConfig['trace_controller_argument_resolvers'] ?? false, ); $container->import(__DIR__ . '/Resources/config/instrumentation/http_kernel.php'); + + $routeNaming = is_string($httpKernelConfig['route_naming'] ?? null) + ? $httpKernelConfig['route_naming'] + : 'path'; + $builder->getDefinition('flow.telemetry.http_kernel.span_subscriber') + ->setArgument('$routeNaming', RouteNaming::from($routeNaming)); } $consoleConfig = $config['console'] ?? []; @@ -2970,6 +2995,17 @@ private function registerInstrumentation(array $config, ContainerConfigurator $c $container->import(__DIR__ . '/Resources/config/instrumentation/security.php'); } + // Outgoing trace-context helpers are not instrumentation; register them alongside it. + $container->import(__DIR__ . '/Resources/config/propagation.php'); + + if (class_exists(AbstractExtension::class)) { + $container->import(__DIR__ . '/Resources/config/twig_propagation.php'); + } + + if (interface_exists(self::URL_GENERATOR_INTERFACE)) { + $container->import(__DIR__ . '/Resources/config/url_propagation.php'); + } + $this->registerParameterOnlyInstrumentation($config, $builder); } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php index b8cd3a1c4c..ec8c1e41fe 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php @@ -5,6 +5,7 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel; use DateTimeImmutable; +use Flow\Bridge\Symfony\HttpFoundationTelemetry\QueryCarrier; use Flow\Bridge\Symfony\HttpFoundationTelemetry\RequestCarrier; use Flow\Bridge\Symfony\HttpFoundationTelemetry\ResponseCarrier; use Flow\Telemetry\Context\Context; @@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\Event\TerminateEvent; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\RouterInterface; use function array_map; use function is_string; @@ -52,6 +54,9 @@ public function __construct( private ContextStorage $contextStorage, private Propagator $propagator, private bool $contextPropagation = true, + private bool $contextPropagationQuery = false, + private ?RouterInterface $router = null, + private RouteNaming $routeNaming = RouteNaming::Path, ) { $this->excludePathRules = array_map( static fn(array $config): PathExclusionRule => PathExclusionRule::fromConfig($config), @@ -81,15 +86,30 @@ public function onController(ControllerEvent $event): void } // @mago-expect analysis:mixed-assignment - if (is_string($route = $request->attributes->get('_route'))) { - $span->setAttribute('http.route', $route); - } - + $route = $request->attributes->get('_route'); $controllerName = ControllerName::resolve($event->getController())?->name; if ($controllerName !== null) { $span->setAttribute('controller', $controllerName); } + + if (is_string($route) && $route !== '') { + $routeValue = $this->routeValue($route); + $span->setAttribute('http.route', $routeValue); + $span->rename("{$request->getMethod()} {$routeValue}"); + } elseif ($controllerName !== null) { + // Sub-requests (render(controller(...))) carry no route, so name them after the controller. + $span->rename("{$request->getMethod()} {$controllerName}"); + } + } + + private function routeValue(string $routeName): string + { + if ($this->routeNaming !== RouteNaming::Path || $this->router === null) { + return $routeName; + } + + return $this->router->getRouteCollection()->get($routeName)?->getPath() ?? $routeName; } public function onException(ExceptionEvent $event): void @@ -120,8 +140,11 @@ public function onRequest(RequestEvent $event): void $kind = $event->isMainRequest() ? SpanKind::SERVER : SpanKind::INTERNAL; + // OTEL HTTP semconv: the span name must be low-cardinality, so start with just the method and + // upgrade to "{method} {route}" once the route is resolved (see onController). The raw path stays + // on the url.path attribute. $tracer = $this->telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel')); - $span = $tracer->span("{$method} {$path}", $kind, [ + $span = $tracer->span($method, $kind, [ 'http.request.method' => $method, 'url.full' => $request->getUri(), 'url.path' => $request->getRequestUri(), @@ -204,6 +227,12 @@ private function extractContextFromRequest(Request $request): void { $propagationContext = $this->propagator->extract(new RequestCarrier($request)); + // Links and full-page navigations cannot send headers, so optionally fall back to the query + // string. Headers win when both are present. + if ($propagationContext->spanContext === null && $this->contextPropagationQuery) { + $propagationContext = $this->propagator->extract(new QueryCarrier($request)); + } + $spanContext = $propagationContext->spanContext; if ($spanContext !== null) { diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/RouteNaming.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/RouteNaming.php new file mode 100644 index 0000000000..9918ef4822 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/RouteNaming.php @@ -0,0 +1,22 @@ +current(); + + if ($context === []) { + return $url; + } + + return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($context); + } + + /** + * The current trace context as propagation fields (e.g. traceparent, tracestate, baggage), or an + * empty array when there is no active span. + * + * @return array + */ + public function current(): array + { + $context = $this->contextStorage->current(); + $activeSpan = $context->activeSpan(); + + if ($activeSpan === null) { + return []; + } + + $carrier = new ArrayCarrier(); + $this->propagator->inject(new PropagationContext($activeSpan, $context->baggage), $carrier); + + return $carrier->unwrap(); + } + + public function traceparent(): string + { + return $this->current()['traceparent'] ?? ''; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php index 0dba1bc4eb..9be438bf4a 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php @@ -21,6 +21,9 @@ service('flow.telemetry.context_storage'), service('flow.telemetry.propagator'), '%flow.telemetry.http_kernel.context_propagation%', + '%flow.telemetry.http_kernel.context_propagation_query%', + service('router')->ignoreOnInvalid(), + // arg $routeNaming (RouteNaming enum) is set in FlowTelemetryBundle::registerInstrumentation. ]) ->tag('kernel.event_subscriber'); diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php new file mode 100644 index 0000000000..cc93244fa7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php @@ -0,0 +1,19 @@ +services(); + + $services->set('flow.telemetry.trace_context_provider', TraceContextProvider::class)->args([ + service('flow.telemetry.propagator'), + service('flow.telemetry.context_storage'), + ]); + + $services->alias(TraceContextProvider::class, 'flow.telemetry.trace_context_provider')->public(); +}; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/twig_propagation.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/twig_propagation.php new file mode 100644 index 0000000000..dae5115382 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/twig_propagation.php @@ -0,0 +1,16 @@ +services() + ->set('flow.telemetry.twig.propagation_extension', TelemetryPropagationExtension::class) + ->args([service('flow.telemetry.trace_context_provider')]) + ->tag('twig.extension'); +}; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/url_propagation.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/url_propagation.php new file mode 100644 index 0000000000..3e650173c6 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/url_propagation.php @@ -0,0 +1,19 @@ +services(); + + $services->set('flow.telemetry.trace_context_url_generator', TraceContextUrlGenerator::class)->args([ + service('router'), + service('flow.telemetry.trace_context_provider'), + ]); + + $services->alias(TraceContextUrlGenerator::class, 'flow.telemetry.trace_context_url_generator')->public(); +}; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Routing/TraceContextUrlGenerator.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Routing/TraceContextUrlGenerator.php new file mode 100644 index 0000000000..8ee1fbfb2b --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Routing/TraceContextUrlGenerator.php @@ -0,0 +1,37 @@ +traceContext->appendToUrl($this->urlGenerator->generate($name, $parameters, $referenceType)); + } + + public function getContext(): RequestContext + { + return $this->urlGenerator->getContext(); + } + + public function setContext(RequestContext $context): void + { + $this->urlGenerator->setContext($context); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Twig/TelemetryPropagationExtension.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Twig/TelemetryPropagationExtension.php new file mode 100644 index 0000000000..9fadf6ffc7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Twig/TelemetryPropagationExtension.php @@ -0,0 +1,51 @@ +traceContext->traceparent(...)), + new TwigFunction('flow_trace_context', $this->traceContext->current(...)), + new TwigFunction('flow_trace_context_meta', $this->renderMeta(...), ['is_safe' => ['html']]), + new TwigFunction('flow_trace_context_url', $this->traceContext->appendToUrl(...)), + ]; + } + + public function renderMeta(): string + { + $tags = []; + + foreach ($this->traceContext->current() as $name => $value) { + $tags[] = sprintf( + '', + htmlspecialchars($name, ENT_QUOTES), + htmlspecialchars($value, ENT_QUOTES), + ); + } + + return implode("\n", $tags); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Routing/FakeUrlGenerator.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Routing/FakeUrlGenerator.php new file mode 100644 index 0000000000..b5d906fcd6 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Routing/FakeUrlGenerator.php @@ -0,0 +1,34 @@ +context = new RequestContext(); + } + + public function generate(string $name, array $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string + { + return $this->generated; + } + + public function getContext(): RequestContext + { + return $this->context; + } + + public function setContext(RequestContext $context): void + { + $this->context = $context; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php index d0a830198a..8bb979f4b3 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php @@ -11,6 +11,8 @@ use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Messenger\AsyncCurlTransportTickSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Messenger\MessengerFlushSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Messenger\MessengerTracePropagation; +use Flow\Bridge\Symfony\TelemetryBundle\Propagation\TraceContextProvider; +use Flow\Bridge\Symfony\TelemetryBundle\Routing\TraceContextUrlGenerator; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\TestKernel; use Flow\Bridge\Telemetry\OTLP\Exporter\OTLPExporter; use Flow\Bridge\Telemetry\OTLP\Transport\AsyncCurlTransport; @@ -577,6 +579,22 @@ public function test_security_instrumentation_is_not_registered_by_default(): vo static::assertFalse($container->hasDefinition('flow.telemetry.security.span_subscriber')); } + public function test_trace_context_propagation_services_are_registered(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.project_dir', sys_get_temp_dir()); + $container->setParameter('kernel.build_dir', sys_get_temp_dir()); + $extension = (new FlowTelemetryBundle())->getContainerExtension(); + assert($extension !== null); + $extension->load([['resource' => []]], $container); + + static::assertTrue($container->hasDefinition('flow.telemetry.trace_context_provider')); + static::assertTrue($container->hasAlias(TraceContextProvider::class)); + static::assertTrue($container->hasDefinition('flow.telemetry.trace_context_url_generator')); + static::assertTrue($container->hasAlias(TraceContextUrlGenerator::class)); + } + public function test_custom_exporter_via_service(): void { $this->bootKernel([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php index d2cd94da40..e8e9f59c70 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php @@ -408,6 +408,157 @@ public function test_extracts_context_from_traceparent_header(): void static::assertSame($incomingSpanId, $span->context()->parentSpanId?->toHex()); } + public function test_extracts_context_from_query_when_enabled(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => [ + 'enabled' => true, + 'context_propagation' => true, + 'context_propagation_query' => true, + ], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $incomingTraceId = '0af7651916cd43dd8448eb211c80319c'; + $incomingSpanId = 'b7ad6b7169203331'; + + $request = Request::create('/test?traceparent=00-' . $incomingTraceId . '-' . $incomingSpanId . '-01', 'GET'); + $kernel->terminate($request, $kernel->handle($request)); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $span = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + + static::assertSame($incomingTraceId, $span->context()->traceId->toHex()); + static::assertSame($incomingSpanId, $span->context()->parentSpanId?->toHex()); + } + + public function test_query_context_is_ignored_when_not_enabled(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true, 'context_propagation' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $incomingTraceId = '0af7651916cd43dd8448eb211c80319c'; + + $request = Request::create('/test?traceparent=00-' . $incomingTraceId . '-b7ad6b7169203331-01', 'GET'); + $kernel->terminate($request, $kernel->handle($request)); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $span = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + + static::assertNotSame($incomingTraceId, $span->context()->traceId->toHex()); + static::assertNull($span->context()->parentSpanId); + } + + public function test_header_takes_precedence_over_query(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => [ + 'enabled' => true, + 'context_propagation' => true, + 'context_propagation_query' => true, + ], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $headerTraceId = '0af7651916cd43dd8448eb211c80319c'; + $queryTraceId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + $request = Request::create('/test?traceparent=00-' . $queryTraceId . '-b7ad6b7169203331-01', 'GET'); + $request->headers->set('traceparent', '00-' . $headerTraceId . '-b7ad6b7169203331-01'); + $kernel->terminate($request, $kernel->handle($request)); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $span = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + + static::assertSame($headerTraceId, $span->context()->traceId->toHex()); + } + public function test_extracted_context_does_not_leak_into_the_next_request(): void { $kernel = $this->bootKernel([ @@ -664,10 +815,139 @@ public function test_traces_successful_http_request(): void $attributes = $span->attributes(); static::assertSame('GET', $attributes['http.request.method']); static::assertSame(200, $attributes['http.response.status_code']); - static::assertSame('test_index', $attributes['http.route']); + static::assertSame('/test', $attributes['http.route']); static::assertSame(TestController::class . '::index', $attributes['controller']); } + public function test_span_name_falls_back_to_method_without_a_matched_route(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + $request = Request::create('/no-such-route', 'GET'); + $kernel->terminate($request, $kernel->handle($request)); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $span = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + + // OTEL semconv: no low-cardinality route, so the span name is just the method. + static::assertSame('GET', $span->name()); + static::assertArrayNotHasKey('http.route', $span->attributes()); + } + + public function test_sub_request_without_a_route_is_named_after_the_controller(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + // A render(controller(...)) sub-request: controller preset, so routing is skipped and there is no _route. + $subRequest = Request::create('/fragment', 'GET'); + $subRequest->attributes->set('_controller', TestController::class . '::index'); + $kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $requestSpan = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => ( + $s->kind() === SpanKind::INTERNAL + && $s->name() === 'GET ' . TestController::class . '::index' + ), + )); + + static::assertCount(1, $requestSpan); + static::assertArrayNotHasKey('http.route', $requestSpan[0]->attributes()); + } + + public function test_route_naming_name_uses_the_symfony_route_name(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => ['processor' => ['type' => 'memory', 'exporter' => 'memory']], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true, 'route_naming' => 'name'], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $request = Request::create('/test', 'GET'); + $kernel->terminate($request, $kernel->handle($request)); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $span = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + + static::assertSame('GET test_index', $span->name()); + static::assertSame('test_index', $span->attributes()['http.route']); + } + public function test_completes_sub_request_span_nested_under_main_request(): void { $kernel = $this->bootKernel([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php new file mode 100644 index 0000000000..1d50200fe6 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php @@ -0,0 +1,60 @@ +bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['void' => ['void' => null]], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $generator = $this->symfonyContext()->getService( + TraceContextUrlGenerator::class, + TraceContextUrlGenerator::class, + ); + + // No active span outside a request, so the URL is returned untouched — this proves the router wiring. + static::assertSame('/test', $generator->generate('test_index')); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php index 2ad361efa7..b0557a82e2 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php @@ -814,6 +814,31 @@ public function test_security_fields_default_to_semconv_keys_and_are_configurabl static::assertSame('getEmailAddress', $customFields['email']['getter']); } + public function test_http_kernel_route_naming_defaults_to_path_and_is_configurable(): void + { + $default = $this->context->processConfig([ + 'resource' => [], + 'instrumentation' => ['http_kernel' => ['enabled' => true]], + ]); + static::assertSame('path', $default['instrumentation']['http_kernel']['route_naming']); + + $custom = $this->context->processConfig([ + 'resource' => [], + 'instrumentation' => ['http_kernel' => ['enabled' => true, 'route_naming' => 'name']], + ]); + static::assertSame('name', $custom['instrumentation']['http_kernel']['route_naming']); + } + + public function test_http_kernel_route_naming_rejects_unknown_value(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'resource' => [], + 'instrumentation' => ['http_kernel' => ['enabled' => true, 'route_naming' => 'controller']], + ]); + } + public function test_security_is_disabled_by_default(): void { $config = $this->context->processConfig(['resource' => []]); diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php new file mode 100644 index 0000000000..b174008cb1 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php @@ -0,0 +1,73 @@ +providerWithActiveSpan()->current(); + + static::assertArrayHasKey('traceparent', $context); + static::assertStringStartsWith('00-' . self::TRACE_ID . '-' . self::SPAN_ID . '-', $context['traceparent']); + } + + public function test_traceparent_returns_the_w3c_header_value(): void + { + static::assertStringStartsWith( + '00-' . self::TRACE_ID . '-' . self::SPAN_ID . '-', + $this->providerWithActiveSpan()->traceparent(), + ); + } + + public function test_append_to_url_adds_context_to_a_plain_url(): void + { + $url = $this->providerWithActiveSpan()->appendToUrl('/next'); + + static::assertStringStartsWith('/next?traceparent=00-' . self::TRACE_ID, $url); + } + + public function test_append_to_url_uses_ampersand_when_a_query_already_exists(): void + { + $url = $this->providerWithActiveSpan()->appendToUrl('/next?page=2'); + + static::assertStringContainsString('page=2&traceparent=00-' . self::TRACE_ID, $url); + } + + public function test_returns_empty_results_without_an_active_span(): void + { + $provider = new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage()); + + static::assertSame([], $provider->current()); + static::assertSame('', $provider->traceparent()); + static::assertSame('/next', $provider->appendToUrl('/next')); + } + + private function providerWithActiveSpan(): TraceContextProvider + { + $storage = new MemoryContextStorage(); + $storage->attach((new Context())->withActiveSpan(SpanContext::create( + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + ))); + + return new TraceContextProvider(new W3CTraceContext(), $storage); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php new file mode 100644 index 0000000000..cbd30beb91 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php @@ -0,0 +1,69 @@ +providerWithActiveSpan(), + ); + + $url = $generator->generate('checkout_step_2'); + + static::assertStringStartsWith('/checkout/step-2?traceparent=00-' . self::TRACE_ID, $url); + } + + public function test_generate_passes_through_when_there_is_no_active_span(): void + { + $generator = new TraceContextUrlGenerator( + new FakeUrlGenerator('/checkout/step-2'), + new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage()), + ); + + static::assertSame('/checkout/step-2', $generator->generate('checkout_step_2')); + } + + public function test_delegates_request_context_to_the_inner_generator(): void + { + $inner = new FakeUrlGenerator(); + $generator = new TraceContextUrlGenerator($inner, $this->providerWithActiveSpan()); + + $context = new RequestContext('/app.php'); + $generator->setContext($context); + + static::assertSame($context, $generator->getContext()); + static::assertSame($context, $inner->getContext()); + } + + private function providerWithActiveSpan(): TraceContextProvider + { + $storage = new MemoryContextStorage(); + $storage->attach((new Context())->withActiveSpan(SpanContext::create( + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex('b7ad6b7169203331'), + ))); + + return new TraceContextProvider(new W3CTraceContext(), $storage); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php new file mode 100644 index 0000000000..cddf5e99ff --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php @@ -0,0 +1,65 @@ +extensionWithActiveSpan()->renderMeta(); + + static::assertStringContainsString(' $config + */ + public function load(array $config): ContainerBuilder + { + $extension = (new FlowPostgreSqlBundle())->getContainerExtension(); + + if (!$extension instanceof ExtensionInterface) { + throw new LogicException('FlowPostgreSqlBundle extension is not loadable.'); + } + + $container = new ContainerBuilder(); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.debug', false); + $container->setParameter('kernel.build_dir', '/tmp'); + $container->setParameter('kernel.project_dir', '/tmp'); + $extension->load([$config], $container); + + return $container; + } +} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php index b1318fe93f..8d44f33e5f 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php @@ -208,6 +208,31 @@ public function test_catalog_providers_multiple_entries(): void static::assertSame('app.second_provider', $config['catalog_providers'][1]['catalog_provider_id']); } + public function test_connection_is_lazy_by_default(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + ]); + + static::assertTrue($config['connections']['default']['lazy']); + } + + public function test_connection_lazy_can_be_disabled(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://user:pass@localhost:5432/db', + 'lazy' => false, + ], + ], + ]); + + static::assertFalse($config['connections']['default']['lazy']); + } + public function test_connection_accepts_dbname_suffix(): void { $config = $this->context->processConfig([ diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConnectionLazyTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConnectionLazyTest.php new file mode 100644 index 0000000000..f059f56d55 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConnectionLazyTest.php @@ -0,0 +1,70 @@ +context = new ExtensionContext(); + } + + public function test_client_is_lazy_proxied_against_client_interface_by_default(): void + { + $container = $this->context->load([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + ]); + + $clientDef = $container->getDefinition('flow.postgresql.default.client'); + + static::assertTrue($clientDef->isLazy()); + static::assertSame([['interface' => Client::class]], $clientDef->getTag('proxy')); + } + + public function test_client_is_not_lazy_when_disabled(): void + { + $container = $this->context->load([ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://user:pass@localhost:5432/db', + 'lazy' => false, + ], + ], + ]); + + $clientDef = $container->getDefinition('flow.postgresql.default.client'); + + static::assertFalse($clientDef->isLazy()); + static::assertFalse($clientDef->hasTag('proxy')); + } + + public function test_test_transaction_rollback_client_is_still_lazy_by_default(): void + { + $container = $this->context->load([ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://user:pass@localhost:5432/db', + 'test_transaction_rollback' => true, + ], + ], + ]); + + $clientDef = $container->getDefinition('flow.postgresql.default.client'); + + static::assertTrue($clientDef->isLazy()); + static::assertSame([['interface' => Client::class]], $clientDef->getTag('proxy')); + } +} From 9f38c3c7ba3605188c7d785fb8e111fa42c06859 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sun, 28 Jun 2026 19:46:32 +0200 Subject: [PATCH 06/10] feat(flow-php/symfony-telemetry-bundle): support long-running worker runtimes - add runtime_mode (auto|classic|worker) with FrankenPHP/RoadRunner auto-detection - flush on terminate in worker mode instead of shutting the transport down - reset trace context between requests via kernel.reset - harden span completion so a failure can't strand the context scope --- .../bridges/symfony-telemetry-bundle.md | 39 ++++++++ .../TelemetryBundle/FlowTelemetryBundle.php | 26 +++++- .../Console/ConsoleFlushSubscriber.php | 19 +++- .../HttpKernel/HttpKernelFlushSubscriber.php | 19 +++- .../HttpKernel/HttpKernelSpanSubscriber.php | 20 ++-- .../config/instrumentation/console.php | 7 +- .../config/instrumentation/http_kernel.php | 7 +- .../Runtime/EnvironmentWorkerModeDetector.php | 43 +++++++++ .../TelemetryBundle/Runtime/RuntimeMode.php | 12 +++ .../Runtime/RuntimeModeResolver.php | 26 ++++++ .../Runtime/WorkerModeDetector.php | 10 ++ .../Tests/Context/RuntimeEnvironment.php | 63 +++++++++++++ .../Runtime/StubWorkerModeDetector.php | 19 ++++ .../Tests/Fixtures/StubHttpKernel.php | 17 ++++ .../Fixtures/Telemetry/SpySpanProcessor.php | 31 +++++++ .../HttpKernelFlushSubscriberTest.php | 59 ++++++++++++ .../HttpKernelSpanSubscriberTest.php | 23 ++++- .../Console/ConsoleFlushSubscriberTest.php | 60 ++++++++++++ .../HttpKernelFlushSubscriberTest.php | 56 ++++++++++++ .../EnvironmentWorkerModeDetectorTest.php | 91 +++++++++++++++++++ .../Unit/Runtime/RuntimeModeResolverTest.php | 50 ++++++++++ .../Context/MemoryContextStorage.php | 7 +- .../Context/ResettableContextStorage.php | 10 ++ .../Unit/Context/MemoryContextStorageTest.php | 18 ++++ 24 files changed, 714 insertions(+), 18 deletions(-) create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/EnvironmentWorkerModeDetector.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/RuntimeMode.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/RuntimeModeResolver.php create mode 100644 src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/WorkerModeDetector.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/RuntimeEnvironment.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Runtime/StubWorkerModeDetector.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/StubHttpKernel.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Telemetry/SpySpanProcessor.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleFlushSubscriberTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/EnvironmentWorkerModeDetectorTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/RuntimeModeResolverTest.php create mode 100644 src/lib/telemetry/src/Flow/Telemetry/Context/ResettableContextStorage.php diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index 1f720c63a3..478721a821 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -103,6 +103,45 @@ flow_telemetry: clock_service_id: 'app.clock' ``` +### Runtime Mode + +- **type**: `enum` +- **default**: `auto` + +Controls how telemetry is drained at request and command boundaries. In a classic, one-process-per-request +runtime (PHP-FPM, mod_php) the bundle shuts telemetry down on `kernel.terminate`/`console.terminate`, which +flushes buffered signals and closes the transport before the process dies. In a long-running worker runtime +(FrankenPHP worker mode, RoadRunner, Swoole, …) the kernel is booted once and reused across requests, so a +terminal shutdown would close the transport permanently and leave every subsequent request unable to export. +There the bundle instead **flushes** on terminate and keeps the transport alive. + +```yaml +flow_telemetry: + runtime_mode: auto # auto|classic|worker +``` + +| Mode | Behaviour | +|-----------|----------------------------------------------------------------------------------------------------| +| `auto` | Detect the runtime per request and pick `worker` or `classic` accordingly (default). | +| `classic` | One process per request: shut telemetry down on terminate (full flush + transport close). | +| `worker` | Long-running runtime: flush on terminate, drain async transports, never shut the transport down. | + +`auto` detection looks for the Symfony Runtime worker signal (`APP_RUNTIME_MODE` containing `worker=1`), +FrankenPHP (`FRANKENPHP_WORKER`), and RoadRunner (`RR_MODE`); when none are present it falls back to `classic`. +Detection runs per request, never at container-compile time, so a container warmed on the CLI is never baked +into the wrong mode. Because dev and prod may run different runtimes, wire it to an environment variable: + +```yaml +flow_telemetry: + runtime_mode: '%env(FLOW_TELEMETRY_RUNTIME_MODE)%' +``` + +To support a runtime the built-in detector does not recognise, override the +`Flow\Bridge\Symfony\TelemetryBundle\Runtime\WorkerModeDetector` service with your own implementation. + +Regardless of mode, the bundle resets per-request trace context between top-level requests (via +`kernel.reset`), so context never leaks from one worker request into the next. + ### Context Storage - **type**: `enum` diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php index 9fe4b47f96..613811d665 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php @@ -27,6 +27,9 @@ use Flow\Bridge\Symfony\TelemetryBundle\Logger\ConsoleOutputLogProcessor; use Flow\Bridge\Symfony\TelemetryBundle\Logger\ConsoleVerbosityLevels; use Flow\Bridge\Symfony\TelemetryBundle\Resource\Detector\SymfonyDeploymentDetector; +use Flow\Bridge\Symfony\TelemetryBundle\Runtime\EnvironmentWorkerModeDetector; +use Flow\Bridge\Symfony\TelemetryBundle\Runtime\RuntimeModeResolver; +use Flow\Bridge\Symfony\TelemetryBundle\Runtime\WorkerModeDetector; use Flow\Bridge\Telemetry\OTLP\Exporter\OTLPExporter; use Flow\Bridge\Telemetry\OTLP\Serializer\JsonSerializer; use Flow\Bridge\Telemetry\OTLP\Serializer\ProtobufSerializer; @@ -367,6 +370,13 @@ public function configure(DefinitionConfigurator $definition): void ->values(['scope', 'signal', 'both']) ->defaultValue('both') ->end() + ->enumNode('runtime_mode') + ->info( + 'How telemetry is drained at request/command boundaries: "classic" (PHP-FPM, one process per request — shutdown on terminate), "worker" (long-running runtime — flush on terminate, never shutdown), or "auto" (detect FrankenPHP/RoadRunner worker mode at runtime, falling back to classic). Default: auto.', + ) + ->values(['auto', 'classic', 'worker']) + ->defaultValue('auto') + ->end() ->arrayNode('context_storage') ->info('Context storage configuration') ->addDefaultsIfNotSet() @@ -847,7 +857,7 @@ public function configure(DefinitionConfigurator $definition): void } /** - * @param array{resource: array{detectors?: array{enabled?: bool, static?: array{cache?: array{enabled?: bool, path?: null|string}, os?: array{enabled?: bool}, host?: array{enabled?: bool}, service?: array{enabled?: bool}, deployment?: array{enabled?: bool}, git?: array{enabled?: bool, binary?: string, working_directory?: null|string}, environment?: array{enabled?: bool}}, dynamic?: array{process?: array{enabled?: bool}}}, custom?: array}, clock_service_id?: null|string, framework_logger?: null|string, capture_framework_channels?: bool, channel_attribute_target?: 'scope'|'signal'|'both', context_storage?: array{type?: string, service_id?: null|string}, propagator?: array{type?: string, service_id?: null|string}, exporters?: array>, error_handlers?: array>, tracer_provider?: array, meter_provider?: array, logger_provider?: array, instrumentation?: array{http_kernel?: array{enabled?: bool, exclude_paths?: array, context_propagation?: bool, trace_controller?: bool, trace_controller_resolution?: bool, trace_controller_arguments?: bool, trace_controller_argument_resolvers?: bool}, console?: array{enabled?: bool, exclude_commands?: array}, messenger?: array{enabled?: bool, context_propagation?: bool, propagation_style?: 'continue'|'link', link_to_worker?: bool}, twig?: array{enabled?: bool, trace_templates?: bool, trace_blocks?: bool, trace_macros?: bool, exclude_templates?: array}, http_client?: array{enabled?: bool, exclude_clients?: array}, psr18_client?: array{enabled?: bool, exclude_clients?: array}, dbal?: array{enabled?: bool, log_sql?: bool, max_sql_length?: int, exclude_connections?: array}, cache?: array{enabled?: bool, exclude_pools?: array}}, profiler?: array{enabled?: bool|null, capture_logs?: bool}, tracers?: array, signal?: array}}>, meters?: array, signal?: array}}>, loggers?: array, signal?: array}}>} $config + * @param array{resource: array{detectors?: array{enabled?: bool, static?: array{cache?: array{enabled?: bool, path?: null|string}, os?: array{enabled?: bool}, host?: array{enabled?: bool}, service?: array{enabled?: bool}, deployment?: array{enabled?: bool}, git?: array{enabled?: bool, binary?: string, working_directory?: null|string}, environment?: array{enabled?: bool}}, dynamic?: array{process?: array{enabled?: bool}}}, custom?: array}, clock_service_id?: null|string, framework_logger?: null|string, capture_framework_channels?: bool, channel_attribute_target?: 'scope'|'signal'|'both', runtime_mode?: 'auto'|'classic'|'worker', context_storage?: array{type?: string, service_id?: null|string}, propagator?: array{type?: string, service_id?: null|string}, exporters?: array>, error_handlers?: array>, tracer_provider?: array, meter_provider?: array, logger_provider?: array, instrumentation?: array{http_kernel?: array{enabled?: bool, exclude_paths?: array, context_propagation?: bool, trace_controller?: bool, trace_controller_resolution?: bool, trace_controller_arguments?: bool, trace_controller_argument_resolvers?: bool}, console?: array{enabled?: bool, exclude_commands?: array}, messenger?: array{enabled?: bool, context_propagation?: bool, propagation_style?: 'continue'|'link', link_to_worker?: bool}, twig?: array{enabled?: bool, trace_templates?: bool, trace_blocks?: bool, trace_macros?: bool, exclude_templates?: array}, http_client?: array{enabled?: bool, exclude_clients?: array}, psr18_client?: array{enabled?: bool, exclude_clients?: array}, dbal?: array{enabled?: bool, log_sql?: bool, max_sql_length?: int, exclude_connections?: array}, cache?: array{enabled?: bool, exclude_pools?: array}}, profiler?: array{enabled?: bool|null, capture_logs?: bool}, tracers?: array, signal?: array}}>, meters?: array, signal?: array}}>, loggers?: array, signal?: array}}>} $config */ #[Override] public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void @@ -2856,9 +2866,21 @@ private function registerGlobalServices(array $config, ContainerBuilder $builder } $builder->setAlias('flow.telemetry.context_storage', $customServiceId); } else { - $builder->setDefinition('flow.telemetry.context_storage', new Definition(MemoryContextStorage::class)); + $contextStorageDefinition = new Definition(MemoryContextStorage::class); + $contextStorageDefinition->addTag('kernel.reset', ['method' => 'reset']); + $builder->setDefinition('flow.telemetry.context_storage', $contextStorageDefinition); } + $runtimeMode = is_string($config['runtime_mode'] ?? null) ? $config['runtime_mode'] : 'auto'; + + $builder->setDefinition('flow.telemetry.worker_mode_detector', new Definition(EnvironmentWorkerModeDetector::class)); + $builder->setAlias(WorkerModeDetector::class, 'flow.telemetry.worker_mode_detector'); + + $builder->setDefinition('flow.telemetry.runtime_mode_resolver', new Definition(RuntimeModeResolver::class, [ + $runtimeMode, + new Reference('flow.telemetry.worker_mode_detector'), + ])); + $builder->setDefinition( 'flow.telemetry.psr3.log_record_converter', new Definition(LogRecordConverter::class), diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleFlushSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleFlushSubscriber.php index ad3396478b..e2bec4423f 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleFlushSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleFlushSubscriber.php @@ -4,6 +4,8 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Console; +use Flow\Bridge\Symfony\TelemetryBundle\Runtime\RuntimeModeResolver; +use Flow\Bridge\Telemetry\OTLP\Transport\AsyncCurlTransport; use Flow\Telemetry\Telemetry; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleTerminateEvent; @@ -11,8 +13,13 @@ final readonly class ConsoleFlushSubscriber implements EventSubscriberInterface { + /** + * @param iterable $asyncCurlTransports + */ public function __construct( private Telemetry $telemetry, + private RuntimeModeResolver $runtimeMode, + private iterable $asyncCurlTransports, ) {} public static function getSubscribedEvents(): array @@ -24,6 +31,16 @@ public static function getSubscribedEvents(): array public function onTerminate(ConsoleTerminateEvent $event): void { - $this->telemetry->shutdown(); + if (!$this->runtimeMode->isWorker()) { + $this->telemetry->shutdown(); + + return; + } + + $this->telemetry->flush(); + + foreach ($this->asyncCurlTransports as $transport) { + $transport->tick(); + } } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelFlushSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelFlushSubscriber.php index 043a8afb62..f471319a6f 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelFlushSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelFlushSubscriber.php @@ -4,6 +4,8 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel; +use Flow\Bridge\Symfony\TelemetryBundle\Runtime\RuntimeModeResolver; +use Flow\Bridge\Telemetry\OTLP\Transport\AsyncCurlTransport; use Flow\Telemetry\Telemetry; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\TerminateEvent; @@ -11,8 +13,13 @@ final readonly class HttpKernelFlushSubscriber implements EventSubscriberInterface { + /** + * @param iterable $asyncCurlTransports + */ public function __construct( private Telemetry $telemetry, + private RuntimeModeResolver $runtimeMode, + private iterable $asyncCurlTransports, ) {} public static function getSubscribedEvents(): array @@ -24,6 +31,16 @@ public static function getSubscribedEvents(): array public function onTerminate(TerminateEvent $event): void { - $this->telemetry->shutdown(); + if (!$this->runtimeMode->isWorker()) { + $this->telemetry->shutdown(); + + return; + } + + $this->telemetry->flush(); + + foreach ($this->asyncCurlTransports as $transport) { + $transport->tick(); + } } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php index ec8c1e41fe..84d7773489 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php @@ -211,16 +211,20 @@ private function completeSpan(Request $request): void return; } - $tracer->complete($span); + try { + $tracer->complete($span); + } finally { + // Always detach so a completion failure cannot strand the context scope into the next + // request when the kernel is reused (worker mode). + // @mago-expect analysis:mixed-assignment + if (($scope = $request->attributes->get(self::PROPAGATION_SCOPE_ATTRIBUTE)) instanceof Scope) { + $scope->detach(); + $request->attributes->remove(self::PROPAGATION_SCOPE_ATTRIBUTE); + } - // @mago-expect analysis:mixed-assignment - if (($scope = $request->attributes->get(self::PROPAGATION_SCOPE_ATTRIBUTE)) instanceof Scope) { - $scope->detach(); - $request->attributes->remove(self::PROPAGATION_SCOPE_ATTRIBUTE); + $request->attributes->remove(self::SPAN_ATTRIBUTE); + $request->attributes->remove(self::TRACER_ATTRIBUTE); } - - $request->attributes->remove(self::SPAN_ATTRIBUTE); - $request->attributes->remove(self::TRACER_ATTRIBUTE); } private function extractContextFromRequest(Request $request): void diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/console.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/console.php index b583f8b764..20e617659e 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/console.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/console.php @@ -8,6 +8,7 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; +use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; return static function (ContainerConfigurator $container): void { $services = $container->services(); @@ -22,6 +23,10 @@ $services ->set('flow.telemetry.console.flush_subscriber', ConsoleFlushSubscriber::class) - ->args([service(Telemetry::class)]) + ->args([ + service(Telemetry::class), + service('flow.telemetry.runtime_mode_resolver'), + tagged_iterator('flow.telemetry.async_curl_transport'), + ]) ->tag('kernel.event_subscriber'); }; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php index 9be438bf4a..77e7666dac 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/instrumentation/http_kernel.php @@ -9,6 +9,7 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; +use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; return static function (ContainerConfigurator $container): void { $services = $container->services(); @@ -29,7 +30,11 @@ $services ->set('flow.telemetry.http_kernel.flush_subscriber', HttpKernelFlushSubscriber::class) - ->args([service(Telemetry::class)]) + ->args([ + service(Telemetry::class), + service('flow.telemetry.runtime_mode_resolver'), + tagged_iterator('flow.telemetry.async_curl_transport'), + ]) ->tag('kernel.event_subscriber'); $services diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/EnvironmentWorkerModeDetector.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/EnvironmentWorkerModeDetector.php new file mode 100644 index 0000000000..d44c6135e2 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/EnvironmentWorkerModeDetector.php @@ -0,0 +1,43 @@ +isTruthy($_SERVER['FRANKENPHP_WORKER'] ?? null)) { + return true; + } + + $roadRunnerMode = $_SERVER['RR_MODE'] ?? getenv('RR_MODE'); + + return is_string($roadRunnerMode) && $roadRunnerMode !== ''; + } + + private function isTruthy(mixed $value): bool + { + if (is_string($value)) { + return $value !== '' && $value !== '0' && $value !== 'false'; + } + + return $value === true || $value === 1; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/RuntimeMode.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/RuntimeMode.php new file mode 100644 index 0000000000..6a2206ea07 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/RuntimeMode.php @@ -0,0 +1,12 @@ +mode = RuntimeMode::from($mode); + } + + public function isWorker(): bool + { + return match ($this->mode) { + RuntimeMode::Worker => true, + RuntimeMode::Classic => false, + RuntimeMode::Auto => $this->detector->detect(), + }; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/WorkerModeDetector.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/WorkerModeDetector.php new file mode 100644 index 0000000000..8927534534 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Runtime/WorkerModeDetector.php @@ -0,0 +1,10 @@ + $server + * @param array $env + */ + public static function with(array $server, array $env, callable $test): void + { + $serverBackup = []; + + foreach ($server as $key => $value) { + $serverBackup[$key] = $_SERVER[$key] ?? null; + + if ($value === null) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $value; + } + } + + $envBackup = []; + + foreach ($env as $key => $value) { + $current = getenv($key); + $envBackup[$key] = $current === false ? null : $current; + + if ($value === null) { + putenv($key); + } else { + putenv($key . '=' . $value); + } + } + + try { + $test(); + } finally { + foreach ($serverBackup as $key => $value) { + if ($value === null) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $value; + } + } + + foreach ($envBackup as $key => $value) { + if ($value === null) { + putenv($key); + } else { + putenv($key . '=' . $value); + } + } + } + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Runtime/StubWorkerModeDetector.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Runtime/StubWorkerModeDetector.php new file mode 100644 index 0000000000..f5f5dd27bb --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Runtime/StubWorkerModeDetector.php @@ -0,0 +1,19 @@ +isWorker; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/StubHttpKernel.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/StubHttpKernel.php new file mode 100644 index 0000000000..4933bfb579 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/StubHttpKernel.php @@ -0,0 +1,17 @@ +flushCount++; + + return true; + } + + public function onEnd(Span $span): void {} + + public function onStart(Span $span): void {} + + public function shutdown(): void + { + $this->shutdownCount++; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php index 3392cbd985..e86a536527 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php @@ -85,6 +85,65 @@ public function test_flush_is_called_on_terminate(): void ); } + public function test_worker_mode_keeps_exporting_across_repeated_request_cycles(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => [ + 'utf8' => true, + 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php', + ], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'runtime_mode' => 'worker', + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => [ + 'type' => 'batching', + 'batch_size' => 100, + 'exporter' => 'memory', + ], + ], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + /** @var MemoryExporter $exporter */ + $exporter = $container->get('flow.telemetry.exporter.memory'); + + $firstRequest = Request::create('/test', 'GET'); + $kernel->terminate($firstRequest, $kernel->handle($firstRequest)); + + static::assertCount(2, $exporter->spans(), 'First worker request should flush its spans on terminate'); + + $secondRequest = Request::create('/test', 'GET'); + $kernel->terminate($secondRequest, $kernel->handle($secondRequest)); + + static::assertCount( + 4, + $exporter->spans(), + 'A second request on the same kernel must still export; worker mode must not shut telemetry down', + ); + } + public function test_flush_is_not_called_when_http_kernel_instrumentation_is_disabled(): void { $kernel = $this->bootKernel([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php index e8e9f59c70..63f3712f50 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php @@ -15,8 +15,11 @@ use Override; use PHPUnit\Framework\Attributes\CoversClass; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Router; @@ -987,12 +990,26 @@ public function test_completes_sub_request_span_nested_under_main_request(): voi '_controller' => TestController::class . '::index', ])); + // A real sub-request (render(controller(...))) is dispatched from within the main request, so + // the kernel's request stack is non-empty and services are not reset between the two spans. + // Issuing it as a separate top-level handle() would instead trip Symfony's services_resetter. + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = $container->get('event_dispatcher'); + $dispatcher->addListener( + KernelEvents::CONTROLLER, + static function (ControllerEvent $event) use ($kernel): void { + if (!$event->isMainRequest()) { + return; + } + + $kernel->handle(Request::create('/test', 'GET'), HttpKernelInterface::SUB_REQUEST); + }, + -100, + ); + $mainRequest = Request::create('/test', 'GET'); $response = $kernel->handle($mainRequest); - $subRequest = Request::create('/test', 'GET'); - $kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST); - $kernel->terminate($mainRequest, $response); /** @var MemorySpanProcessor $processor */ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleFlushSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleFlushSubscriberTest.php new file mode 100644 index 0000000000..5f536189e3 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleFlushSubscriberTest.php @@ -0,0 +1,60 @@ +tracer('test'); + + $subscriber = new ConsoleFlushSubscriber( + $telemetry, + new RuntimeModeResolver('classic', new StubWorkerModeDetector(true)), + [], + ); + + $subscriber->onTerminate( + new ConsoleTerminateEvent(new Command('test'), new ArrayInput([]), new NullOutput(), 0), + ); + + static::assertSame(1, $processor->shutdownCount); + } + + public function test_worker_mode_flushes_telemetry_without_shutting_down_on_terminate(): void + { + $processor = new SpySpanProcessor(); + $telemetry = TelemetryMother::withSpanProcessor($processor); + $telemetry->tracer('test'); + + $subscriber = new ConsoleFlushSubscriber( + $telemetry, + new RuntimeModeResolver('worker', new StubWorkerModeDetector(false)), + [], + ); + + $subscriber->onTerminate( + new ConsoleTerminateEvent(new Command('test'), new ArrayInput([]), new NullOutput(), 0), + ); + + static::assertSame(0, $processor->shutdownCount); + static::assertSame(1, $processor->flushCount); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php new file mode 100644 index 0000000000..f18252c209 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/HttpKernelFlushSubscriberTest.php @@ -0,0 +1,56 @@ +tracer('test'); + + $subscriber = new HttpKernelFlushSubscriber( + $telemetry, + new RuntimeModeResolver('classic', new StubWorkerModeDetector(true)), + [], + ); + + $subscriber->onTerminate(new TerminateEvent(new StubHttpKernel(), Request::create('/'), new Response())); + + static::assertSame(1, $processor->shutdownCount); + } + + public function test_worker_mode_flushes_telemetry_without_shutting_down_on_terminate(): void + { + $processor = new SpySpanProcessor(); + $telemetry = TelemetryMother::withSpanProcessor($processor); + $telemetry->tracer('test'); + + $subscriber = new HttpKernelFlushSubscriber( + $telemetry, + new RuntimeModeResolver('worker', new StubWorkerModeDetector(false)), + [], + ); + + $subscriber->onTerminate(new TerminateEvent(new StubHttpKernel(), Request::create('/'), new Response())); + + static::assertSame(0, $processor->shutdownCount); + static::assertSame(1, $processor->flushCount); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/EnvironmentWorkerModeDetectorTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/EnvironmentWorkerModeDetectorTest.php new file mode 100644 index 0000000000..a580ceb457 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/EnvironmentWorkerModeDetectorTest.php @@ -0,0 +1,91 @@ + 'web=1&worker=1', 'FRANKENPHP_WORKER' => null, 'RR_MODE' => null], + ['RR_MODE' => null], + function (): void { + static::assertTrue((new EnvironmentWorkerModeDetector())->detect()); + }, + ); + } + + public function test_detects_worker_from_frankenphp_worker(): void + { + RuntimeEnvironment::with( + ['APP_RUNTIME_MODE' => null, 'FRANKENPHP_WORKER' => '1', 'RR_MODE' => null], + ['RR_MODE' => null], + function (): void { + static::assertTrue((new EnvironmentWorkerModeDetector())->detect()); + }, + ); + } + + public function test_detects_worker_from_roadrunner_env_fallback(): void + { + RuntimeEnvironment::with( + ['APP_RUNTIME_MODE' => null, 'FRANKENPHP_WORKER' => null, 'RR_MODE' => null], + ['RR_MODE' => 'http'], + function (): void { + static::assertTrue((new EnvironmentWorkerModeDetector())->detect()); + }, + ); + } + + public function test_detects_worker_from_roadrunner_server_mode(): void + { + RuntimeEnvironment::with( + ['APP_RUNTIME_MODE' => null, 'FRANKENPHP_WORKER' => null, 'RR_MODE' => 'http'], + ['RR_MODE' => null], + function (): void { + static::assertTrue((new EnvironmentWorkerModeDetector())->detect()); + }, + ); + } + + public function test_does_not_detect_worker_from_disabled_frankenphp_worker(): void + { + RuntimeEnvironment::with( + ['APP_RUNTIME_MODE' => null, 'FRANKENPHP_WORKER' => '0', 'RR_MODE' => null], + ['RR_MODE' => null], + function (): void { + static::assertFalse((new EnvironmentWorkerModeDetector())->detect()); + }, + ); + } + + public function test_does_not_detect_worker_from_web_only_app_runtime_mode(): void + { + RuntimeEnvironment::with( + ['APP_RUNTIME_MODE' => 'web=1', 'FRANKENPHP_WORKER' => null, 'RR_MODE' => null], + ['RR_MODE' => null], + function (): void { + static::assertFalse((new EnvironmentWorkerModeDetector())->detect()); + }, + ); + } + + public function test_does_not_detect_worker_without_any_signal(): void + { + RuntimeEnvironment::with( + ['APP_RUNTIME_MODE' => null, 'FRANKENPHP_WORKER' => null, 'RR_MODE' => null], + ['RR_MODE' => null], + function (): void { + static::assertFalse((new EnvironmentWorkerModeDetector())->detect()); + }, + ); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/RuntimeModeResolverTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/RuntimeModeResolverTest.php new file mode 100644 index 0000000000..f19ce62c0a --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Runtime/RuntimeModeResolverTest.php @@ -0,0 +1,50 @@ +isWorker()); + } + + public function test_auto_mode_is_worker_when_detector_reports_worker(): void + { + $resolver = new RuntimeModeResolver('auto', new StubWorkerModeDetector(true)); + + static::assertTrue($resolver->isWorker()); + } + + public function test_classic_mode_is_never_worker_even_when_detector_reports_worker(): void + { + $resolver = new RuntimeModeResolver('classic', new StubWorkerModeDetector(true)); + + static::assertFalse($resolver->isWorker()); + } + + public function test_invalid_mode_is_rejected(): void + { + $this->expectException(ValueError::class); + + new RuntimeModeResolver('invalid', new StubWorkerModeDetector(false)); + } + + public function test_worker_mode_is_always_worker_even_when_detector_reports_classic(): void + { + $resolver = new RuntimeModeResolver('worker', new StubWorkerModeDetector(false)); + + static::assertTrue($resolver->isWorker()); + } +} diff --git a/src/lib/telemetry/src/Flow/Telemetry/Context/MemoryContextStorage.php b/src/lib/telemetry/src/Flow/Telemetry/Context/MemoryContextStorage.php index bc06a22ea6..d86c73830c 100644 --- a/src/lib/telemetry/src/Flow/Telemetry/Context/MemoryContextStorage.php +++ b/src/lib/telemetry/src/Flow/Telemetry/Context/MemoryContextStorage.php @@ -4,7 +4,7 @@ namespace Flow\Telemetry\Context; -final class MemoryContextStorage implements ContextStorage +final class MemoryContextStorage implements ContextStorage, ResettableContextStorage { private Context $context; @@ -26,6 +26,11 @@ public function current(): Context return $this->context; } + public function reset(): void + { + $this->context = Context::root(); + } + /** * Internal method for scope detachment. * diff --git a/src/lib/telemetry/src/Flow/Telemetry/Context/ResettableContextStorage.php b/src/lib/telemetry/src/Flow/Telemetry/Context/ResettableContextStorage.php new file mode 100644 index 0000000000..d135711ca2 --- /dev/null +++ b/src/lib/telemetry/src/Flow/Telemetry/Context/ResettableContextStorage.php @@ -0,0 +1,10 @@ +attach(Context::root()->withActiveSpan(SpanContext::create(TraceId::generate(), SpanId::generate()))); + + static::assertFalse($storage->current()->isRootContext()); + + $storage->reset(); + + static::assertTrue($storage->current()->isRootContext()); + } } From 3eeccfd934a4bc654f261ae55b85715e999e7ca4 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sun, 28 Jun 2026 20:30:35 +0200 Subject: [PATCH 07/10] fix(flow-php/symfony-telemetry-bundle): browser trace-context helpers emit the request span - resolve the request (SERVER) span via RequestStack instead of the active inner span - keeps documentLoad/AJAX parented under the request regardless of call site or Twig tracing --- .../bridges/symfony-telemetry-bundle.md | 2 +- .../Propagation/TraceContextProvider.php | 32 ++--- .../Resources/config/propagation.php | 1 + .../FlowTelemetryExtensionTest.php | 4 + .../Routing/TraceContextUrlGeneratorTest.php | 62 +++++++++- .../Propagation/TraceContextProviderTest.php | 117 +++++++++++++++--- .../Routing/TraceContextUrlGeneratorTest.php | 47 ++++--- .../TelemetryPropagationExtensionTest.php | 48 ++++--- 8 files changed, 243 insertions(+), 70 deletions(-) diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index 478721a821..7050e9421e 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -1697,7 +1697,7 @@ you can continue a trace into AJAX requests or multi-step flows. | Helper | Returns | |-------------------------------|----------------------------------------------------------------------| -| `flow_traceparent()` | the W3C `traceparent` string (empty when there is no active span) | +| `flow_traceparent()` | the W3C `traceparent` string (empty when there is no request span) | | `flow_trace_context()` | array of propagation fields (`traceparent`, `tracestate`, `baggage`) | | `flow_trace_context_meta()` | one `` tag per field (HTML-safe) | | `flow_trace_context_url(url)` | the URL with the context appended to its query string | diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Propagation/TraceContextProvider.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Propagation/TraceContextProvider.php index 446f6626d2..e471553bf8 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Propagation/TraceContextProvider.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Propagation/TraceContextProvider.php @@ -4,30 +4,27 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Propagation; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Telemetry\Context\ContextStorage; use Flow\Telemetry\Propagation\ArrayCarrier; use Flow\Telemetry\Propagation\PropagationContext; use Flow\Telemetry\Propagation\Propagator; +use Flow\Telemetry\Tracer\Span; +use Symfony\Component\HttpFoundation\RequestStack; use function http_build_query; use function str_contains; -/** - * Exposes the current trace context for outgoing propagation, so it can be carried to subsequent - * requests (AJAX via headers, or links / multi-step flows via the URL query string). - * - * Inject this in controllers/services; the Twig helpers and {@see TraceContextUrlGenerator} delegate - * to it. - */ final readonly class TraceContextProvider { public function __construct( private Propagator $propagator, private ContextStorage $contextStorage, + private ?RequestStack $requestStack = null, ) {} /** - * Append the current trace context to a URL's query string (no-op when there is no active span). + * Append the current trace context to a URL's query string (no-op when there is no request span). */ public function appendToUrl(string $url): string { @@ -41,22 +38,29 @@ public function appendToUrl(string $url): string } /** - * The current trace context as propagation fields (e.g. traceparent, tracestate, baggage), or an - * empty array when there is no active span. + * The request span's trace context as propagation fields (e.g. traceparent, tracestate, baggage), or + * an empty array when there is no request span. * * @return array */ public function current(): array { - $context = $this->contextStorage->current(); - $activeSpan = $context->activeSpan(); + $request = $this->requestStack?->getMainRequest(); - if ($activeSpan === null) { + if ($request === null) { + return []; + } + + // @mago-expect analysis:mixed-assignment + if (!($span = $request->attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE)) instanceof Span) { return []; } $carrier = new ArrayCarrier(); - $this->propagator->inject(new PropagationContext($activeSpan, $context->baggage), $carrier); + $this->propagator->inject( + new PropagationContext($span->context(), $this->contextStorage->current()->baggage), + $carrier, + ); return $carrier->unwrap(); } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php index cc93244fa7..12c4b64fd7 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Resources/config/propagation.php @@ -13,6 +13,7 @@ $services->set('flow.telemetry.trace_context_provider', TraceContextProvider::class)->args([ service('flow.telemetry.propagator'), service('flow.telemetry.context_storage'), + service('request_stack')->ignoreOnInvalid(), ]); $services->alias(TraceContextProvider::class, 'flow.telemetry.trace_context_provider')->public(); diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php index 8bb979f4b3..a3c5903783 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php @@ -593,6 +593,10 @@ public function test_trace_context_propagation_services_are_registered(): void static::assertTrue($container->hasAlias(TraceContextProvider::class)); static::assertTrue($container->hasDefinition('flow.telemetry.trace_context_url_generator')); static::assertTrue($container->hasAlias(TraceContextUrlGenerator::class)); + + $providerDefinition = $container->getDefinition('flow.telemetry.trace_context_provider'); + static::assertInstanceOf(Reference::class, $providerDefinition->getArgument(2)); + static::assertSame('request_stack', (string) $providerDefinition->getArgument(2)); } public function test_custom_exporter_via_service(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php index 1d50200fe6..8262117d32 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Routing/TraceContextUrlGeneratorTest.php @@ -4,19 +4,30 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Tests\Integration\Routing; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Routing\TraceContextUrlGenerator; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Controller\TestController; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\TestKernel; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Integration\KernelTestCase; +use Flow\Telemetry\Context\SpanId; +use Flow\Telemetry\Context\TraceId; +use Flow\Telemetry\Tests\Mother\SpanMother; +use Flow\Telemetry\Tracer\SpanKind; use Override; use PHPUnit\Framework\Attributes\CoversClass; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Router; #[CoversClass(TraceContextUrlGenerator::class)] final class TraceContextUrlGeneratorTest extends KernelTestCase { + private const string TRACE_ID = '0af7651916cd43dd8448eb211c80319c'; + + private const string SPAN_ID = 'b7ad6b7169203331'; + #[Override] protected function tearDown(): void { @@ -54,7 +65,56 @@ public function test_is_wired_with_the_real_router(): void TraceContextUrlGenerator::class, ); - // No active span outside a request, so the URL is returned untouched — this proves the router wiring. + // No request span outside a request, so the URL is returned untouched — this proves the router wiring. static::assertSame('/test', $generator->generate('test_index')); } + + public function test_appends_the_request_span_trace_context(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => ['utf8' => true, 'resource' => __DIR__ . '/../../Fixtures/config/routes.php'], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['void' => ['void' => null]], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_index', new Route('/test', [ + '_controller' => TestController::class . '::index', + ])); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + null, + SpanKind::SERVER, + )); + + /** @var RequestStack $requestStack */ + $requestStack = $container->get('request_stack'); + $requestStack->push($request); + + $generator = $this->symfonyContext()->getService( + TraceContextUrlGenerator::class, + TraceContextUrlGenerator::class, + ); + + static::assertStringStartsWith( + '/test?traceparent=00-' . self::TRACE_ID . '-' . self::SPAN_ID . '-', + $generator->generate('test_index'), + ); + } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php index b174008cb1..398220b593 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Propagation/TraceContextProviderTest.php @@ -4,15 +4,20 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Tests\Unit\Propagation; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Propagation\TraceContextProvider; use Flow\Telemetry\Context\Context; use Flow\Telemetry\Context\MemoryContextStorage; use Flow\Telemetry\Context\SpanId; use Flow\Telemetry\Context\TraceId; use Flow\Telemetry\Propagation\W3CTraceContext; +use Flow\Telemetry\Tests\Mother\SpanMother; use Flow\Telemetry\Tracer\SpanContext; +use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; #[CoversClass(TraceContextProvider::class)] final class TraceContextProviderTest extends TestCase @@ -21,53 +26,133 @@ final class TraceContextProviderTest extends TestCase private const string SPAN_ID = 'b7ad6b7169203331'; - public function test_current_exposes_the_active_traceparent(): void + public function test_current_emits_the_request_span_traceparent(): void { - $context = $this->providerWithActiveSpan()->current(); + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + null, + SpanKind::SERVER, + )); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $context = (new TraceContextProvider( + new W3CTraceContext(), + new MemoryContextStorage(), + $requestStack, + ))->current(); static::assertArrayHasKey('traceparent', $context); static::assertStringStartsWith('00-' . self::TRACE_ID . '-' . self::SPAN_ID . '-', $context['traceparent']); } - public function test_traceparent_returns_the_w3c_header_value(): void + public function test_traceparent_returns_the_request_span_w3c_value(): void { + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + null, + SpanKind::SERVER, + )); + $requestStack = new RequestStack(); + $requestStack->push($request); + static::assertStringStartsWith( '00-' . self::TRACE_ID . '-' . self::SPAN_ID . '-', - $this->providerWithActiveSpan()->traceparent(), + (new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage(), $requestStack))->traceparent(), ); } - public function test_append_to_url_adds_context_to_a_plain_url(): void + public function test_request_span_wins_over_the_active_inner_span(): void { - $url = $this->providerWithActiveSpan()->appendToUrl('/next'); + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + null, + SpanKind::SERVER, + )); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $storage = new MemoryContextStorage(); + $storage->attach((new Context())->withActiveSpan(SpanContext::create( + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex('aaaaaaaaaaaaaaaa'), + ))); + + $context = (new TraceContextProvider(new W3CTraceContext(), $storage, $requestStack))->current(); + + static::assertStringStartsWith('00-' . self::TRACE_ID . '-' . self::SPAN_ID . '-', $context['traceparent']); + static::assertStringNotContainsString('aaaaaaaaaaaaaaaa', $context['traceparent']); + } + + public function test_append_to_url_adds_request_context_to_a_plain_url(): void + { + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + null, + SpanKind::SERVER, + )); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $url = (new TraceContextProvider( + new W3CTraceContext(), + new MemoryContextStorage(), + $requestStack, + ))->appendToUrl('/next'); static::assertStringStartsWith('/next?traceparent=00-' . self::TRACE_ID, $url); } public function test_append_to_url_uses_ampersand_when_a_query_already_exists(): void { - $url = $this->providerWithActiveSpan()->appendToUrl('/next?page=2'); + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + null, + SpanKind::SERVER, + )); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $url = (new TraceContextProvider( + new W3CTraceContext(), + new MemoryContextStorage(), + $requestStack, + ))->appendToUrl('/next?page=2'); static::assertStringContainsString('page=2&traceparent=00-' . self::TRACE_ID, $url); } - public function test_returns_empty_results_without_an_active_span(): void + public function test_returns_empty_when_there_is_no_main_request(): void { - $provider = new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage()); + $provider = new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage(), new RequestStack()); static::assertSame([], $provider->current()); static::assertSame('', $provider->traceparent()); static::assertSame('/next', $provider->appendToUrl('/next')); } - private function providerWithActiveSpan(): TraceContextProvider + public function test_returns_empty_when_the_main_request_has_no_span(): void { - $storage = new MemoryContextStorage(); - $storage->attach((new Context())->withActiveSpan(SpanContext::create( - TraceId::fromHex(self::TRACE_ID), - SpanId::fromHex(self::SPAN_ID), - ))); + $requestStack = new RequestStack(); + $requestStack->push(new Request()); + + $provider = new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage(), $requestStack); - return new TraceContextProvider(new W3CTraceContext(), $storage); + static::assertSame([], $provider->current()); } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php index cbd30beb91..5b35402077 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Routing/TraceContextUrlGeneratorTest.php @@ -4,17 +4,20 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Tests\Unit\Routing; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Propagation\TraceContextProvider; use Flow\Bridge\Symfony\TelemetryBundle\Routing\TraceContextUrlGenerator; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Routing\FakeUrlGenerator; -use Flow\Telemetry\Context\Context; use Flow\Telemetry\Context\MemoryContextStorage; use Flow\Telemetry\Context\SpanId; use Flow\Telemetry\Context\TraceId; use Flow\Telemetry\Propagation\W3CTraceContext; -use Flow\Telemetry\Tracer\SpanContext; +use Flow\Telemetry\Tests\Mother\SpanMother; +use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\RequestContext; #[CoversClass(TraceContextUrlGenerator::class)] @@ -24,21 +27,33 @@ final class TraceContextUrlGeneratorTest extends TestCase public function test_generate_appends_trace_context_to_the_generated_url(): void { + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex('b7ad6b7169203331'), + null, + SpanKind::SERVER, + )); + $requestStack = new RequestStack(); + $requestStack->push($request); + $generator = new TraceContextUrlGenerator( new FakeUrlGenerator('/checkout/step-2'), - $this->providerWithActiveSpan(), + new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage(), $requestStack), ); - $url = $generator->generate('checkout_step_2'); - - static::assertStringStartsWith('/checkout/step-2?traceparent=00-' . self::TRACE_ID, $url); + static::assertStringStartsWith( + '/checkout/step-2?traceparent=00-' . self::TRACE_ID, + $generator->generate('checkout_step_2'), + ); } - public function test_generate_passes_through_when_there_is_no_active_span(): void + public function test_generate_passes_through_when_there_is_no_request_span(): void { $generator = new TraceContextUrlGenerator( new FakeUrlGenerator('/checkout/step-2'), - new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage()), + new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage(), new RequestStack()), ); static::assertSame('/checkout/step-2', $generator->generate('checkout_step_2')); @@ -47,7 +62,10 @@ public function test_generate_passes_through_when_there_is_no_active_span(): voi public function test_delegates_request_context_to_the_inner_generator(): void { $inner = new FakeUrlGenerator(); - $generator = new TraceContextUrlGenerator($inner, $this->providerWithActiveSpan()); + $generator = new TraceContextUrlGenerator( + $inner, + new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage(), new RequestStack()), + ); $context = new RequestContext('/app.php'); $generator->setContext($context); @@ -55,15 +73,4 @@ public function test_delegates_request_context_to_the_inner_generator(): void static::assertSame($context, $generator->getContext()); static::assertSame($context, $inner->getContext()); } - - private function providerWithActiveSpan(): TraceContextProvider - { - $storage = new MemoryContextStorage(); - $storage->attach((new Context())->withActiveSpan(SpanContext::create( - TraceId::fromHex(self::TRACE_ID), - SpanId::fromHex('b7ad6b7169203331'), - ))); - - return new TraceContextProvider(new W3CTraceContext(), $storage); - } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php index cddf5e99ff..96d76e8e82 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Twig/TelemetryPropagationExtensionTest.php @@ -4,16 +4,19 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Tests\Unit\Twig; +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Propagation\TraceContextProvider; use Flow\Bridge\Symfony\TelemetryBundle\Twig\TelemetryPropagationExtension; -use Flow\Telemetry\Context\Context; use Flow\Telemetry\Context\MemoryContextStorage; use Flow\Telemetry\Context\SpanId; use Flow\Telemetry\Context\TraceId; use Flow\Telemetry\Propagation\W3CTraceContext; -use Flow\Telemetry\Tracer\SpanContext; +use Flow\Telemetry\Tests\Mother\SpanMother; +use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; #[CoversClass(TelemetryPropagationExtension::class)] final class TelemetryPropagationExtensionTest extends TestCase @@ -24,15 +27,31 @@ final class TelemetryPropagationExtensionTest extends TestCase public function test_meta_renders_a_tag_per_field(): void { - $meta = $this->extensionWithActiveSpan()->renderMeta(); + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, SpanMother::create( + 'request', + TraceId::fromHex(self::TRACE_ID), + SpanId::fromHex(self::SPAN_ID), + null, + SpanKind::SERVER, + )); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $extension = new TelemetryPropagationExtension( + new TraceContextProvider(new W3CTraceContext(), new MemoryContextStorage(), $requestStack), + ); - static::assertStringContainsString('setStatus(SpanStatus::ok()); + $span->setAttribute('error.type', (string) $statusCode); } return $response; } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; diff --git a/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Integration/PSR18TraceableClientIntegrationTest.php b/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Integration/PSR18TraceableClientIntegrationTest.php index 41862d0571..6aca0ae141 100644 --- a/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Integration/PSR18TraceableClientIntegrationTest.php +++ b/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Integration/PSR18TraceableClientIntegrationTest.php @@ -54,13 +54,8 @@ public function test_real_http_request_creates_span(): void static::assertSame(8080, $attributes['server.port']); static::assertSame(200, $attributes['http.response.status_code']); - $status = $span->status(); - - if ($status === null) { - static::fail('Expected span to have status'); - } - - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); } private function createTelemetry(MemorySpanProcessor $spanProcessor): Telemetry diff --git a/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Unit/PSR18TraceableClientTest.php b/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Unit/PSR18TraceableClientTest.php index 8cade9bcb6..e6124909e8 100644 --- a/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Unit/PSR18TraceableClientTest.php +++ b/src/bridge/psr18/telemetry/tests/Flow/Bridge/Psr18/Telemetry/Tests/Unit/PSR18TraceableClientTest.php @@ -65,6 +65,7 @@ public function test_exception_is_recorded_and_rethrown(): void static::assertTrue($status->isError()); static::assertSame('Connection failed', $status->description); + static::assertSame(RuntimeException::class, $span->attributes()['error.type']); } } @@ -94,6 +95,7 @@ public function test_request_with_4xx_status_creates_error_span(): void static::assertTrue($status->isError()); static::assertSame('HTTP 404', $status->description); static::assertSame(404, $span->attributes()['http.response.status_code']); + static::assertSame('404', $span->attributes()['error.type']); } public function test_request_with_5xx_status_creates_error_span(): void @@ -122,6 +124,7 @@ public function test_request_with_5xx_status_creates_error_span(): void static::assertTrue($status->isError()); static::assertSame('HTTP 500', $status->description); static::assertSame(500, $span->attributes()['http.response.status_code']); + static::assertSame('500', $span->attributes()['error.type']); } public function test_span_has_correct_attributes(): void @@ -188,13 +191,10 @@ public function test_successful_request_creates_span_with_ok_status(): void $span = $spans[0]; static::assertSame('GET api.example.com', $span->name()); - $status = $span->status(); - - if ($status === null) { - static::fail('Expected span to have status'); - } - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); + static::assertArrayNotHasKey('error.type', $span->attributes()); } private function createTelemetry(MemorySpanProcessor $spanProcessor): Telemetry diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TagAwareTraceableCacheAdapter.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TagAwareTraceableCacheAdapter.php index bda93b5c26..30f8924e2d 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TagAwareTraceableCacheAdapter.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TagAwareTraceableCacheAdapter.php @@ -63,12 +63,10 @@ public function clear(string $prefix = ''): bool $span = $this->tracer->span("Cache Clear {$this->poolName}", SpanKind::CLIENT, $attributes); try { - $result = $this->adapter->clear($prefix); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->clear($prefix); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -85,12 +83,10 @@ public function commit(): bool ]); try { - $result = $this->adapter->commit(); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->commit(); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -116,12 +112,10 @@ public function delete(string $key): bool ]); try { - $result = $this->adapter->delete($key); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->delete($key); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -141,12 +135,10 @@ public function deleteItem(mixed $key): bool ]); try { - $result = $this->adapter->deleteItem($keyString); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->deleteItem($keyString); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -167,12 +159,10 @@ public function deleteItems(array $keys): bool ]); try { - $result = $this->adapter->deleteItems($keys); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->deleteItems($keys); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -281,12 +271,10 @@ public function invalidateTags(array $tags): bool ]); try { - $result = $this->adapter->invalidateTags($tags); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->invalidateTags($tags); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -307,12 +295,10 @@ public function prune(): bool ]); try { - $result = $this->adapter->prune(); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->prune(); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -334,9 +320,9 @@ public function reset(): void try { $this->adapter->reset(); - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -355,12 +341,10 @@ public function save(CacheItemInterface $item): bool ]); try { - $result = $this->adapter->save($item); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->save($item); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -379,12 +363,10 @@ public function saveDeferred(CacheItemInterface $item): bool ]); try { - $result = $this->adapter->saveDeferred($item); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->saveDeferred($item); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TraceableCacheAdapter.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TraceableCacheAdapter.php index 9973def9e8..13e48c342b 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TraceableCacheAdapter.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Cache/TraceableCacheAdapter.php @@ -63,12 +63,10 @@ public function clear(string $prefix = ''): bool $span = $this->tracer->span("Cache Clear {$this->poolName}", SpanKind::CLIENT, $attributes); try { - $result = $this->adapter->clear($prefix); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->clear($prefix); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -85,12 +83,10 @@ public function commit(): bool ]); try { - $result = $this->adapter->commit(); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->commit(); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -116,12 +112,10 @@ public function delete(string $key): bool ]); try { - $result = $this->adapter->delete($key); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->delete($key); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -141,12 +135,10 @@ public function deleteItem(mixed $key): bool ]); try { - $result = $this->adapter->deleteItem($keyString); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->deleteItem($keyString); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -167,12 +159,10 @@ public function deleteItems(array $keys): bool ]); try { - $result = $this->adapter->deleteItems($keys); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->deleteItems($keys); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -280,12 +270,10 @@ public function prune(): bool ]); try { - $result = $this->adapter->prune(); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->prune(); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -307,9 +295,9 @@ public function reset(): void try { $this->adapter->reset(); - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -328,12 +316,10 @@ public function save(CacheItemInterface $item): bool ]); try { - $result = $this->adapter->save($item); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->save($item); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -352,12 +338,10 @@ public function saveDeferred(CacheItemInterface $item): bool ]); try { - $result = $this->adapter->saveDeferred($item); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $this->adapter->saveDeferred($item); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleSpanSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleSpanSubscriber.php index 18d3e3080e..ac6461c2bb 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleSpanSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Console/ConsoleSpanSubscriber.php @@ -93,9 +93,9 @@ public function onTerminate(ConsoleTerminateEvent $event): void $exitCode = $event->getExitCode(); $this->span->setAttribute('process.exit_code', $exitCode); - if ($exitCode === 0) { - $this->span->setStatus(SpanStatus::ok()); - } else { + // OTEL spec: instrumentation leaves the status Unset on success; only a non-zero exit is an error. + if ($exitCode !== 0) { + $this->span->setAttribute('error.type', (string) $exitCode); $this->span->setStatus(SpanStatus::error("Exit code: {$exitCode}")); } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingConnection.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingConnection.php index 9d606e62b6..286629277c 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingConnection.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingConnection.php @@ -39,10 +39,9 @@ public function beginTransaction(): void try { parent::beginTransaction(); - - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -60,10 +59,9 @@ public function commit(): void try { parent::commit(); - - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -86,13 +84,10 @@ public function exec(string $sql): int|string $span = $tracer->span('doctrine.dbal.connection.exec', SpanKind::CLIENT, $attributes); try { - $result = parent::exec($sql); - - $span->setStatus(SpanStatus::ok()); - - return $result; + return parent::exec($sql); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -117,11 +112,10 @@ public function prepare(string $sql): DriverStatement try { $statement = parent::prepare($sql); - $span->setStatus(SpanStatus::ok()); - return new TracingStatement($statement, $this->telemetry); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -144,13 +138,10 @@ public function query(string $sql): Result $span = $tracer->span('doctrine.dbal.connection.query', SpanKind::CLIENT, $attributes); try { - $result = parent::query($sql); - - $span->setStatus(SpanStatus::ok()); - - return $result; + return parent::query($sql); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -168,10 +159,9 @@ public function rollBack(): void try { parent::rollBack(); - - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingDriver.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingDriver.php index 0fc8577abc..89d32c05db 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingDriver.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingDriver.php @@ -56,11 +56,11 @@ public function connect(#[SensitiveParameter] array $params): Connection $connection = parent::connect($params); $span->setAttribute('db.system.name', $this->getSemanticDbSystem($connection->getServerVersion())); - $span->setStatus(SpanStatus::ok()); return new TracingConnection($connection, $this->telemetry, $this->logSql, $this->maxSqlLength); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingStatement.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingStatement.php index 2147b4f13e..fcba991b97 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingStatement.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Doctrine/DBAL/TracingStatement.php @@ -32,13 +32,10 @@ public function execute(): Result $span = $tracer->span('doctrine.dbal.statement.execute', SpanKind::CLIENT); try { - $result = parent::execute(); - - $span->setStatus(SpanStatus::ok()); - - return $result; + return parent::execute(); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpClient/TraceableResponse.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpClient/TraceableResponse.php index ab2b209cc1..44af0d65fb 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpClient/TraceableResponse.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpClient/TraceableResponse.php @@ -197,10 +197,10 @@ private function recordStatus(int $statusCode): void $this->statusRecorded = true; $this->span->setAttribute('http.response.status_code', $statusCode); + // OTEL HTTP semconv: for SpanKind.CLIENT both 4xx and 5xx are Errors; 1xx-3xx leaves the status unset. if ($statusCode >= 400) { $this->span->setStatus(SpanStatus::error("HTTP {$statusCode}")); - } else { - $this->span->setStatus(SpanStatus::ok()); + $this->span->setAttribute('error.type', (string) $statusCode); } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerSpanSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerSpanSubscriber.php index 87dcf2bfe2..f00055b02e 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerSpanSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerSpanSubscriber.php @@ -93,7 +93,7 @@ public function onComplete(ViewEvent|ResponseEvent $event): void return; } - $span->setStatus(SpanStatus::ok()); + // OTEL spec: instrumentation leaves the status Unset on success. $this->tracer()->complete($span); $request->attributes->remove(self::CONTROLLER_SPAN_ATTRIBUTE); @@ -110,6 +110,7 @@ public function onException(ExceptionEvent $event): void $throwable = $event->getThrowable(); $span->recordException($throwable, new DateTimeImmutable()); + $span->setAttribute('error.type', $throwable::class); $span->setStatus(SpanStatus::error($throwable->getMessage())); $this->tracer()->complete($span); diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php index 84d7773489..7318d2b3b7 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/HttpKernelSpanSubscriber.php @@ -170,10 +170,11 @@ public function onResponse(ResponseEvent $event): void $span->setAttribute('http.response.status_code', $statusCode); - if ($statusCode >= 400) { + // OTEL HTTP semconv: for SpanKind.SERVER the span status MUST be left unset for 1xx-4xx; only + // 5xx (or other server-caused failures) is an Error. A 4xx is the client's fault, not the server's. + if ($statusCode >= 500) { $span->setStatus(SpanStatus::error("HTTP {$statusCode}")); - } else { - $span->setStatus(SpanStatus::ok()); + $span->setAttribute('error.type', (string) $statusCode); } if ($event->isMainRequest() && $this->contextPropagation) { diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingArgumentResolver.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingArgumentResolver.php index 84d360bd35..2dbbc59562 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingArgumentResolver.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingArgumentResolver.php @@ -4,6 +4,7 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel; +use DateTimeImmutable; use Flow\Telemetry\PackageVersion; use Flow\Telemetry\Telemetry; use Flow\Telemetry\Tracer\Span; @@ -12,6 +13,7 @@ use ReflectionFunctionAbstract; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface; +use Throwable; final readonly class TracingArgumentResolver implements ArgumentResolverInterface { @@ -34,8 +36,14 @@ public function getArguments( try { return $this->resolver->getArguments($request, $controller, $reflector); + } catch (Throwable $exception) { + $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); + $span->setStatus(SpanStatus::error($exception->getMessage())); + + throw $exception; } finally { - $span->setStatus(SpanStatus::ok()); + // OTEL spec: instrumentation leaves the status Unset on success. $tracer->complete($span); } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingControllerResolver.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingControllerResolver.php index 7cb8694487..d9f619e8af 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingControllerResolver.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingControllerResolver.php @@ -4,6 +4,7 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel; +use DateTimeImmutable; use Flow\Telemetry\PackageVersion; use Flow\Telemetry\Telemetry; use Flow\Telemetry\Tracer\Span; @@ -11,6 +12,7 @@ use Flow\Telemetry\Tracer\SpanStatus; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface; +use Throwable; final readonly class TracingControllerResolver implements ControllerResolverInterface { @@ -30,8 +32,14 @@ public function getController(Request $request): callable|false try { return $this->resolver->getController($request); + } catch (Throwable $exception) { + $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); + $span->setStatus(SpanStatus::error($exception->getMessage())); + + throw $exception; } finally { - $span->setStatus(SpanStatus::ok()); + // OTEL spec: instrumentation leaves the status Unset on success. $tracer->complete($span); } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingValueResolver.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingValueResolver.php index 12238399ce..45665eb5ed 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingValueResolver.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingValueResolver.php @@ -4,6 +4,7 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel; +use DateTimeImmutable; use Flow\Telemetry\PackageVersion; use Flow\Telemetry\Telemetry; use Flow\Telemetry\Tracer\Span; @@ -12,6 +13,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Throwable; final readonly class TracingValueResolver implements ValueResolverInterface { @@ -36,8 +38,14 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable try { yield from $this->inner->resolve($request, $argument); - $span->setStatus(SpanStatus::ok()); + } catch (Throwable $exception) { + $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); + $span->setStatus(SpanStatus::error($exception->getMessage())); + + throw $exception; } finally { + // OTEL spec: instrumentation leaves the status Unset on success. $tracer->complete($span); } } diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php index 7f5ce7d595..4c8d0bbe25 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Messenger/TracingMiddleware.php @@ -121,12 +121,10 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope } try { - $result = $stack->next()->handle($envelope, $stack); - $span->setStatus(SpanStatus::ok()); - - return $result; + return $stack->next()->handle($envelope, $stack); } catch (Throwable $e) { $span->recordException($e, new DateTimeImmutable()); + $span->setAttribute('error.type', $e::class); $span->setStatus(SpanStatus::error($e->getMessage())); throw $e; diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Twig/TracingTwigExtension.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Twig/TracingTwigExtension.php index cdb908a6db..fecab53a59 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Twig/TracingTwigExtension.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/Twig/TracingTwigExtension.php @@ -95,7 +95,7 @@ public function leave(Profile $profile): void // @mago-expect analysis:no-value(2),redundant-type-comparison(2),redundant-logical-operation(2) if (is_array($spanData) && $spanData['tracer'] instanceof Tracer && $spanData['span'] instanceof Span) { - $spanData['span']->setStatus(SpanStatus::ok()); + // OTEL spec: instrumentation leaves the status Unset on success. $spanData['tracer']->complete($spanData['span']); } @@ -109,6 +109,7 @@ public function reset(): void $spanData = $this->activeSpans[$profile]; if (is_array($spanData) && $spanData['tracer'] instanceof Tracer && $spanData['span'] instanceof Span) { + $spanData['span']->setAttribute('error.type', 'incomplete_render'); $spanData['span']->setStatus(SpanStatus::error('Twig rendering did not complete')); $spanData['tracer']->complete($spanData['span']); } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Controller/TestController.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Controller/TestController.php index 0fdc587602..b0f5ae943d 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Controller/TestController.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Controller/TestController.php @@ -26,6 +26,11 @@ public function index(): Response return new JsonResponse(['status' => 'ok']); } + public function serverError(): Response + { + return new JsonResponse(['error' => 'boom'], 500); + } + public function withArgument(Request $request): Response { return new JsonResponse(['method' => $request->getMethod()]); diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Console/ConsoleSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Console/ConsoleSpanSubscriberTest.php index 726634d050..39e4a85e8c 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Console/ConsoleSpanSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Console/ConsoleSpanSubscriberTest.php @@ -305,8 +305,7 @@ public function test_traces_successful_console_command(): void static::assertSame(TestCommand::class, $attributes['command.class']); static::assertSame(0, $attributes['process.exit_code']); - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Doctrine/DBAL/TracingMiddlewareTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Doctrine/DBAL/TracingMiddlewareTest.php index 7f46094071..b0c1c9afd4 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Doctrine/DBAL/TracingMiddlewareTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Doctrine/DBAL/TracingMiddlewareTest.php @@ -306,7 +306,7 @@ public function test_exec_creates_span_with_sql_attribute(): void 'CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)', $execSpan->attributes()['db.query.text'], ); - static::assertTrue($execSpan->status()?->isOk()); + static::assertNull($execSpan->status()); } public function test_failed_transaction_creates_rollback_span(): void @@ -375,7 +375,7 @@ public function test_failed_transaction_creates_rollback_span(): void static::assertNotNull($rollbackSpan, 'Rollback span should exist'); static::assertSame(SpanKind::CLIENT, $rollbackSpan->kind()); - static::assertTrue($rollbackSpan->status()?->isOk()); + static::assertNull($rollbackSpan->status()); } public function test_long_sql_is_truncated_when_max_length_configured(): void @@ -518,10 +518,10 @@ public function test_prepared_statement_creates_prepare_and_execute_spans(): voi static::assertNotNull($prepareSpan, 'Prepare span should exist'); static::assertSame('INSERT INTO test_table (name) VALUES (:name)', $prepareSpan->attributes()['db.query.text']); - static::assertTrue($prepareSpan->status()?->isOk()); + static::assertNull($prepareSpan->status()); static::assertNotNull($executeSpan, 'Execute span should exist'); - static::assertTrue($executeSpan->status()?->isOk()); + static::assertNull($executeSpan->status()); } public function test_query_creates_span_with_sql_attribute(): void @@ -576,13 +576,13 @@ public function test_query_creates_span_with_sql_attribute(): void static::assertSame('doctrine.dbal.connection', $connectionSpan->name()); static::assertSame(SpanKind::CLIENT, $connectionSpan->kind()); static::assertSame('default', $connectionSpan->attributes()['db.connection.name']); - static::assertTrue($connectionSpan->status()?->isOk()); + static::assertNull($connectionSpan->status()); $querySpan = $spans[1]; static::assertSame('doctrine.dbal.connection.query', $querySpan->name()); static::assertSame(SpanKind::CLIENT, $querySpan->kind()); static::assertSame('SELECT 1 as value', $querySpan->attributes()['db.query.text']); - static::assertTrue($querySpan->status()?->isOk()); + static::assertNull($querySpan->status()); } public function test_query_error_creates_span_with_error_status(): void @@ -790,10 +790,10 @@ public function test_transaction_creates_begin_commit_spans(): void static::assertNotNull($beginSpan, 'Begin transaction span should exist'); static::assertSame(SpanKind::CLIENT, $beginSpan->kind()); - static::assertTrue($beginSpan->status()?->isOk()); + static::assertNull($beginSpan->status()); static::assertNotNull($commitSpan, 'Commit span should exist'); static::assertSame(SpanKind::CLIENT, $commitSpan->kind()); - static::assertTrue($commitSpan->status()?->isOk()); + static::assertNull($commitSpan->status()); } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpClient/TracableHttpClientTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpClient/TracableHttpClientTest.php index df1a458099..13ea16bb6a 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpClient/TracableHttpClientTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpClient/TracableHttpClientTest.php @@ -290,10 +290,10 @@ public function test_wrapped_client_creates_span_with_correct_attributes(): void static::assertSame('api.example.com', $attributes['server.address']); static::assertSame('test.api_client', $attributes['http.client.name']); static::assertSame(200, $attributes['http.response.status_code']); + static::assertArrayNotHasKey('error.type', $attributes); - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL semconv: 1xx-3xx leaves the span status unset. + static::assertNull($span->status()); } public function test_wrapped_client_records_exception_and_creates_error_span(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php index 63f3712f50..523d8cbda5 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/HttpKernelSpanSubscriberTest.php @@ -701,7 +701,7 @@ public function test_handles_missing_trace_headers_gracefully(): void static::assertNull($span->context()->parentSpanId); } - public function test_traces_http_request_with_error_status(): void + public function test_client_error_status_leaves_server_span_status_unset(): void { $kernel = $this->bootKernel([ 'config' => static function (TestKernel $kernel): void { @@ -754,11 +754,73 @@ public function test_traces_http_request_with_error_status(): void $span = array_values(array_filter($spans, static fn(Span $s): bool => $s->kind() === SpanKind::SERVER))[0]; $attributes = $span->attributes(); static::assertSame(404, $attributes['http.response.status_code']); + static::assertArrayNotHasKey('error.type', $attributes); + + // OTEL semconv: a 4xx is the client's fault, so the SERVER span status stays unset. + static::assertNull($span->status()); + } + + public function test_server_error_status_marks_server_span_as_error(): void + { + $kernel = $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestBundle(FrameworkBundle::class); + $kernel->addTestExtensionConfig('framework', [ + 'router' => [ + 'utf8' => true, + 'resource' => __DIR__ . '/../../../Fixtures/config/routes.php', + ], + 'http_method_override' => false, + 'handle_all_throwables' => true, + ]); + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => [ + 'type' => 'memory', + 'exporter' => 'memory', + ], + ], + 'instrumentation' => [ + 'http_kernel' => ['enabled' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $routes = $router->getRouteCollection(); + $routes->add('test_server_error', new Route('/server-error', [ + '_controller' => TestController::class . '::serverError', + ])); + + $request = Request::create('/server-error', 'GET'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + static::assertSame(500, $response->getStatusCode()); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $spans = $processor->endedSpans(); + + static::assertCount(2, $spans); + + $span = array_values(array_filter($spans, static fn(Span $s): bool => $s->kind() === SpanKind::SERVER))[0]; + $attributes = $span->attributes(); + static::assertSame(500, $attributes['http.response.status_code']); + static::assertSame('500', $attributes['error.type']); $status = $span->status(); static::assertNotNull($status); static::assertTrue($status->isError()); - static::assertSame('HTTP 404', $status->description); + static::assertSame('HTTP 500', $status->description); } public function test_traces_successful_http_request(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php index 609a0dfed8..4db75f487f 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Messenger/TracingMiddlewareTest.php @@ -945,9 +945,8 @@ public function test_traces_message_dispatch(): void static::assertSame('send', $attributes['messaging.operation.name']); static::assertSame('command.bus', $attributes['messaging.symfony.bus']); - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); } public function test_traces_message_with_exception(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Psr18/Psr18ClientTelemetryPassTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Psr18/Psr18ClientTelemetryPassTest.php index 0e70bb84a4..19eed26b4e 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Psr18/Psr18ClientTelemetryPassTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Psr18/Psr18ClientTelemetryPassTest.php @@ -271,9 +271,8 @@ public function test_wrapped_client_creates_span_with_correct_attributes(): void static::assertSame('api.example.com', $attributes['server.address']); static::assertSame(200, $attributes['http.response.status_code']); - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); } public function test_wrapped_client_records_exception_and_creates_error_span(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleSpanSubscriberTest.php index 88a58ccc05..66158c46ed 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleSpanSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Console/ConsoleSpanSubscriberTest.php @@ -47,9 +47,8 @@ public function test_exit_code_0_sets_ok_status(): void $spans = $spanProcessor->endedSpans(); static::assertCount(1, $spans); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($spans[0]->status()); } public function test_exit_code_nonzero_sets_error_status(): void @@ -73,6 +72,7 @@ public function test_exit_code_nonzero_sets_error_status(): void static::assertNotNull($status); static::assertTrue($status->isError()); static::assertSame('Exit code: 1', $status->description); + static::assertSame('1', $spans[0]->attributes()['error.type']); } public function test_get_subscribed_events_returns_correct_events(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableHttpClientTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableHttpClientTest.php index 14b682a8db..7123db5a94 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableHttpClientTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableHttpClientTest.php @@ -200,6 +200,7 @@ public function test_request_sets_error_status_for_4xx_codes(): void $spans = $spanProcessor->endedSpans(); static::assertCount(1, $spans); + static::assertSame('404', $spans[0]->attributes()['error.type']); $status = $spans[0]->status(); static::assertNotNull($status); @@ -220,6 +221,7 @@ public function test_request_sets_error_status_for_5xx_codes(): void $spans = $spanProcessor->endedSpans(); static::assertCount(1, $spans); + static::assertSame('500', $spans[0]->attributes()['error.type']); $status = $spans[0]->status(); static::assertNotNull($status); @@ -227,7 +229,7 @@ public function test_request_sets_error_status_for_5xx_codes(): void static::assertSame('HTTP 500', $status->description); } - public function test_request_sets_ok_status_for_2xx_codes(): void + public function test_request_leaves_status_unset_for_2xx_codes(): void { $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); $tracable = new TracableHttpClient( @@ -241,12 +243,11 @@ public function test_request_sets_ok_status_for_2xx_codes(): void $spans = $spanProcessor->endedSpans(); static::assertCount(1, $spans); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL semconv: 1xx-3xx leaves the span status unset. + static::assertNull($spans[0]->status()); } - public function test_request_sets_ok_status_for_3xx_codes(): void + public function test_request_leaves_status_unset_for_3xx_codes(): void { $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); $tracable = new TracableHttpClient( @@ -260,9 +261,7 @@ public function test_request_sets_ok_status_for_3xx_codes(): void $spans = $spanProcessor->endedSpans(); static::assertCount(1, $spans); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + static::assertNull($spans[0]->status()); } public function test_request_span_name_includes_method_and_host(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableResponseTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableResponseTest.php index f64b2e078d..a8ffadccca 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableResponseTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpClient/TracableResponseTest.php @@ -79,6 +79,7 @@ public function test_get_content_completes_span_with_error_status_for_4xx(): voi static::assertTrue($span->isEnded()); static::assertCount(1, $processor->endedSpans()); + static::assertSame('404', $span->attributes()['error.type']); $status = $span->status(); static::assertNotNull($status); @@ -86,7 +87,7 @@ public function test_get_content_completes_span_with_error_status_for_4xx(): voi static::assertSame('HTTP 404', $status->description); } - public function test_get_content_completes_span_with_ok_status(): void + public function test_get_content_completes_span_with_unset_status_on_success(): void { $processor = new MemorySpanProcessor(new MemoryExporter()); $tracer = TelemetryMother::withSpanProcessor($processor)->tracer('test'); @@ -98,10 +99,10 @@ public function test_get_content_completes_span_with_ok_status(): void static::assertTrue($span->isEnded()); static::assertCount(1, $processor->endedSpans()); static::assertSame(200, $span->attributes()['http.response.status_code']); + static::assertArrayNotHasKey('error.type', $span->attributes()); - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL semconv: 1xx-3xx leaves the span status unset. + static::assertNull($span->status()); } public function test_get_headers_records_status_without_completing_span(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerSpanSubscriberTest.php index 3989a1a61a..36605f19c8 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerSpanSubscriberTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerSpanSubscriberTest.php @@ -80,8 +80,8 @@ public function test_opens_and_completes_body_span_on_response(): void static::assertSame('test_index', $attributes['http.route']); static::assertSame($requestSpan->context()->spanId->toHex(), $span->context()->parentSpanId?->toHex()); - static::assertNotNull($span->status()); - static::assertTrue($span->status()?->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); } public function test_completes_body_span_on_view(): void diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Twig/TracingTwigExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Twig/TracingTwigExtensionTest.php index fa7606ff1c..14506cb774 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Twig/TracingTwigExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Twig/TracingTwigExtensionTest.php @@ -273,9 +273,8 @@ public function test_span_has_ok_status_after_leave(): void $spans = $spanProcessor->endedSpans(); static::assertCount(1, $spans); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($spans[0]->status()); } public function test_span_kind_is_internal(): void diff --git a/src/core/etl/src/Flow/ETL/Cache/Implementation/TraceableCache.php b/src/core/etl/src/Flow/ETL/Cache/Implementation/TraceableCache.php index 5f253b4134..5aa27496c6 100644 --- a/src/core/etl/src/Flow/ETL/Cache/Implementation/TraceableCache.php +++ b/src/core/etl/src/Flow/ETL/Cache/Implementation/TraceableCache.php @@ -45,9 +45,9 @@ public function clear(): void try { $this->cache->clear(); - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -65,9 +65,9 @@ public function delete(string $key): void try { $this->cache->delete($key); - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; @@ -122,9 +122,9 @@ public function set(string $key, Row|Rows|CacheIndex $value): void try { $this->cache->set($key, $value); - $span->setStatus(SpanStatus::ok()); } catch (Throwable $exception) { $span->recordException($exception, new DateTimeImmutable()); + $span->setAttribute('error.type', $exception::class); $span->setStatus(SpanStatus::error($exception->getMessage())); throw $exception; diff --git a/src/core/etl/src/Flow/ETL/Config/Telemetry/TelemetryContext.php b/src/core/etl/src/Flow/ETL/Config/Telemetry/TelemetryContext.php index dd90df0d7f..ce2bc11ddc 100644 --- a/src/core/etl/src/Flow/ETL/Config/Telemetry/TelemetryContext.php +++ b/src/core/etl/src/Flow/ETL/Config/Telemetry/TelemetryContext.php @@ -102,18 +102,14 @@ public function dataFrameCompleted(FlowContext $context, array $attributes = []) $throughput = $durationSeconds > 0 ? round($this->totalRowsProcessed / $durationSeconds, 2) : 0.0; } - $this->tracer->complete( - $dataFrameSpan - ->setAttributes(array_merge($attributes, [ - 'dataframe.id' => $context->config->id(), - 'dataframe.name' => $context->config->name(), - 'rows.total' => $this->totalRowsProcessed, - 'rows.throughput.per_second' => $throughput, - 'memory.min.mb' => $this->memory->min()->inMb(), - 'memory.max.mb' => $this->memory->max()->inMb(), - ])) - ->setStatus(SpanStatus::ok()), - ); + $this->tracer->complete($dataFrameSpan->setAttributes(array_merge($attributes, [ + 'dataframe.id' => $context->config->id(), + 'dataframe.name' => $context->config->name(), + 'rows.total' => $this->totalRowsProcessed, + 'rows.throughput.per_second' => $throughput, + 'memory.min.mb' => $this->memory->min()->inMb(), + 'memory.max.mb' => $this->memory->max()->inMb(), + ]))); if ($this->counterProcessedRows !== null) { $this->meter->complete($this->counterProcessedRows); @@ -166,6 +162,7 @@ public function dataFrameFailed(FlowContext $context, Throwable $exception, arra 'memory.min.mb' => $this->memory->min()->inMb(), 'memory.max.mb' => $this->memory->max()->inMb(), ])) + ->setAttribute('error.type', $exception::class) ->setStatus(SpanStatus::error($exception->getMessage())), ); @@ -248,7 +245,7 @@ public function loadingCompleted(Loader $loader, array $attributes = []): void if ($this->loadingSpan === null) { return; } - $this->tracer->complete($this->loadingSpan->setAttributes($attributes)->setStatus(SpanStatus::ok())); + $this->tracer->complete($this->loadingSpan->setAttributes($attributes)); $this->loadingSpan = null; } @@ -264,7 +261,10 @@ public function loadingFailed(Loader $loader, Throwable $exception, array $attri $this->logger->error('Loading failed', ['exception' => $exception->getMessage(), 'loader' => $loader::class]); $this->tracer->complete( - $this->loadingSpan->setAttributes($attributes)->setStatus(SpanStatus::error($exception->getMessage())), + $this->loadingSpan + ->setAttributes($attributes) + ->setAttribute('error.type', $exception::class) + ->setStatus(SpanStatus::error($exception->getMessage())), ); $this->loadingSpan = null; @@ -304,7 +304,7 @@ public function transformationCompleted(Transformer $transformer, array $attribu return; } - $this->tracer->complete($this->transformationSpan->setAttributes($attributes)->setStatus(SpanStatus::ok())); + $this->tracer->complete($this->transformationSpan->setAttributes($attributes)); } /** @@ -323,6 +323,7 @@ public function transformationFailed(Transformer $transformer, Throwable $except $this->tracer->complete( $this->transformationSpan ->setAttributes($attributes) + ->setAttribute('error.type', $exception::class) ->setStatus(SpanStatus::error($exception->getMessage())), ); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/TelemetryTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/TelemetryTest.php index e4ffd54b38..1aef8954c2 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/TelemetryTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/TelemetryTest.php @@ -104,9 +104,8 @@ public function test_dataframe_loading_traced_when_enabled(): void static::assertNotEmpty($loadingSpans, 'Loading spans should be created when trace_loading is enabled'); foreach ($loadingSpans as $span) { - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); static::assertArrayHasKey('loader.class', $span->attributes()); } } @@ -138,9 +137,8 @@ public function test_dataframe_run_creates_telemetry_span(): void $dataFrameSpan = $endedSpans[0]; static::assertSame('DataFrame flow_dataframe', $dataFrameSpan->name()); - $status = $dataFrameSpan->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($dataFrameSpan->status()); } public function test_dataframe_run_logs_start_and_completion(): void @@ -268,9 +266,8 @@ public function test_dataframe_transformations_traced_when_enabled(): void ); foreach ($transformerSpans as $span) { - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); static::assertArrayHasKey('transformer.class', $span->attributes()); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php index a273bd053b..ecc1705f03 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php @@ -262,6 +262,7 @@ public function test_set_records_exception_on_error(): void static::assertNotNull($status); static::assertTrue($status->isError()); static::assertSame('Test error', $status->description); + static::assertSame(RuntimeException::class, $span->attributes()['error.type']); static::assertCount(1, $span->events()); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php index 4e4e48f34a..4ffcffb2e1 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php @@ -115,9 +115,8 @@ public function test_dataframe_completed_finalizes_span_with_statistics(): void $span = $spans[0]; static::assertSame('DataFrame flow_dataframe', $span->name()); - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); $attributes = $span->attributes(); static::assertArrayHasKey('rows.total', $attributes); @@ -173,6 +172,7 @@ public function test_dataframe_failed_logs_error_and_sets_span_status(): void static::assertNotNull($status); static::assertTrue($status->isError()); static::assertSame('Processing failed due to invalid data', $status->description); + static::assertSame(RuntimeException::class, $endedSpans[0]->attributes()['error.type']); $attributes = $endedSpans[0]->attributes(); static::assertArrayHasKey('rows.total', $attributes); @@ -254,9 +254,8 @@ public function test_loading_completed_finalizes_span_with_ok_status(): void static::assertCount(1, $endedSpans); static::assertSame('StreamLoader', $endedSpans[0]->name()); static::assertSame(StreamLoader::class, $endedSpans[0]->attributes()['loader.class']); - $status = $endedSpans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($endedSpans[0]->status()); } public function test_loading_failed_logs_error_and_sets_span_status(): void @@ -303,6 +302,7 @@ public function test_loading_failed_logs_error_and_sets_span_status(): void static::assertNotNull($status); static::assertTrue($status->isError()); static::assertSame('Loading failed due to disk error', $status->description); + static::assertSame(RuntimeException::class, $endedSpans[0]->attributes()['error.type']); } public function test_loading_started_creates_span_when_trace_loading_enabled(): void @@ -539,9 +539,8 @@ public function test_transformation_completed_finalizes_span(): void static::assertCount(1, $endedSpans); static::assertSame('LimitTransformer', $endedSpans[0]->name()); static::assertSame(LimitTransformer::class, $endedSpans[0]->attributes()['transformer.class']); - $status = $endedSpans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($endedSpans[0]->status()); } public function test_transformation_failed_logs_error_and_sets_span_status(): void @@ -588,6 +587,7 @@ public function test_transformation_failed_logs_error_and_sets_span_status(): vo static::assertNotNull($status); static::assertTrue($status->isError()); static::assertSame('Transformation failed', $status->description); + static::assertSame(RuntimeException::class, $endedSpans[0]->attributes()['error.type']); } public function test_transformation_started_creates_span_when_trace_transformations_enabled(): void diff --git a/src/lib/filesystem/src/Flow/Filesystem/Telemetry/FilesystemTelemetryAttributes.php b/src/lib/filesystem/src/Flow/Filesystem/Telemetry/FilesystemTelemetryAttributes.php index 9988592f6c..a1339a26aa 100644 --- a/src/lib/filesystem/src/Flow/Filesystem/Telemetry/FilesystemTelemetryAttributes.php +++ b/src/lib/filesystem/src/Flow/Filesystem/Telemetry/FilesystemTelemetryAttributes.php @@ -14,6 +14,8 @@ final class FilesystemTelemetryAttributes public const string ATTR_BYTES_WRITTEN = 'bytes.written'; + public const string ATTR_ERROR_TYPE = 'error.type'; + public const string ATTR_FILESYSTEM_OPERATION = 'filesystem.operation'; public const string ATTR_FILESYSTEM_PROTOCOL = 'filesystem.protocol'; diff --git a/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableDestinationStream.php b/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableDestinationStream.php index 939d19c1ad..2d59be9ae5 100644 --- a/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableDestinationStream.php +++ b/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableDestinationStream.php @@ -88,11 +88,11 @@ public function close(): void if ($span !== null) { $span->setAttribute(FilesystemTelemetryAttributes::ATTR_BYTES_TOTAL_WRITTEN, $this->totalBytesWritten); - $span->setStatus(SpanStatus::ok()); } } catch (Throwable $e) { if ($span !== null) { $span->setAttribute(FilesystemTelemetryAttributes::ATTR_BYTES_TOTAL_WRITTEN, $this->totalBytesWritten); + $span->setAttribute(FilesystemTelemetryAttributes::ATTR_ERROR_TYPE, $e::class); $span->recordException($e, $this->telemetryConfig->clock->now()); $span->setStatus(SpanStatus::error($e->getMessage())); } diff --git a/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableSourceStream.php b/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableSourceStream.php index 4fc41b39f5..949389c36f 100644 --- a/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableSourceStream.php +++ b/src/lib/filesystem/src/Flow/Filesystem/Telemetry/TraceableSourceStream.php @@ -76,11 +76,11 @@ public function close(): void if ($span !== null) { $span->setAttribute(FilesystemTelemetryAttributes::ATTR_BYTES_TOTAL_READ, $this->totalBytesRead); - $span->setStatus(SpanStatus::ok()); } } catch (Throwable $e) { if ($span !== null) { $span->setAttribute(FilesystemTelemetryAttributes::ATTR_BYTES_TOTAL_READ, $this->totalBytesRead); + $span->setAttribute(FilesystemTelemetryAttributes::ATTR_ERROR_TYPE, $e::class); $span->recordException($e, $this->telemetryConfig->clock->now()); $span->setStatus(SpanStatus::error($e->getMessage())); } diff --git a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Integration/Telemetry/TraceableFilesystemIntegrationTest.php b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Integration/Telemetry/TraceableFilesystemIntegrationTest.php index 2898d66a0c..fef89d10ee 100644 --- a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Integration/Telemetry/TraceableFilesystemIntegrationTest.php +++ b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Integration/Telemetry/TraceableFilesystemIntegrationTest.php @@ -61,9 +61,8 @@ public function test_complete_read_write_workflow_produces_lifecycle_spans(): vo static::assertCount(2, $spans); foreach ($spans as $span) { - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); } $destinationSpans = array_values(array_filter( @@ -128,9 +127,8 @@ public function test_from_resource_tracks_bytes_in_lifecycle_span(): void )); static::assertCount(1, $destinationSpans); - $status = $destinationSpans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($destinationSpans[0]->status()); static::assertSame( 'destination', $destinationSpans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_STREAM_TYPE], @@ -297,9 +295,8 @@ public function test_read_lines_tracks_bytes_in_lifecycle_span(): void $sourceSpans = array_values(array_filter($spans, static fn($span) => $span->name() === 'Read lines_test.txt')); static::assertCount(1, $sourceSpans); - $status = $sourceSpans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($sourceSpans[0]->status()); } public function test_rm_operation_does_not_create_span(): void diff --git a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableDestinationStreamTest.php b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableDestinationStreamTest.php index 352a236576..d334a1958c 100644 --- a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableDestinationStreamTest.php +++ b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableDestinationStreamTest.php @@ -48,9 +48,8 @@ public function test_append_tracks_bytes_written(): void strlen($data), $spans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_BYTES_TOTAL_WRITTEN], ); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($spans[0]->status()); } public function test_close_completes_lifecycle_span_with_final_attributes(): void @@ -77,9 +76,8 @@ public function test_close_completes_lifecycle_span_with_final_attributes(): voi strlen($data), $spans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_BYTES_TOTAL_WRITTEN], ); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($spans[0]->status()); } public function test_close_records_exception_and_rethrows(): void @@ -108,6 +106,10 @@ public function test_close_records_exception_and_rethrows(): void $status = $spans[0]->status(); static::assertNotNull($status); static::assertTrue($status->isError()); + static::assertSame( + RuntimeException::class, + $spans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_ERROR_TYPE], + ); static::assertNotEmpty($spans[0]->events()); } } @@ -154,9 +156,8 @@ public function test_from_resource_tracks_bytes_written(): void static::assertSame('Write test.txt', $spans[0]->name()); static::assertSame('destination', $spans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_STREAM_TYPE]); static::assertSame($path->uri(), $spans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_PATH_URI]); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($spans[0]->status()); fclose($resource); } diff --git a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableSourceStreamTest.php b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableSourceStreamTest.php index c91bcbdc7d..f284a25883 100644 --- a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableSourceStreamTest.php +++ b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Telemetry/TraceableSourceStreamTest.php @@ -46,9 +46,8 @@ public function test_close_completes_lifecycle_span_with_final_attributes(): voi strlen($content), $spans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_BYTES_TOTAL_READ], ); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($spans[0]->status()); } public function test_close_records_exception_and_rethrows(): void @@ -77,6 +76,10 @@ public function test_close_records_exception_and_rethrows(): void $status = $spans[0]->status(); static::assertNotNull($status); static::assertTrue($status->isError()); + static::assertSame( + RuntimeException::class, + $spans[0]->attributes()[FilesystemTelemetryAttributes::ATTR_ERROR_TYPE], + ); static::assertNotEmpty($spans[0]->events()); } } diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableClient.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableClient.php index 35ac3093ae..2bc2c13483 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableClient.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableClient.php @@ -133,10 +133,10 @@ public function commit(): void try { $this->client->commit(); - $this->completeTransactionSpan($nestingLevel, SpanStatus::ok()); + $this->completeTransactionSpan($nestingLevel); $this->recordDuration($startTime, $this->buildTransactionAttributes($nestingLevel)); } catch (Throwable $e) { - $this->completeTransactionSpan($nestingLevel, SpanStatus::error($e->getMessage()), $e); + $this->completeTransactionSpan($nestingLevel, $e); $this->recordDuration($startTime, $this->buildTransactionAttributes($nestingLevel)); throw $e; @@ -443,10 +443,10 @@ public function rollBack(): void try { $this->client->rollBack(); - $this->completeAllTransactionSpans($nestingLevel, SpanStatus::ok()); + $this->completeAllTransactionSpans($nestingLevel); $this->recordDuration($startTime, $this->buildTransactionAttributes($nestingLevel)); } catch (Throwable $e) { - $this->completeAllTransactionSpans($nestingLevel, SpanStatus::error($e->getMessage()), $e); + $this->completeAllTransactionSpans($nestingLevel, $e); $this->recordDuration($startTime, $this->buildTransactionAttributes($nestingLevel)); throw $e; @@ -586,14 +586,14 @@ private function buildTransactionSpanName(string $operation, int $nestingLevel): return $operation . ' TRANSACTION'; } - private function completeAllTransactionSpans(int $fromLevel, SpanStatus $status, ?Throwable $exception = null): void + private function completeAllTransactionSpans(int $fromLevel, ?Throwable $exception = null): void { for ($level = $fromLevel; $level >= 1; $level--) { - $this->completeTransactionSpan($level, $status, $exception); + $this->completeTransactionSpan($level, $exception); } } - private function completeTransactionSpan(int $nestingLevel, SpanStatus $status, ?Throwable $exception = null): void + private function completeTransactionSpan(int $nestingLevel, ?Throwable $exception = null): void { $tracer = $this->tracer; @@ -603,10 +603,9 @@ private function completeTransactionSpan(int $nestingLevel, SpanStatus $status, $span = $this->transactionSpans[$nestingLevel]; + // OTEL spec: instrumentation leaves the status Unset on success; only errors set a status. if ($exception !== null) { $this->recordFailure($span, $exception); - } else { - $span->setStatus($status); } $tracer->complete($span); @@ -703,15 +702,12 @@ private function traceQuery( try { $result = $operation(); - if ($span !== null) { - if ($rowCountExtractor !== null) { - $rowCount = $rowCountExtractor($result); - $span->setAttribute(PostgreSqlTelemetryAttributes::DB_RESPONSE_RETURNED_ROWS, $rowCount); - $this->recordRowCount($rowCount, $queryAttrs); - } - - $span->setStatus(SpanStatus::ok()); + if ($span !== null && $rowCountExtractor !== null) { + $rowCount = $rowCountExtractor($result); + $span->setAttribute(PostgreSqlTelemetryAttributes::DB_RESPONSE_RETURNED_ROWS, $rowCount); + $this->recordRowCount($rowCount, $queryAttrs); } + // OTEL spec: instrumentation leaves the status Unset on success. $this->recordDuration($startTime, $attributes); diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableCursor.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableCursor.php index daec449c11..0a4a51fa39 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableCursor.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Telemetry/TraceableCursor.php @@ -91,9 +91,9 @@ public function free(): void { try { $this->cursor->free(); - $this->completeSpan(SpanStatus::ok()); + $this->completeSpan(); } catch (Throwable $e) { - $this->completeSpan(SpanStatus::error($e->getMessage()), $e); + $this->completeSpan($e); throw $e; } @@ -116,9 +116,9 @@ public function iterate(): Generator yield $row; } - $this->completeSpan(SpanStatus::ok()); + $this->completeSpan(); } catch (Throwable $e) { - $this->completeSpan(SpanStatus::error($e->getMessage()), $e); + $this->completeSpan($e); throw $e; } @@ -140,9 +140,9 @@ public function map(RowMapper $mapper): Generator yield $object; } - $this->completeSpan(SpanStatus::ok()); + $this->completeSpan(); } catch (Throwable $e) { - $this->completeSpan(SpanStatus::error($e->getMessage()), $e); + $this->completeSpan($e); throw $e; } @@ -215,7 +215,7 @@ private function buildSpanName(): string return 'cursor'; } - private function completeSpan(SpanStatus $status, ?Throwable $exception = null): void + private function completeSpan(?Throwable $exception = null): void { $span = $this->span; @@ -227,13 +227,13 @@ private function completeSpan(SpanStatus $status, ?Throwable $exception = null): $span->setAttribute(PostgreSqlTelemetryAttributes::DB_RESPONSE_RETURNED_ROWS, $this->rowsIterated); + // OTEL spec: instrumentation leaves the status Unset on success; only errors set a status. if ($exception !== null) { $span->recordException($exception, $this->telemetryConfig->clock->now()); $span->setAttribute(PostgreSqlTelemetryAttributes::ERROR_TYPE, $exception::class); + $span->setStatus(SpanStatus::error($exception->getMessage())); } - $span->setStatus($status); - $tracer = $this->tracer; if ($tracer !== null) { diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTelemetryTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTelemetryTest.php index a5ce98fd19..056515e4fc 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTelemetryTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTelemetryTest.php @@ -157,9 +157,8 @@ public function test_commit_completes_transaction_span(): void $spans = $spanProcessor->endedSpans(); static::assertCount(1, $spans); - $status = $spans[0]->status(); - static::assertNotNull($status); - static::assertFalse($status->isError()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($spans[0]->status()); } public function test_duration_metric_is_recorded_when_metrics_enabled(): void diff --git a/src/lib/telemetry/src/Flow/Telemetry/Tracer/Tracer.php b/src/lib/telemetry/src/Flow/Telemetry/Tracer/Tracer.php index 3b01346885..eee17a621d 100644 --- a/src/lib/telemetry/src/Flow/Telemetry/Tracer/Tracer.php +++ b/src/lib/telemetry/src/Flow/Telemetry/Tracer/Tracer.php @@ -283,12 +283,11 @@ public function trace( $span = $this->span($name, $kind, [], [], $parentContext); try { - $result = $callback(); - $span->setStatus(SpanStatus::ok()); - - return $result; + // OTEL spec: instrumentation leaves the status Unset on success; only errors set a status. + return $callback(); } catch (Throwable $e) { $span->recordException($e, $this->clock->now()); + $span->setAttribute('error.type', $e::class); $span->setStatus(SpanStatus::error($e->getMessage())); throw $e; diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Tracer/TracingIntegrationTest.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Tracer/TracingIntegrationTest.php index 0dcf8a1a43..5496ac45b0 100644 --- a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Tracer/TracingIntegrationTest.php +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Tracer/TracingIntegrationTest.php @@ -116,6 +116,7 @@ public function test_exception_handling_in_trace(): void static::assertNotNull($status); static::assertTrue($status->isError()); static::assertSame('Database connection failed', $status->description); + static::assertSame(RuntimeException::class, $span->attributes()['error.type']); static::assertCount(1, $span->events()); $event = $span->events()[0]; @@ -213,9 +214,9 @@ public function test_trace_helper_completes_span_on_success(): void $span = $processor->endedSpans()[0]; static::assertSame('calculate', $span->name()); static::assertTrue($span->isEnded()); - $status = $span->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($span->status()); } private function clock(): ClockInterface diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Tracer/TracerTest.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Tracer/TracerTest.php index b5a4ecfdb2..0ddd02bcae 100644 --- a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Tracer/TracerTest.php +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Tracer/TracerTest.php @@ -265,21 +265,22 @@ public function test_trace_sets_error_status_on_exception(): void } catch (RuntimeException) { } - $status = $processor->endedSpans()[0]->status(); + $span = $processor->endedSpans()[0]; + $status = $span->status(); static::assertNotNull($status); static::assertTrue($status->isError()); + static::assertSame(RuntimeException::class, $span->attributes()['error.type']); } - public function test_trace_sets_ok_status_on_success(): void + public function test_trace_leaves_status_unset_on_success(): void { $processor = TracerMother::createMemoryProcessor(); $tracer = TracerMother::withInMemoryProcessor($processor); $tracer->trace('test-span', static fn() => 'ok'); - $status = $processor->endedSpans()[0]->status(); - static::assertNotNull($status); - static::assertTrue($status->isOk()); + // OTEL spec: instrumentation leaves the status Unset on success. + static::assertNull($processor->endedSpans()[0]->status()); } public function test_version_returns_tracer_version(): void From efc98b6ea77518fb8b744c709a36ed757d267419 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Sun, 28 Jun 2026 23:36:00 +0200 Subject: [PATCH 09/10] fix(flow-php/symfony-telemetry-bundle): key static resource cache by kernel environment - env-keyed default cache path so dev/prod don't freeze deployment.environment.name - track new cache filename in test cleanup --- .../bridges/symfony-telemetry-bundle.md | 6 ++-- documentation/upgrading.md | 8 +++++ .../TelemetryBundle/FlowTelemetryBundle.php | 8 +++-- .../Tests/Context/SymfonyContext.php | 3 +- .../FlowTelemetryExtensionTest.php | 36 +++++++++++++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index 7050e9421e..c97f079a25 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -50,7 +50,7 @@ flow_telemetry: static: cache: enabled: true # Cache static attributes (default: true) - path: null # Cache file path (default: sys_get_temp_dir()/flow_telemetry_resource.cache) + path: null # Cache file path (default: sys_get_temp_dir()/flow_telemetry_resource_.cache) os: enabled: true # Detect os.type, os.name, os.version, os.description host: @@ -86,7 +86,9 @@ Static detectors are cached by default. Dynamic detectors run on every request/c The cache file lives outside Symfony's cache lifecycle on purpose: building the Symfony cache (via `cache:warmup`) at image build time would otherwise freeze runtime-dependent attributes such as `host.name` or `process.pid` from the build container. Defaulting to -`sys_get_temp_dir()` keeps the cache per-runtime and avoids that pitfall. To invalidate it, +`sys_get_temp_dir()` keeps the cache per-runtime and avoids that pitfall. The default filename +is keyed by the kernel environment (`flow_telemetry_resource_.cache`) so switching `APP_ENV` +(e.g. dev → prod) does not serve a stale `deployment.environment.name`. To invalidate it, delete the cache file or restart the process; `cache:clear` does not touch it. Custom attributes override auto-detected values. diff --git a/documentation/upgrading.md b/documentation/upgrading.md index d006904c6e..96c19dd7f4 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -105,6 +105,14 @@ flow_postgresql: connection: analytics ``` +### 7) `flow-php/symfony-telemetry-bundle` - static resource cache file is keyed by kernel environment + +| Before | After | +|---------------------------------------------------------|-------------------------------------------------------------------| +| `sys_get_temp_dir()/flow_telemetry_resource.cache` | `sys_get_temp_dir()/flow_telemetry_resource_.cache` | + +Delete the orphaned `flow_telemetry_resource.cache` from the temp dir; a per-env file is written on the next run. + --- ## Upgrading from 0.39.x to 0.40.x diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php index 613811d665..8e1f5890a9 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php @@ -136,6 +136,7 @@ use function is_int; use function is_string; use function sprintf; +use function sys_get_temp_dir; use function ucfirst; use const LOG_PID; @@ -249,7 +250,7 @@ public function configure(DefinitionConfigurator $definition): void ->defaultTrue() ->end() ->scalarNode('path') - ->info('Absolute path to the cache file. Default: sys_get_temp_dir()/flow_telemetry_resource.cache.') + ->info('Absolute path to the cache file. Default: sys_get_temp_dir()/flow_telemetry_resource_.cache (keyed by kernel environment so switching APP_ENV does not serve a stale deployment.environment.name).') ->defaultNull() ->end() ->end() @@ -3579,7 +3580,10 @@ private function registerResource(array $resourceConfig, ContainerBuilder $build if ($cacheEnabled) { $cachingDefinition = new Definition(CachingDetector::class); $cachingDefinition->setArgument(0, new Reference('flow.telemetry.resource.detector.static.chain')); - $cachingDefinition->setArgument(1, $cacheConfig['path'] ?? null); + $cachingDefinition->setArgument( + 1, + $cacheConfig['path'] ?? sys_get_temp_dir() . '/flow_telemetry_resource_%kernel.environment%.cache', + ); $builder->setDefinition('flow.telemetry.resource.detector.static', $cachingDefinition); } else { $builder->setAlias( diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php index 190ed7d9c6..3cd48aed0b 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php @@ -95,6 +95,7 @@ public function shutdown(): void } $runDir = dirname($this->kernel->getCacheDir()); + $environment = $this->kernel->getEnvironment(); $this->kernel->shutdown(); $this->kernel = null; @@ -105,7 +106,7 @@ public function shutdown(): void $filesystem->remove($runDir); } - $defaultResourceCache = sys_get_temp_dir() . '/flow_telemetry_resource.cache'; + $defaultResourceCache = sys_get_temp_dir() . '/flow_telemetry_resource_' . $environment . '.cache'; if ($filesystem->exists($defaultResourceCache)) { $filesystem->remove($defaultResourceCache); diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php index a3c5903783..faef3c8934 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php @@ -127,6 +127,42 @@ public function test_caching_detector_writes_to_configured_path(): void } } + public function test_caching_detector_default_path_is_keyed_by_kernel_environment(): void + { + $expectedPath = sys_get_temp_dir() . '/flow_telemetry_resource_test.cache'; + + if (is_file($expectedPath)) { + unlink($expectedPath); + } + + try { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [ + 'custom' => ['service.name' => 'env-keyed-service'], + ], + ]); + }, + ]); + + $container = $this->getContainer(); + static::assertInstanceOf( + CachingDetector::class, + $container->get('flow.telemetry.resource.detector.static'), + ); + + $resource = $container->get('flow.telemetry.resource'); + static::assertInstanceOf(Resource::class, $resource); + static::assertSame('env-keyed-service', $resource->get('service.name')); + static::assertFileExists($expectedPath); + } finally { + if (is_file($expectedPath)) { + unlink($expectedPath); + } + } + } + public function test_git_detector_is_not_registered_by_default(): void { $this->bootKernel([ From 9ed11c76d59ac1ce8dc5ca869929f00e13d7b3f3 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Mon, 29 Jun 2026 08:53:38 +0200 Subject: [PATCH 10/10] fix: changes test coverage --- .../Cache/FailingTagAwareCacheAdapter.php | 105 +++++++ .../Cache/TraceableCacheAdapterTest.php | 260 ++++++++++++++++++ .../TraceContextUrlGeneratorPassTest.php | 47 ++++ .../Doctrine/DBAL/TracingConnectionTest.php | 44 +++ .../Doctrine/DBAL/TracingDriverTest.php | 26 ++ .../Doctrine/DBAL/TracingStatementTest.php | 60 ++++ .../TracingArgumentResolverTest.php | 32 +++ .../TracingControllerResolverTest.php | 32 +++ .../HttpKernel/TracingValueResolverTest.php | 33 +++ .../Implementation/TraceableCacheTest.php | 44 +++ .../Client/Telemetry/TraceableClientTest.php | 26 ++ .../Client/Telemetry/TraceableCursorTest.php | 30 ++ 12 files changed, 739 insertions(+) create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Cache/FailingTagAwareCacheAdapter.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/TraceContextUrlGeneratorPassTest.php create mode 100644 src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingStatementTest.php diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Cache/FailingTagAwareCacheAdapter.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Cache/FailingTagAwareCacheAdapter.php new file mode 100644 index 0000000000..b4da51653c --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Cache/FailingTagAwareCacheAdapter.php @@ -0,0 +1,105 @@ +errorMessage); + } + + public function commit(): bool + { + throw new RuntimeException($this->errorMessage); + } + + public function delete(string $key): bool + { + throw new RuntimeException($this->errorMessage); + } + + public function deleteItem(mixed $key): bool + { + throw new RuntimeException($this->errorMessage); + } + + /** + * @param array $keys + */ + public function deleteItems(array $keys): bool + { + throw new RuntimeException($this->errorMessage); + } + + public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed + { + throw new RuntimeException($this->errorMessage); + } + + public function getItem(mixed $key): CacheItem + { + throw new RuntimeException($this->errorMessage); + } + + /** + * @param array $keys + * + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + throw new RuntimeException($this->errorMessage); + } + + public function hasItem(mixed $key): bool + { + throw new RuntimeException($this->errorMessage); + } + + /** + * @param array $tags + */ + public function invalidateTags(array $tags): bool + { + throw new RuntimeException($this->errorMessage); + } + + public function prune(): bool + { + throw new RuntimeException($this->errorMessage); + } + + public function reset(): void + { + throw new RuntimeException($this->errorMessage); + } + + public function save(CacheItemInterface $item): bool + { + throw new RuntimeException($this->errorMessage); + } + + public function saveDeferred(CacheItemInterface $item): bool + { + throw new RuntimeException($this->errorMessage); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Cache/TraceableCacheAdapterTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Cache/TraceableCacheAdapterTest.php index 1bc82076ff..583b1dc2a7 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Cache/TraceableCacheAdapterTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/Cache/TraceableCacheAdapterTest.php @@ -8,6 +8,8 @@ use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Cache\TagAwareTraceableCacheAdapter; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\Cache\TraceableCacheAdapter; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Cache\ArrayCacheAdapter; +use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Cache\FailingCacheAdapter; +use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Cache\FailingTagAwareCacheAdapter; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Cache\TagAwareArrayCacheAdapter; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\TestKernel; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Integration\KernelTestCase; @@ -16,6 +18,7 @@ use Flow\Telemetry\Telemetry; use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; +use RuntimeException; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -535,6 +538,263 @@ public function test_tag_aware_adapters_are_wrapped_with_tag_aware_traceable(): static::assertInstanceOf(TagAwareTraceableCacheAdapter::class, $container->get('test.cache.tags')); } + public function test_traceable_adapter_records_a_span_for_every_traced_operation(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container + ->register('test.cache.app', ArrayCacheAdapter::class) + ->addTag('cache.pool') + ->setPublic(true); + }); + + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => ['type' => 'memory', 'exporter' => 'memory'], + ], + 'instrumentation' => [ + 'http_kernel' => false, + 'console' => false, + 'messenger' => false, + 'cache' => true, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var TraceableCacheAdapter $cache */ + $cache = $container->get('test.cache.app'); + $item = $cache->getItem('save-key'); + + $cache->clear(); + $cache->commit(); + $cache->delete('delete-key'); + $cache->deleteItem('delete-item-key'); + $cache->deleteItems(['a', 'b']); + $cache->prune(); + $cache->reset(); + $cache->save($item); + $cache->saveDeferred($item); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $operations = array_map(static fn($span) => $span->attributes()['cache.operation'], $processor->endedSpans()); + + static::assertSame( + ['clear', 'commit', 'delete', 'deleteItem', 'deleteItems', 'prune', 'reset', 'save', 'saveDeferred'], + $operations, + ); + } + + public function test_traceable_adapter_records_error_span_when_operation_fails(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container + ->register('test.cache.app', FailingCacheAdapter::class) + ->addTag('cache.pool') + ->setPublic(true); + }); + + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => ['type' => 'memory', 'exporter' => 'memory'], + ], + 'instrumentation' => [ + 'http_kernel' => false, + 'console' => false, + 'messenger' => false, + 'cache' => true, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var TraceableCacheAdapter $cache */ + $cache = $container->get('test.cache.app'); + $item = (new ArrayCacheAdapter())->getItem('save-key'); + + $failures = 0; + + foreach ([ + static fn() => $cache->clear(), + static fn() => $cache->commit(), + static fn() => $cache->delete('key'), + static fn() => $cache->deleteItem('key'), + static fn() => $cache->deleteItems(['a']), + static fn() => $cache->prune(), + static fn() => $cache->reset(), + static fn() => $cache->save($item), + static fn() => $cache->saveDeferred($item), + ] as $operation) { + try { + $operation(); + } catch (RuntimeException) { + $failures++; + } + } + + static::assertSame(9, $failures); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $spans = $processor->endedSpans(); + + static::assertCount(9, $spans); + + foreach ($spans as $span) { + $status = $span->status(); + static::assertNotNull($status); + static::assertTrue($status->isError()); + static::assertSame(RuntimeException::class, $span->attributes()['error.type']); + } + } + + public function test_tag_aware_adapter_records_a_span_for_every_traced_operation(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container + ->register('test.cache.tags', TagAwareArrayCacheAdapter::class) + ->addTag('cache.pool') + ->setPublic(true); + }); + + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => ['type' => 'memory', 'exporter' => 'memory'], + ], + 'instrumentation' => [ + 'http_kernel' => false, + 'console' => false, + 'messenger' => false, + 'cache' => true, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var TagAwareTraceableCacheAdapter $cache */ + $cache = $container->get('test.cache.tags'); + $item = $cache->getItem('save-key'); + + $cache->clear(); + $cache->commit(); + $cache->delete('delete-key'); + $cache->deleteItem('delete-item-key'); + $cache->deleteItems(['a', 'b']); + $cache->invalidateTags(['tag1']); + $cache->prune(); + $cache->reset(); + $cache->save($item); + $cache->saveDeferred($item); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $operations = array_map(static fn($span) => $span->attributes()['cache.operation'], $processor->endedSpans()); + + static::assertSame( + [ + 'clear', + 'commit', + 'delete', + 'deleteItem', + 'deleteItems', + 'invalidateTags', + 'prune', + 'reset', + 'save', + 'saveDeferred', + ], + $operations, + ); + } + + public function test_tag_aware_adapter_records_error_span_when_operation_fails(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container + ->register('test.cache.tags', FailingTagAwareCacheAdapter::class) + ->addTag('cache.pool') + ->setPublic(true); + }); + + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'exporters' => ['memory' => ['memory' => null], 'void' => ['void' => null]], + 'tracer_provider' => [ + 'processor' => ['type' => 'memory', 'exporter' => 'memory'], + ], + 'instrumentation' => [ + 'http_kernel' => false, + 'console' => false, + 'messenger' => false, + 'cache' => true, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var TagAwareTraceableCacheAdapter $cache */ + $cache = $container->get('test.cache.tags'); + $item = (new TagAwareArrayCacheAdapter())->getItem('save-key'); + + $failures = 0; + + foreach ([ + static fn() => $cache->clear(), + static fn() => $cache->commit(), + static fn() => $cache->delete('key'), + static fn() => $cache->deleteItem('key'), + static fn() => $cache->deleteItems(['a']), + static fn() => $cache->invalidateTags(['tag1']), + static fn() => $cache->prune(), + static fn() => $cache->reset(), + static fn() => $cache->save($item), + static fn() => $cache->saveDeferred($item), + ] as $operation) { + try { + $operation(); + } catch (RuntimeException) { + $failures++; + } + } + + static::assertSame(10, $failures); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $spans = $processor->endedSpans(); + + static::assertCount(10, $spans); + + foreach ($spans as $span) { + $status = $span->status(); + static::assertNotNull($status); + static::assertTrue($status->isError()); + static::assertSame(RuntimeException::class, $span->attributes()['error.type']); + } + } + public function test_tag_aware_cache_creates_span_for_invalidate_tags(): void { $this->bootKernel([ diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/TraceContextUrlGeneratorPassTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/TraceContextUrlGeneratorPassTest.php new file mode 100644 index 0000000000..d73da6d020 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/TraceContextUrlGeneratorPassTest.php @@ -0,0 +1,47 @@ +process($container); + + static::assertFalse($container->hasDefinition('flow.telemetry.trace_context_url_generator')); + } + + public function test_keeps_generator_when_router_is_present(): void + { + $container = new ContainerBuilder(); + $container->register('flow.telemetry.trace_context_url_generator', stdClass::class); + $container->register('router', stdClass::class); + + (new TraceContextUrlGeneratorPass())->process($container); + + static::assertTrue($container->hasDefinition('flow.telemetry.trace_context_url_generator')); + } + + public function test_removes_generator_when_router_is_absent(): void + { + $container = new ContainerBuilder(); + $container->register('flow.telemetry.trace_context_url_generator', stdClass::class); + $container->setAlias(TraceContextUrlGenerator::class, 'flow.telemetry.trace_context_url_generator'); + + (new TraceContextUrlGeneratorPass())->process($container); + + static::assertFalse($container->hasDefinition('flow.telemetry.trace_context_url_generator')); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingConnectionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingConnectionTest.php index b3bae77669..3f2331d6c7 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingConnectionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingConnectionTest.php @@ -22,6 +22,7 @@ use Flow\Telemetry\Tracer\TracerProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use stdClass; use function mb_strlen; @@ -177,6 +178,49 @@ public function test_truncate_sql_truncates_and_appends_ellipsis(): void static::assertSame('SELECT * FROM users ...', $spans[0]->attributes()['db.query.text']); } + public function test_records_exception_when_operation_fails(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = $this->createTelemetry($spanProcessor); + + $connection = $this->createStub(ConnectionInterface::class); + $connection->method('beginTransaction')->willThrowException(new RuntimeException('boom')); + $connection->method('commit')->willThrowException(new RuntimeException('boom')); + $connection->method('exec')->willThrowException(new RuntimeException('boom')); + $connection->method('prepare')->willThrowException(new RuntimeException('boom')); + $connection->method('query')->willThrowException(new RuntimeException('boom')); + $connection->method('rollBack')->willThrowException(new RuntimeException('boom')); + + $tracing = new TracingConnection($connection, $telemetry, logSql: true, maxSqlLength: 100); + + $failures = 0; + + foreach ([ + static fn() => $tracing->beginTransaction(), + static fn() => $tracing->commit(), + static fn() => $tracing->exec('SELECT 1'), + static fn() => $tracing->prepare('SELECT 1'), + static fn() => $tracing->query('SELECT 1'), + static fn() => $tracing->rollBack(), + ] as $operation) { + try { + $operation(); + } catch (RuntimeException) { + $failures++; + } + } + + static::assertSame(6, $failures); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(6, $spans); + + foreach ($spans as $span) { + static::assertTrue($span->status()?->isError()); + static::assertSame(RuntimeException::class, $span->attributes()['error.type']); + } + } + private function createMockConnection(): ConnectionInterface { return new class implements ConnectionInterface { diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingDriverTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingDriverTest.php index 328f0e2092..b1f199ed11 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingDriverTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingDriverTest.php @@ -226,6 +226,32 @@ public function test_span_includes_db_namespace_from_params(): void static::assertSame('my_database', $spans[0]->attributes()['db.namespace']); } + public function test_records_exception_when_connect_fails(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = $this->createTelemetry($spanProcessor); + + $driver = $this->createStub(Driver::class); + $driver->method('connect')->willThrowException(new RuntimeException('connection refused')); + + $tracingDriver = new TracingDriver($telemetry, $driver, 'default', logSql: true, maxSqlLength: 100); + + $caught = false; + + try { + $tracingDriver->connect([]); + } catch (RuntimeException) { + $caught = true; + } + + static::assertTrue($caught); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertSame(RuntimeException::class, $spans[0]->attributes()['error.type']); + } + private function createMockDriverWithPlatform(AbstractPlatform $platform): Driver { return new readonly class($platform) implements Driver { diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingStatementTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingStatementTest.php new file mode 100644 index 0000000000..4ffd02b461 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/Doctrine/DBAL/TracingStatementTest.php @@ -0,0 +1,60 @@ +createStub(StatementInterface::class); + $statement->method('execute')->willReturn($this->createStub(Result::class)); + + (new TracingStatement($statement, $telemetry))->execute(); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertSame('doctrine.dbal.statement.execute', $spans[0]->name()); + static::assertSame(SpanKind::CLIENT, $spans[0]->kind()); + } + + public function test_records_exception_when_execution_fails(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + + $statement = $this->createStub(StatementInterface::class); + $statement->method('execute')->willThrowException(new RuntimeException('execute failed')); + + $caught = false; + + try { + (new TracingStatement($statement, $telemetry))->execute(); + } catch (RuntimeException) { + $caught = true; + } + + static::assertTrue($caught); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertSame(RuntimeException::class, $spans[0]->attributes()['error.type']); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingArgumentResolverTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingArgumentResolverTest.php index c7975b182d..73dcf28ac6 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingArgumentResolverTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingArgumentResolverTest.php @@ -13,6 +13,7 @@ use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface; @@ -64,4 +65,35 @@ public function test_passes_through_when_request_span_absent(): void static::assertSame(['a'], $arguments); static::assertCount(0, $spanProcessor->endedSpans()); } + + public function test_records_exception_when_inner_resolver_fails(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + $requestSpan = $telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel'))->span( + 'GET /test', + SpanKind::SERVER, + ); + + $inner = $this->createStub(ArgumentResolverInterface::class); + $inner->method('getArguments')->willThrowException(new RuntimeException('arguments failed')); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + $caught = false; + + try { + (new TracingArgumentResolver($inner, $telemetry))->getArguments($request, static fn(): null => null); + } catch (RuntimeException) { + $caught = true; + } + + static::assertTrue($caught); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertSame(RuntimeException::class, $spans[0]->attributes()['error.type']); + } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingControllerResolverTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingControllerResolverTest.php index 4d248f47a7..c535a2f502 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingControllerResolverTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingControllerResolverTest.php @@ -13,6 +13,7 @@ use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface; @@ -59,4 +60,35 @@ public function test_passes_through_when_request_span_absent(): void ); static::assertCount(0, $spanProcessor->endedSpans()); } + + public function test_records_exception_when_inner_resolver_fails(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + $requestSpan = $telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel'))->span( + 'GET /test', + SpanKind::SERVER, + ); + + $inner = $this->createStub(ControllerResolverInterface::class); + $inner->method('getController')->willThrowException(new RuntimeException('controller failed')); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + $caught = false; + + try { + (new TracingControllerResolver($inner, $telemetry))->getController($request); + } catch (RuntimeException) { + $caught = true; + } + + static::assertTrue($caught); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertSame(RuntimeException::class, $spans[0]->attributes()['error.type']); + } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingValueResolverTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingValueResolverTest.php index aab77fc1f7..7d61a07c17 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingValueResolverTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingValueResolverTest.php @@ -13,6 +13,7 @@ use Flow\Telemetry\Tracer\SpanKind; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; @@ -67,4 +68,36 @@ public function test_passes_through_when_request_span_absent(): void static::assertSame(['resolved'], $resolved); static::assertCount(0, $spanProcessor->endedSpans()); } + + public function test_records_exception_when_inner_resolver_fails(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + $requestSpan = $telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel'))->span( + 'GET /test', + SpanKind::SERVER, + ); + + $inner = $this->createStub(ValueResolverInterface::class); + $inner->method('resolve')->willThrowException(new RuntimeException('resolver failed')); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + $argument = new ArgumentMetadata('id', 'int', false, false, null); + + $caught = false; + + try { + iterator_to_array((new TracingValueResolver($inner, $telemetry))->resolve($request, $argument)); + } catch (RuntimeException) { + $caught = true; + } + + static::assertTrue($caught); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertSame(RuntimeException::class, $spans[0]->attributes()['error.type']); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php index ecc1705f03..d55bed8f63 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/Implementation/TraceableCacheTest.php @@ -239,6 +239,50 @@ public function test_set_creates_span_with_rows_value_type(): void static::assertSame('Rows', $attributes['cache.value_type']); } + public function test_clear_records_exception_on_error(): void + { + $innerCache = $this->createMock(Cache::class); + $innerCache->method('clear')->willThrowException(new RuntimeException('Test error')); + + $cache = new TraceableCache($innerCache, $this->telemetry); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Test error'); + + try { + $cache->clear(); + } finally { + $this->telemetry->flush(); + $spans = $this->spanProcessor->endedSpans(); + + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertSame(RuntimeException::class, $spans[0]->attributes()['error.type']); + } + } + + public function test_delete_records_exception_on_error(): void + { + $innerCache = $this->createMock(Cache::class); + $innerCache->method('delete')->willThrowException(new RuntimeException('Test error')); + + $cache = new TraceableCache($innerCache, $this->telemetry); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Test error'); + + try { + $cache->delete('test-key'); + } finally { + $this->telemetry->flush(); + $spans = $this->spanProcessor->endedSpans(); + + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertSame(RuntimeException::class, $spans[0]->attributes()['error.type']); + } + } + public function test_set_records_exception_on_error(): void { $innerCache = $this->createMock(Cache::class); diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTest.php index b1f56f4e7f..0a3abdc03c 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableClientTest.php @@ -166,6 +166,32 @@ public function test_execute_rethrows_exception_and_records_error(): void } } + public function test_commit_rethrows_exception_and_records_error(): void + { + $config = $this->createConfig(memory_span_processor(void_exporter())); + + $mockClient = $this->createMockClient(); + $mockClient->method('commit')->willThrowException(new RuntimeException('Commit failed')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Commit failed'); + + traceable_postgresql_client($mockClient, $config)->commit(); + } + + public function test_roll_back_rethrows_exception_and_records_error(): void + { + $config = $this->createConfig(memory_span_processor(void_exporter())); + + $mockClient = $this->createMockClient(); + $mockClient->method('rollBack')->willThrowException(new RuntimeException('Rollback failed')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Rollback failed'); + + traceable_postgresql_client($mockClient, $config)->rollBack(); + } + public function test_fetch_all_creates_span_with_row_count(): void { $spanProcessor = memory_span_processor(void_exporter()); diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableCursorTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableCursorTest.php index b3f5cb2280..88a5f00753 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableCursorTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Telemetry/TraceableCursorTest.php @@ -184,6 +184,36 @@ public function test_map_completes_span_after_full_iteration(): void static::assertSame(2, $spans[0]->attributes()[PostgreSqlTelemetryAttributes::DB_RESPONSE_RETURNED_ROWS]); } + public function test_map_records_error_on_exception(): void + { + $spanProcessor = memory_span_processor(void_exporter()); + $config = $this->createConfig($spanProcessor); + + $mockCursor = $this->createMock(Cursor::class); + $mockCursor + ->method('map') + ->willReturnCallback(static function (): Generator { + yield new stdClass(); + + throw new RuntimeException('Mapping failed'); + }); + + $cursor = new TraceableCursor($mockCursor, $config, $this->connectionParams(), 'SELECT * FROM users'); + + $this->expectException(RuntimeException::class); + + try { + foreach ($cursor->map(new SpyRowMapper()) as $_object) { + } + } finally { + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + $status = $spans[0]->status(); + static::assertNotNull($status); + static::assertTrue($status->isError()); + } + } + public function test_next_increments_row_count(): void { $spanProcessor = memory_span_processor(void_exporter());