diff --git a/.phan/config.php b/.phan/config.php index 1e76040852d..10ab06366a2 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -117,6 +117,8 @@ $indirectDeps = [ 'pear/net_url2', 'pear/pear-core-minimal', 'phpunit/phpunit', + 'psr/http-client', + 'psr/http-factory', 'psr/http-message', 'seld/jsonlint', 'wikimedia/testing-access-wrapper', diff --git a/autoload.php b/autoload.php index 9206fd95a58..1800bd25cf2 100644 --- a/autoload.php +++ b/autoload.php @@ -3625,6 +3625,20 @@ $wgAutoloadLocalClasses = [ 'Wikimedia\\Stats\\StatsFactory' => __DIR__ . '/includes/libs/Stats/StatsFactory.php', 'Wikimedia\\Stats\\StatsUtils' => __DIR__ . '/includes/libs/Stats/StatsUtils.php', 'Wikimedia\\Stats\\StatsdAwareInterface' => __DIR__ . '/includes/libs/Stats/StatsdAwareInterface.php', + 'Wikimedia\\Telemetry\\Clock' => __DIR__ . '/includes/libs/telemetry/Clock.php', + 'Wikimedia\\Telemetry\\ExporterInterface' => __DIR__ . '/includes/libs/telemetry/ExporterInterface.php', + 'Wikimedia\\Telemetry\\NoopSpan' => __DIR__ . '/includes/libs/telemetry/NoopSpan.php', + 'Wikimedia\\Telemetry\\NoopTracer' => __DIR__ . '/includes/libs/telemetry/NoopTracer.php', + 'Wikimedia\\Telemetry\\OtlpHttpExporter' => __DIR__ . '/includes/libs/telemetry/OtlpHttpExporter.php', + 'Wikimedia\\Telemetry\\OtlpSerializer' => __DIR__ . '/includes/libs/telemetry/OtlpSerializer.php', + 'Wikimedia\\Telemetry\\ProbabilisticSampler' => __DIR__ . '/includes/libs/telemetry/ProbabilisticSampler.php', + 'Wikimedia\\Telemetry\\SamplerInterface' => __DIR__ . '/includes/libs/telemetry/SamplerInterface.php', + 'Wikimedia\\Telemetry\\Span' => __DIR__ . '/includes/libs/telemetry/Span.php', + 'Wikimedia\\Telemetry\\SpanContext' => __DIR__ . '/includes/libs/telemetry/SpanContext.php', + 'Wikimedia\\Telemetry\\SpanInterface' => __DIR__ . '/includes/libs/telemetry/SpanInterface.php', + 'Wikimedia\\Telemetry\\Tracer' => __DIR__ . '/includes/libs/telemetry/Tracer.php', + 'Wikimedia\\Telemetry\\TracerInterface' => __DIR__ . '/includes/libs/telemetry/TracerInterface.php', + 'Wikimedia\\Telemetry\\TracerState' => __DIR__ . '/includes/libs/telemetry/TracerState.php', 'Wikimedia\\UUID\\GlobalIdGenerator' => __DIR__ . '/includes/libs/uuid/GlobalIdGenerator.php', 'Wikimedia\\WRStats\\ArrayStatsStore' => __DIR__ . '/includes/libs/WRStats/ArrayStatsStore.php', 'Wikimedia\\WRStats\\BagOStuffStatsStore' => __DIR__ . '/includes/libs/WRStats/BagOStuffStatsStore.php', diff --git a/docs/config-schema.yaml b/docs/config-schema.yaml index 26d6f9b249c..deea4f4f478 100644 --- a/docs/config-schema.yaml +++ b/docs/config-schema.yaml @@ -6251,6 +6251,28 @@ config-schema: Defaults to: 'mediawiki' Note: this only affects metrics instantiated by the StatsFactory service @since 1.41 + OpenTelemetryConfig: + default: null + type: + - object + - 'null' + description: |- + Configuration for OpenTelemetry instrumentation, or `null` to disable it. + Possible keys: + - `samplingProbability`: probability in % of sampling a trace for which no sampling decision has been + taken yet. Must be between 0 and 100. + - `serviceName`: name of the service being instrumented. + - `endpoint`: URL of the OpenTelemetry collector to send trace data to. + This has to be an endpoint accepting OTLP data over HTTP (not gRPC). + An example config to send data to a local OpenTelemetry or Jaeger collector instance: + ``` + $wgOpenTelemetryConfig = [ + 'samplingProbability' => 0.1, + 'serviceName' => 'mediawiki-local', + 'endpoint' => 'http://127.0.0.1:4318/v1/traces', + ]; + ``` + @since 1.43 PageInfoTransclusionLimit: default: 50 description: |- diff --git a/docs/config-vars.php b/docs/config-vars.php index ccfe11505d2..004026986c7 100644 --- a/docs/config-vars.php +++ b/docs/config-vars.php @@ -3359,6 +3359,12 @@ $wgStatsFormat = null; */ $wgStatsPrefix = null; +/** + * Config variable stub for the OpenTelemetryConfig setting, for use by phpdoc and IDEs. + * @see MediaWiki\MainConfigSchema::OpenTelemetryConfig + */ +$wgOpenTelemetryConfig = null; + /** * Config variable stub for the PageInfoTransclusionLimit setting, for use by phpdoc and IDEs. * @see MediaWiki\MainConfigSchema::PageInfoTransclusionLimit diff --git a/includes/MainConfigNames.php b/includes/MainConfigNames.php index fd814cebe49..ebce86417cc 100644 --- a/includes/MainConfigNames.php +++ b/includes/MainConfigNames.php @@ -3374,6 +3374,12 @@ class MainConfigNames { */ public const StatsPrefix = 'StatsPrefix'; + /** + * Name constant for the OpenTelemetryConfig setting, for use with Config::get() + * @see MainConfigSchema::OpenTelemetryConfig + */ + public const OpenTelemetryConfig = 'OpenTelemetryConfig'; + /** * Name constant for the PageInfoTransclusionLimit setting, for use with Config::get() * @see MainConfigSchema::PageInfoTransclusionLimit diff --git a/includes/MainConfigSchema.php b/includes/MainConfigSchema.php index f015144e45c..73a5900fe8e 100644 --- a/includes/MainConfigSchema.php +++ b/includes/MainConfigSchema.php @@ -9997,6 +9997,30 @@ class MainConfigSchema { 'type' => 'string', ]; + /** + * Configuration for OpenTelemetry instrumentation, or `null` to disable it. + * Possible keys: + * - `samplingProbability`: probability in % of sampling a trace for which no sampling decision has been + * taken yet. Must be between 0 and 100. + * - `serviceName`: name of the service being instrumented. + * - `endpoint`: URL of the OpenTelemetry collector to send trace data to. + * This has to be an endpoint accepting OTLP data over HTTP (not gRPC). + * + * An example config to send data to a local OpenTelemetry or Jaeger collector instance: + * ``` + * $wgOpenTelemetryConfig = [ + * 'samplingProbability' => 0.1, + * 'serviceName' => 'mediawiki-local', + * 'endpoint' => 'http://127.0.0.1:4318/v1/traces', + * ]; + * ``` + * @since 1.43 + */ + public const OpenTelemetryConfig = [ + 'default' => null, + 'type' => 'map|null' + ]; + /** * InfoAction retrieves a list of transclusion links (both to and from). * diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index fb23c22c81c..d687871c15d 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -224,6 +224,7 @@ use Wikimedia\Services\SalvageableService; use Wikimedia\Services\ServiceContainer; use Wikimedia\Stats\IBufferingStatsdDataFactory; use Wikimedia\Stats\StatsFactory; +use Wikimedia\Telemetry\TracerInterface; use Wikimedia\UUID\GlobalIdGenerator; use Wikimedia\WRStats\WRStatsFactory; @@ -1978,6 +1979,10 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'TitleParser' ); } + public function getTracer(): TracerInterface { + return $this->getService( 'Tracer' ); + } + /** * @since 1.38 */ diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 3c9871caeb3..38a8df3e28e 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -41,6 +41,8 @@ * @file */ +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; use MediaWiki\Actions\ActionFactory; use MediaWiki\Auth\AuthManager; @@ -244,6 +246,7 @@ use MediaWiki\Watchlist\WatchedItemQueryService; use MediaWiki\Watchlist\WatchedItemStore; use MediaWiki\Watchlist\WatchlistManager; use MediaWiki\WikiMap\WikiMap; +use Psr\Http\Client\ClientInterface; use Wikimedia\DependencyStore\KeyValueDependencyStore; use Wikimedia\DependencyStore\SqlModuleDependencyStore; use Wikimedia\EventRelayer\EventRelayerGroup; @@ -268,6 +271,13 @@ use Wikimedia\Stats\IBufferingStatsdDataFactory; use Wikimedia\Stats\PrefixingStatsdDataFactoryProxy; use Wikimedia\Stats\StatsCache; use Wikimedia\Stats\StatsFactory; +use Wikimedia\Telemetry\Clock; +use Wikimedia\Telemetry\NoopTracer; +use Wikimedia\Telemetry\OtlpHttpExporter; +use Wikimedia\Telemetry\ProbabilisticSampler; +use Wikimedia\Telemetry\Tracer; +use Wikimedia\Telemetry\TracerInterface; +use Wikimedia\Telemetry\TracerState; use Wikimedia\UUID\GlobalIdGenerator; use Wikimedia\WRStats\BagOStuffStatsStore; use Wikimedia\WRStats\WRStatsFactory; @@ -2362,6 +2372,30 @@ return [ return $services->getService( '_MediaWikiTitleCodec' ); }, + 'Tracer' => static function ( MediaWikiServices $services ): TracerInterface { + $otelConfig = $services->getMainConfig()->get( MainConfigNames::OpenTelemetryConfig ); + if ( $otelConfig === null ) { + return new NoopTracer(); + } + + $tracerState = TracerState::getInstance(); + $exporter = new OtlpHttpExporter( + $services->getService( '_TracerHTTPClient' ), + new HttpFactory(), + LoggerFactory::getInstance( 'tracing' ), + $otelConfig['endpoint'], + $otelConfig['serviceName'], + wfHostname() + ); + + return new Tracer( + new Clock(), + new ProbabilisticSampler( $otelConfig['samplingProbability'] ), + $exporter, + $tracerState + ); + }, + 'TrackingCategories' => static function ( MediaWikiServices $services ): TrackingCategories { return new TrackingCategories( new ServiceOptions( @@ -2744,6 +2778,10 @@ return [ return $services->getBlobStoreFactory()->newSqlBlobStore(); }, + '_TracerHTTPClient' => static function (): ClientInterface { + return new Client( [ 'http_errors' => false ] ); + }, + '_UserBlockCommandFactory' => static function ( MediaWikiServices $services ): UserBlockCommandFactory { return new UserBlockCommandFactory( new ServiceOptions( UserBlockCommandFactory::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ), diff --git a/includes/config-schema.php b/includes/config-schema.php index 78939f95db2..34c3e60751e 100644 --- a/includes/config-schema.php +++ b/includes/config-schema.php @@ -1917,6 +1917,7 @@ return [ 'StatsTarget' => null, 'StatsFormat' => null, 'StatsPrefix' => 'mediawiki', + 'OpenTelemetryConfig' => null, 'PageInfoTransclusionLimit' => 50, 'EnableJavaScriptTest' => false, 'CachePrefix' => false, @@ -2904,6 +2905,10 @@ return [ 'null', ], 'StatsPrefix' => 'string', + 'OpenTelemetryConfig' => [ + 'object', + 'null', + ], 'OpenSearchTemplates' => 'object', 'NamespacesToBeSearchedDefault' => 'object', 'SitemapNamespaces' => [ diff --git a/includes/libs/telemetry/Clock.php b/includes/libs/telemetry/Clock.php new file mode 100644 index 00000000000..65675c6d601 --- /dev/null +++ b/includes/libs/telemetry/Clock.php @@ -0,0 +1,53 @@ += 8, + 'The Clock class requires 64-bit integers to support nanosecond timing' + ); + } + + /** + * Get the current time, represented as the number of nanoseconds since the UNIX epoch. + * @return int + */ + public function getCurrentNanoTime(): int { + $this->referenceTime ??= (int)( 1e9 * microtime( true ) ) - hrtime( true ); + return self::$mockTime ?? ( $this->referenceTime + hrtime( true ) ); + } + + /** + * Set a mock time to override the timestamp returned by {@link Clock::getCurrentNanoTime()}. + * Useful for testing. + * + * @param int|null $epochNanos The override timestamp, or `null` to return to using the current time. + * @return void + */ + public static function setMockTime( ?int $epochNanos ): void { + Assert::precondition( defined( 'MW_PHPUNIT_TEST' ), 'This method should only be used in tests' ); + self::$mockTime = $epochNanos; + } +} diff --git a/includes/libs/telemetry/ExporterInterface.php b/includes/libs/telemetry/ExporterInterface.php new file mode 100644 index 00000000000..e3ed687353b --- /dev/null +++ b/includes/libs/telemetry/ExporterInterface.php @@ -0,0 +1,17 @@ +context = $context; + } + + /** @inheritDoc */ + public function getContext(): SpanContext { + return $this->context; + } + + /** @inheritDoc */ + public function setAttributes( array $attributes ): SpanInterface { + return $this; + } + + /** @inheritDoc */ + public function setSpanKind( int $spanKind ): SpanInterface { + return $this; + } + + /** @inheritDoc */ + public function start( ?int $epochNanos = null ): SpanInterface { + return $this; + } + + /** @inheritDoc */ + public function end( ?int $epochNanos = null ): void { + // no-op + } + + /** @inheritDoc */ + public function activate(): void { + // no-op + } + + /** @inheritDoc */ + public function deactivate(): void { + // no-op + } +} diff --git a/includes/libs/telemetry/NoopTracer.php b/includes/libs/telemetry/NoopTracer.php new file mode 100644 index 00000000000..0e4631be875 --- /dev/null +++ b/includes/libs/telemetry/NoopTracer.php @@ -0,0 +1,38 @@ +noopSpanContext = new SpanContext( '', '', null, '', false ); + } + + /** @inheritDoc */ + public function createSpan( string $spanName, $parentSpan = null ): SpanInterface { + return new NoopSpan( $this->noopSpanContext ); + } + + /** @inheritDoc */ + public function createRootSpan( string $spanName ): SpanInterface { + return new NoopSpan( $this->noopSpanContext ); + } + + /** @inheritDoc */ + public function createSpanWithParent( string $spanName, SpanContext $parentSpanContext ): SpanInterface { + return new NoopSpan( $this->noopSpanContext ); + } + + /** @inheritDoc */ + public function shutdown(): void { + // no-op + } +} diff --git a/includes/libs/telemetry/OtlpHttpExporter.php b/includes/libs/telemetry/OtlpHttpExporter.php new file mode 100644 index 00000000000..6edcdf6121b --- /dev/null +++ b/includes/libs/telemetry/OtlpHttpExporter.php @@ -0,0 +1,102 @@ +client = $client; + $this->requestFactory = $requestFactory; + $this->logger = $logger; + $this->endpoint = $uri; + $this->serviceName = $serviceName; + $this->hostName = $hostName; + } + + /** @inheritDoc */ + public function export( TracerState $tracerState ): void { + $spanContexts = $tracerState->getSpanContexts(); + if ( count( $spanContexts ) === 0 ) { + return; + } + + $resourceInfo = array_filter( [ + 'service.name' => $this->serviceName, + 'host.name' => $this->hostName, + "server.socket.address" => $_SERVER['SERVER_ADDR'] ?? null, + ] ); + + $data = [ + 'resourceSpans' => [ + [ + 'resource' => [ + 'attributes' => OtlpSerializer::serializeKeyValuePairs( $resourceInfo ) + ], + 'scopeSpans' => [ + [ + 'scope' => [ + 'name' => 'org.wikimedia.telemetry', + ], + 'spans' => $spanContexts + ] + ] + ] + ] + ]; + + $request = $this->requestFactory->createRequest( 'POST', $this->endpoint ) + ->withHeader( 'Content-Type', 'application/json' ) + ->withBody( Utils::streamFor( json_encode( $data ) ) ); + + try { + $response = $this->client->sendRequest( $request ); + if ( $response->getStatusCode() !== 200 ) { + $this->logger->error( 'Failed to export trace data' ); + } + } catch ( ClientExceptionInterface $e ) { + $this->logger->error( 'Failed to connect to exporter', [ 'exception' => $e ] ); + } + + // Clear out finished spans after exporting them. + $tracerState->clearSpanContexts(); + } +} diff --git a/includes/libs/telemetry/OtlpSerializer.php b/includes/libs/telemetry/OtlpSerializer.php new file mode 100644 index 00000000000..6846d7e5ad5 --- /dev/null +++ b/includes/libs/telemetry/OtlpSerializer.php @@ -0,0 +1,43 @@ + 'stringValue', + 'integer' => 'intValue', + 'boolean' => 'boolValue', + 'double' => 'doubleValue', + 'array' => 'arrayValue' + ]; + + /** + * Serialize an associative array into the format expected by the OTLP JSON format. + * @param array $keyValuePairs The associative array to serialize + * @return array + */ + public static function serializeKeyValuePairs( array $keyValuePairs ): array { + $serialized = []; + + foreach ( $keyValuePairs as $key => $value ) { + $type = gettype( $value ); + + if ( isset( self::TYPE_MAP[$type] ) ) { + $serialized[] = [ + 'key' => $key, + 'value' => [ self::TYPE_MAP[$type] => $value ] + ]; + } + } + + return $serialized; + } +} diff --git a/includes/libs/telemetry/ProbabilisticSampler.php b/includes/libs/telemetry/ProbabilisticSampler.php new file mode 100644 index 00000000000..be59d608520 --- /dev/null +++ b/includes/libs/telemetry/ProbabilisticSampler.php @@ -0,0 +1,37 @@ += 0 && $percentChance <= 100, + '$percentChance', + 'must be between 0 and 100 inclusive' + ); + $this->percentChance = $percentChance; + } + + /** @inheritDoc */ + public function shouldSample( ?SpanContext $parentSpanContext ): bool { + if ( $parentSpanContext !== null ) { + return $parentSpanContext->isSampled(); + } + + return mt_rand( 1, 100 ) <= $this->percentChance; + } +} diff --git a/includes/libs/telemetry/README.md b/includes/libs/telemetry/README.md new file mode 100644 index 00000000000..1a7efa6f37b --- /dev/null +++ b/includes/libs/telemetry/README.md @@ -0,0 +1,7 @@ +The `telemetry` library implements a minimal OpenTelemetry tracing client +that is compatible with the OTEL data model but not compliant with the OTEL client specification. + +This was developed to avoid taking a dependency on the official OpenTelemetry PHP client, +which was deemed too complex to integrate with MediaWiki ([T340552](https://phabricator.wikimedia.org/T340552)). + +`telemetry` requires a PSR-3 logger, a PSR-18 HTTP client and a PSR-17 HTTP factory. diff --git a/includes/libs/telemetry/SamplerInterface.php b/includes/libs/telemetry/SamplerInterface.php new file mode 100644 index 00000000000..d1a843367d4 --- /dev/null +++ b/includes/libs/telemetry/SamplerInterface.php @@ -0,0 +1,17 @@ +clock = $clock; + $this->tracerState = $tracerState; + $this->context = $context; + } + + public function __destruct() { + $this->end(); + $activeSpanContext = $this->tracerState->getActiveSpanContext(); + if ( $this->context->equals( $activeSpanContext ) ) { + $this->deactivate(); + } + } + + /** @inheritDoc */ + public function getContext(): SpanContext { + return $this->context; + } + + /** @inheritDoc */ + public function setAttributes( array $attributes ): SpanInterface { + $this->context->setAttributes( $attributes ); + return $this; + } + + /** @inheritDoc */ + public function setSpanKind( int $spanKind ): SpanInterface { + $this->context->setSpanKind( $spanKind ); + return $this; + } + + /** @inheritDoc */ + public function start( ?int $epochNanos = null ): SpanInterface { + Assert::precondition( + !$this->context->wasStarted(), + 'Cannot start a span more than once' + ); + + $this->context->setStartEpochNanos( $epochNanos ?? $this->clock->getCurrentNanoTime() ); + + return $this; + } + + /** @inheritDoc */ + public function end( ?int $epochNanos = null ): void { + Assert::precondition( + $this->context->wasStarted(), + 'Cannot end a span that has not been started' + ); + + // Make duplicate end() calls a no-op, since it may occur legitimately, + // e.g. when a span wrapped in an RAII ScopedSpan wrapper is ended explicitly. + if ( !$this->context->wasEnded() ) { + $this->context->setEndEpochNanos( $epochNanos ?? $this->clock->getCurrentNanoTime() ); + $this->tracerState->addSpanContext( $this->context ); + } + } + + /** @inheritDoc */ + public function activate(): void { + $this->tracerState->activateSpan( $this->getContext() ); + } + + /** @inheritDoc */ + public function deactivate(): void { + $this->tracerState->deactivateSpan( $this->getContext() ); + } + +} diff --git a/includes/libs/telemetry/SpanContext.php b/includes/libs/telemetry/SpanContext.php new file mode 100644 index 00000000000..a6859bdc912 --- /dev/null +++ b/includes/libs/telemetry/SpanContext.php @@ -0,0 +1,157 @@ +traceId = $traceId; + $this->spanId = $spanId; + $this->parentSpanId = $parentSpanId; + $this->name = $name; + $this->sampled = $sampled; + } + + public function setEndEpochNanos( int $endEpochNanos ): void { + $this->endEpochNanos = $endEpochNanos; + } + + public function setStartEpochNanos( int $startEpochNanos ): void { + $this->startEpochNanos = $startEpochNanos; + } + + public function setSpanKind( int $spanKind ): void { + $this->spanKind = $spanKind; + } + + public function setAttributes( array $attributes ): void { + $this->attributes = $attributes; + } + + public function isSampled(): bool { + return $this->sampled; + } + + public function getSpanId(): string { + return $this->spanId; + } + + public function getTraceId(): string { + return $this->traceId; + } + + public function getParentSpanId(): ?string { + return $this->parentSpanId; + } + + public function wasStarted(): bool { + return $this->startEpochNanos !== null; + } + + public function wasEnded(): bool { + return $this->endEpochNanos !== null; + } + + public function jsonSerialize(): array { + $json = [ + 'traceId' => $this->traceId, + 'parentSpanId' => $this->parentSpanId, + 'spanId' => $this->spanId, + 'name' => $this->name, + 'startTimeUnixNano' => $this->startEpochNanos, + 'endTimeUnixNano' => $this->endEpochNanos, + 'kind' => $this->spanKind + ]; + + if ( $this->attributes ) { + $json['attributes'] = OtlpSerializer::serializeKeyValuePairs( $this->attributes ); + } + + return $json; + } + + /** + * Check whether the given SpanContext belongs to the same span. + * + * @param SpanContext|null $other + * @return bool + */ + public function equals( ?SpanContext $other ): bool { + if ( $other === null ) { + return false; + } + + return $other->spanId === $this->spanId; + } +} diff --git a/includes/libs/telemetry/SpanInterface.php b/includes/libs/telemetry/SpanInterface.php new file mode 100644 index 00000000000..462e349f8f7 --- /dev/null +++ b/includes/libs/telemetry/SpanInterface.php @@ -0,0 +1,95 @@ +Semantic Conventions where + * applicable. + * + * @param array $attributes key-value mapping of attribute names to values + * @return SpanInterface fluent interface + */ + public function setAttributes( array $attributes ): SpanInterface; + + /** + * Set the kind of this span, which describes how it relates to its parent and children + * within the overarching trace. + * + * @param int $spanKind One of the SpanInterface::SPAN_KIND_** constants + * @see https://opentelemetry.io/docs/specs/otel/trace/api/#spankind + * @return SpanInterface fluent interface + */ + public function setSpanKind( int $spanKind ): SpanInterface; + + /** + * Start this span, optionally specifying an override for its start time. + * @param int|null $epochNanos The start time to use, or `null` to use the current time. + * @return SpanInterface + */ + public function start( ?int $epochNanos = null ): SpanInterface; + + /** + * End this span, optionally specifying an override for its end time. + * @param int|null $epochNanos The end time to use, or `null` to use the current time. + * @return void + */ + public function end( ?int $epochNanos = null ): void; + + /** + * Make this span the active span. + * + * This will cause any spans started without specifying an explicit parent to automatically + * become children of this span as long as it remains active. + * + * @return void + */ + public function activate(): void; + + /** + * Deactivate this span. + * @return void + */ + public function deactivate(): void; +} diff --git a/includes/libs/telemetry/Tracer.php b/includes/libs/telemetry/Tracer.php new file mode 100644 index 00000000000..27a9339e970 --- /dev/null +++ b/includes/libs/telemetry/Tracer.php @@ -0,0 +1,110 @@ +clock = $clock; + $this->sampler = $sampler; + $this->exporter = $exporter; + $this->tracerState = $tracerState; + } + + /** @inheritDoc */ + public function createSpan( string $spanName ): SpanInterface { + $activeSpanContext = $this->tracerState->getActiveSpanContext(); + + // Gracefully handle attempts to instrument code after shutdown() was called. + if ( !$this->wasShutdown ) { + Assert::precondition( + $activeSpanContext !== null, + 'Attempted to create a span with the currently active span as the implicit parent, ' . + 'but no span was active. Use createRootSpan() to create a span with no parent (i.e. a root span).' + ); + } + + return $this->newSpan( $spanName, $activeSpanContext ); + } + + /** @inheritDoc */ + public function createRootSpan( string $spanName ): SpanInterface { + return $this->newSpan( $spanName, null ); + } + + /** @inheritDoc */ + public function createSpanWithParent( string $spanName, SpanContext $parentSpanContext ): SpanInterface { + return $this->newSpan( $spanName, $parentSpanContext ); + } + + private function newSpan( string $spanName, ?SpanContext $parentSpanContext ): SpanInterface { + $traceId = $parentSpanContext !== null ? + $parentSpanContext->getTraceId() : $this->generateId( self::TRACE_ID_BYTES_LENGTH ); + $spanId = $this->generateId( self::SPAN_ID_BYTES_LENGTH ); + $sampled = $this->sampler->shouldSample( $parentSpanContext ); + + $spanContext = new SpanContext( + $traceId, + $spanId, + $parentSpanContext !== null ? $parentSpanContext->getSpanId() : null, + $spanName, + $sampled + ); + + if ( $this->wasShutdown || !$sampled ) { + return new NoopSpan( $spanContext ); + } + + return new Span( + $this->clock, + $this->tracerState, + $spanContext + ); + } + + /** @inheritDoc */ + public function shutdown(): void { + $this->wasShutdown = true; + $this->exporter->export( $this->tracerState ); + } + + /** + * Generate a valid hexadecimal string for use as a trace or span ID, with the given length in bytes. + * + * @param int $bytesLength The byte length of the ID + * @return string The ID as a hexadecimal string + */ + private function generateId( int $bytesLength ): string { + return bin2hex( random_bytes( $bytesLength ) ); + } +} diff --git a/includes/libs/telemetry/TracerInterface.php b/includes/libs/telemetry/TracerInterface.php new file mode 100644 index 00000000000..a524742444a --- /dev/null +++ b/includes/libs/telemetry/TracerInterface.php @@ -0,0 +1,49 @@ +OTEL Tracing API + * spec for recommended naming conventions. + * @return SpanInterface + * @throws PreconditionException If no span was active + */ + public function createSpan( string $spanName ): SpanInterface; + + /** + * Create a new root span, i.e. a span with no parent that forms the basis for a new trace. + * + * @param string $spanName The descriptive name of this span. + * Refer to the OTEL Tracing API + * spec for recommended naming conventions. + * @return SpanInterface + */ + public function createRootSpan( string $spanName ): SpanInterface; + + /** + * Create a span with the given name and parent. + * + * @param string $spanName The descriptive name of this span. + * Refer to the OTEL Tracing API + * spec for recommended naming conventions. + * @param SpanContext $parentSpanContext Context of the parent span this span should be associated with. + * @return SpanInterface + */ + public function createSpanWithParent( string $spanName, SpanContext $parentSpanContext ): SpanInterface; + + /** + * Shut down this tracer and export collected trace data. + * @return void + */ + public function shutdown(): void; +} diff --git a/includes/libs/telemetry/TracerState.php b/includes/libs/telemetry/TracerState.php new file mode 100644 index 00000000000..d9669cfec1b --- /dev/null +++ b/includes/libs/telemetry/TracerState.php @@ -0,0 +1,112 @@ +finishedSpanContexts[] = $context; + } + + /** + * Get the list of finished spans. + * @return SpanContext[] + */ + public function getSpanContexts(): array { + return $this->finishedSpanContexts; + } + + /** + * Clear the list of finished spans. + */ + public function clearSpanContexts(): void { + $this->finishedSpanContexts = []; + } + + /** + * Make the given span the active span. + * @param SpanContext $spanContext Context of the span to activate + * @return void + */ + public function activateSpan( SpanContext $spanContext ): void { + $this->activeSpanContextStack[] = $spanContext; + } + + /** + * Deactivate the given span, if it was the active span. + * @param SpanContext $spanContext Context of the span to deactivate + * @return void + */ + public function deactivateSpan( SpanContext $spanContext ): void { + $activeSpanContext = $this->getActiveSpanContext(); + + Assert::invariant( + $activeSpanContext !== null && $activeSpanContext->getSpanId() === $spanContext->getSpanId(), + 'Attempted to deactivate a span which is not the active span.' + ); + + array_pop( $this->activeSpanContextStack ); + } + + /** + * Get the context of the currently active span, or `null` if no span is active. + * @return SpanContext|null + */ + public function getActiveSpanContext(): ?SpanContext { + return $this->activeSpanContextStack[count( $this->activeSpanContextStack ) - 1] ?? null; + } +} diff --git a/tests/common/TestSetup.php b/tests/common/TestSetup.php index 48a3b1ca1a1..41e34aaaa42 100644 --- a/tests/common/TestSetup.php +++ b/tests/common/TestSetup.php @@ -49,6 +49,7 @@ class TestSetup { global $wgAuthManagerConfig; global $wgShowExceptionDetails, $wgShowHostnames; global $wgDBStrictWarnings, $wgUsePigLatinVariant; + global $wgOpenTelemetryConfig; $wgShowExceptionDetails = true; $wgShowHostnames = true; @@ -152,6 +153,9 @@ class TestSetup { // This is often used for variant testing $wgUsePigLatinVariant = true; + // Disable tracing in tests. + $wgOpenTelemetryConfig = null; + // xdebug's default of 100 is too low for MediaWiki ini_set( 'xdebug.max_nesting_level', 1000 ); diff --git a/tests/phpunit/includes/libs/telemetry/TelemetryIntegrationTest.php b/tests/phpunit/includes/libs/telemetry/TelemetryIntegrationTest.php new file mode 100644 index 00000000000..d6b9ef3e386 --- /dev/null +++ b/tests/phpunit/includes/libs/telemetry/TelemetryIntegrationTest.php @@ -0,0 +1,141 @@ + 'test-service', + 'samplingProbability' => 100, + 'endpoint' => 'http://198.51.100.42:4318/v1/traces' + ]; + + private MockHandler $handler; + + protected function setUp(): void { + parent::setUp(); + + $this->handler = new MockHandler(); + $this->setService( '_TracerHTTPClient', new Client( [ + 'handler' => $this->handler, + 'http_errors' => false + ] ) ); + } + + protected function tearDown(): void { + parent::tearDown(); + Clock::setMockTime( null ); + TracerState::destroyInstance(); + } + + public function testShouldDoNothingWhenTracingDisabled(): void { + $this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, null ); + + $tracer = $this->getServiceContainer()->getTracer(); + $span = $tracer->createSpan( 'test' ) + ->start(); + + $span->end(); + + $tracer->shutdown(); + + $this->assertInstanceOf( NoopTracer::class, $tracer ); + $this->assertNull( $this->handler->getLastRequest() ); + } + + public function testShouldNotExportDataWhenNoSpansWereCreated(): void { + $this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, self::EXAMPLE_TRACING_CONFIG ); + + $tracer = $this->getServiceContainer()->getTracer(); + + $tracer->shutdown(); + + $this->assertInstanceOf( Tracer::class, $tracer ); + $this->assertNull( $this->handler->getLastRequest() ); + } + + public function testShouldNotExportDataWhenTracerWasNotExplicitlyShutdown(): void { + $this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, self::EXAMPLE_TRACING_CONFIG ); + + $tracer = $this->getServiceContainer()->getTracer(); + $span = $tracer->createRootSpan( 'test' ) + ->start(); + + $span->end(); + + $this->assertInstanceOf( Tracer::class, $tracer ); + $this->assertNull( $this->handler->getLastRequest() ); + } + + public function testShouldExportDataOnShutdownWhenTracingEnabled(): void { + $this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, self::EXAMPLE_TRACING_CONFIG ); + $this->handler->append( new Response( 200 ) ); + + $mockTime = 5481675965496; + Clock::setMockTime( $mockTime ); + + $tracer = $this->getServiceContainer()->getTracer(); + $span = $tracer->createRootSpan( 'test' ) + ->setSpanKind( SpanInterface::SPAN_KIND_SERVER ) + ->start(); + + $span->activate(); + + $mockTime += 100; + Clock::setMockTime( $mockTime ); + + $childSpan = $tracer->createSpan( 'child' ) + ->setAttributes( [ 'some-key' => 'test', 'ignored' => new \stdClass() ] ) + ->start(); + + $mockTime += 250; + Clock::setMockTime( $mockTime ); + + $childSpan->end(); + + $mockTime += 74; + Clock::setMockTime( $mockTime ); + + $span->end(); + + $this->assertNull( + $this->handler->getLastRequest(), + 'Exporting trace data should be deferred until the tracer is explicitly shut down' + ); + + $tracer->shutdown(); + + $request = $this->handler->getLastRequest(); + + $this->assertInstanceOf( Tracer::class, $tracer ); + $this->assertSame( 'http://198.51.100.42:4318/v1/traces', (string)$request->getUri() ); + $this->assertSame( 'application/json', $request->getHeaderLine( 'Content-Type' ) ); + + $expected = file_get_contents( __DIR__ . '/expected-trace-data.json' ); + + $expected = strtr( $expected, [ + '' => $span->getContext()->getTraceId(), + '' => $span->getContext()->getSpanId(), + '' => $childSpan->getContext()->getSpanId(), + '' => wfHostname() + ] ); + + $this->assertJsonStringEqualsJsonString( + $expected, + (string)$request->getBody() + ); + } +} diff --git a/tests/phpunit/includes/libs/telemetry/expected-trace-data.json b/tests/phpunit/includes/libs/telemetry/expected-trace-data.json new file mode 100644 index 00000000000..086a9e246b3 --- /dev/null +++ b/tests/phpunit/includes/libs/telemetry/expected-trace-data.json @@ -0,0 +1,57 @@ +{ + "resourceSpans": [ + { + "resource": { + "attributes": [ + { + "key": "service.name", + "value": { + "stringValue": "test-service" + } + }, + { + "key": "host.name", + "value": { + "stringValue": "" + } + } + ] + }, + "scopeSpans": [ + { + "scope": { + "name": "org.wikimedia.telemetry" + }, + "spans": [ + { + "traceId": "", + "parentSpanId": "", + "spanId": "", + "name": "child", + "startTimeUnixNano": 5481675965596, + "endTimeUnixNano": 5481675965846, + "kind": 1, + "attributes": [ + { + "key": "some-key", + "value": { + "stringValue": "test" + } + } + ] + }, + { + "traceId": "", + "parentSpanId": null, + "spanId": "", + "name": "test", + "startTimeUnixNano": 5481675965496, + "endTimeUnixNano": 5481675965920, + "kind": 2 + } + ] + } + ] + } + ] +} diff --git a/tests/phpunit/unit/includes/libs/telemetry/ClockTest.php b/tests/phpunit/unit/includes/libs/telemetry/ClockTest.php new file mode 100644 index 00000000000..c6994f93af2 --- /dev/null +++ b/tests/phpunit/unit/includes/libs/telemetry/ClockTest.php @@ -0,0 +1,60 @@ +clock = new Clock(); + } + + public function testShouldReturnCurrentTime(): void { + $this->assertClockReturnsCurrentTime(); + } + + public function testShouldAllowMockingTimestamp(): void { + Clock::setMockTime( 2_000 ); + + $this->assertSame( 2_000, $this->clock->getCurrentNanoTime() ); + + Clock::setMockTime( null ); + + $this->assertClockReturnsCurrentTime(); + } + + /** + * Utility function to assert that the Clock being tested returns the current time, with some leeway. + * @return void + */ + private function assertClockReturnsCurrentTime() { + $referenceTime = (int)( 1e9 * microtime( true ) ) - hrtime( true ); + $currentTime = $referenceTime + hrtime( true ); + + usleep( 1 ); + + $now = $this->clock->getCurrentNanoTime(); + + usleep( 1 ); + + $afterTime = $referenceTime + hrtime( true ); + + $this->assertGreaterThan( + $currentTime, + $now, + 'Too large time difference' + ); + $this->assertLessThanOrEqual( + $afterTime, + $now, + 'Too large time difference' + ); + } +} diff --git a/tests/phpunit/unit/includes/libs/telemetry/NoopTracerTest.php b/tests/phpunit/unit/includes/libs/telemetry/NoopTracerTest.php new file mode 100644 index 00000000000..3f7f1c17e8c --- /dev/null +++ b/tests/phpunit/unit/includes/libs/telemetry/NoopTracerTest.php @@ -0,0 +1,42 @@ +tracer = new NoopTracer(); + } + + public function testShouldCreateNoopSpan(): void { + $span = $this->tracer->createSpan( 'test' ); + + $this->assertInstanceOf( NoopSpan::class, $span ); + $this->assertNull( $span->getContext()->getParentSpanId() ); + } + + public function testShouldCreateNoopRootSpan(): void { + $span = $this->tracer->createRootSpan( 'test' ); + + $this->assertInstanceOf( NoopSpan::class, $span ); + $this->assertNull( $span->getContext()->getParentSpanId() ); + } + + public function testShouldCreateNoopSpanWithParent(): void { + $span = $this->tracer->createSpanWithParent( 'test', new SpanContext( '', '', null, '', false ) ); + + $this->assertInstanceOf( NoopSpan::class, $span ); + $this->assertNull( $span->getContext()->getParentSpanId() ); + } +} diff --git a/tests/phpunit/unit/includes/libs/telemetry/OtlpSerializerTest.php b/tests/phpunit/unit/includes/libs/telemetry/OtlpSerializerTest.php new file mode 100644 index 00000000000..1103dc19252 --- /dev/null +++ b/tests/phpunit/unit/includes/libs/telemetry/OtlpSerializerTest.php @@ -0,0 +1,40 @@ +assertSame( $expected, $actual ); + } + + public static function provideKeyValuePairSerializationData(): iterable { + yield 'empty data' => [ [], [] ]; + yield 'mixed data' => [ + [ + 'string-value' => 'string', + 'numeric-value' => 123, + 'float-value' => 1.5, + 'object-value' => new \stdClass(), + 'boolean-value' => true, + 'list-value' => [ 'a', 'b' ] + ], + [ + [ 'key' => 'string-value', 'value' => [ 'stringValue' => 'string' ] ], + [ 'key' => 'numeric-value', 'value' => [ 'intValue' => 123 ] ], + [ 'key' => 'float-value', 'value' => [ 'doubleValue' => 1.5 ] ], + [ 'key' => 'boolean-value', 'value' => [ 'boolValue' => true ] ], + [ 'key' => 'list-value', 'value' => [ 'arrayValue' => [ 'a', 'b' ] ] ] + ] + ]; + } +} diff --git a/tests/phpunit/unit/includes/libs/telemetry/ProbabilisticSamplerTest.php b/tests/phpunit/unit/includes/libs/telemetry/ProbabilisticSamplerTest.php new file mode 100644 index 00000000000..7e47f80fa4d --- /dev/null +++ b/tests/phpunit/unit/includes/libs/telemetry/ProbabilisticSamplerTest.php @@ -0,0 +1,66 @@ +sampler = new ProbabilisticSampler( 30 ); + } + + public function testShouldNotSampleSpanWithNoParentBasedOnChance(): void { + mt_srand( self::DO_NOT_SAMPLE_SEED ); + + $sampled = $this->sampler->shouldSample( null ); + + $this->assertFalse( $sampled ); + } + + public function testShouldSampleSpanWithNoParentBasedOnChance(): void { + mt_srand( self::SAMPLE_SEED ); + + $sampled = $this->sampler->shouldSample( null ); + + $this->assertTrue( $sampled ); + } + + public function testShouldSampleSpanWithParentBasedOnParentDecision(): void { + mt_srand( self::DO_NOT_SAMPLE_SEED ); + + $parentSpanContext = new SpanContext( '', '', null, '', true ); + + $sampled = $this->sampler->shouldSample( $parentSpanContext ); + + $this->assertTrue( $sampled ); + } + + public function testShouldNotSampleSpanWithParentBasedOnParentDecision(): void { + mt_srand( self::SAMPLE_SEED ); + + $parentSpanContext = new SpanContext( '', '', null, '', false ); + + $sampled = $this->sampler->shouldSample( $parentSpanContext ); + + $this->assertFalse( $sampled ); + } + + public function testShouldThrowOnInvalidPercentChance(): void { + $this->expectException( ParameterAssertionException::class ); + $this->expectExceptionMessage( 'Bad value for parameter $percentChance: must be between 0 and 100 inclusive' ); + + new ProbabilisticSampler( 900 ); + } +} diff --git a/tests/phpunit/unit/includes/libs/telemetry/SpanTest.php b/tests/phpunit/unit/includes/libs/telemetry/SpanTest.php new file mode 100644 index 00000000000..38093eb82f4 --- /dev/null +++ b/tests/phpunit/unit/includes/libs/telemetry/SpanTest.php @@ -0,0 +1,164 @@ +clock = $this->createMock( Clock::class ); + $this->tracerState = $this->createMock( TracerState::class ); + } + + private function createSpan(): Span { + $context = new SpanContext( + str_repeat( 'a', 64 ), + str_repeat( 'b', 16 ), + null, + 'test', + true + ); + return new Span( + $this->clock, + $this->tracerState, + $context + ); + } + + public function testDuplicateSpanStartShouldError(): void { + $this->expectException( PreconditionException::class ); + $this->expectExceptionMessage( 'Cannot start a span more than once' ); + + $span = $this->createSpan(); + + $span->start(); + $span->start(); + } + + public function testEndingUnstartedSpanShouldError(): void { + $this->expectException( PreconditionException::class ); + $this->expectExceptionMessage( 'Cannot end a span that has not been started' ); + + $span = $this->createSpan(); + + $span->end(); + } + + public function testShouldExposeDataViaContext(): void { + $traceId = str_repeat( 'c', 64 ); + $spanId = str_repeat( 'd', 16 ); + $context = new SpanContext( + $traceId, + $spanId, + null, + 'test', + true + ); + + $span = new Span( + $this->clock, + $this->tracerState, + $context + ); + $span->start(); + + $this->assertSame( $traceId, $span->getContext()->getTraceId() ); + $this->assertSame( $spanId, $span->getContext()->getSpanId() ); + $this->assertTrue( $span->getContext()->isSampled() ); + } + + /** + * @dataProvider provideInactiveSpanTestCases + */ + public function testShouldNotDeactivateSpanInSharedStateByGoingOutOfScopeIfItWasNeverActive( + ?SpanContext $activeSpanContext + ): void { + $span = $this->createSpan(); + + $this->tracerState->expects( $this->never() ) + ->method( 'activateSpan' ); + + $this->tracerState->method( 'getActiveSpanContext' ) + ->willReturn( $activeSpanContext ); + + $this->tracerState->expects( $this->never() ) + ->method( 'deactivateSpan' ); + + $span->start(); + + $span = null; + } + + public static function provideInactiveSpanTestCases(): iterable { + yield 'no active span' => [ null ]; + yield 'different active span' => [ new SpanContext( '', 'bbb', null, '', false ) ]; + } + + public function testShouldActivateAndDeactivateSpanInSharedStateByGoingOutOfScope(): void { + $span = $this->createSpan(); + + $this->tracerState->expects( $this->once() ) + ->method( 'activateSpan' ) + ->with( $span->getContext() ); + + $this->tracerState->method( 'getActiveSpanContext' ) + ->willReturn( $span->getContext() ); + + $this->tracerState->expects( $this->once() ) + ->method( 'deactivateSpan' ) + ->with( $span->getContext() ); + + $span->start(); + $span->activate(); + + $span = null; + } + + public function testShouldDeactivateSpanInSharedStateExplicitly(): void { + $span = $this->createSpan(); + + $this->tracerState->expects( $this->once() ) + ->method( 'deactivateSpan' ) + ->with( $span->getContext() ); + + $span->start(); + $span->deactivate(); + } + + public function testShouldAddSpanToSharedStateOnceWhenEndedExplicitly(): void { + $span = $this->createSpan(); + + $this->tracerState->expects( $this->once() ) + ->method( 'addSpanContext' ) + ->with( $span->getContext() ); + + $span->start(); + + $span->end(); + $span->end(); + } + + public function testShouldAddSpanToSharedStateOnceWhenEndedByGoingOutOfScope(): void { + $span = $this->createSpan(); + + $this->tracerState->expects( $this->once() ) + ->method( 'addSpanContext' ) + ->with( $span->getContext() ); + + $span->start(); + + $span = null; + } +} diff --git a/tests/phpunit/unit/includes/libs/telemetry/TracerStateTest.php b/tests/phpunit/unit/includes/libs/telemetry/TracerStateTest.php new file mode 100644 index 00000000000..91b50a84dd5 --- /dev/null +++ b/tests/phpunit/unit/includes/libs/telemetry/TracerStateTest.php @@ -0,0 +1,121 @@ +tracerState = new TracerState(); + } + + public function testShouldMaintainStackOfActiveSpans(): void { + $firstSpanContext = new SpanContext( + '', + str_repeat( 'a', 16 ), + null, + '', + true + ); + + $secondSpanContext = new SpanContext( + '', + str_repeat( 'b', 16 ), + null, + '', + true + ); + + $this->assertNull( $this->tracerState->getActiveSpanContext() ); + + $this->tracerState->activateSpan( $firstSpanContext ); + $this->assertSame( $firstSpanContext, $this->tracerState->getActiveSpanContext() ); + + $this->tracerState->activateSpan( $secondSpanContext ); + $this->assertSame( $secondSpanContext, $this->tracerState->getActiveSpanContext() ); + + $this->tracerState->deactivateSpan( $secondSpanContext ); + $this->assertSame( $firstSpanContext, $this->tracerState->getActiveSpanContext() ); + + $this->tracerState->deactivateSpan( $firstSpanContext ); + $this->assertNull( $this->tracerState->getActiveSpanContext() ); + } + + public function testShouldRejectDeactivatingSpanWhenNoSpanIsActive(): void { + $this->expectException( InvariantException::class ); + $this->expectExceptionMessage( 'Attempted to deactivate a span which is not the active span.' ); + + try { + $span = $this->createMock( SpanInterface::class ); + $this->tracerState->deactivateSpan( new SpanContext( '', '', null, '', false ) ); + } finally { + $this->assertNull( $this->tracerState->getActiveSpanContext() ); + } + } + + public function testShouldRejectDeactivatingSpanWhenItIsNotActive(): void { + $this->expectException( InvariantException::class ); + $this->expectExceptionMessage( 'Attempted to deactivate a span which is not the active span.' ); + + $activeSpanContext = new SpanContext( + '', + str_repeat( 'a', 16 ), + null, + '', + true + ); + + $this->tracerState->activateSpan( $activeSpanContext ); + + try { + $otherSpanContext = new SpanContext( + '', + str_repeat( 'b', 16 ), + null, + '', + true + ); + + $this->tracerState->deactivateSpan( $otherSpanContext ); + } finally { + $this->assertSame( $activeSpanContext, $this->tracerState->getActiveSpanContext() ); + } + } + + public function testShouldAddAndReturnSpans(): void { + $firstSpanContext = new SpanContext( + '', + str_repeat( 'a', 16 ), + null, + '', + true + ); + + $secondSpanContext = new SpanContext( + '', + str_repeat( 'b', 16 ), + null, + '', + true + ); + + $this->tracerState->addSpanContext( $firstSpanContext ); + $this->tracerState->addSpanContext( $secondSpanContext ); + + $this->assertSame( [ $firstSpanContext, $secondSpanContext ], $this->tracerState->getSpanContexts() ); + + $this->tracerState->clearSpanContexts(); + + $this->assertSame( [], $this->tracerState->getSpanContexts() ); + } +} diff --git a/tests/phpunit/unit/includes/libs/telemetry/TracerTest.php b/tests/phpunit/unit/includes/libs/telemetry/TracerTest.php new file mode 100644 index 00000000000..5955afd18a0 --- /dev/null +++ b/tests/phpunit/unit/includes/libs/telemetry/TracerTest.php @@ -0,0 +1,225 @@ +clock = $this->createMock( Clock::class ); + $this->sampler = $this->createMock( SamplerInterface::class ); + $this->exporter = $this->createMock( ExporterInterface::class ); + $this->tracerState = new TracerState(); + + $this->clock->method( 'getCurrentNanoTime' ) + ->willReturnCallback( fn () => hrtime( true ) ); + + $this->tracer = new Tracer( + $this->clock, + $this->sampler, + $this->exporter, + $this->tracerState + ); + } + + public function testSpanAndTraceIds(): void { + $this->sampler->method( 'shouldSample' ) + ->willReturn( true ); + + $rootSpan = $this->tracer->createRootSpan( 'test span' ) + ->start(); + $rootSpan->activate(); + + $span = $this->tracer->createSpan( 'test' ) + ->start(); + + $explicitSpan = $this->tracer->createSpanWithParent( 'test', $span->getContext() ) + ->start(); + + foreach ( [ $rootSpan, $span, $explicitSpan ] as $span ) { + $this->assertMatchesRegularExpression( + '/^[a-f0-9]{32}$/', + $span->getContext()->getTraceId(), + 'The trace ID should be a 128-bit hexadecimal string' + ); + $this->assertMatchesRegularExpression( + '/^[a-f0-9]{16}$/', + $span->getContext()->getSpanId(), + 'The span ID should be a 64-bit hexadecimal string' + ); + } + } + + public function testSpanCreation(): void { + $this->sampler->method( 'shouldSample' ) + ->willReturn( true ); + + $activeSpan = $this->tracer->createRootSpan( 'test active span' ) + ->start(); + $activeSpan->activate(); + + $span = $this->tracer->createRootSpan( 'test span', false ) + ->start(); + + $this->assertNull( + $span->getContext()->getParentSpanId(), + 'The test span should have been explicitly created as a root span, ignoring the active span' + ); + $this->assertNotEquals( + $activeSpan->getContext()->getTraceId(), + $span->getContext()->getTraceId(), + 'The test span should have started a new trace because it is a root span' + ); + } + + public function testMultipleSpanCreation(): void { + $this->sampler->method( 'shouldSample' ) + ->willReturn( true ); + + /** @var SpanInterface[] $spans */ + $spans = []; + $spanIds = []; + for ( $i = 1; $i <= 4; $i++ ) { + $span = $i === 1 ? $this->tracer->createRootSpan( "test span #$i" ) : + $this->tracer->createSpan( "test span #$i" ); + $span = $span->start(); + $span->activate(); + + if ( $i === 1 ) { + $this->assertNull( + $span->getContext()->getParentSpanId(), + 'The first span should have no parent, because there is no active span' + ); + } else { + $this->assertSame( + $spans[$i - 2]->getContext()->getSpanId(), + $span->getContext()->getParentSpanId(), + "The parent of span #$i should have been the previous span, which was active" + ); + $this->assertSame( + $spans[0]->getContext()->getTraceId(), + $span->getContext()->getTraceId(), + 'All spans should be part of the same trace' + ); + } + + $spans[] = $span; + $spanIds[] = $span->getContext()->getSpanId(); + } + + $span = null; + + while ( array_pop( $spans ) !== null ); + + $exportedSpanIds = array_map( + fn ( SpanContext $spanContext ) => $spanContext->getSpanId(), + $this->tracerState->getSpanContexts() + ); + + $this->assertSame( + array_reverse( $spanIds ), + $exportedSpanIds + ); + } + + public function testCreatingSpansWithoutActiveSpan(): void { + $this->clock->method( 'getCurrentNanoTime' ) + ->willReturnCallback( fn () => hrtime( true ) ); + + $this->sampler->method( 'shouldSample' ) + ->willReturn( true ); + + $traceIds = []; + + for ( $i = 1; $i <= 2; $i++ ) { + $span = $this->tracer->createRootSpan( "test span #$i" ) + ->start(); + + $this->assertNull( $span->getContext()->getParentSpanId(), "span #$i should have no parent" ); + $this->assertNotContains( + $span->getContext()->getTraceId(), + $traceIds, + 'All spans should be root spans, starting a new trace' + ); + + $traceIds[] = $span->getContext()->getTraceId(); + } + } + + public function testCreatingSpanWithExplicitParent(): void { + $this->sampler->method( 'shouldSample' ) + ->willReturn( true ); + + $activeSpan = $this->tracer->createRootSpan( 'test active span' ) + ->start(); + + $parentSpan = $this->tracer->createRootSpan( 'parent span' ) + ->start(); + + $span = $this->tracer->createSpanWithParent( 'test span', $parentSpan->getContext() ) + ->start(); + + $this->assertSame( + $parentSpan->getContext()->getSpanId(), + $span->getContext()->getParentSpanId(), + 'The test span should have been assigned the given span as the parent, ignoring the active span' + ); + $this->assertSame( + $parentSpan->getContext()->getTraceId(), + $span->getContext()->getTraceId(), + 'The test span should have been part of the same trace as its parent' + ); + } + + public function testShouldExportSharedStateOnShutdown(): void { + $this->exporter->expects( $this->once() ) + ->method( 'export' ) + ->with( $this->tracerState ); + + $this->tracer->shutdown(); + } + + public function testShouldMakeSpanCreationNoopPostShutdown(): void { + $this->sampler->method( 'shouldSample' ) + ->willReturn( true ); + + $this->tracer->shutdown(); + + $orphanedSpan = $this->tracer->createSpan( 'orphaned span' ) + ->start(); + + $rootSpan = $this->tracer->createRootSpan( 'parent span' ) + ->start(); + + $span = $this->tracer->createSpan( 'orphaned span' ) + ->start(); + + $explicitParentSpan = $this->tracer->createSpanWithParent( 'test span', $orphanedSpan->getContext() ) + ->start(); + + $this->assertInstanceOf( NoopSpan::class, $orphanedSpan ); + $this->assertInstanceOf( NoopSpan::class, $rootSpan ); + $this->assertInstanceOf( NoopSpan::class, $span ); + $this->assertInstanceOf( NoopSpan::class, $explicitParentSpan ); + } +}