Introduce minimal OTEL tracing library

In T340552, the official PHP OpenTelemetry client was effectively
rejected for inclusion in MediaWiki due to its size. Implement a minimal
tracing library instead that eschews conformance with the OTEL client
specification in favor of simplicity, while remaining capable of
emitting trace data in OTLP format and thus retaining compatibility with
any ingestion endpoint capable of handling OTLP.

In its current state, the library supports a basic feature set that
should be sufficient for basic tracing integration:

* Span creation, inclusive span activation and automatic parent span
  assignment,
* Span attributes and span kinds,
* Basic resource (process/request)-level metadata generation,
* Data export over OTLP.

Additional functionality, such as trace propagation, can then be
incrementally added to the library.

Bug: T340552
Change-Id: Ibc3910058cd7ed064cad293a3cdc091344e66b86
This commit is contained in:
Máté Szabó 2024-08-12 03:18:57 +02:00
parent e4df7ad756
commit 16ec1a3703
34 changed files with 2022 additions and 0 deletions

View file

@ -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',

View file

@ -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',

View file

@ -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: |-

View file

@ -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

View file

@ -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

View file

@ -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).
*

View file

@ -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
*/

View file

@ -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() ),

View file

@ -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' => [

View file

@ -0,0 +1,53 @@
<?php
namespace Wikimedia\Telemetry;
use Wikimedia\Assert\Assert;
/**
* A click providing the current time in nanoseconds, backed by {@link hrtime}.
*
* @since 1.43
* @internal
*/
class Clock {
/**
* Timestamp to return in place of the current time, or `null` to use the current time.
* @var int|null
*/
private static ?int $mockTime = null;
/**
* The reference UNIX timestamp in nanoseconds which hrtime() offsets should be added to
* to derive an absolute timestamp.
* @var int|null
*/
private ?int $referenceTime = null;
public function __construct() {
Assert::precondition(
PHP_INT_SIZE >= 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;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Wikimedia\Telemetry;
/**
* Base interface for OTEL trace data exporters.
* @since 1.43
* @internal
*/
interface ExporterInterface {
/**
* Export all trace data.
* @param TracerState $tracerState
* @return void
*/
public function export( TracerState $tracerState ): void;
}

View file

@ -0,0 +1,51 @@
<?php
namespace Wikimedia\Telemetry;
/**
* An unsampled span that does nothing and persists no data.
*
* @since 1.43
* @internal
*/
class NoopSpan implements SpanInterface {
private SpanContext $context;
public function __construct( SpanContext $context ) {
$this->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
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Wikimedia\Telemetry;
/**
* A no-op tracer that creates no-op spans and persists no data.
* Useful for scenarios where tracing is disabled.
*
* @since 1.43
* @internal
*/
class NoopTracer implements TracerInterface {
private SpanContext $noopSpanContext;
public function __construct() {
$this->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
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace Wikimedia\Telemetry;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Log\LoggerInterface;
/**
* An {@link ExporterInterface} that exports collected data over HTTP, serialized in OTLP JSON format.
*
* @since 1.43
* @internal
*/
class OtlpHttpExporter implements ExporterInterface {
private ClientInterface $client;
private RequestFactoryInterface $requestFactory;
private LoggerInterface $logger;
/**
* URI of the OTLP receiver endpoint to send data to.
* @var string
*/
private string $endpoint;
/**
* Descriptive name used to identify this service in traces.
* @var string
*/
private string $serviceName;
/**
* The host name of this server to be reported in traces.
* @var string
*/
private string $hostName;
public function __construct(
ClientInterface $client,
RequestFactoryInterface $requestFactory,
LoggerInterface $logger,
string $uri,
string $serviceName,
string $hostName
) {
$this->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();
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Wikimedia\Telemetry;
/**
* Utility class for serializing data in OTLP JSON format.
*
* @since 1.43
* @internal
*/
class OtlpSerializer {
/**
* Map of PHP types to their corresponding type name used in the OTLP JSON format.
*/
private const TYPE_MAP = [
'string' => '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;
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace Wikimedia\Telemetry;
use Wikimedia\Assert\Assert;
/**
* A {@link SamplerInterface} implementation that samples a given percentage of root spans,
* while respecting sampling decisions made by other samplers for a given trace.
*
* @since 1.43
* @internal
*/
class ProbabilisticSampler implements SamplerInterface {
/**
* The chance of sampling a root span, as a percentage (0-100).
* @var int
*/
private int $percentChance;
public function __construct( int $percentChance ) {
Assert::parameter(
$percentChance >= 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;
}
}

View file

@ -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.

View file

@ -0,0 +1,17 @@
<?php
namespace Wikimedia\Telemetry;
/**
* Interface for OTEL span samplers.
* @since 1.43
*/
interface SamplerInterface {
/**
* Determine whether a newly created span should be sampled based on its parent span data.
*
* @param SpanContext|null $parentSpanContext Context of he parent span of the newly created span,
* or `null` if the newly created span is a root span.
* @return bool Whether the newly created span should be sampled.
*/
public function shouldSample( ?SpanContext $parentSpanContext ): bool;
}

View file

@ -0,0 +1,92 @@
<?php
namespace Wikimedia\Telemetry;
use Wikimedia\Assert\Assert;
/**
* Represents an OpenTelemetry span, i.e. a single operation within a trace.
*
* @since 1.43
* @see https://opentelemetry.io/docs/specs/otel/trace/api/#span
*/
class Span implements SpanInterface {
private Clock $clock;
private TracerState $tracerState;
private SpanContext $context;
public function __construct(
Clock $clock,
TracerState $tracerState,
SpanContext $context
) {
$this->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() );
}
}

View file

@ -0,0 +1,157 @@
<?php
namespace Wikimedia\Telemetry;
use JsonSerializable;
/**
* Data transfer object holding data associated with a given span.
*
* @since 1.43
*/
class SpanContext implements JsonSerializable {
/**
* The ID of the trace this span is part of, as a hexadecimal string.
* @var string
*/
private string $traceId;
/**
* The ID of this span, as a hexadecimal string.
* @var string
*/
private string $spanId;
/**
* The ID of this span, as a hexadecimal string, or `null` if this is a root span.
* @var string|null
*/
private ?string $parentSpanId;
/**
* A concise description of the work represented by this span.
* @see TracerInterface::createSpan()
* @var string
*/
private string $name;
/**
* Whether the active sampler decided to sample and record this span.
* @var bool
*/
private bool $sampled;
/**
* Key-value metadata associated with this span.
* @see Span::setAttributes()
* @var array
*/
private array $attributes = [];
/**
* Describes the relationship of this span to other spans within the same trace.
* @see Span::setSpanKind()
* @var int
*/
private int $spanKind = SpanInterface::SPAN_KIND_INTERNAL;
/**
* UNIX epoch timestamp in nanoseconds at which this span was started,
* or `null` if this span was not started yet.
* @var int|null
*/
private ?int $startEpochNanos = null;
/**
* UNIX epoch timestamp in nanoseconds at which this span was ended,
* or `null` if this span was not ended yet.
* @var int|null
*/
private ?int $endEpochNanos = null;
public function __construct(
string $traceId,
string $spanId,
?string $parentSpanId,
string $name,
bool $sampled
) {
$this->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;
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace Wikimedia\Telemetry;
/**
* Represents an OpenTelemetry span, i.e. a single operation within a trace.
*
* @since 1.43
* @see https://opentelemetry.io/docs/specs/otel/trace/api/#span
*/
interface SpanInterface {
/**
* Default value. Indicates that the span represents an internal operation within an application,
* as opposed to an operations with remote parents or children.
*/
public const SPAN_KIND_INTERNAL = 1;
/**
* Indicates that the span covers server-side handling of a synchronous RPC or other remote request.
*/
public const SPAN_KIND_SERVER = 2;
/**
* Indicates that the span describes a request to some remote service.
*/
public const SPAN_KIND_CLIENT = 3;
/**
* Indicates that the span describes the initiators of an asynchronous request.
*/
public const SPAN_KIND_PRODUCER = 4;
/**
* Indicates that the span describes a child of an asynchronous
* {@link SpanInterface::SPAN_KIND_PRODUCER} request.
*/
public const SPAN_KIND_CONSUMER = 5;
/**
* Get the context holding data for this span.
* @return SpanContext
*/
public function getContext(): SpanContext;
/**
* Set attributes (arbitrary metadata) for this span.
*
* When deciding on the set of attributes to register as well as their naming, consider following
* <a href="https://opentelemetry.io/docs/specs/semconv/general/trace/">Semantic Conventions</a> 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;
}

View file

@ -0,0 +1,110 @@
<?php
namespace Wikimedia\Telemetry;
use Wikimedia\Assert\Assert;
/**
* @since 1.43
* @internal
*/
class Tracer implements TracerInterface {
/**
* The length of a trace ID in bytes, as specified by the OTEL specification.
*/
private const TRACE_ID_BYTES_LENGTH = 16;
/**
* The length of a span ID in bytes, as specified by the OTEL specification.
*/
private const SPAN_ID_BYTES_LENGTH = 8;
private Clock $clock;
private SamplerInterface $sampler;
private ExporterInterface $exporter;
private TracerState $tracerState;
/**
* Whether tracing has been explicitly ended by calling shutdown() on this instance.
* @var bool
*/
private bool $wasShutdown = false;
public function __construct(
Clock $clock,
SamplerInterface $sampler,
ExporterInterface $exporter,
TracerState $tracerState
) {
$this->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 ) );
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Wikimedia\Telemetry;
use Wikimedia\Assert\PreconditionException;
/**
* Base interface for an OpenTelemetry tracer responsible for creating spans.
* @since 1.43
*/
interface TracerInterface {
/**
* Create a span with the given name and the currently active span as the implicit parent.
* This requires a span to be already active and will throw an error otherwise.
*
* @param string $spanName The descriptive name of this span.
* Refer to the <a href="https://opentelemetry.io/docs/specs/otel/trace/api/#span">OTEL Tracing API
* spec</a> 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 <a href="https://opentelemetry.io/docs/specs/otel/trace/api/#span">OTEL Tracing API
* spec</a> 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 <a href="https://opentelemetry.io/docs/specs/otel/trace/api/#span">OTEL Tracing API
* spec</a> 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;
}

View file

@ -0,0 +1,112 @@
<?php
namespace Wikimedia\Telemetry;
use Wikimedia\Assert\Assert;
/**
* Holds shared telemetry state, such as finished span data buffered for export.
*
* Since this data is tied to the lifetime of a given web request or process, this class is a singleton to
* avoid discarding data in the case of MediaWiki service container resets.
*
* @since 1.43
* @internal
*/
class TracerState {
/**
* Shared tracer state for the current process or web request, `null` if uninitialized.
* @var TracerState|null
*/
private static ?TracerState $instance = null;
/**
* List of already finished spans to be exported.
* @var SpanContext[]
*/
private array $finishedSpanContexts = [];
/**
* Stack holding contexts for activated spans.
* @var SpanContext[]
*/
private array $activeSpanContextStack = [];
/**
* Get or initialize the shared tracer state for the current process or web request.
* @return TracerState
*/
public static function getInstance(): TracerState {
self::$instance ??= new self();
return self::$instance;
}
/**
* Reset shared tracer state. Useful for testing.
* @return void
*/
public static function destroyInstance(): void {
Assert::precondition(
defined( 'MW_PHPUNIT_TEST' ),
'This function can only be called in tests'
);
self::$instance = null;
}
/**
* Add the given span to the list of finished spans.
* @param SpanContext $context
* @return void
*/
public function addSpanContext( SpanContext $context ): void {
$this->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;
}
}

View file

@ -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 );

View file

@ -0,0 +1,141 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;
use Wikimedia\Telemetry\Clock;
use Wikimedia\Telemetry\NoopTracer;
use Wikimedia\Telemetry\SpanInterface;
use Wikimedia\Telemetry\Tracer;
use Wikimedia\Telemetry\TracerState;
/**
* @covers \Wikimedia\Telemetry\Tracer
* @covers \Wikimedia\Telemetry\OtlpHttpExporter
*/
class TelemetryIntegrationTest extends MediaWikiIntegrationTestCase {
private const EXAMPLE_TRACING_CONFIG = [
'serviceName' => '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, [
'<TRACE-ID>' => $span->getContext()->getTraceId(),
'<SPAN-1-ID>' => $span->getContext()->getSpanId(),
'<SPAN-2-ID>' => $childSpan->getContext()->getSpanId(),
'<HOST-NAME>' => wfHostname()
] );
$this->assertJsonStringEqualsJsonString(
$expected,
(string)$request->getBody()
);
}
}

View file

@ -0,0 +1,57 @@
{
"resourceSpans": [
{
"resource": {
"attributes": [
{
"key": "service.name",
"value": {
"stringValue": "test-service"
}
},
{
"key": "host.name",
"value": {
"stringValue": "<HOST-NAME>"
}
}
]
},
"scopeSpans": [
{
"scope": {
"name": "org.wikimedia.telemetry"
},
"spans": [
{
"traceId": "<TRACE-ID>",
"parentSpanId": "<SPAN-1-ID>",
"spanId": "<SPAN-2-ID>",
"name": "child",
"startTimeUnixNano": 5481675965596,
"endTimeUnixNano": 5481675965846,
"kind": 1,
"attributes": [
{
"key": "some-key",
"value": {
"stringValue": "test"
}
}
]
},
{
"traceId": "<TRACE-ID>",
"parentSpanId": null,
"spanId": "<SPAN-1-ID>",
"name": "test",
"startTimeUnixNano": 5481675965496,
"endTimeUnixNano": 5481675965920,
"kind": 2
}
]
}
]
}
]
}

View file

@ -0,0 +1,60 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use MediaWikiUnitTestCase;
use Wikimedia\Telemetry\Clock;
/**
* @covers \Wikimedia\Telemetry\Clock
*/
class ClockTest extends MediaWikiUnitTestCase {
private Clock $clock;
protected function setUp(): void {
parent::setUp();
$this->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'
);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use MediaWikiUnitTestCase;
use Wikimedia\Telemetry\NoopSpan;
use Wikimedia\Telemetry\NoopTracer;
use Wikimedia\Telemetry\SpanContext;
use Wikimedia\Telemetry\TracerInterface;
/**
* @covers \Wikimedia\Telemetry\NoopTracer
*/
class NoopTracerTest extends MediaWikiUnitTestCase {
private TracerInterface $tracer;
protected function setUp(): void {
parent::setUp();
$this->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() );
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use MediaWikiUnitTestCase;
use Wikimedia\Telemetry\OtlpSerializer;
/**
* @covers \Wikimedia\Telemetry\OtlpSerializer
*/
class OtlpSerializerTest extends MediaWikiUnitTestCase {
/**
* @dataProvider provideKeyValuePairSerializationData
*/
public function testKeyValuePairSerialization( array $input, array $expected ): void {
$actual = OtlpSerializer::serializeKeyValuePairs( $input );
$this->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' ] ] ]
]
];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use MediaWikiUnitTestCase;
use Wikimedia\Assert\ParameterAssertionException;
use Wikimedia\Telemetry\ProbabilisticSampler;
use Wikimedia\Telemetry\SamplerInterface;
use Wikimedia\Telemetry\SpanContext;
/**
* @covers \Wikimedia\Telemetry\ProbabilisticSampler
*/
class ProbabilisticSamplerTest extends MediaWikiUnitTestCase {
private const DO_NOT_SAMPLE_SEED = 12;
private const SAMPLE_SEED = 5;
private SamplerInterface $sampler;
public function setUp(): void {
parent::setUp();
$this->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 );
}
}

View file

@ -0,0 +1,164 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use MediaWikiUnitTestCase;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Telemetry\Clock;
use Wikimedia\Telemetry\Span;
use Wikimedia\Telemetry\SpanContext;
use Wikimedia\Telemetry\TracerState;
/**
* @covers \Wikimedia\Telemetry\Span
*/
class SpanTest extends MediaWikiUnitTestCase {
private Clock $clock;
private TracerState $tracerState;
protected function setUp(): void {
parent::setUp();
$this->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;
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use MediaWikiUnitTestCase;
use Wikimedia\Assert\InvariantException;
use Wikimedia\Telemetry\SpanContext;
use Wikimedia\Telemetry\SpanInterface;
use Wikimedia\Telemetry\TracerState;
/**
* @covers \Wikimedia\Telemetry\TracerState
*/
class TracerStateTest extends MediaWikiUnitTestCase {
private TracerState $tracerState;
protected function setUp(): void {
parent::setUp();
$this->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() );
}
}

View file

@ -0,0 +1,225 @@
<?php
namespace Wikimedia\Tests\Telemetry;
use MediaWikiUnitTestCase;
use Wikimedia\Telemetry\Clock;
use Wikimedia\Telemetry\ExporterInterface;
use Wikimedia\Telemetry\NoopSpan;
use Wikimedia\Telemetry\SamplerInterface;
use Wikimedia\Telemetry\SpanContext;
use Wikimedia\Telemetry\SpanInterface;
use Wikimedia\Telemetry\Tracer;
use Wikimedia\Telemetry\TracerState;
/**
* @covers \Wikimedia\Telemetry\Tracer
* @covers \Wikimedia\Telemetry\Span
*/
class TracerTest extends MediaWikiUnitTestCase {
private Clock $clock;
private SamplerInterface $sampler;
private ExporterInterface $exporter;
private TracerState $tracerState;
private Tracer $tracer;
protected function setUp(): void {
parent::setUp();
$this->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 );
}
}