diff --git a/.changeset/ambient-exception-traces.md b/.changeset/ambient-exception-traces.md new file mode 100644 index 0000000..6481208 --- /dev/null +++ b/.changeset/ambient-exception-traces.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Add the active OpenTelemetry trace and span IDs to captured exception events when the application provides OpenTelemetry. diff --git a/api/public-api.json b/api/public-api.json index 63cfd03..7490d22 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -2041,6 +2041,13 @@ } ] }, + "getTraceProperties": { + "static": true, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, "overridePrimaryMechanism": { "static": true, "abstract": false, diff --git a/composer.json b/composer.json index aefcb41..adbcd70 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "require-dev": { "phpunit/phpunit": "^11.0", "squizlabs/php_codesniffer": "^4.0", - "ext-zlib": "*" + "ext-zlib": "*", + "open-telemetry/api": "^1.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index fcd6c27..8ddfdc4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "520fd5d31c8da486e608e28c4972f358", + "content-hash": "e677b9ceb49491f0433c1208ea621630", "packages": [ { "name": "psr/clock", @@ -501,6 +501,135 @@ }, "time": "2025-12-06T11:56:16+00:00" }, + { + "name": "open-telemetry/api", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/api.git", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", + "shasum": "" + }, + "require": { + "open-telemetry/context": "^1.4", + "php": "^8.1", + "psr/log": "^1.1|^2.0|^3.0", + "symfony/polyfill-php82": "^1.26" + }, + "conflict": { + "open-telemetry/sdk": "<=1.11" + }, + "type": "library", + "extra": { + "spi": { + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [ + "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager" + ] + }, + "branch-alias": { + "dev-main": "1.8.x-dev" + } + }, + "autoload": { + "files": [ + "Trace/functions.php" + ], + "psr-4": { + "OpenTelemetry\\API\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "API for OpenTelemetry PHP.", + "keywords": [ + "Metrics", + "api", + "apm", + "logging", + "opentelemetry", + "otel", + "tracing" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2026-07-06T12:28:04+00:00" + }, + { + "name": "open-telemetry/context", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/opentelemetry-php/context.git", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/polyfill-php82": "^1.26" + }, + "suggest": { + "ext-ffi": "To allow context switching in Fibers" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "files": [ + "fiber/initialize_fiber_handler.php" + ], + "psr-4": { + "OpenTelemetry\\Context\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "opentelemetry-php contributors", + "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors" + } + ], + "description": "Context implementation for OpenTelemetry PHP.", + "keywords": [ + "Context", + "opentelemetry", + "otel" + ], + "support": { + "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", + "docs": "https://opentelemetry.io/docs/languages/php", + "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", + "source": "https://github.com/open-telemetry/opentelemetry-php" + }, + "time": "2025-10-19T06:44:33+00:00" + }, { "name": "phar-io/manifest", "version": "2.0.4", @@ -1076,6 +1205,56 @@ ], "time": "2026-02-18T12:37:06+00:00" }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, { "name": "sebastian/cli-parser", "version": "3.0.2", @@ -2198,6 +2377,86 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/lib/Client.php b/lib/Client.php index ebe498f..0f8ffda 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -621,7 +621,12 @@ public function captureException( return false; } + $traceProperties = array_diff_key( + ExceptionPayloadBuilder::getTraceProperties(), + $this->getContext()['properties'] ?? [] + ); $properties = array_merge( + $traceProperties, $additionalProperties, [ '$exception_list' => $exceptionList, diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 32c18c6..c285f07 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -429,7 +429,11 @@ private static function sendExceptionEvent( $properties['$php_error_severity'] = $severity; } - $properties = array_merge($providerContext['properties'], $properties); + $properties = array_merge( + ExceptionPayloadBuilder::getTraceProperties(), + $providerContext['properties'], + $properties + ); $distinctId = $providerContext['distinctId']; if ($distinctId === null) { diff --git a/lib/ExceptionPayloadBuilder.php b/lib/ExceptionPayloadBuilder.php index a0aa4af..f5921e5 100644 --- a/lib/ExceptionPayloadBuilder.php +++ b/lib/ExceptionPayloadBuilder.php @@ -2,6 +2,8 @@ namespace PostHog; +use OpenTelemetry\API\Trace\Span; + /** * Builds PostHog error tracking exception payloads. * @@ -12,6 +14,33 @@ class ExceptionPayloadBuilder private const CONTEXT_LINES = 5; private const DEFAULT_MAX_FRAMES = 20; + public static function getTraceProperties(): array + { + try { + if (!class_exists(Span::class)) { + return []; + } + + $context = Span::getCurrent()->getContext(); + if (!$context->isValid()) { + return []; + } + + $traceId = $context->getTraceId(); + $spanId = $context->getSpanId(); + if ( + !preg_match('/^[0-9a-f]{32}$/D', $traceId) || $traceId === str_repeat('0', 32) + || !preg_match('/^[0-9a-f]{16}$/D', $spanId) || $spanId === str_repeat('0', 16) + ) { + return []; + } + + return ['$trace_id' => $traceId, '$span_id' => $spanId]; + } catch (\Throwable $error) { + return []; + } + } + /** * Build a parsed exception list from a Throwable or string. * diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 2d4dec0..a959a04 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -2,6 +2,8 @@ namespace PostHog\Test; +use OpenTelemetry\API\Trace\Span; +use OpenTelemetry\API\Trace\SpanContext; use PHPUnit\Framework\TestCase; use PostHog\Client; use PostHog\ExceptionCapture; @@ -147,6 +149,10 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v }; set_exception_handler($previousExceptionHandler); + $scope = Span::wrap(SpanContext::create( + '0123456789abcdef0123456789abcdef', + '0123456789abcdef' + ))->activate(); try { $this->buildClient(['error_tracking' => ['enabled' => true]]); @@ -160,6 +166,8 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v $event = $this->findExceptionEvent(); $this->assertSame('$exception', $event['event']); + $this->assertSame('0123456789abcdef0123456789abcdef', $event['properties']['$trace_id']); + $this->assertSame('0123456789abcdef', $event['properties']['$span_id']); $this->assertFalse($event['properties']['$exception_handled']); $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); $this->assertSame( @@ -173,6 +181,7 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v $event['distinct_id'] ); } finally { + $scope->detach(); ExceptionCapture::resetForTests(); restore_exception_handler(); } diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php index 5e5b705..4aeed0d 100644 --- a/test/ExceptionPayloadBuilderTest.php +++ b/test/ExceptionPayloadBuilderTest.php @@ -2,6 +2,13 @@ namespace PostHog\Test; +use OpenTelemetry\API\Trace\Span; +use OpenTelemetry\API\Trace\SpanContext; +use OpenTelemetry\API\Trace\SpanContextInterface; +use OpenTelemetry\API\Trace\TraceFlags; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use PostHog\Client; use PostHog\ExceptionPayloadBuilder; @@ -429,6 +436,150 @@ public function testCaptureExceptionSendsExceptionEvent(): void }); } + public static function activeTraceCases(): array + { + return [ + 'sampled' => [TraceFlags::SAMPLED, '0123456789abcdef0123456789abcdef', '0123456789abcdef', []], + 'unsampled' => [TraceFlags::DEFAULT, '0123456789abcdef0123456789abcdef', '0123456789abcdef', []], + 'leading zeros' => [TraceFlags::DEFAULT, str_repeat('0', 31) . '3', str_repeat('0', 15) . '4', []], + 'caller properties' => [TraceFlags::SAMPLED, '0123456789abcdef0123456789abcdef', '0123456789abcdef', + ['$trace_id' => 'caller-trace', '$span_id' => 'caller-span']], + 'request trace ID' => [ + TraceFlags::SAMPLED, '0123456789abcdef0123456789abcdef', '0123456789abcdef', + [], ['$trace_id' => 'request-trace'], + ], + 'request span ID' => [ + TraceFlags::SAMPLED, '0123456789abcdef0123456789abcdef', '0123456789abcdef', + [], ['$span_id' => 'request-span'], + ], + 'event overrides request' => [ + TraceFlags::SAMPLED, '0123456789abcdef0123456789abcdef', '0123456789abcdef', + ['$trace_id' => 'event-trace'], ['$trace_id' => 'request-trace', '$span_id' => 'request-span'], + ], + 'empty request values' => [ + TraceFlags::SAMPLED, '0123456789abcdef0123456789abcdef', '0123456789abcdef', + [], ['$trace_id' => '', '$span_id' => null], + ], + ]; + } + + #[DataProvider('activeTraceCases')] + public function testCaptureExceptionIncludesActiveTrace( + int $flags, + string $traceId, + string $spanId, + array $additionalProperties, + array $contextProperties = [] + ): void { + $scope = Span::wrap(SpanContext::create($traceId, $spanId, $flags))->activate(); + try { + $this->assertTrue($this->client->withContext( + ['properties' => $contextProperties], + fn (): bool => $this->client->captureException('example error', 'example-user', $additionalProperties) + )); + } finally { + $scope->detach(); + } + PostHog::flush(); + + $payload = json_decode($this->findBatchCall()['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('$exception', $event['event']); + $expected = array_merge( + ['$trace_id' => $traceId, '$span_id' => $spanId], + $contextProperties, + $additionalProperties + ); + $this->assertSame($expected['$trace_id'], $event['properties']['$trace_id']); + $this->assertSame($expected['$span_id'], $event['properties']['$span_id']); + + $this->httpClient->calls = []; + $this->client->captureException('outside span', 'example-user'); + PostHog::flush(); + $payload = json_decode($this->findBatchCall()['payload'], true); + $this->assertArrayNotHasKey('$trace_id', $payload['batch'][0]['properties']); + $this->assertArrayNotHasKey('$span_id', $payload['batch'][0]['properties']); + } + + public static function invalidTraceCases(): array + { + return [ + 'zero trace' => [str_repeat('0', 32), '0123456789abcdef'], + 'zero span' => ['0123456789abcdef0123456789abcdef', str_repeat('0', 16)], + 'short trace' => ['1234', '0123456789abcdef'], + 'non-hex span' => ['0123456789abcdef0123456789abcdef', 'not-a-hex-string!'], + 'uppercase trace' => ['0123456789ABCDEF0123456789ABCDEF', '0123456789abcdef'], + ]; + } + + #[DataProvider('invalidTraceCases')] + public function testCaptureExceptionOmitsInvalidTrace(string $traceId, string $spanId): void + { + $context = $this->createMock(SpanContextInterface::class); + $context->method('isValid')->willReturn(true); + $context->method('getTraceId')->willReturn($traceId); + $context->method('getSpanId')->willReturn($spanId); + $scope = Span::wrap($context)->activate(); + try { + $this->assertTrue($this->client->captureException('example error', 'example-user')); + } finally { + $scope->detach(); + } + PostHog::flush(); + $payload = json_decode($this->findBatchCall()['payload'], true); + $this->assertSame('$exception', $payload['batch'][0]['event']); + $this->assertArrayNotHasKey('$trace_id', $payload['batch'][0]['properties']); + $this->assertArrayNotHasKey('$span_id', $payload['batch'][0]['properties']); + } + + public function testCaptureExceptionSurvivesTraceContextFailure(): void + { + $context = $this->createMock(SpanContextInterface::class); + $context->method('isValid')->willReturn(true); + $context->method('getTraceId')->willThrowException(new \Error('example context failure')); + $scope = Span::wrap($context)->activate(); + try { + $this->assertTrue($this->client->captureException('example error', 'example-user')); + } finally { + $scope->detach(); + } + PostHog::flush(); + $payload = json_decode($this->findBatchCall()['payload'], true); + $this->assertSame('$exception', $payload['batch'][0]['event']); + $this->assertArrayNotHasKey('$trace_id', $payload['batch'][0]['properties']); + $this->assertArrayNotHasKey('$span_id', $payload['batch'][0]['properties']); + } + + public static function unavailableTraceApiCases(): array + { + return ['not installed' => [false], 'autoload failure' => [true]]; + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + #[DataProvider('unavailableTraceApiCases')] + public function testCaptureExceptionWithoutTraceApi(bool $autoloadFailure): void + { + $loader = require dirname(__DIR__) . '/vendor/autoload.php'; + $loader->setPsr4('OpenTelemetry\\API\\', []); + if ($autoloadFailure) { + spl_autoload_register(static function (string $class): void { + if ($class === Span::class) { + throw new \Error('example autoload failure'); + } + }, true, true); + } else { + $this->assertFalse(class_exists(Span::class)); + } + + $this->assertTrue($this->client->captureException('example error', 'example-user')); + PostHog::flush(); + $payload = json_decode($this->findBatchCall()['payload'], true); + $this->assertSame('$exception', $payload['batch'][0]['event']); + $this->assertArrayNotHasKey('$trace_id', $payload['batch'][0]['properties']); + $this->assertArrayNotHasKey('$span_id', $payload['batch'][0]['properties']); + } + public function testCaptureExceptionUsesOuterExceptionAsPrimaryForChains(): void { $cause = new \InvalidArgumentException('root cause');