diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0799642..605843cbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,10 @@ jobs: if: ${{ matrix.os == 'windows-latest' || matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} run: composer remove spiral/roadrunner-http spiral/roadrunner-worker --dev --no-interaction --no-update + - name: Remove OpenTelemetry dependencies on unsupported PHP versions + if: ${{ matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} + run: composer remove open-telemetry/api open-telemetry/exporter-otlp open-telemetry/sdk --dev --no-interaction --no-update + - name: Set phpunit/phpunit version constraint run: composer require phpunit/phpunit:'${{ matrix.php.phpunit }}' --dev --no-interaction --no-update diff --git a/composer.json b/composer.json index ba94d3f13..0bfd60f6c 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,9 @@ "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.0", + "open-telemetry/exporter-otlp": "^1.0", + "open-telemetry/sdk": "^1.0", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", "phpunit/phpunit": "^8.5.52|^9.6.34", @@ -74,7 +77,11 @@ "phpstan": "vendor/bin/phpstan analyse" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": false, + "tbachert/spi": false + } }, "prefer-stable": true } diff --git a/src/Dsn.php b/src/Dsn.php index b6f952297..d89d6804b 100644 --- a/src/Dsn.php +++ b/src/Dsn.php @@ -192,6 +192,14 @@ public function getCspReportEndpointUrl(): string return $this->getBaseEndpointUrl() . '/security/?sentry_key=' . $this->publicKey; } + /** + * Returns the URL of the API for the OTLP traces endpoint. + */ + public function getOtlpTracesEndpointUrl(): string + { + return $this->getBaseEndpointUrl() . '/integration/otlp/v1/traces/'; + } + /** * @see https://www.php.net/manual/en/language.oop5.magic.php#object.tostring */ diff --git a/src/Integration/OTLPIntegration.php b/src/Integration/OTLPIntegration.php new file mode 100644 index 000000000..9f6050170 --- /dev/null +++ b/src/Integration/OTLPIntegration.php @@ -0,0 +1,206 @@ +setupOtlpTracesExporter = $setupOtlpTracesExporter; + $this->collectorUrl = $collectorUrl; + } + + public function setOptions(Options $options): void + { + $this->options = $options; + } + + public function setupOnce(): void + { + $options = $this->options; + + if ($options === null) { + $this->logDebug('Skipping OTLPIntegration setup because client options were not provided.'); + + return; + } + + if ($options->isTracingEnabled()) { + $this->logDebug('Skipping OTLPIntegration because Sentry tracing is enabled. Disable "traces_sample_rate", "traces_sampler", and "enable_tracing" before using OTLPIntegration.'); + + return; + } + + Scope::registerExternalPropagationContext(static function (): ?array { + $currentHub = SentrySdk::getCurrentHub(); + $integration = $currentHub->getIntegration(self::class); + + if (!$integration instanceof self) { + return null; + } + + return $integration->getCurrentOpenTelemetryPropagationContext(); + }); + + if ($this->setupOtlpTracesExporter) { + $this->configureOtlpTracesExporter($options); + } + } + + public function getCollectorUrl(): ?string + { + return $this->collectorUrl; + } + + /** + * @return array{trace_id: string, span_id: string}|null + */ + private function getCurrentOpenTelemetryPropagationContext(): ?array + { + if (!class_exists(\OpenTelemetry\API\Trace\Span::class)) { + return null; + } + + $spanContext = \OpenTelemetry\API\Trace\Span::getCurrent()->getContext(); + + if (!$spanContext->isValid()) { + return null; + } + + return [ + 'trace_id' => $spanContext->getTraceId(), + 'span_id' => $spanContext->getSpanId(), + ]; + } + + private function configureOtlpTracesExporter(Options $options): void + { + $endpoint = $this->collectorUrl; + $headers = []; + $dsn = $options->getDsn(); + + if ($endpoint === null && $dsn !== null) { + $endpoint = $dsn->getOtlpTracesEndpointUrl(); + $headers['X-Sentry-Auth'] = Http::getSentryAuthHeader($dsn, Client::SDK_IDENTIFIER, Client::SDK_VERSION); + } + + if ($endpoint === null) { + $this->logDebug('Skipping automatic OTLP exporter setup because neither a DSN nor a collector URL is configured.'); + + return; + } + + if (!$this->shouldConfigureOtlpTracesExporter()) { + return; + } + + try { + $transport = (new \OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory())->create( + $endpoint, + \OpenTelemetry\Contrib\Otlp\ContentTypes::PROTOBUF, + $headers + ); + $spanExporter = new \OpenTelemetry\Contrib\Otlp\SpanExporter($transport); + $batchSpanProcessor = new \OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor( + $spanExporter, + \OpenTelemetry\API\Common\Time\Clock::getDefault() + ); + + (new \OpenTelemetry\SDK\SdkBuilder()) + ->setTracerProvider(new \OpenTelemetry\SDK\Trace\TracerProvider($batchSpanProcessor)) + ->buildAndRegisterGlobal(); + } catch (\Throwable $exception) { + $this->logDebug(\sprintf('Skipping automatic OTLP exporter setup because it could not be configured: %s', $exception->getMessage())); + } + } + + private function shouldConfigureOtlpTracesExporter(): bool + { + if (\PHP_VERSION_ID < 80100) { + $this->logDebug('Skipping automatic OTLP exporter setup because it requires PHP 8.1 or newer.'); + + return false; + } + + foreach ([ + \OpenTelemetry\API\Globals::class, + \OpenTelemetry\API\Common\Time\Clock::class, + \OpenTelemetry\SDK\SdkBuilder::class, + \OpenTelemetry\SDK\Trace\TracerProvider::class, + \OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor::class, + \OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory::class, + \OpenTelemetry\Contrib\Otlp\SpanExporter::class, + ] as $className) { + if (!class_exists($className)) { + $this->logDebug('Skipping automatic OTLP exporter setup because the required OpenTelemetry SDK/exporter classes are not available.'); + + return false; + } + } + + try { + if (!$this->isNoopTracerProvider(\OpenTelemetry\API\Globals::tracerProvider())) { + $this->logDebug('Skipping automatic OTLP exporter setup because the existing OpenTelemetry tracer provider cannot be modified after construction.'); + + return false; + } + } catch (\Throwable $exception) { + $this->logDebug(\sprintf('Skipping automatic OTLP exporter setup because the current OpenTelemetry tracer provider could not be inspected: %s', $exception->getMessage())); + + return false; + } + + return true; + } + + private function isNoopTracerProvider(?object $tracerProvider): bool + { + return $tracerProvider === null || $tracerProvider instanceof \OpenTelemetry\API\Trace\NoopTracerProvider; + } + + private function logDebug(string $message): void + { + $this->getLogger()->debug($message); + } + + private function getLogger(): LoggerInterface + { + if ($this->options !== null) { + return $this->options->getLoggerOrNullLogger(); + } + + $currentHub = SentrySdk::getCurrentHub(); + $client = $currentHub->getClient(); + + if ($client !== null) { + return $client->getOptions()->getLoggerOrNullLogger(); + } + + return new NullLogger(); + } +} diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 16a61adb7..d953fe035 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -72,11 +72,15 @@ public function add( $formattedMessage = $message; } - $log = (new Log($timestamp, $this->getTraceId($hub), $level, $formattedMessage)) + $traceContext = $this->getTraceContext($hub); + $traceId = $traceContext['trace_id']; + $parentSpanId = $traceContext['span_id']; + + $log = (new Log($timestamp, $traceId, $level, $formattedMessage)) ->setAttribute('sentry.release', $options->getRelease()) ->setAttribute('sentry.environment', $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT) ->setAttribute('sentry.server.address', $options->getServerName()) - ->setAttribute('sentry.trace.parent_span_id', $hub->getSpan() ? $hub->getSpan()->getSpanId() : null); + ->setAttribute('sentry.trace.parent_span_id', $parentSpanId); if ($client instanceof Client) { $log->setAttribute('sentry.sdk.name', $client->getSdkIdentifier()); @@ -176,20 +180,18 @@ public function all(): array return $this->logs; } - private function getTraceId(HubInterface $hub): string + /** + * @return array{trace_id: string, span_id: string} + */ + private function getTraceContext(HubInterface $hub): array { - $span = $hub->getSpan(); - - if ($span !== null) { - return (string) $span->getTraceId(); - } - - $traceId = ''; + $traceContext = null; - $hub->configureScope(static function (Scope $scope) use (&$traceId) { - $traceId = (string) $scope->getPropagationContext()->getTraceId(); + $hub->configureScope(static function (Scope $scope) use (&$traceContext): void { + $traceContext = $scope->getTraceContext(); }); - return $traceId; + /** @var array{trace_id: string, span_id: string} $traceContext */ + return $traceContext; } } diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index 46f015be0..47bfcb64d 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -14,6 +14,8 @@ use Sentry\SentrySdk; use Sentry\State\HubInterface; use Sentry\State\Scope; +use Sentry\Tracing\SpanId; +use Sentry\Tracing\TraceId; use Sentry\Unit; use Sentry\Util\RingBuffer; @@ -104,24 +106,12 @@ public function add( $attributes += $defaultAttributes; } - $spanId = null; - $traceId = null; - - $span = $hub->getSpan(); - if ($span !== null) { - $spanId = $span->getSpanId(); - $traceId = $span->getTraceId(); - } else { - $hub->configureScope(static function (Scope $scope) use (&$traceId, &$spanId) { - $propagationContext = $scope->getPropagationContext(); - $traceId = $propagationContext->getTraceId(); - $spanId = $propagationContext->getSpanId(); - }); - } + $traceContext = $this->getTraceContext($hub); + $traceId = new TraceId($traceContext['trace_id']); + $spanId = new SpanId($traceContext['span_id']); $metricTypeClass = self::METRIC_TYPES[$type]; /** @var Metric $metric */ - /** @phpstan-ignore-next-line */ $metric = new $metricTypeClass($name, $value, $traceId, $spanId, $attributes, microtime(true), $unit); if ($client !== null) { @@ -146,4 +136,19 @@ public function flush(?HubInterface $hub = null): ?EventId return $hub->captureEvent($event); } + + /** + * @return array{trace_id: string, span_id: string} + */ + private function getTraceContext(HubInterface $hub): array + { + $traceContext = null; + + $hub->configureScope(static function (Scope $scope) use (&$traceContext): void { + $traceContext = $scope->getTraceContext(); + }); + + /** @var array{trace_id: string, span_id: string} $traceContext */ + return $traceContext; + } } diff --git a/src/State/Scope.php b/src/State/Scope.php index 6edaddf14..600e5857b 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -94,6 +94,11 @@ class Scope */ private static $globalEventProcessors = []; + /** + * @var callable|null + */ + private static $externalPropagationContextCallback; + public function __construct(?PropagationContext $propagationContext = null) { $this->propagationContext = $propagationContext ?? PropagationContext::fromDefaults(); @@ -359,6 +364,53 @@ public static function addGlobalEventProcessor(callable $eventProcessor): void self::$globalEventProcessors[] = $eventProcessor; } + public static function registerExternalPropagationContext(callable $callback): void + { + self::$externalPropagationContextCallback = $callback; + } + + public static function clearExternalPropagationContext(): void + { + self::$externalPropagationContextCallback = null; + } + + /** + * @return array{trace_id: string, span_id: string}|null + */ + public static function getExternalPropagationContext(): ?array + { + $callback = self::$externalPropagationContextCallback; + if (!\is_callable($callback)) { + return null; + } + + try { + $context = $callback(); + } catch (\Throwable $exception) { + return null; + } + + if (!\is_array($context)) { + return null; + } + + $traceId = $context['trace_id'] ?? null; + $spanId = $context['span_id'] ?? null; + + if (!\is_string($traceId) || preg_match('/^[0-9a-f]{32}$/i', $traceId) !== 1) { + return null; + } + + if (!\is_string($spanId) || preg_match('/^[0-9a-f]{16}$/i', $spanId) !== 1) { + return null; + } + + return [ + 'trace_id' => $traceId, + 'span_id' => $spanId, + ]; + } + /** * Clears the scope and resets any data it contains. * @@ -430,24 +482,30 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op /** * Apply the trace context to errors if there is a Span on the Scope. - * Else fallback to the propagation context. + * Else fallback to the external propagation context or to the + * propagation context. * But do not override a trace context already present. */ - if ($this->span !== null) { - if (!\array_key_exists('trace', $event->getContexts())) { - $event->setContext('trace', $this->span->getTraceContext()); - } + $externalPropagationContext = null; + if ($this->span === null) { + $externalPropagationContext = self::getExternalPropagationContext(); + } + + $traceContext = $this->span !== null + ? $this->span->getTraceContext() + : ($externalPropagationContext ?? $this->propagationContext->getTraceContext()); + if (!\array_key_exists('trace', $event->getContexts())) { + $event->setContext('trace', $traceContext); + } + + if ($this->span !== null) { // Apply the dynamic sampling context to errors if there is a Transaction on the Scope $transaction = $this->span->getTransaction(); if ($transaction !== null) { $event->setSdkMetadata('dynamic_sampling_context', $transaction->getDynamicSamplingContext()); } - } else { - if (!\array_key_exists('trace', $event->getContexts())) { - $event->setContext('trace', $this->propagationContext->getTraceContext()); - } - + } elseif ($externalPropagationContext === null) { $dynamicSamplingContext = $this->propagationContext->getDynamicSamplingContext(); if ($dynamicSamplingContext === null && $options !== null) { $dynamicSamplingContext = DynamicSamplingContext::fromOptions($options, $this); @@ -513,6 +571,33 @@ public function getTransaction(): ?Transaction return null; } + public function hasExternalPropagationContext(): bool + { + return $this->span === null && self::getExternalPropagationContext() !== null; + } + + /** + * @return array{ + * trace_id: string, + * span_id: string, + * parent_span_id?: string, + * data?: array, + * description?: string, + * op?: string, + * status?: string, + * tags?: array, + * origin?: string + * } + */ + public function getTraceContext(): array + { + if ($this->span !== null) { + return $this->span->getTraceContext(); + } + + return self::getExternalPropagationContext() ?? $this->propagationContext->getTraceContext(); + } + public function getPropagationContext(): PropagationContext { return $this->propagationContext; diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index b4268c0f7..8e277e4c0 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -63,9 +63,15 @@ public static function trace(?HubInterface $hub = null): \Closure } if (self::shouldAttachTracingHeaders($client, $request)) { - $request = $request - ->withHeader('sentry-trace', getTraceparent()) - ->withHeader('baggage', getBaggage()); + $traceParent = getTraceparent(); + if ($traceParent !== '') { + $request = $request->withHeader('sentry-trace', $traceParent); + } + + $baggage = getBaggage(); + if ($baggage !== '') { + $request = $request->withHeader('baggage', $baggage); + } } $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $childSpan, $parentSpan, $partialUri) { diff --git a/src/Tracing/PropagationContext.php b/src/Tracing/PropagationContext.php index a4e855176..1162484e1 100644 --- a/src/Tracing/PropagationContext.php +++ b/src/Tracing/PropagationContext.php @@ -112,7 +112,7 @@ public function toBaggage(): string } /** - * @return array + * @return array{trace_id: string, span_id: string, parent_span_id?: string} */ public function getTraceContext(): array { diff --git a/src/Util/Http.php b/src/Util/Http.php index efe903ad7..ee2490dbb 100644 --- a/src/Util/Http.php +++ b/src/Util/Http.php @@ -12,10 +12,7 @@ */ final class Http { - /** - * @return string[] - */ - public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): array + public static function getSentryAuthHeader(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): string { $authHeader = [ 'sentry_version=' . Client::PROTOCOL_VERSION, @@ -23,9 +20,17 @@ public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string 'sentry_key=' . $dsn->getPublicKey(), ]; + return 'Sentry ' . implode(', ', $authHeader); + } + + /** + * @return string[] + */ + public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): array + { return [ 'Content-Type: application/x-sentry-envelope', - 'X-Sentry-Auth: Sentry ' . implode(', ', $authHeader), + 'X-Sentry-Auth: ' . self::getSentryAuthHeader($dsn, $sdkIdentifier, $sdkVersion), ]; } diff --git a/src/functions.php b/src/functions.php index 913778daf..a0b709bd6 100644 --- a/src/functions.php +++ b/src/functions.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\IntegrationInterface; +use Sentry\Integration\OTLPIntegration; use Sentry\Logs\Logs; use Sentry\Metrics\Metrics; use Sentry\Metrics\TraceMetrics; @@ -308,6 +309,31 @@ function trace(callable $trace, SpanContext $context) }); } +/** + * Returns the OTLP traces endpoint configured for the current client. + */ +function getOtlpTracesEndpointUrl(): ?string +{ + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + if ($client === null) { + return null; + } + + $integration = $hub->getIntegration(OTLPIntegration::class); + if ($integration instanceof OTLPIntegration && $integration->getCollectorUrl() !== null) { + return $integration->getCollectorUrl(); + } + + $dsn = $client->getOptions()->getDsn(); + if ($dsn === null) { + return null; + } + + return $dsn->getOtlpTracesEndpointUrl(); +} + /** * Creates the current Sentry traceparent string, to be used as a HTTP header value * or HTML meta tag value. @@ -332,6 +358,10 @@ function getTraceparent(): string $traceParent = ''; $hub->configureScope(static function (Scope $scope) use (&$traceParent) { + if ($scope->hasExternalPropagationContext()) { + return; + } + $traceParent = $scope->getPropagationContext()->toTraceparent(); }); @@ -375,6 +405,10 @@ function getBaggage(): string $baggage = ''; $hub->configureScope(static function (Scope $scope) use (&$baggage) { + if ($scope->hasExternalPropagationContext()) { + return; + } + $baggage = $scope->getPropagationContext()->toBaggage(); }); diff --git a/tests/DsnTest.php b/tests/DsnTest.php index f8bb169d8..129e15f84 100644 --- a/tests/DsnTest.php +++ b/tests/DsnTest.php @@ -245,6 +245,44 @@ public static function getCspReportEndpointUrlDataProvider(): \Generator ]; } + /** + * @dataProvider getOtlpTracesEndpointUrlDataProvider + */ + public function testGetOtlpTracesEndpointUrl(string $value, string $expectedUrl): void + { + $dsn = Dsn::createFromString($value); + + $this->assertSame($expectedUrl, $dsn->getOtlpTracesEndpointUrl()); + } + + public static function getOtlpTracesEndpointUrlDataProvider(): \Generator + { + yield [ + 'http://public@example.com/sentry/1', + 'http://example.com/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'http://public@example.com/1', + 'http://example.com/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'http://public@example.com:8080/sentry/1', + 'http://example.com:8080/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'https://public@example.com/sentry/1', + 'https://example.com/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'https://public@example.com:4343/sentry/1', + 'https://example.com:4343/sentry/api/1/integration/otlp/v1/traces/', + ]; + } + /** * @dataProvider toStringDataProvider */ diff --git a/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php b/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php new file mode 100644 index 000000000..2118637e8 --- /dev/null +++ b/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php @@ -0,0 +1,30 @@ + + */ + public static function getCandidates(string $type): array + { + if (is_a(ClientInterface::class, $type, true)) { + return [['class' => StubOtelHttpClient::class, 'condition' => StubOtelHttpClient::class]]; + } + + if (is_a(RequestFactoryInterface::class, $type, true) || is_a(StreamFactoryInterface::class, $type, true)) { + return [['class' => Psr17Factory::class, 'condition' => Psr17Factory::class]]; + } + + return []; + } +} diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index b92926d2b..7d712f652 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -12,6 +12,7 @@ use Sentry\Event; use Sentry\EventHint; use Sentry\EventId; +use Sentry\Integration\OTLPIntegration; use Sentry\MonitorConfig; use Sentry\MonitorSchedule; use Sentry\Options; @@ -41,6 +42,7 @@ use function Sentry\continueTrace; use function Sentry\endContext; use function Sentry\getBaggage; +use function Sentry\getOtlpTracesEndpointUrl; use function Sentry\getTraceparent; use function Sentry\init; use function Sentry\startContext; @@ -552,6 +554,27 @@ public function testTraceparentWithTracingEnabled(): void $this->assertSame('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', $traceParent); } + public function testTraceHeadersAreEmptyWhenExternalPropagationContextIsActive(): void + { + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $propagationContext->setSpanId(new SpanId('566e3688a61d4bc8')); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + SentrySdk::setCurrentHub(new Hub(null, new Scope($propagationContext))); + + $this->assertSame('', getTraceparent()); + $this->assertSame('', getBaggage()); + + Scope::clearExternalPropagationContext(); + } + public function testBaggageWithTracingDisabled(): void { $propagationContext = PropagationContext::fromDefaults(); @@ -610,6 +633,43 @@ public function testBaggageWithTracingEnabled(): void $this->assertSame('sentry-trace_id=566e3688a61d4bc888951642d6f14a19,sentry-sample_rate=1,sentry-transaction=Test,sentry-release=1.0.0,sentry-environment=development,sentry-sampled=true,sentry-sample_rand=0.25', $baggage); } + public function testGetOtlpTracesEndpointUrlFallsBackToDsn(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn(null); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame('https://example.com/api/1/integration/otlp/v1/traces/', getOtlpTracesEndpointUrl()); + } + + public function testGetOtlpTracesEndpointUrlPrefersCollectorUrl(): void + { + $integration = new OTLPIntegration(false, 'http://collector:4318/v1/traces'); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn($integration); + $client->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame('http://collector:4318/v1/traces', getOtlpTracesEndpointUrl()); + } + public function testContinueTrace(): void { $hub = new Hub(); diff --git a/tests/Integration/OTLPIntegrationTest.php b/tests/Integration/OTLPIntegrationTest.php new file mode 100644 index 000000000..a0f3e5680 --- /dev/null +++ b/tests/Integration/OTLPIntegrationTest.php @@ -0,0 +1,300 @@ +discoveryStrategies = iterator_to_array($strategies); + } else { + $this->discoveryStrategies = $strategies; + } + } + + if (class_exists(StubOtelHttpClient::class, false)) { + StubOtelHttpClient::reset(); + } + + StubLogger::$logs = []; + } + + protected function tearDown(): void + { + if (class_exists(Context::class) && class_exists(ContextStorage::class)) { + Context::setStorage(new ContextStorage()); + } + + if ($this->discoveryStrategies !== null && class_exists(ClassDiscovery::class)) { + ClassDiscovery::setStrategies($this->discoveryStrategies); + } + + if (class_exists(HttpClientDiscovery::class) && method_exists(HttpClientDiscovery::class, 'reset')) { + HttpClientDiscovery::reset(); + } + + if (class_exists(StubOtelHttpClient::class, false)) { + StubOtelHttpClient::reset(); + } + + parent::tearDown(); + } + + public function testSetupOnceLogsAndSkipsWhenSentryTracingIsEnabled(): void + { + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'logger' => StubLogger::getInstance(), + 'traces_sample_rate' => 1.0, + ])); + + $integration->setupOnce(); + + $this->assertNull(Scope::getExternalPropagationContext()); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame('debug', StubLogger::$logs[0]['level']); + $this->assertStringContainsString('Skipping OTLPIntegration because Sentry tracing is enabled.', StubLogger::$logs[0]['message']); + } + + public function testSetupOnceRegistersExternalPropagationContext(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'dsn' => null, + ])); + $integration->setupOnce(); + + $otelScope = $this->activateOpenTelemetrySpan(); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn($integration); + + try { + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame([ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], Scope::getExternalPropagationContext()); + } finally { + $otelScope->detach(); + } + } + + public function testExternalPropagationContextIsIgnoredWhenCurrentClientDoesNotHaveIntegration(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'dsn' => null, + ])); + $integration->setupOnce(); + + $otelScope = $this->activateOpenTelemetrySpan(); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn(null); + + try { + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertNull(Scope::getExternalPropagationContext()); + } finally { + $otelScope->detach(); + } + } + + public function testSetupOnceCreatesTracerProviderWhenMissing(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertSame($tracerProvider, Globals::tracerProvider()); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(1, StubOtelHttpClient::$requests); + $this->assertSame('https://example.com/api/1/integration/otlp/v1/traces/', (string) StubOtelHttpClient::$requests[0]->getUri()); + $this->assertStringContainsString('sentry_key=public', StubOtelHttpClient::$requests[0]->getHeaderLine('X-Sentry-Auth')); + } + + public function testSetupOnceLogsAndSkipsWhenExistingTracerProviderCannotBeModified(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $existingTracerProvider = new TracerProvider(); + (new SdkBuilder()) + ->setTracerProvider($existingTracerProvider) + ->buildAndRegisterGlobal(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'logger' => StubLogger::getInstance(), + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + $this->assertSame($existingTracerProvider, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(0, StubOtelHttpClient::$requests); + $this->assertCount(1, StubLogger::$logs); + $this->assertStringContainsString('existing OpenTelemetry tracer provider cannot be modified after construction', StubLogger::$logs[0]['message']); + } + + public function testSetupOnceUsesCollectorUrlWithoutSentryAuthHeader(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $integration = new OTLPIntegration(true, 'http://collector:4318/v1/traces'); + $integration->setOptions(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(1, StubOtelHttpClient::$requests); + $this->assertSame('http://collector:4318/v1/traces', (string) StubOtelHttpClient::$requests[0]->getUri()); + $this->assertSame('', StubOtelHttpClient::$requests[0]->getHeaderLine('X-Sentry-Auth')); + } + + public function testSetupOnceLogsAndSkipsExporterSetupWhenEndpointCannotBeResolved(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'dsn' => null, + 'logger' => StubLogger::getInstance(), + ])); + + $integration->setupOnce(); + + $this->assertNotInstanceOf(TracerProvider::class, Globals::tracerProvider()); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame('debug', StubLogger::$logs[0]['level']); + $this->assertStringContainsString('Skipping automatic OTLP exporter setup because neither a DSN nor a collector URL is configured.', StubLogger::$logs[0]['message']); + } + + private function requireOpenTelemetry(): void + { + if (\PHP_VERSION_ID < 80100) { + $this->markTestSkipped('OpenTelemetry integration tests require PHP 8.1 or newer.'); + } + + foreach ([ + Globals::class, + Span::class, + SpanContext::class, + Context::class, + ContextStorage::class, + HttpClientDiscovery::class, + TracerProvider::class, + SdkBuilder::class, + ClassDiscovery::class, + ] as $className) { + if (!class_exists($className) && !interface_exists($className)) { + $this->markTestSkipped(\sprintf('OpenTelemetry integration tests require the optional package that provides "%s".', $className)); + } + } + } + + private function activateOpenTelemetrySpan() + { + return Span::wrap(SpanContext::create( + '771a43a4192642f0b136d5159a501700', + '1234567890abcdef' + ))->activate(); + } + + private function useCapturingHttpClient(): void + { + $this->requireOpenTelemetry(); + + if (method_exists(HttpClientDiscovery::class, 'setDiscoverers')) { + HttpClientDiscovery::setDiscoverers([new TestClientDiscoverer()]); + } else { + ClassDiscovery::prependStrategy(TestDiscoveryStrategy::class); + } + + StubOtelHttpClient::reset(); + } + + private function exportSpan(TracerProvider $tracerProvider): void + { + $span = $tracerProvider + ->getTracer('sentry.tests.otlp') + ->spanBuilder('otlp-test-span') + ->startSpan(); + + $span->end(); + $tracerProvider->shutdown(); + } +} diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index 89e175a70..37a67e3ff 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -207,4 +207,31 @@ public function testAttributesAreAddedToLogMessage(): void $this->assertSame('foo@example.com', $attributes->get('user.email')->getValue()); $this->assertSame('my_user', $attributes->get('user.name')->getValue()); } + + public function testUsesExternalPropagationContextWhenNoLocalSpanExists(): void + { + $client = ClientBuilder::create([ + 'enable_logs' => true, + ])->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $aggregator = new LogsAggregator(); + $aggregator->add(LogLevel::info(), 'Test message'); + + $logs = $aggregator->all(); + $this->assertCount(1, $logs); + $this->assertSame('771a43a4192642f0b136d5159a501700', $logs[0]->getTraceId()); + $this->assertSame('1234567890abcdef', $logs[0]->attributes()->get('sentry.trace.parent_span_id')->getValue()); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 972eab9cd..0faab3a2f 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -13,6 +13,7 @@ use Sentry\Metrics\Types\Metric; use Sentry\Options; use Sentry\State\HubAdapter; +use Sentry\State\Scope; use function Sentry\traceMetrics; @@ -155,4 +156,24 @@ public function testInvalidTypeIsDiscarded(): void $this->assertEmpty(StubTransport::$events); } + + public function testMetricsUseExternalPropagationContextWhenNoLocalSpanExists(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $metric = StubTransport::$events[0]->getMetrics()[0]; + $this->assertSame('771a43a4192642f0b136d5159a501700', (string) $metric->getTraceId()); + $this->assertSame('1234567890abcdef', (string) $metric->getSpanId()); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/SentrySdkExtension.php b/tests/SentrySdkExtension.php index 82ce50761..b50bc2ed8 100644 --- a/tests/SentrySdkExtension.php +++ b/tests/SentrySdkExtension.php @@ -31,6 +31,8 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + StubTransport::$events = []; + $reflectionProperty = new \ReflectionProperty(Scope::class, 'globalEventProcessors'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); @@ -40,6 +42,15 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + $reflectionProperty = new \ReflectionProperty(Scope::class, 'externalPropagationContextCallback'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + $reflectionProperty->setValue(null, null); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(false); + } + $reflectionProperty = new \ReflectionProperty(IntegrationRegistry::class, 'integrations'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); diff --git a/tests/State/ScopeTest.php b/tests/State/ScopeTest.php index f2d99db01..7ee6ff2c8 100644 --- a/tests/State/ScopeTest.php +++ b/tests/State/ScopeTest.php @@ -8,6 +8,7 @@ use Sentry\Breadcrumb; use Sentry\Event; use Sentry\EventHint; +use Sentry\Options; use Sentry\Severity; use Sentry\State\Scope; use Sentry\Tracing\DynamicSamplingContext; @@ -528,4 +529,84 @@ public function testApplyToEvent(): void $this->assertSame('foo', $dynamicSamplingContext->get('transaction')); $this->assertSame('566e3688a61d4bc888951642d6f14a19', $dynamicSamplingContext->get('trace_id')); } + + public function testGetTraceContextPrefersExternalPropagationContextOverPropagationContext(): void + { + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $propagationContext->setSpanId(new SpanId('566e3688a61d4bc8')); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $scope = new Scope($propagationContext); + + $this->assertSame([ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], $scope->getTraceContext()); + + Scope::clearExternalPropagationContext(); + } + + public function testGetTraceContextPrefersLocalSpanOverExternalPropagationContext(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $transaction = new Transaction(new TransactionContext('foo')); + $transaction->setSpanId(new SpanId('8c2df92a922b4efe')); + $transaction->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $span = $transaction->startChild(new SpanContext()); + $span->setSpanId(new SpanId('566e3688a61d4bc8')); + + $scope = new Scope(); + $scope->setSpan($span); + + $this->assertSame([ + 'span_id' => '566e3688a61d4bc8', + 'trace_id' => '566e3688a61d4bc888951642d6f14a19', + 'origin' => 'manual', + 'parent_span_id' => '8c2df92a922b4efe', + ], $scope->getTraceContext()); + + Scope::clearExternalPropagationContext(); + } + + public function testApplyToEventSkipsDynamicSamplingContextWhenUsingExternalPropagationContext(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $scope = new Scope(); + $event = $scope->applyToEvent(Event::createEvent(), null, new Options([ + 'dsn' => 'http://public@example.com/1', + 'release' => '1.0.0', + 'environment' => 'test', + 'traces_sample_rate' => 1.0, + ])); + + $this->assertNotNull($event); + $this->assertSame([ + 'trace' => [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], + ], $event->getContexts()); + $this->assertNull($event->getSdkMetadata('dynamic_sampling_context')); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index f43b7fcf2..becfa84a8 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -15,6 +15,7 @@ use Sentry\Event; use Sentry\EventType; use Sentry\Options; +use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\State\Scope; use Sentry\Tracing\GuzzleTracingMiddleware; @@ -33,6 +34,7 @@ public function testTraceCreatesBreadcrumbIfSpanIsNotSet(): void ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(TransactionContext::make()); @@ -77,6 +79,7 @@ public function testTraceCreatesBreadcrumbIfSpanIsRecorded(): void ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(TransactionContext::make()); @@ -118,11 +121,12 @@ public function testTraceCreatesBreadcrumbIfSpanIsRecorded(): void public function testTraceHeaders(Request $request, Options $options, bool $headersShouldBePresent): void { $client = $this->createMock(ClientInterface::class); - $client->expects($this->once()) + $client->expects($this->atLeastOnce()) ->method('getOptions') ->willReturn($options); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $expectedPromiseResult = new Response(); @@ -154,6 +158,7 @@ public function testTraceHeadersWithTransaction(Request $request, Options $optio ->willReturn($options); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(new TransactionContext()); @@ -180,6 +185,39 @@ public function testTraceHeadersWithTransaction(Request $request, Options $optio $transaction->finish(); } + public function testTraceHeadersAreNotAddedWhenExternalPropagationContextIsActive(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'trace_propagation_targets' => null, + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + $expectedPromiseResult = new Response(); + + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($expectedPromiseResult): PromiseInterface { + $this->assertEmpty($request->getHeader('sentry-trace')); + $this->assertEmpty($request->getHeader('baggage')); + + return new FulfilledPromise($expectedPromiseResult); + }); + + $function(new Request('GET', 'https://www.example.com'), []); + + Scope::clearExternalPropagationContext(); + } + public static function traceHeadersDataProvider(): iterable { // Test cases here are duplicated with sampling enabled and disabled because trace headers hould be added regardless of the sample decision @@ -282,7 +320,7 @@ public static function traceHeadersDataProvider(): iterable public function testTrace(Request $request, $expectedPromiseResult, array $expectedBreadcrumbData, array $expectedSpanData): void { $client = $this->createMock(ClientInterface::class); - $client->expects($this->exactly(4)) + $client->expects($this->atLeast(4)) ->method('getOptions') ->willReturn(new Options([ 'traces_sample_rate' => 1, @@ -292,6 +330,7 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $client->expects($this->once()) ->method('captureEvent') @@ -341,6 +380,7 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec $function = $middleware(function (Request $request) use ($expectedPromiseResult): PromiseInterface { $this->assertNotEmpty($request->getHeader('sentry-trace')); $this->assertNotEmpty($request->getHeader('baggage')); + if ($expectedPromiseResult instanceof \Throwable) { return new RejectedPromise($expectedPromiseResult); } diff --git a/tests/Util/HttpTest.php b/tests/Util/HttpTest.php index 342ad1b75..c4a390a4f 100644 --- a/tests/Util/HttpTest.php +++ b/tests/Util/HttpTest.php @@ -10,6 +10,16 @@ final class HttpTest extends TestCase { + public function testGetSentryAuthHeader(): void + { + $dsn = Dsn::createFromString('http://public@example.com/1'); + + $this->assertSame( + 'Sentry sentry_version=7, sentry_client=sentry.sdk.identifier/1.2.3, sentry_key=public', + Http::getSentryAuthHeader($dsn, 'sentry.sdk.identifier', '1.2.3') + ); + } + /** * @dataProvider getRequestHeadersDataProvider */ @@ -26,7 +36,11 @@ public static function getRequestHeadersDataProvider(): \Generator '1.2.3', [ 'Content-Type: application/x-sentry-envelope', - 'X-Sentry-Auth: Sentry sentry_version=7, sentry_client=sentry.sdk.identifier/1.2.3, sentry_key=public', + 'X-Sentry-Auth: ' . Http::getSentryAuthHeader( + Dsn::createFromString('http://public@example.com/1'), + 'sentry.sdk.identifier', + '1.2.3' + ), ], ]; }