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 );
+ }
+}