diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e56469..018b04a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add support for distributed tracing of Symfony request events (#423) - Add support for distributed tracing of Twig template rendering (#430) - Add support for distributed tracing of SQL queries while using Doctrine DBAL (#426) - Added missing `capture-soft-fails` config schema option (#417) diff --git a/composer.json b/composer.json index 6d625810..5f4ec0e4 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "symfony/dependency-injection": "^3.4.44||^4.4.12||^5.0.11", "symfony/event-dispatcher": "^3.4.44||^4.4.12||^5.0.11", "symfony/http-kernel": "^3.4.44||^4.4.12||^5.0.11", + "symfony/polyfill-php80": "^1.22", "symfony/psr-http-message-bridge": "^2.0", "symfony/security-core": "^3.4.44||^4.4.12||^5.0.11" }, @@ -49,7 +50,6 @@ "symfony/messenger": "^4.4.12||^5.0.11", "symfony/monolog-bundle": "^3.4", "symfony/phpunit-bridge": "^5.0", - "symfony/polyfill-php80": "^1.22", "symfony/twig-bundle": "^3.4.44||^4.4.12||^5.0.11", "symfony/yaml": "^3.4.44||^4.4.12||^5.0.11", "vimeo/psalm": "^4.3" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a61cd388..1bc3da57 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -105,6 +105,16 @@ parameters: count: 2 path: src/aliases.php + - + message: "#^Class Symfony\\\\Component\\\\HttpKernel\\\\Event\\\\FilterResponseEvent not found\\.$#" + count: 1 + path: src/aliases.php + + - + message: "#^Class Symfony\\\\Component\\\\HttpKernel\\\\Event\\\\PostResponseEvent not found\\.$#" + count: 1 + path: src/aliases.php + - message: "#^Class Symfony\\\\Component\\\\HttpKernel\\\\Event\\\\FilterControllerEvent not found\\.$#" count: 1 diff --git a/phpunit.xml b/phpunit.xml index f087d8c1..72fe2378 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -2,7 +2,7 @@ diff --git a/src/DependencyInjection/SentryExtension.php b/src/DependencyInjection/SentryExtension.php index 2862cffd..500812fc 100644 --- a/src/DependencyInjection/SentryExtension.php +++ b/src/DependencyInjection/SentryExtension.php @@ -62,8 +62,8 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container $this->registerConfiguration($container, $mergedConfig); $this->registerErrorListenerConfiguration($container, $mergedConfig); $this->registerMessengerListenerConfiguration($container, $mergedConfig['messenger']); - $this->registerTracingConfiguration($container, $mergedConfig['tracing']); - $this->registerTracingTwigExtensionConfiguration($container, $mergedConfig['tracing']); + $this->registerDbalTracingConfiguration($container, $mergedConfig['tracing']); + $this->registerTwigTracingConfiguration($container, $mergedConfig['tracing']); } /** @@ -159,7 +159,7 @@ private function registerMessengerListenerConfiguration(ContainerBuilder $contai /** * @param array $config */ - private function registerTracingConfiguration(ContainerBuilder $container, array $config): void + private function registerDbalTracingConfiguration(ContainerBuilder $container, array $config): void { $isConfigEnabled = $this->isConfigEnabled($container, $config['dbal']); @@ -178,7 +178,7 @@ private function registerTracingConfiguration(ContainerBuilder $container, array /** * @param array $config */ - private function registerTracingTwigExtensionConfiguration(ContainerBuilder $container, array $config): void + private function registerTwigTracingConfiguration(ContainerBuilder $container, array $config): void { $isConfigEnabled = $this->isConfigEnabled($container, $config['twig']); diff --git a/src/EventListener/AbstractTracingRequestListener.php b/src/EventListener/AbstractTracingRequestListener.php new file mode 100644 index 00000000..dc93ec18 --- /dev/null +++ b/src/EventListener/AbstractTracingRequestListener.php @@ -0,0 +1,74 @@ +hub = $hub; + } + + /** + * This method is called once a response for the current HTTP request is + * created, but before it is sent off to the client. Its use is mainly for + * gathering information like the HTTP status code and attaching them as + * tags of the span/transaction. + * + * @param RequestListenerResponseEvent $event The event + */ + public function handleKernelResponseEvent(RequestListenerResponseEvent $event): void + { + /** @var Response $response */ + $response = $event->getResponse(); + $span = $this->hub->getSpan(); + + if (null === $span) { + return; + } + + $span->setHttpStatus($response->getStatusCode()); + } + + /** + * Gets the name of the route or fallback to the controller FQCN if the + * route is anonymous (e.g. a subrequest). + * + * @param Request $request The HTTP request + */ + protected function getRouteName(Request $request): string + { + $route = $request->attributes->get('_route'); + + if ($route instanceof Route) { + $route = $route->getPath(); + } + + if (null === $route) { + $route = $request->attributes->get('_controller'); + + if (\is_array($route) && \is_callable($route, true)) { + $route = sprintf('%s::%s', \is_object($route[0]) ? get_debug_type($route[0]) : $route[0], $route[1]); + } + } + + return \is_string($route) ? $route : ''; + } +} diff --git a/src/EventListener/TracingRequestListener.php b/src/EventListener/TracingRequestListener.php new file mode 100644 index 00000000..ab6b6a3b --- /dev/null +++ b/src/EventListener/TracingRequestListener.php @@ -0,0 +1,112 @@ +isMasterRequest()) { + return; + } + + /** @var Request $request */ + $request = $event->getRequest(); + $requestStartTime = $request->server->get('REQUEST_TIME_FLOAT', microtime(true)); + + $context = TransactionContext::fromSentryTrace($request->headers->get('sentry-trace', '')); + $context->setOp('http.server'); + $context->setName(sprintf('%s %s%s%s', $request->getMethod(), $request->getSchemeAndHttpHost(), $request->getBaseUrl(), $request->getPathInfo())); + $context->setStartTimestamp($requestStartTime); + $context->setTags($this->getTags($request)); + + $this->hub->setSpan($this->hub->startTransaction($context)); + } + + /** + * This method is called for each request handled by the framework and + * ends the tracing. + * + * @param FinishRequestEvent $event The event + */ + public function handleKernelFinishRequestEvent(FinishRequestEvent $event): void + { + if (!$event->isMasterRequest()) { + return; + } + + $transaction = $this->hub->getTransaction(); + + if (null === $transaction) { + return; + } + + $transaction->finish(); + } + + /** + * Gets the tags to attach to the transaction. + * + * @param Request $request The HTTP request + * + * @return array + */ + private function getTags(Request $request): array + { + $client = $this->hub->getClient(); + $tags = [ + 'net.host.port' => (string) $request->getPort(), + 'http.method' => $request->getMethod(), + 'http.url' => $request->getUri(), + 'http.flavor' => $this->getHttpFlavor($request), + 'route' => $this->getRouteName($request), + ]; + + if (false !== filter_var($request->getHost(), \FILTER_VALIDATE_IP)) { + $tags['net.host.ip'] = $request->getHost(); + } else { + $tags['net.host.name'] = $request->getHost(); + } + + if (null !== $request->getClientIp() && null !== $client && $client->getOptions()->shouldSendDefaultPii()) { + $tags['net.peer.ip'] = $request->getClientIp(); + } + + return $tags; + } + + /** + * Gets the HTTP flavor from the request. + * + * @param Request $request The HTTP request + */ + protected function getHttpFlavor(Request $request): string + { + $protocolVersion = $request->getProtocolVersion(); + + if (str_starts_with($protocolVersion, 'HTTP/')) { + return substr($protocolVersion, \strlen('HTTP/')); + } + + return $protocolVersion; + } +} diff --git a/src/EventListener/TracingSubRequestListener.php b/src/EventListener/TracingSubRequestListener.php new file mode 100644 index 00000000..cd81b2e4 --- /dev/null +++ b/src/EventListener/TracingSubRequestListener.php @@ -0,0 +1,68 @@ +isMasterRequest()) { + return; + } + + $request = $event->getRequest(); + $span = $this->hub->getSpan(); + + if (null === $span) { + return; + } + + $spanContext = new SpanContext(); + $spanContext->setOp('http.server'); + $spanContext->setDescription(sprintf('%s %s%s%s', $request->getMethod(), $request->getSchemeAndHttpHost(), $request->getBaseUrl(), $request->getPathInfo())); + $spanContext->setTags([ + 'http.method' => $request->getMethod(), + 'http.url' => $request->getUri(), + 'route' => $this->getRouteName($request), + ]); + + $this->hub->setSpan($span->startChild($spanContext)); + } + + /** + * This method is called for each subrequest handled by the framework and + * ends the tracing. + * + * @param FinishRequestEvent $event The event + */ + public function handleKernelFinishRequestEvent(FinishRequestEvent $event): void + { + if ($event->isMasterRequest()) { + return; + } + + $span = $this->hub->getSpan(); + + if (null === $span) { + return; + } + + $span->finish(); + } +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 5710faf3..3c026895 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -54,6 +54,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/aliases.php b/src/aliases.php index 4d5a40c0..ecd968d5 100644 --- a/src/aliases.php +++ b/src/aliases.php @@ -14,6 +14,8 @@ use Sentry\SentryBundle\EventListener\ErrorListenerExceptionEvent; use Sentry\SentryBundle\EventListener\RequestListenerControllerEvent; use Sentry\SentryBundle\EventListener\RequestListenerRequestEvent; +use Sentry\SentryBundle\EventListener\RequestListenerResponseEvent; +use Sentry\SentryBundle\EventListener\RequestListenerTerminateEvent; use Sentry\SentryBundle\EventListener\SubRequestListenerRequestEvent; use Sentry\SentryBundle\Tracing\Doctrine\DBAL\Compatibility\DriverInterface; use Sentry\SentryBundle\Tracing\Doctrine\DBAL\Compatibility\ExceptionConverterDriverInterface; @@ -21,9 +23,13 @@ use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Event\FilterControllerEvent; +use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; +use Symfony\Component\HttpKernel\Event\PostResponseEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\Event\TerminateEvent; use Symfony\Component\HttpKernel\Kernel; if (version_compare(Kernel::VERSION, '4.3.0', '>=')) { @@ -42,6 +48,16 @@ class_alias(RequestEvent::class, RequestListenerRequestEvent::class); class_alias(ControllerEvent::class, RequestListenerControllerEvent::class); } + if (!class_exists(RequestListenerResponseEvent::class, false)) { + /** @psalm-suppress UndefinedClass */ + class_alias(ResponseEvent::class, RequestListenerResponseEvent::class); + } + + if (!class_exists(RequestListenerTerminateEvent::class, false)) { + /** @psalm-suppress UndefinedClass */ + class_alias(TerminateEvent::class, RequestListenerTerminateEvent::class); + } + if (!class_exists(SubRequestListenerRequestEvent::class, false)) { /** @psalm-suppress UndefinedClass */ class_alias(RequestEvent::class, SubRequestListenerRequestEvent::class); @@ -62,6 +78,16 @@ class_alias(GetResponseEvent::class, RequestListenerRequestEvent::class); class_alias(FilterControllerEvent::class, RequestListenerControllerEvent::class); } + if (!class_exists(RequestListenerResponseEvent::class, false)) { + /** @psalm-suppress UndefinedClass */ + class_alias(FilterResponseEvent::class, RequestListenerResponseEvent::class); + } + + if (!class_exists(RequestListenerTerminateEvent::class, false)) { + /** @psalm-suppress UndefinedClass */ + class_alias(PostResponseEvent::class, RequestListenerTerminateEvent::class); + } + if (!class_exists(SubRequestListenerRequestEvent::class, false)) { /** @psalm-suppress UndefinedClass */ class_alias(GetResponseEvent::class, SubRequestListenerRequestEvent::class); diff --git a/tests/DependencyInjection/Fixtures/yml/full.yml b/tests/DependencyInjection/Fixtures/yml/full.yml index 4b873b38..e413964b 100644 --- a/tests/DependencyInjection/Fixtures/yml/full.yml +++ b/tests/DependencyInjection/Fixtures/yml/full.yml @@ -41,6 +41,6 @@ sentry: dbal: enabled: false connections: - - enabled + - default twig: enabled: false diff --git a/tests/EventListener/TracingRequestListenerTest.php b/tests/EventListener/TracingRequestListenerTest.php new file mode 100644 index 00000000..eb7f14b2 --- /dev/null +++ b/tests/EventListener/TracingRequestListenerTest.php @@ -0,0 +1,380 @@ +hub = $this->createMock(HubInterface::class); + $this->listener = new TracingRequestListener($this->hub); + } + + /** + * @dataProvider handleKernelRequestEventDataProvider + */ + public function testHandleKernelRequestEvent(Options $options, Request $request, TransactionContext $expectedTransactionContext): void + { + ClockMock::withClockMock(1613493597.010275); + + $transaction = new Transaction(new TransactionContext()); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn($options); + + $this->hub->expects($this->once()) + ->method('getClient') + ->willReturn($client); + + $this->hub->expects($this->once()) + ->method('startTransaction') + ->with($this->callback(function (TransactionContext $context) use ($expectedTransactionContext): bool { + $this->assertEquals($context, $expectedTransactionContext); + + return true; + })) + ->willReturn($transaction); + + $this->hub->expects($this->once()) + ->method('setSpan') + ->with($transaction); + + $this->listener->handleKernelRequestEvent(new RequestListenerRequestEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MASTER_REQUEST + )); + } + + /** + * @return \Generator + */ + public function handleKernelRequestEventDataProvider(): \Generator + { + $transactionContext = new TransactionContext(); + $transactionContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $transactionContext->setParentSpanId(new SpanId('566e3688a61d4bc8')); + $transactionContext->setParentSampled(true); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => '', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.server.sentry-trace EXISTS' => [ + new Options(['send_default_pii' => false]), + Request::create( + 'http://www.example.com', + 'GET', + [], + [], + [], + ['HTTP_sentry-trace' => '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1'] + ), + $transactionContext, + ]; + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => '', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.server.REQUEST_TIME_FLOAT NOT EXISTS' => [ + new Options(['send_default_pii' => false]), + Request::create('http://www.example.com'), + $transactionContext, + ]; + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010819); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => '', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.server.REQUEST_TIME_FLOAT EXISTS' => [ + new Options(['send_default_pii' => false]), + Request::create( + 'http://www.example.com', + 'GET', + [], + [], + [], + ['REQUEST_TIME_FLOAT' => 1613493597.010819] + ), + $transactionContext, + ]; + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://127.0.0.1/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://127.0.0.1/', + 'http.flavor' => '1.1', + 'route' => '', + 'net.host.ip' => '127.0.0.1', + ]); + + yield 'request.server.HOST IS IPV4' => [ + new Options(['send_default_pii' => false]), + Request::create('http://127.0.0.1'), + $transactionContext, + ]; + + $request = Request::create('http://www.example.com/path'); + $request->attributes->set('_route', 'app_homepage'); + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/path'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/path', + 'http.flavor' => '1.1', + 'route' => 'app_homepage', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.attributes.route IS STRING' => [ + new Options(['send_default_pii' => false]), + $request, + $transactionContext, + ]; + + $request = Request::create('http://www.example.com/path'); + $request->attributes->set('_route', new Route('/path')); + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/path'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/path', + 'http.flavor' => '1.1', + 'route' => '/path', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.attributes.route IS INSTANCEOF Symfony\Component\Routing\Route' => [ + new Options(['send_default_pii' => false]), + $request, + $transactionContext, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', 'App\\Controller::indexAction'); + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => 'App\\Controller::indexAction', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.attributes.controller IS STRING' => [ + new Options(['send_default_pii' => false]), + $request, + $transactionContext, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', ['App\\Controller', 'indexAction']); + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => 'App\\Controller::indexAction', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.attributes.controller IS CALLABLE (1)' => [ + new Options(['send_default_pii' => false]), + $request, + $transactionContext, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', [new class() {}, 'indexAction']); + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => 'class@anonymous::indexAction', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.attributes.controller IS CALLABLE (2)' => [ + new Options(['send_default_pii' => false]), + $request, + $transactionContext, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', [10]); + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => '', + 'net.host.name' => 'www.example.com', + ]); + + yield 'request.attributes.controller IS ARRAY and NOT VALID CALLABLE' => [ + new Options(['send_default_pii' => false]), + $request, + $transactionContext, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', [10]); + + $transactionContext = new TransactionContext(); + $transactionContext->setName('GET http://www.example.com/'); + $transactionContext->setOp('http.server'); + $transactionContext->setStartTimestamp(1613493597.010275); + $transactionContext->setTags([ + 'net.host.port' => '80', + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'http.flavor' => '1.1', + 'route' => '', + 'net.host.name' => 'www.example.com', + 'net.peer.ip' => '127.0.0.1', + ]); + + yield 'request.server.REMOTE_ADDR EXISTS and client.options.send_default_pii = TRUE' => [ + new Options(['send_default_pii' => true]), + $request, + $transactionContext, + ]; + } + + public function testHandleKernelRequestEventDoesNothingIfRequestTypeIsSubRequest(): void + { + $this->hub->expects($this->never()) + ->method('startTransaction'); + + $this->listener->handleKernelRequestEvent(new RequestListenerRequestEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::SUB_REQUEST + )); + } + + public function testHandleResponseRequestEvent(): void + { + $transaction = new Transaction(new TransactionContext()); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($transaction); + + $this->listener->handleKernelResponseEvent(new RequestListenerResponseEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::MASTER_REQUEST, + new Response() + )); + + $this->assertSame(SpanStatus::ok(), $transaction->getStatus()); + $this->assertSame(['http.status_code' => '200'], $transaction->getTags()); + } + + public function testHandleResponseRequestEventDoesNothingIfNoTransactionIsSetOnHub(): void + { + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->listener->handleKernelResponseEvent(new RequestListenerResponseEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::MASTER_REQUEST, + new Response() + )); + } +} diff --git a/tests/EventListener/TracingSubRequestListenerTest.php b/tests/EventListener/TracingSubRequestListenerTest.php new file mode 100644 index 00000000..eb77f58a --- /dev/null +++ b/tests/EventListener/TracingSubRequestListenerTest.php @@ -0,0 +1,243 @@ +hub = $this->createMock(HubInterface::class); + $this->listener = new TracingSubRequestListener($this->hub); + } + + /** + * @dataProvider handleKernelRequestEventDataProvider + */ + public function testHandleKernelRequestEvent(Request $request, Span $expectedSpan): void + { + $span = new Span(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($span); + + $this->hub->expects($this->once()) + ->method('setSpan') + ->with($this->callback(function (Span $span) use ($expectedSpan): bool { + $this->assertSame($expectedSpan->getOp(), $span->getOp()); + $this->assertSame($expectedSpan->getDescription(), $span->getDescription()); + $this->assertSame($expectedSpan->getTags(), $span->getTags()); + + return true; + })) + ->willReturnSelf(); + + $this->listener->handleKernelRequestEvent(new SubRequestListenerRequestEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::SUB_REQUEST + )); + } + + /** + * @return \Generator + */ + public function handleKernelRequestEventDataProvider(): \Generator + { + $request = Request::create('http://www.example.com/path'); + $request->attributes->set('_controller', 'App\\Controller::indexAction'); + + $span = new Span(); + $span->setOp('http.server'); + $span->setDescription('GET http://www.example.com/path'); + $span->setTags([ + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/path', + 'route' => 'App\\Controller::indexAction', + ]); + + yield 'request.attributes.controller IS STRING' => [ + $request, + $span, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', ['App\\Controller', 'indexAction']); + + $span = new Span(); + $span->setOp('http.server'); + $span->setDescription('GET http://www.example.com/'); + $span->setTags([ + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'route' => 'App\\Controller::indexAction', + ]); + + yield 'request.attributes.controller IS CALLABLE (1)' => [ + $request, + $span, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', [new class() {}, 'indexAction']); + + $span = new Span(); + $span->setOp('http.server'); + $span->setDescription('GET http://www.example.com/'); + $span->setTags([ + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'route' => 'class@anonymous::indexAction', + ]); + + yield 'request.attributes.controller IS CALLABLE (2)' => [ + $request, + $span, + ]; + + $request = Request::create('http://www.example.com/'); + $request->attributes->set('_controller', [10]); + + $span = new Span(); + $span->setOp('http.server'); + $span->setDescription('GET http://www.example.com/'); + $span->setTags([ + 'http.method' => 'GET', + 'http.url' => 'http://www.example.com/', + 'route' => '', + ]); + + yield 'request.attributes.controller IS ARRAY and NOT VALID CALLABLE' => [ + $request, + $span, + ]; + } + + public function testHandleKernelRequestEventDoesNothingIfRequestTypeIsMasterRequest(): void + { + $this->hub->expects($this->never()) + ->method('getSpan'); + + $this->listener->handleKernelRequestEvent(new SubRequestListenerRequestEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::MASTER_REQUEST + )); + } + + public function testHandleKernelRequestEventDoesNothingIfNoSpanIsSetOnHub(): void + { + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->listener->handleKernelRequestEvent(new SubRequestListenerRequestEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::SUB_REQUEST + )); + } + + /** + * @group time-sensitive + */ + public function testHandleKernelFinishRequestEvent(): void + { + $span = new Span(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($span); + + $this->listener->handleKernelFinishRequestEvent(new FinishRequestEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::SUB_REQUEST + )); + + $this->assertSame(microtime(true), $span->getEndTimestamp()); + } + + public function testHandleKernelFinishRequestEventDoesNothingIfRequestTypeIsMasterRequest(): void + { + $this->hub->expects($this->never()) + ->method('getSpan'); + + $this->listener->handleKernelFinishRequestEvent(new FinishRequestEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::MASTER_REQUEST + )); + } + + public function testHandleKernelFinishRequestEventDoesNothingIfNoSpanIsSetOnHub(): void + { + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->listener->handleKernelFinishRequestEvent(new FinishRequestEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::SUB_REQUEST + )); + } + + public function testHandleResponseRequestEvent(): void + { + $span = new Span(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($span); + + $this->listener->handleKernelResponseEvent(new RequestListenerResponseEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::SUB_REQUEST, + new Response() + )); + + $this->assertSame(SpanStatus::ok(), $span->getStatus()); + $this->assertSame(['http.status_code' => '200'], $span->getTags()); + } + + public function testHandleResponseRequestEventDoesNothingIfNoTransactionIsSetOnHub(): void + { + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->listener->handleKernelResponseEvent(new RequestListenerResponseEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::SUB_REQUEST, + new Response() + )); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..6f0720a8 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,23 @@ +