diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index e1443aec5a..15897aad66 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -1067,6 +1067,10 @@ flow_telemetry: http_kernel: enabled: true context_propagation: true # Extract context from incoming headers + 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) + trace_controller_argument_resolvers: false # per-resolver controller.argument_value_resolver spans (default OFF) exclude_paths: - path: '/_profiler' - path: '/_wdt' @@ -1075,6 +1079,25 @@ 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 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. + +- `trace_controller` (default **true**) — the controller **body** execution. The span is named after the + resolved controller (e.g. `App\Controller\OrderController::import`) and carries `code.namespace`, + `code.function` and `controller` attributes. It starts after argument resolution and completes at + `kernel.view`/`kernel.response`, so it excludes resolution time and appears in both the OTLP export and the + Flow Telemetry profiler panel. +- `trace_controller_resolution` (default **false**) — controller resolution + (`ControllerResolverInterface::getController()`), emitted as a `controller.get_callable` span. +- `trace_controller_arguments` (default **false**) — argument resolution as a single aggregate + `controller.get_arguments` span. +- `trace_controller_argument_resolvers` (default **false**) — one `controller.argument_value_resolver` span + per value resolver invocation (finer-grained, higher cardinality). + +The resolution and argument toggles install service decorators only when enabled, so they add zero overhead +when off. + #### Console Traces console commands. diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ArgumentResolverTelemetryPass.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ArgumentResolverTelemetryPass.php new file mode 100644 index 0000000000..a57e721be2 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ArgumentResolverTelemetryPass.php @@ -0,0 +1,40 @@ +hasParameter('flow.telemetry.http_kernel.trace_controller_arguments')) { + return; + } + + if ($container->getParameter('flow.telemetry.http_kernel.trace_controller_arguments') !== true) { + return; + } + + if (!$container->hasDefinition('argument_resolver')) { + return; + } + + $decoratorId = 'argument_resolver.flow_telemetry'; + $decoratedId = $decoratorId . '.inner'; + + $definition = new Definition(TracingArgumentResolver::class); + $definition->setDecoratedService('argument_resolver'); + $definition->setArgument(0, new Reference($decoratedId)); + $definition->setArgument(1, new Reference(Telemetry::class)); + + $container->setDefinition($decoratorId, $definition); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ArgumentValueResolverTelemetryPass.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ArgumentValueResolverTelemetryPass.php new file mode 100644 index 0000000000..9e1fd7170f --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ArgumentValueResolverTelemetryPass.php @@ -0,0 +1,42 @@ +hasParameter('flow.telemetry.http_kernel.trace_controller_argument_resolvers')) { + return; + } + + if ($container->getParameter('flow.telemetry.http_kernel.trace_controller_argument_resolvers') !== true) { + return; + } + + foreach ($container->findTaggedServiceIds('controller.argument_value_resolver') as $serviceId => $_tags) { + if ($container->getDefinition($serviceId)->isAbstract()) { + continue; + } + + $decoratorId = $serviceId . '.flow_telemetry'; + $decoratedId = $decoratorId . '.inner'; + + $definition = new Definition(TracingValueResolver::class); + $definition->setDecoratedService($serviceId); + $definition->setArgument(0, new Reference($decoratedId)); + $definition->setArgument(1, new Reference(Telemetry::class)); + + $container->setDefinition($decoratorId, $definition); + } + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ControllerResolverTelemetryPass.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ControllerResolverTelemetryPass.php new file mode 100644 index 0000000000..e4c83e4b07 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/ControllerResolverTelemetryPass.php @@ -0,0 +1,40 @@ +hasParameter('flow.telemetry.http_kernel.trace_controller_resolution')) { + return; + } + + if ($container->getParameter('flow.telemetry.http_kernel.trace_controller_resolution') !== true) { + return; + } + + if (!$container->hasDefinition('controller_resolver')) { + return; + } + + $decoratorId = 'controller_resolver.flow_telemetry'; + $decoratedId = $decoratorId . '.inner'; + + $definition = new Definition(TracingControllerResolver::class); + $definition->setDecoratedService('controller_resolver'); + $definition->setArgument(0, new Reference($decoratedId)); + $definition->setArgument(1, new Reference(Telemetry::class)); + + $container->setDefinition($decoratorId, $definition); + } +} 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 4d45ffec41..3aa594224d 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 @@ -7,8 +7,11 @@ use Flow\Bridge\Psr3\Telemetry\LogRecordConverter; use Flow\Bridge\Psr3\Telemetry\TelemetryLogger; use Flow\Bridge\Symfony\TelemetryBundle\Attribute\WithTelemetryChannel; +use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\ArgumentResolverTelemetryPass; +use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\ArgumentValueResolverTelemetryPass; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\CacheTelemetryPass; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\ChannelLoggerPass; +use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\ControllerResolverTelemetryPass; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\DBALTelemetryPass; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\FrameworkLoggerPass; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\HttpClientTelemetryPass; @@ -158,6 +161,9 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new OTLPAvailabilityPass()); $container->addCompilerPass(new ProfilerSignalCapturePass()); + $container->addCompilerPass(new ControllerResolverTelemetryPass()); + $container->addCompilerPass(new ArgumentResolverTelemetryPass()); + $container->addCompilerPass(new ArgumentValueResolverTelemetryPass()); $container->addCompilerPass(new FrameworkLoggerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -64); $container->registerAttributeForAutoconfiguration( @@ -498,6 +504,22 @@ 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('trace_controller') + ->info('Trace controller body execution as a child of the request span (span name = resolved controller)') + ->defaultTrue() + ->end() + ->booleanNode('trace_controller_resolution') + ->info('Trace controller resolution (controller.get_callable span); opt-in, finer detail') + ->defaultFalse() + ->end() + ->booleanNode('trace_controller_arguments') + ->info('Trace argument resolution as a single aggregate span (controller.get_arguments); opt-in') + ->defaultFalse() + ->end() + ->booleanNode('trace_controller_argument_resolvers') + ->info('Trace each argument value resolver individually (controller.argument_value_resolver span); opt-in, higher cardinality') + ->defaultFalse() + ->end() ->end() ->end() ->arrayNode('console') @@ -740,7 +762,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}, 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}, 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}, 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 */ #[Override] public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void @@ -2649,7 +2671,7 @@ private function registerGlobalServices(array $config, ContainerBuilder $builder } /** - * @param array{http_kernel?: array{enabled?: bool, exclude_routes?: array, exclude_paths?: array, context_propagation?: 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}} $config + * @param array{http_kernel?: array{enabled?: bool, exclude_routes?: array, 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}} $config */ private function registerInstrumentation(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { @@ -2664,6 +2686,22 @@ 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.trace_controller', + $httpKernelConfig['trace_controller'] ?? true, + ); + $builder->setParameter( + 'flow.telemetry.http_kernel.trace_controller_resolution', + $httpKernelConfig['trace_controller_resolution'] ?? false, + ); + $builder->setParameter( + 'flow.telemetry.http_kernel.trace_controller_arguments', + $httpKernelConfig['trace_controller_arguments'] ?? false, + ); + $builder->setParameter( + 'flow.telemetry.http_kernel.trace_controller_argument_resolvers', + $httpKernelConfig['trace_controller_argument_resolvers'] ?? false, + ); $container->import(__DIR__ . '/Resources/config/instrumentation/http_kernel.php'); } @@ -3019,7 +3057,7 @@ private function registerNamedExporters(array $config, ContainerBuilder $builder } /** - * @param array{http_kernel?: array{enabled?: bool, exclude_routes?: array, exclude_paths?: array, context_propagation?: 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}} $config + * @param array{http_kernel?: array{enabled?: bool, exclude_routes?: array, 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}} $config */ private function registerParameterOnlyInstrumentation(array $config, ContainerBuilder $builder): void { diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerName.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerName.php new file mode 100644 index 0000000000..a10aeb93f7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerName.php @@ -0,0 +1,54 @@ +|callable|object $controller + */ + public static function resolve(callable|object|array $controller): ?self + { + if (is_array($controller)) { + if (count($controller) === 2) { + $firstElement = $controller[0]; + $secondElement = $controller[1]; + $class = is_object($firstElement) ? $firstElement::class : $firstElement; + $method = is_string($secondElement) ? $secondElement : ''; + + return new self("{$class}::{$method}", $class, $method); + } + + return null; + } + + if (is_object($controller)) { + if ($controller instanceof Closure) { + return new self('Closure'); + } + + return new self($controller::class . '::__invoke', $controller::class, '__invoke'); + } + + if (is_string($controller)) { + // @mago-expect analysis:no-value + return new self($controller); + } + + return null; + } +} 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 new file mode 100644 index 0000000000..87dcf2bfe2 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/ControllerSpanSubscriber.php @@ -0,0 +1,123 @@ + ['onControllerArguments', -10000], + KernelEvents::VIEW => ['onComplete', 10000], + KernelEvents::RESPONSE => ['onComplete', 10000], + KernelEvents::EXCEPTION => ['onException', 10000], + ]; + } + + public function onControllerArguments(ControllerArgumentsEvent $event): void + { + if (!$this->traceController) { + return; + } + + $request = $event->getRequest(); + + if (!$request->attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE) instanceof Span) { + return; + } + + if ($request->attributes->get(self::CONTROLLER_SPAN_ATTRIBUTE) instanceof Span) { + return; + } + + $resolved = ControllerName::resolve($event->getController()); + $name = 'controller'; + + /** @var array $attributes */ + $attributes = []; + + if ($resolved !== null) { + $name = $resolved->name; + $attributes['controller'] = $resolved->name; + + if ($resolved->namespace !== null) { + $attributes['code.namespace'] = $resolved->namespace; + } + + if ($resolved->function !== null) { + $attributes['code.function'] = $resolved->function; + } + } + + // @mago-expect analysis:mixed-assignment + if (is_string($route = $request->attributes->get('_route'))) { + $attributes['http.route'] = $route; + } + + $span = $this->tracer()->span($name, SpanKind::INTERNAL, $attributes); + + $request->attributes->set(self::CONTROLLER_SPAN_ATTRIBUTE, $span); + } + + public function onComplete(ViewEvent|ResponseEvent $event): void + { + $request = $event->getRequest(); + + // @mago-expect analysis:mixed-assignment + if (!($span = $request->attributes->get(self::CONTROLLER_SPAN_ATTRIBUTE)) instanceof Span) { + return; + } + + $span->setStatus(SpanStatus::ok()); + $this->tracer()->complete($span); + + $request->attributes->remove(self::CONTROLLER_SPAN_ATTRIBUTE); + } + + public function onException(ExceptionEvent $event): void + { + $request = $event->getRequest(); + + // @mago-expect analysis:mixed-assignment + if (!($span = $request->attributes->get(self::CONTROLLER_SPAN_ATTRIBUTE)) instanceof Span) { + return; + } + + $throwable = $event->getThrowable(); + $span->recordException($throwable, new DateTimeImmutable()); + $span->setStatus(SpanStatus::error($throwable->getMessage())); + $this->tracer()->complete($span); + + $request->attributes->remove(self::CONTROLLER_SPAN_ATTRIBUTE); + } + + private function tracer(): Tracer + { + return $this->telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel')); + } +} 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 76349132ad..202f9312fd 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 @@ -4,7 +4,6 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel; -use Closure; use DateTimeImmutable; use Flow\Bridge\Symfony\HttpFoundationTelemetry\RequestCarrier; use Flow\Bridge\Symfony\HttpFoundationTelemetry\ResponseCarrier; @@ -29,14 +28,11 @@ use Symfony\Component\HttpKernel\KernelEvents; use function array_map; -use function count; -use function is_array; -use function is_object; use function is_string; final readonly class HttpKernelSpanSubscriber implements EventSubscriberInterface { - private const string SPAN_ATTRIBUTE = '_flow_telemetry_span'; + public const string SPAN_ATTRIBUTE = '_flow_telemetry_span'; private const string TRACER_ATTRIBUTE = '_flow_telemetry_tracer'; @@ -84,8 +80,7 @@ public function onController(ControllerEvent $event): void $span->setAttribute('http.route', $route); } - $controller = $event->getController(); - $controllerName = $this->resolveControllerName($controller); + $controllerName = ControllerName::resolve($event->getController())?->name; if ($controllerName !== null) { $span->setAttribute('controller', $controllerName); @@ -202,40 +197,6 @@ private function injectContextIntoResponse(Span $span, Response $response): void $this->propagator->inject($propagationContext, new ResponseCarrier($response)); } - /** - * @param array|callable|object $controller - */ - private function resolveControllerName(callable|object|array $controller): ?string - { - if (is_array($controller)) { - if (count($controller) === 2) { - $firstElement = $controller[0]; - $secondElement = $controller[1]; - $class = is_object($firstElement) ? $firstElement::class : $firstElement; - $method = is_string($secondElement) ? $secondElement : ''; - - return "{$class}::{$method}"; - } - - return null; - } - - if (is_object($controller)) { - if ($controller instanceof Closure) { - return 'Closure'; - } - - return $controller::class . '::__invoke'; - } - - if (is_string($controller)) { - // @mago-expect analysis:never-return - return $controller; - } - - return null; - } - private function shouldTraceByPath(string $path, string $method): bool { foreach ($this->excludePathRules as $rule) { 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 new file mode 100644 index 0000000000..84d360bd35 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingArgumentResolver.php @@ -0,0 +1,42 @@ +attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE) instanceof Span) { + return $this->resolver->getArguments($request, $controller, $reflector); + } + + $tracer = $this->telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel')); + $span = $tracer->span('controller.get_arguments', SpanKind::INTERNAL); + + try { + return $this->resolver->getArguments($request, $controller, $reflector); + } finally { + $span->setStatus(SpanStatus::ok()); + $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 new file mode 100644 index 0000000000..7cb8694487 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingControllerResolver.php @@ -0,0 +1,38 @@ +attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE) instanceof Span) { + return $this->resolver->getController($request); + } + + $tracer = $this->telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel')); + $span = $tracer->span('controller.get_callable', SpanKind::INTERNAL); + + try { + return $this->resolver->getController($request); + } finally { + $span->setStatus(SpanStatus::ok()); + $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 new file mode 100644 index 0000000000..12238399ce --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/Instrumentation/HttpKernel/TracingValueResolver.php @@ -0,0 +1,44 @@ +attributes->get(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE) instanceof Span) { + yield from $this->inner->resolve($request, $argument); + + return; + } + + $tracer = $this->telemetry->tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel')); + $span = $tracer->span('controller.argument_value_resolver', SpanKind::INTERNAL, [ + 'code.namespace' => $this->inner::class, + 'controller.argument' => $argument->getName(), + ]); + + try { + yield from $this->inner->resolve($request, $argument); + $span->setStatus(SpanStatus::ok()); + } finally { + $tracer->complete($span); + } + } +} 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 93b4ab9523..0dba1bc4eb 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 @@ -2,6 +2,7 @@ declare(strict_types=1); +use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\ControllerSpanSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelFlushSubscriber; use Flow\Bridge\Symfony\TelemetryBundle\Instrumentation\HttpKernel\HttpKernelSpanSubscriber; use Flow\Telemetry\Telemetry; @@ -27,4 +28,12 @@ ->set('flow.telemetry.http_kernel.flush_subscriber', HttpKernelFlushSubscriber::class) ->args([service(Telemetry::class)]) ->tag('kernel.event_subscriber'); + + $services + ->set('flow.telemetry.http_kernel.controller_span_subscriber', ControllerSpanSubscriber::class) + ->args([ + service(Telemetry::class), + '%flow.telemetry.http_kernel.trace_controller%', + ]) + ->tag('kernel.event_subscriber'); }; 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 e21f8474d5..0fdc587602 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 @@ -6,6 +6,7 @@ use RuntimeException; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; final class TestController @@ -24,4 +25,9 @@ public function index(): Response { return new JsonResponse(['status' => 'ok']); } + + 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/HttpKernel/ControllerSpanSubscriberTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/ControllerSpanSubscriberTest.php new file mode 100644 index 0000000000..61247979b4 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/Instrumentation/HttpKernel/ControllerSpanSubscriberTest.php @@ -0,0 +1,442 @@ +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', + ])); + + $request = Request::create('/test', 'GET'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + static::assertSame(200, $response->getStatusCode()); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $spans = $processor->endedSpans(); + + static::assertCount(2, $spans); + + $requestSpan = array_values(array_filter( + $spans, + static fn(Span $span): bool => $span->kind() === SpanKind::SERVER, + ))[0]; + $controllerSpan = array_values(array_filter( + $spans, + static fn(Span $span): bool => $span->name() === TestController::class . '::index', + ))[0]; + + static::assertSame(SpanKind::INTERNAL, $controllerSpan->kind()); + static::assertSame( + $requestSpan->context()->spanId->toHex(), + $controllerSpan->context()->parentSpanId?->toHex(), + ); + static::assertSame($requestSpan->context()->traceId->toHex(), $controllerSpan->context()->traceId->toHex()); + + $attributes = $controllerSpan->attributes(); + static::assertSame(TestController::class, $attributes['code.namespace']); + static::assertSame('index', $attributes['code.function']); + static::assertSame('test_index', $attributes['http.route']); + } + + public function test_no_controller_span_when_disabled(): 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, 'trace_controller' => false], + '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'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $spans = $processor->endedSpans(); + + static::assertCount(1, $spans); + static::assertSame(SpanKind::SERVER, $spans[0]->kind()); + } + + public function test_no_controller_span_for_excluded_path(): 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, 'exclude_paths' => [['path' => '/test']]], + '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'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + + static::assertCount(0, $processor->endedSpans()); + } + + public function test_records_controller_exception(): 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_exception', new Route('/exception', [ + '_controller' => TestController::class . '::exception', + ])); + + $request = Request::create('/exception', 'GET'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $controllerSpan = array_values(array_filter( + $processor->endedSpans(), + static fn(Span $span): bool => $span->name() === TestController::class . '::exception', + ))[0]; + + static::assertTrue($controllerSpan->status()?->isError()); + static::assertNotEmpty($controllerSpan->events()); + } + + public function test_emits_resolution_span_only_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, 'trace_controller_resolution' => 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', + ])); + + $request = Request::create('/test', 'GET'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $names = array_map(static fn(Span $span): string => $span->name(), $processor->endedSpans()); + + static::assertContains('controller.get_callable', $names); + } + + public function test_no_resolution_span_by_default(): 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', + ])); + + $request = Request::create('/test', 'GET'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $names = array_map(static fn(Span $span): string => $span->name(), $processor->endedSpans()); + + static::assertNotContains('controller.get_callable', $names); + } + + public function test_emits_aggregate_arguments_span_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, 'trace_controller_arguments' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_arg', new Route('/with-arg', [ + '_controller' => TestController::class . '::withArgument', + ])); + + $request = Request::create('/with-arg', 'GET'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $names = array_map(static fn(Span $span): string => $span->name(), $processor->endedSpans()); + + static::assertContains('controller.get_arguments', $names); + } + + public function test_emits_per_value_resolver_spans_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, 'trace_controller_argument_resolvers' => true], + 'console' => ['enabled' => false], + 'messenger' => false, + ], + ]); + }, + ]); + + $container = $this->getContainer(); + + /** @var Router $router */ + $router = $container->get('router'); + $router->getRouteCollection()->add('test_arg', new Route('/with-arg', [ + '_controller' => TestController::class . '::withArgument', + ])); + + $request = Request::create('/with-arg', 'GET'); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $names = array_map(static fn(Span $span): string => $span->name(), $processor->endedSpans()); + + static::assertContains('controller.argument_value_resolver', $names); + } + + public function test_sub_request_gets_its_own_controller_span(): 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); + $kernel->terminate($mainRequest, $response); + + $subRequest = Request::create('/test', 'GET'); + $kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST); + + /** @var MemorySpanProcessor $processor */ + $processor = $container->get('flow.telemetry.tracer_provider.processor'); + $controllerSpans = array_filter( + $processor->endedSpans(), + static fn(Span $span): bool => $span->name() === TestController::class . '::index', + ); + + static::assertGreaterThanOrEqual(2, count($controllerSpans)); + } +} 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 8a06625d75..3392cbd985 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 @@ -78,7 +78,11 @@ public function test_flush_is_called_on_terminate(): void $spansAfterTerminate = $exporter->spans(); - static::assertCount(1, $spansAfterTerminate, 'Spans should be exported after terminate when flush is called'); + static::assertCount( + 2, + $spansAfterTerminate, + 'Request and controller spans should be exported after terminate when flush is called', + ); } public function test_flush_is_not_called_when_http_kernel_instrumentation_is_disabled(): 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 42c4c56d38..1d3c3c6ff5 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 @@ -10,6 +10,7 @@ use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\TestKernel; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Integration\KernelTestCase; use Flow\Telemetry\Provider\Memory\MemorySpanProcessor; +use Flow\Telemetry\Tracer\Span; use Flow\Telemetry\Tracer\SpanKind; use Override; use PHPUnit\Framework\Attributes\CoversClass; @@ -85,9 +86,9 @@ public function test_does_not_extract_context_when_propagation_disabled(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); + static::assertCount(2, $spans); - $span = $spans[0]; + $span = array_values(array_filter($spans, static fn(Span $s): bool => $s->kind() === SpanKind::SERVER))[0]; static::assertNotSame($incomingTraceId, $span->context()->traceId->toHex()); static::assertNull($span->context()->parentSpanId); } @@ -197,8 +198,12 @@ public function test_excludes_path_with_exact_match(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); - static::assertSame('GET /test', $spans[0]->name()); + static::assertCount(2, $spans); + $requestSpan = array_values(array_filter( + $spans, + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + static::assertSame('GET /test', $requestSpan->name()); } public function test_excludes_path_with_method_filter(): void @@ -260,8 +265,12 @@ public function test_excludes_path_with_method_filter(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); - static::assertSame('POST /_wdt', $spans[0]->name()); + static::assertCount(2, $spans); + $requestSpan = array_values(array_filter( + $spans, + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + static::assertSame('POST /_wdt', $requestSpan->name()); } public function test_excludes_path_with_regex_pattern(): void @@ -327,8 +336,12 @@ public function test_excludes_path_with_regex_pattern(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); - static::assertSame('GET /test', $spans[0]->name()); + static::assertCount(2, $spans); + $requestSpan = array_values(array_filter( + $spans, + static fn(Span $s): bool => $s->kind() === SpanKind::SERVER, + ))[0]; + static::assertSame('GET /test', $requestSpan->name()); } public function test_extracts_context_from_traceparent_header(): void @@ -387,9 +400,9 @@ public function test_extracts_context_from_traceparent_header(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); + static::assertCount(2, $spans); - $span = $spans[0]; + $span = array_values(array_filter($spans, static fn(Span $s): bool => $s->kind() === SpanKind::SERVER))[0]; static::assertSame($incomingTraceId, $span->context()->traceId->toHex()); static::assertSame($incomingSpanId, $span->context()->parentSpanId?->toHex()); } @@ -445,9 +458,9 @@ public function test_handles_missing_trace_headers_gracefully(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); + static::assertCount(2, $spans); - $span = $spans[0]; + $span = array_values(array_filter($spans, static fn(Span $s): bool => $s->kind() === SpanKind::SERVER))[0]; static::assertNotEmpty($span->context()->traceId->toHex()); static::assertNull($span->context()->parentSpanId); } @@ -500,9 +513,9 @@ public function test_traces_http_request_with_error_status(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); + static::assertCount(2, $spans); - $span = $spans[0]; + $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']); @@ -560,9 +573,9 @@ public function test_traces_successful_http_request(): void $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); + static::assertCount(2, $spans); - $span = $spans[0]; + $span = array_values(array_filter($spans, static fn(Span $s): bool => $s->kind() === SpanKind::SERVER))[0]; static::assertSame('GET /test', $span->name()); static::assertSame(SpanKind::SERVER, $span->kind()); @@ -624,9 +637,9 @@ public function test_injects_context_into_response_when_propagation_enabled(): v $processor = $container->get('flow.telemetry.tracer_provider.processor'); $spans = $processor->endedSpans(); - static::assertCount(1, $spans); + static::assertCount(2, $spans); - $span = $spans[0]; + $span = array_values(array_filter($spans, static fn(Span $s): bool => $s->kind() === SpanKind::SERVER))[0]; static::assertSame( "00-{$span->context()->traceId->toHex()}-{$span->context()->spanId->toHex()}-01", $response->headers->get('traceparent'), diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ArgumentResolverTelemetryPassTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ArgumentResolverTelemetryPassTest.php new file mode 100644 index 0000000000..5ff68d33a7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ArgumentResolverTelemetryPassTest.php @@ -0,0 +1,54 @@ +setDefinition('argument_resolver', new Definition()); + + (new ArgumentResolverTelemetryPass())->process($container); + + static::assertFalse($container->hasDefinition('argument_resolver.flow_telemetry')); + } + + public function test_no_op_when_parameter_false(): void + { + $container = new ContainerBuilder(); + $container->setParameter('flow.telemetry.http_kernel.trace_controller_arguments', false); + $container->setDefinition('argument_resolver', new Definition()); + + (new ArgumentResolverTelemetryPass())->process($container); + + static::assertFalse($container->hasDefinition('argument_resolver.flow_telemetry')); + } + + public function test_decorates_argument_resolver_when_enabled(): void + { + $container = new ContainerBuilder(); + $container->setParameter('flow.telemetry.http_kernel.trace_controller_arguments', true); + $container->setDefinition('argument_resolver', new Definition()); + + (new ArgumentResolverTelemetryPass())->process($container); + + $decorator = $container->getDefinition('argument_resolver.flow_telemetry'); + static::assertSame(TracingArgumentResolver::class, $decorator->getClass()); + + $decorated = $decorator->getDecoratedService(); + static::assertNotNull($decorated); + static::assertSame('argument_resolver', $decorated[0]); + static::assertSame('argument_resolver.flow_telemetry.inner', (string) $decorator->getArgument(0)); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ArgumentValueResolverTelemetryPassTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ArgumentValueResolverTelemetryPassTest.php new file mode 100644 index 0000000000..9fa946199e --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ArgumentValueResolverTelemetryPassTest.php @@ -0,0 +1,77 @@ +setDefinition( + 'value_resolver.test', + (new Definition())->addTag('controller.argument_value_resolver'), + ); + + (new ArgumentValueResolverTelemetryPass())->process($container); + + static::assertFalse($container->hasDefinition('value_resolver.test.flow_telemetry')); + } + + public function test_no_op_when_parameter_false(): void + { + $container = new ContainerBuilder(); + $container->setParameter('flow.telemetry.http_kernel.trace_controller_argument_resolvers', false); + $container->setDefinition( + 'value_resolver.test', + (new Definition())->addTag('controller.argument_value_resolver'), + ); + + (new ArgumentValueResolverTelemetryPass())->process($container); + + static::assertFalse($container->hasDefinition('value_resolver.test.flow_telemetry')); + } + + public function test_decorates_concrete_tagged_resolvers_only(): void + { + $container = new ContainerBuilder(); + $container->setParameter('flow.telemetry.http_kernel.trace_controller_argument_resolvers', true); + $container->setDefinition( + 'value_resolver.one', + (new Definition())->addTag('controller.argument_value_resolver'), + ); + $container->setDefinition( + 'value_resolver.two', + (new Definition())->addTag('controller.argument_value_resolver'), + ); + $container->setDefinition( + 'value_resolver.abstract', + (new Definition()) + ->setAbstract(true) + ->addTag('controller.argument_value_resolver'), + ); + + (new ArgumentValueResolverTelemetryPass())->process($container); + + static::assertFalse($container->hasDefinition('value_resolver.abstract.flow_telemetry')); + + foreach (['value_resolver.one', 'value_resolver.two'] as $serviceId) { + $decorator = $container->getDefinition($serviceId . '.flow_telemetry'); + static::assertSame(TracingValueResolver::class, $decorator->getClass()); + + $decorated = $decorator->getDecoratedService(); + static::assertNotNull($decorated); + static::assertSame($serviceId, $decorated[0]); + static::assertSame($serviceId . '.flow_telemetry.inner', (string) $decorator->getArgument(0)); + } + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ControllerResolverTelemetryPassTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ControllerResolverTelemetryPassTest.php new file mode 100644 index 0000000000..33ca7de1a0 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/Compiler/ControllerResolverTelemetryPassTest.php @@ -0,0 +1,54 @@ +setDefinition('controller_resolver', new Definition()); + + (new ControllerResolverTelemetryPass())->process($container); + + static::assertFalse($container->hasDefinition('controller_resolver.flow_telemetry')); + } + + public function test_no_op_when_parameter_false(): void + { + $container = new ContainerBuilder(); + $container->setParameter('flow.telemetry.http_kernel.trace_controller_resolution', false); + $container->setDefinition('controller_resolver', new Definition()); + + (new ControllerResolverTelemetryPass())->process($container); + + static::assertFalse($container->hasDefinition('controller_resolver.flow_telemetry')); + } + + public function test_decorates_controller_resolver_when_enabled(): void + { + $container = new ContainerBuilder(); + $container->setParameter('flow.telemetry.http_kernel.trace_controller_resolution', true); + $container->setDefinition('controller_resolver', new Definition()); + + (new ControllerResolverTelemetryPass())->process($container); + + $decorator = $container->getDefinition('controller_resolver.flow_telemetry'); + static::assertSame(TracingControllerResolver::class, $decorator->getClass()); + + $decorated = $decorator->getDecoratedService(); + static::assertNotNull($decorated); + static::assertSame('controller_resolver', $decorated[0]); + static::assertSame('controller_resolver.flow_telemetry.inner', (string) $decorator->getArgument(0)); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerNameTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerNameTest.php new file mode 100644 index 0000000000..e9504a0993 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerNameTest.php @@ -0,0 +1,73 @@ +name); + static::assertSame(TestController::class, $resolved->namespace); + static::assertSame('index', $resolved->function); + } + + public function test_resolves_object_method_array(): void + { + $resolved = ControllerName::resolve([new TestController(), 'index']); + + static::assertNotNull($resolved); + static::assertSame(TestController::class . '::index', $resolved->name); + static::assertSame(TestController::class, $resolved->namespace); + static::assertSame('index', $resolved->function); + } + + public function test_resolves_invokable_object(): void + { + $controller = new class { + public function __invoke(): void {} + }; + + $resolved = ControllerName::resolve($controller); + + static::assertNotNull($resolved); + static::assertSame($controller::class . '::__invoke', $resolved->name); + static::assertSame($controller::class, $resolved->namespace); + static::assertSame('__invoke', $resolved->function); + } + + public function test_resolves_closure(): void + { + $resolved = ControllerName::resolve(static function (): void {}); + + static::assertNotNull($resolved); + static::assertSame('Closure', $resolved->name); + static::assertNull($resolved->namespace); + static::assertNull($resolved->function); + } + + public function test_resolves_string_controller(): void + { + $resolved = ControllerName::resolve('strtoupper'); + + static::assertNotNull($resolved); + static::assertSame('strtoupper', $resolved->name); + static::assertNull($resolved->namespace); + static::assertNull($resolved->function); + } + + public function test_returns_null_for_unsupported_array_shape(): void + { + static::assertNull(ControllerName::resolve([TestController::class, 'index', 'extra'])); + } +} 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 new file mode 100644 index 0000000000..3989a1a61a --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/ControllerSpanSubscriberTest.php @@ -0,0 +1,241 @@ +tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel'))->span( + 'GET /test', + SpanKind::SERVER, + ); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + $request->attributes->set('_route', 'test_index'); + + $subscriber = new ControllerSpanSubscriber($telemetry); + $kernel = $this->createStub(HttpKernelInterface::class); + + $subscriber->onControllerArguments( + new ControllerArgumentsEvent( + $kernel, + [new TestController(), 'index'], + [], + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + $subscriber->onComplete( + new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()), + ); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + + $span = $spans[0]; + static::assertSame(TestController::class . '::index', $span->name()); + static::assertSame(SpanKind::INTERNAL, $span->kind()); + + $attributes = $span->attributes(); + static::assertSame(TestController::class, $attributes['code.namespace']); + static::assertSame('index', $attributes['code.function']); + static::assertSame(TestController::class . '::index', $attributes['controller']); + 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()); + } + + public function test_completes_body_span_on_view(): 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, + ); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + $subscriber = new ControllerSpanSubscriber($telemetry); + $kernel = $this->createStub(HttpKernelInterface::class); + + $subscriber->onControllerArguments( + new ControllerArgumentsEvent( + $kernel, + [new TestController(), 'index'], + [], + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + $subscriber->onComplete(new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['data' => 1])); + + static::assertCount(1, $spanProcessor->endedSpans()); + } + + public function test_completes_once_when_view_then_response(): 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, + ); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + $subscriber = new ControllerSpanSubscriber($telemetry); + $kernel = $this->createStub(HttpKernelInterface::class); + + $subscriber->onControllerArguments( + new ControllerArgumentsEvent( + $kernel, + [new TestController(), 'index'], + [], + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + $subscriber->onComplete(new ViewEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, ['data' => 1])); + $subscriber->onComplete( + new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()), + ); + + static::assertCount(1, $spanProcessor->endedSpans()); + } + + public function test_records_exception_and_completes(): 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, + ); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + $subscriber = new ControllerSpanSubscriber($telemetry); + $kernel = $this->createStub(HttpKernelInterface::class); + + $subscriber->onControllerArguments( + new ControllerArgumentsEvent( + $kernel, + [new TestController(), 'exception'], + [], + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + $subscriber->onException( + new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new RuntimeException('boom')), + ); + $subscriber->onComplete( + new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()), + ); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertTrue($spans[0]->status()?->isError()); + static::assertNotEmpty($spans[0]->events()); + } + + public function test_no_span_when_trace_controller_disabled(): 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, + ); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + $subscriber = new ControllerSpanSubscriber($telemetry, traceController: false); + $kernel = $this->createStub(HttpKernelInterface::class); + + $subscriber->onControllerArguments( + new ControllerArgumentsEvent( + $kernel, + [new TestController(), 'index'], + [], + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + $subscriber->onComplete( + new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()), + ); + + static::assertCount(0, $spanProcessor->endedSpans()); + } + + public function test_no_span_when_request_span_absent(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + + $request = new Request(); + + $subscriber = new ControllerSpanSubscriber($telemetry); + $kernel = $this->createStub(HttpKernelInterface::class); + + $subscriber->onControllerArguments( + new ControllerArgumentsEvent( + $kernel, + [new TestController(), 'index'], + [], + $request, + HttpKernelInterface::MAIN_REQUEST, + ), + ); + $subscriber->onComplete( + new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response()), + ); + + static::assertCount(0, $spanProcessor->endedSpans()); + } +} 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 new file mode 100644 index 0000000000..c7975b182d --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingArgumentResolverTest.php @@ -0,0 +1,67 @@ +tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel'))->span( + 'GET /test', + SpanKind::SERVER, + ); + + $inner = $this->createStub(ArgumentResolverInterface::class); + $inner->method('getArguments')->willReturn(['a', 'b']); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + $arguments = (new TracingArgumentResolver($inner, $telemetry))->getArguments( + $request, + static fn(): null => null, + ); + + static::assertSame(['a', 'b'], $arguments); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertSame('controller.get_arguments', $spans[0]->name()); + static::assertSame(SpanKind::INTERNAL, $spans[0]->kind()); + static::assertSame($requestSpan->context()->spanId->toHex(), $spans[0]->context()->parentSpanId?->toHex()); + } + + public function test_passes_through_when_request_span_absent(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + + $inner = $this->createStub(ArgumentResolverInterface::class); + $inner->method('getArguments')->willReturn(['a']); + + $arguments = (new TracingArgumentResolver($inner, $telemetry))->getArguments( + new Request(), + static fn(): null => null, + ); + + static::assertSame(['a'], $arguments); + static::assertCount(0, $spanProcessor->endedSpans()); + } +} 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 new file mode 100644 index 0000000000..4d248f47a7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingControllerResolverTest.php @@ -0,0 +1,62 @@ +tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel'))->span( + 'GET /test', + SpanKind::SERVER, + ); + + $controller = static fn(): string => 'ok'; + $inner = $this->createStub(ControllerResolverInterface::class); + $inner->method('getController')->willReturn($controller); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + + static::assertSame($controller, (new TracingControllerResolver($inner, $telemetry))->getController($request)); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertSame('controller.get_callable', $spans[0]->name()); + static::assertSame(SpanKind::INTERNAL, $spans[0]->kind()); + static::assertSame($requestSpan->context()->spanId->toHex(), $spans[0]->context()->parentSpanId?->toHex()); + } + + public function test_passes_through_when_request_span_absent(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + + $controller = static fn(): string => 'ok'; + $inner = $this->createStub(ControllerResolverInterface::class); + $inner->method('getController')->willReturn($controller); + + static::assertSame( + $controller, + (new TracingControllerResolver($inner, $telemetry))->getController(new Request()), + ); + static::assertCount(0, $spanProcessor->endedSpans()); + } +} 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 new file mode 100644 index 0000000000..aab77fc1f7 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/Instrumentation/HttpKernel/TracingValueResolverTest.php @@ -0,0 +1,70 @@ +tracer('flow.symfony.http_kernel', PackageVersion::get('symfony/http-kernel'))->span( + 'GET /test', + SpanKind::SERVER, + ); + + $inner = $this->createStub(ValueResolverInterface::class); + $inner->method('resolve')->willReturn(['resolved']); + + $request = new Request(); + $request->attributes->set(HttpKernelSpanSubscriber::SPAN_ATTRIBUTE, $requestSpan); + $argument = new ArgumentMetadata('id', 'int', false, false, null); + + $resolved = iterator_to_array((new TracingValueResolver($inner, $telemetry))->resolve($request, $argument)); + + static::assertSame(['resolved'], $resolved); + + $spans = $spanProcessor->endedSpans(); + static::assertCount(1, $spans); + static::assertSame('controller.argument_value_resolver', $spans[0]->name()); + static::assertSame(SpanKind::INTERNAL, $spans[0]->kind()); + static::assertSame($inner::class, $spans[0]->attributes()['code.namespace']); + static::assertSame('id', $spans[0]->attributes()['controller.argument']); + static::assertSame($requestSpan->context()->spanId->toHex(), $spans[0]->context()->parentSpanId?->toHex()); + } + + public function test_passes_through_when_request_span_absent(): void + { + $spanProcessor = new MemorySpanProcessor(new MemoryExporter()); + $telemetry = TelemetryMother::withSpanProcessor($spanProcessor); + + $inner = $this->createStub(ValueResolverInterface::class); + $inner->method('resolve')->willReturn(['resolved']); + + $resolved = iterator_to_array((new TracingValueResolver($inner, $telemetry))->resolve( + new Request(), + new ArgumentMetadata('id', 'int', false, false, null), + )); + + static::assertSame(['resolved'], $resolved); + static::assertCount(0, $spanProcessor->endedSpans()); + } +}