From c4eb2a9842c908d0a0b8acdba35db63b48e32edf Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 15 Jun 2026 16:14:28 +0200 Subject: [PATCH] feat(tracer): add support for the tracing extension --- composer.json | 1 + .../FunctionTracingIntegration.php | 23 ++ src/Integration/IntegrationRegistry.php | 4 + src/Tracing/FunctionTracingCallbacks.php | 117 +++++++++ stubs/SentryTracer.stub | 36 +++ stubs/autoload.php | 16 +- tests/Integration/IntegrationRegistryTest.php | 118 +++++---- .../Tracing/FunctionTracingCallbacksTest.php | 237 ++++++++++++++++++ .../FunctionTracingIntegrationSmokeTest.php | 65 +++++ 9 files changed, 557 insertions(+), 60 deletions(-) create mode 100644 src/Integration/FunctionTracingIntegration.php create mode 100644 src/Tracing/FunctionTracingCallbacks.php create mode 100644 stubs/SentryTracer.stub create mode 100644 tests/Tracing/FunctionTracingCallbacksTest.php create mode 100644 tests/Tracing/FunctionTracingIntegrationSmokeTest.php diff --git a/composer.json b/composer.json index a7554ac773..6389974aaa 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ }, "suggest": { "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", + "ext-sentry": "Enable automatic function and method tracing with the Sentry PHP tracer extension.", "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." }, "conflict": { diff --git a/src/Integration/FunctionTracingIntegration.php b/src/Integration/FunctionTracingIntegration.php new file mode 100644 index 0000000000..005d5006f6 --- /dev/null +++ b/src/Integration/FunctionTracingIntegration.php @@ -0,0 +1,23 @@ +getDsn() !== null || $options->isSpotlightEnabled()) { array_unshift($integrations, new ExceptionListenerIntegration(), new ErrorListenerIntegration(), new FatalErrorListenerIntegration()); } diff --git a/src/Tracing/FunctionTracingCallbacks.php b/src/Tracing/FunctionTracingCallbacks.php new file mode 100644 index 0000000000..db0662b926 --- /dev/null +++ b/src/Tracing/FunctionTracingCallbacks.php @@ -0,0 +1,117 @@ + $data + * + * @return array{Span, Span}|null + */ + public static function handleStart(array $data): ?array + { + $hub = SentrySdk::getCurrentHub(); + + if (!$hub->getIntegration(FunctionTracingIntegration::class) instanceof FunctionTracingIntegration) { + return null; + } + + $parentSpan = $hub->getSpan(); + if ($parentSpan === null || $parentSpan->getSampled() !== true) { + return null; + } + + if (!isset($data['name']) || !\is_string($data['name'])) { + return null; + } + + $context = self::createSpanContext($data['name'], $data); + $childSpan = $parentSpan->startChild($context); + + $hub->setSpan($childSpan); + + return [$childSpan, $parentSpan]; + } + + /** + * @param array $data + * @param mixed $callbackState + */ + public static function handleEnd(array $data, $callbackState = null): void + { + if (!\is_array($callbackState) + || !\array_key_exists(0, $callbackState) + || !\array_key_exists(1, $callbackState) + || !$callbackState[0] instanceof Span + || !$callbackState[1] instanceof Span + ) { + return; + } + + if (!isset($data['end_time']) || (!\is_int($data['end_time']) && !\is_float($data['end_time']))) { + return; + } + + $callbackState[0]->finish((float) $data['end_time']); + SentrySdk::getCurrentHub()->setSpan($callbackState[1]); + } + + /** + * @param array $data + */ + private static function createSpanContext(string $name, array $data): SpanContext + { + $metadata = []; + if (isset($data['metadata']) && \is_array($data['metadata'])) { + foreach (array_keys($data['metadata']) as $key) { + if (\is_string($key)) { + $metadata[$key] = $data['metadata'][$key]; + } + } + } + + $description = $name; + $op = 'function'; + $origin = 'auto.function.sentry_php_tracer'; + + if (isset($metadata['sentry.description']) && \is_string($metadata['sentry.description'])) { + $description = $metadata['sentry.description']; + unset($metadata['sentry.description']); + } + + if (isset($metadata['sentry.op']) && \is_string($metadata['sentry.op'])) { + $op = $metadata['sentry.op']; + unset($metadata['sentry.op']); + } + + if (isset($metadata['sentry.origin']) && \is_string($metadata['sentry.origin'])) { + $origin = $metadata['sentry.origin']; + unset($metadata['sentry.origin']); + } + + $context = SpanContext::make() + ->setDescription($description) + ->setOp($op) + ->setOrigin($origin) + ->setData($metadata); + + if (isset($data['start_time']) && (\is_int($data['start_time']) || \is_float($data['start_time']))) { + $context->setStartTimestamp((float) $data['start_time']); + } + + return $context; + } +} diff --git a/stubs/SentryTracer.stub b/stubs/SentryTracer.stub new file mode 100644 index 0000000000..3c40b3c271 --- /dev/null +++ b/stubs/SentryTracer.stub @@ -0,0 +1,36 @@ + $extra_metadata + */ + function instrument(?string $class_name, string $function_name, array $extra_metadata = []): bool + { + } + + /** + * @phpstan-param callable(array{name: string, start_time: float, end_time: float, duration: float, metadata: array}, mixed): mixed $callback + */ + function setEndCallback(callable $callback): bool + { + } + + /** + * @phpstan-param callable(array{name: string, start_time: float, metadata: array}): mixed $callback + */ + function setStartCallback(callable $callback): bool + { + } + + final class Trace + { + /** + * @param array $metadata + */ + public function __construct(array $metadata = []) + { + } + } +} diff --git a/stubs/autoload.php b/stubs/autoload.php index 07e53c8d2a..25a6eff100 100644 --- a/stubs/autoload.php +++ b/stubs/autoload.php @@ -2,12 +2,14 @@ declare(strict_types=1); -if (extension_loaded('excimer')) { - return; +if (!extension_loaded('excimer')) { + require_once __DIR__ . '/ExcimerLog.stub'; + require_once __DIR__ . '/ExcimerLogEntry.stub'; + require_once __DIR__ . '/ExcimerProfiler.stub'; + require_once __DIR__ . '/ExcimerTimer.stub'; + require_once __DIR__ . '/globals.stub'; } -require_once __DIR__ . '/ExcimerLog.stub'; -require_once __DIR__ . '/ExcimerLogEntry.stub'; -require_once __DIR__ . '/ExcimerProfiler.stub'; -require_once __DIR__ . '/ExcimerTimer.stub'; -require_once __DIR__ . '/globals.stub'; +if (!function_exists('Sentry\\instrument')) { + require_once __DIR__ . '/SentryTracer.stub'; +} diff --git a/tests/Integration/IntegrationRegistryTest.php b/tests/Integration/IntegrationRegistryTest.php index 43caf28728..92f42d70ec 100644 --- a/tests/Integration/IntegrationRegistryTest.php +++ b/tests/Integration/IntegrationRegistryTest.php @@ -11,6 +11,7 @@ use Sentry\Integration\ExceptionListenerIntegration; use Sentry\Integration\FatalErrorListenerIntegration; use Sentry\Integration\FrameContextifierIntegration; +use Sentry\Integration\FunctionTracingIntegration; use Sentry\Integration\IntegrationInterface; use Sentry\Integration\IntegrationRegistry; use Sentry\Integration\ModulesIntegration; @@ -77,16 +78,7 @@ public function setupOnce(): void 'dsn' => 'http://public@example.com/sentry/1', 'default_integrations' => true, ]), - [ - ExceptionListenerIntegration::class => new ExceptionListenerIntegration(), - ErrorListenerIntegration::class => ErrorListenerIntegration::make($options), - FatalErrorListenerIntegration::class => new FatalErrorListenerIntegration(), - RequestIntegration::class => new RequestIntegration(), - TransactionIntegration::class => new TransactionIntegration(), - FrameContextifierIntegration::class => new FrameContextifierIntegration(), - EnvironmentIntegration::class => new EnvironmentIntegration(), - ModulesIntegration::class => new ModulesIntegration(), - ], + self::getExpectedDefaultIntegrations($options, true), ]; yield 'No default integrations and some user integrations' => [ @@ -112,15 +104,7 @@ public function setupOnce(): void $integration2, ], ]), - [ - ExceptionListenerIntegration::class => new ExceptionListenerIntegration(), - ErrorListenerIntegration::class => ErrorListenerIntegration::make($options), - FatalErrorListenerIntegration::class => new FatalErrorListenerIntegration(), - RequestIntegration::class => new RequestIntegration(), - TransactionIntegration::class => new TransactionIntegration(), - FrameContextifierIntegration::class => new FrameContextifierIntegration(), - EnvironmentIntegration::class => new EnvironmentIntegration(), - ModulesIntegration::class => new ModulesIntegration(), + self::getExpectedDefaultIntegrations($options, true) + [ $integration1ClassName => $integration1, $integration2ClassName => $integration2, ], @@ -135,14 +119,7 @@ public function setupOnce(): void $integration1, ], ]), - [ - ExceptionListenerIntegration::class => new ExceptionListenerIntegration(), - ErrorListenerIntegration::class => ErrorListenerIntegration::make($options), - FatalErrorListenerIntegration::class => new FatalErrorListenerIntegration(), - RequestIntegration::class => new RequestIntegration(), - FrameContextifierIntegration::class => new FrameContextifierIntegration(), - EnvironmentIntegration::class => new EnvironmentIntegration(), - ModulesIntegration::class => new ModulesIntegration(), + self::getExpectedDefaultIntegrations($options, true, [TransactionIntegration::class]) + [ TransactionIntegration::class => new TransactionIntegration(), $integration1ClassName => $integration1, ], @@ -156,14 +133,7 @@ public function setupOnce(): void new ModulesIntegration(), ], ]), - [ - ExceptionListenerIntegration::class => new ExceptionListenerIntegration(), - ErrorListenerIntegration::class => ErrorListenerIntegration::make($options), - FatalErrorListenerIntegration::class => new FatalErrorListenerIntegration(), - RequestIntegration::class => new RequestIntegration(), - TransactionIntegration::class => new TransactionIntegration(), - FrameContextifierIntegration::class => new FrameContextifierIntegration(), - EnvironmentIntegration::class => new EnvironmentIntegration(), + self::getExpectedDefaultIntegrations($options, true, [ModulesIntegration::class]) + [ ModulesIntegration::class => new ModulesIntegration(), ], ]; @@ -199,34 +169,76 @@ public function setupOnce(): void return $defaultIntegrations; }, ]), - [ - ExceptionListenerIntegration::class => new ExceptionListenerIntegration(), - ErrorListenerIntegration::class => ErrorListenerIntegration::make($options), - FatalErrorListenerIntegration::class => new FatalErrorListenerIntegration(), - RequestIntegration::class => new RequestIntegration(), - TransactionIntegration::class => new TransactionIntegration(), - FrameContextifierIntegration::class => new FrameContextifierIntegration(), - EnvironmentIntegration::class => new EnvironmentIntegration(), - ModulesIntegration::class => new ModulesIntegration(), - ], + self::getExpectedDefaultIntegrations($options, true), ]; yield 'Default integrations with DSN set to null' => [ - new Options([ + $options = new Options([ 'dsn' => null, 'default_integrations' => true, 'integrations' => static function (array $defaultIntegrations): array { return $defaultIntegrations; }, ]), - [ - RequestIntegration::class => new RequestIntegration(), - TransactionIntegration::class => new TransactionIntegration(), - FrameContextifierIntegration::class => new FrameContextifierIntegration(), - EnvironmentIntegration::class => new EnvironmentIntegration(), - ModulesIntegration::class => new ModulesIntegration(), - ], + self::getExpectedDefaultIntegrations($options, false), + ]; + } + + /** + * @param class-string[] $excludedDefaultIntegrations + * + * @return array, IntegrationInterface> + */ + private static function getExpectedDefaultIntegrations(Options $options, bool $withErrorIntegrations, array $excludedDefaultIntegrations = []): array + { + $integrations = [ + RequestIntegration::class => new RequestIntegration(), + TransactionIntegration::class => new TransactionIntegration(), + FrameContextifierIntegration::class => new FrameContextifierIntegration(), + EnvironmentIntegration::class => new EnvironmentIntegration(), + ModulesIntegration::class => new ModulesIntegration(), ]; + + if (\function_exists('Sentry\\setStartCallback') && \function_exists('Sentry\\setEndCallback')) { + $integrations[FunctionTracingIntegration::class] = new FunctionTracingIntegration(); + } + + foreach ($excludedDefaultIntegrations as $excludedDefaultIntegration) { + unset($integrations[$excludedDefaultIntegration]); + } + + if ($withErrorIntegrations) { + $integrations = [ + ExceptionListenerIntegration::class => new ExceptionListenerIntegration(), + ErrorListenerIntegration::class => ErrorListenerIntegration::make($options), + FatalErrorListenerIntegration::class => new FatalErrorListenerIntegration(), + ] + $integrations; + } + + return $integrations; + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testDefaultIntegrationsIncludeFunctionTracingIntegrationWhenExtensionCallbacksExist(): void + { + if (!\function_exists('Sentry\\setStartCallback')) { + eval('namespace Sentry { function setStartCallback(callable $callback): bool { return true; } function setEndCallback(callable $callback): bool { return true; } }'); + } + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('debug'); + + $integrations = IntegrationRegistry::getInstance()->setupIntegrations(new Options([ + 'dsn' => null, + 'default_integrations' => true, + ]), $logger); + + $this->assertArrayHasKey(FunctionTracingIntegration::class, $integrations); } /** diff --git a/tests/Tracing/FunctionTracingCallbacksTest.php b/tests/Tracing/FunctionTracingCallbacksTest.php new file mode 100644 index 0000000000..83dadbf3e8 --- /dev/null +++ b/tests/Tracing/FunctionTracingCallbacksTest.php @@ -0,0 +1,237 @@ +createActiveTransaction(true, true); + + $callbackState = FunctionTracingCallbacks::handleStart([ + 'name' => 'foo', + 'start_time' => 123.456, + 'metadata' => [], + ]); + + $this->assertIsArray($callbackState); + $this->assertInstanceOf(Span::class, $callbackState[0]); + $this->assertSame($transaction, $callbackState[1]); + $this->assertSame($transaction->getSpanId(), $callbackState[0]->getParentSpanId()); + $this->assertSame($transaction->getTraceId(), $callbackState[0]->getTraceId()); + $this->assertSame(123.456, $callbackState[0]->getStartTimestamp()); + $this->assertSame($callbackState[0], SentrySdk::getCurrentHub()->getSpan()); + } + + public function testHandleEndFinishesSpanWithEndTimeAndRestoresParent(): void + { + $transaction = $this->createActiveTransaction(true, true); + + $callbackState = FunctionTracingCallbacks::handleStart([ + 'name' => 'foo', + 'start_time' => 123.456, + 'metadata' => [], + ]); + + $this->assertIsArray($callbackState); + + FunctionTracingCallbacks::handleEnd([ + 'name' => 'foo', + 'start_time' => 123.456, + 'end_time' => 234.567, + 'duration' => 111.111, + 'metadata' => [], + ], $callbackState); + + $this->assertSame(234.567, $callbackState[0]->getEndTimestamp()); + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + } + + public function testNestedCallbacksRestoreParentSpansInOrder(): void + { + $transaction = $this->createActiveTransaction(true, true); + + $outerState = FunctionTracingCallbacks::handleStart([ + 'name' => 'outer', + 'start_time' => 1.0, + 'metadata' => [], + ]); + + $this->assertIsArray($outerState); + + $innerState = FunctionTracingCallbacks::handleStart([ + 'name' => 'inner', + 'start_time' => 2.0, + 'metadata' => [], + ]); + + $this->assertIsArray($innerState); + $this->assertSame($outerState[0]->getSpanId(), $innerState[0]->getParentSpanId()); + $this->assertSame($innerState[0], SentrySdk::getCurrentHub()->getSpan()); + + FunctionTracingCallbacks::handleEnd([ + 'name' => 'inner', + 'start_time' => 2.0, + 'end_time' => 3.0, + 'duration' => 1.0, + 'metadata' => [], + ], $innerState); + + $this->assertSame($outerState[0], SentrySdk::getCurrentHub()->getSpan()); + + FunctionTracingCallbacks::handleEnd([ + 'name' => 'outer', + 'start_time' => 1.0, + 'end_time' => 4.0, + 'duration' => 3.0, + 'metadata' => [], + ], $outerState); + + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + } + + /** + * @dataProvider handleStartNoOpDataProvider + */ + public function testHandleStartReturnsNullWhenSpanCannotBeCreated(bool $withIntegration, bool $withActiveSpan, bool $sampled): void + { + $this->createHub($withIntegration); + + if ($withActiveSpan) { + $transaction = SentrySdk::getCurrentHub()->startTransaction(new TransactionContext()); + $transaction->setSampled($sampled); + SentrySdk::getCurrentHub()->setSpan($transaction); + } + + $this->assertNull(FunctionTracingCallbacks::handleStart([ + 'name' => 'foo', + 'start_time' => 123.456, + 'metadata' => [], + ])); + } + + public static function handleStartNoOpDataProvider(): iterable + { + yield 'disabled integration' => [false, true, true]; + yield 'no active span' => [true, false, true]; + yield 'unsampled active span' => [true, true, false]; + } + + /** + * @dataProvider malformedCallbackStateDataProvider + * + * @param mixed $callbackState + */ + public function testHandleEndIgnoresMalformedCallbackState($callbackState): void + { + $transaction = $this->createActiveTransaction(true, true); + + FunctionTracingCallbacks::handleEnd([ + 'name' => 'foo', + 'start_time' => 123.456, + 'end_time' => 234.567, + 'duration' => 111.111, + 'metadata' => [], + ], $callbackState); + + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + } + + public static function malformedCallbackStateDataProvider(): iterable + { + $span = new Span(); + + yield 'null' => [null]; + yield 'missing parent' => [[$span]]; + yield 'invalid child' => [['foo', $span]]; + yield 'invalid parent' => [[$span, 'foo']]; + } + + public function testMetadataIsMappedToSpanContext(): void + { + $this->createActiveTransaction(true, true); + + $callbackState = FunctionTracingCallbacks::handleStart([ + 'name' => 'foo', + 'start_time' => 123.456, + 'metadata' => [ + 'sentry.description' => 'custom description', + 'sentry.op' => 'custom.op', + 'sentry.origin' => 'auto.custom', + 'custom' => 'value', + ], + ]); + + $this->assertIsArray($callbackState); + $span = $callbackState[0]; + + $this->assertSame('custom description', $span->getDescription()); + $this->assertSame('custom.op', $span->getOp()); + $this->assertSame('auto.custom', $span->getOrigin()); + $this->assertSame(['custom' => 'value'], $span->getData()); + } + + public function testMetadataDefaultsAreUsed(): void + { + $this->createActiveTransaction(true, true); + + $callbackState = FunctionTracingCallbacks::handleStart([ + 'name' => 'foo', + 'start_time' => 123.456, + 'metadata' => [ + 'custom' => 'value', + ], + ]); + + $this->assertIsArray($callbackState); + $span = $callbackState[0]; + + $this->assertSame('foo', $span->getDescription()); + $this->assertSame('function', $span->getOp()); + $this->assertSame('auto.function.sentry_php_tracer', $span->getOrigin()); + $this->assertSame(['custom' => 'value'], $span->getData()); + } + + private function createActiveTransaction(bool $withIntegration, bool $sampled): Transaction + { + $hub = $this->createHub($withIntegration); + $transaction = $hub->startTransaction(new TransactionContext()); + $transaction->setSampled($sampled); + if ($sampled && $transaction->getSpanRecorder() === null) { + $transaction->initSpanRecorder(); + } + + $hub->setSpan($transaction); + + return $transaction; + } + + private function createHub(bool $withIntegration): Hub + { + $integrations = $withIntegration ? [new FunctionTracingIntegration()] : []; + $options = new Options([ + 'default_integrations' => false, + 'integrations' => $integrations, + 'traces_sample_rate' => 1.0, + ]); + + $hub = new Hub(new Client($options, StubTransport::getInstance())); + SentrySdk::setCurrentHub($hub); + + return $hub; + } +} diff --git a/tests/Tracing/FunctionTracingIntegrationSmokeTest.php b/tests/Tracing/FunctionTracingIntegrationSmokeTest.php new file mode 100644 index 0000000000..a3eae00463 --- /dev/null +++ b/tests/Tracing/FunctionTracingIntegrationSmokeTest.php @@ -0,0 +1,65 @@ +markTestSkipped('The Sentry PHP tracer extension is not loaded.'); + } + + init([ + 'dsn' => null, + 'default_integrations' => true, + 'traces_sample_rate' => 1.0, + 'transport' => StubTransport::getInstance(), + ]); + + $transaction = startTransaction(new TransactionContext('tracer-smoke')); + configureScope(static function (Scope $scope) use ($transaction): void { + $scope->setSpan($transaction); + }); + + \Sentry\instrument(self::class, 'tracerSmokeOuter'); + \Sentry\instrument(self::class, 'tracerSmokeInner'); + + self::tracerSmokeOuter(); + $transaction->finish(); + + $this->assertCount(1, StubTransport::$events); + + $spans = StubTransport::$events[0]->getSpans(); + $this->assertCount(2, $spans); + + $this->assertSame(self::class . '::tracerSmokeOuter', $spans[0]->getDescription()); + $this->assertSame(self::class . '::tracerSmokeInner', $spans[1]->getDescription()); + $this->assertSame($transaction->getSpanId(), $spans[0]->getParentSpanId()); + $this->assertSame($spans[0]->getSpanId(), $spans[1]->getParentSpanId()); + $this->assertNotNull($spans[0]->getEndTimestamp()); + $this->assertNotNull($spans[1]->getEndTimestamp()); + $this->assertGreaterThanOrEqual($spans[0]->getStartTimestamp(), $spans[0]->getEndTimestamp()); + $this->assertGreaterThanOrEqual($spans[1]->getStartTimestamp(), $spans[1]->getEndTimestamp()); + } + + public static function tracerSmokeOuter(): void + { + self::tracerSmokeInner(); + } + + public static function tracerSmokeInner(): void + { + } +}