diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9c8ad5d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 3.0.0 + +### Breaking changes + +- Removed `Span::addExporter()`. Use `Span::setExporters(Exporter ...$exporters)` to register exporters; calls replace the full set. +- Removed `Span::resetExporters()`. Call `Span::setExporters()` with no arguments to clear. +- Removed `Span::resetStorage()` and `Span::reset()`. `Span::setStorage()` now accepts `?Storage` — pass `null` to clear. +- `Exporter` interface gained a `sample(Span $span): bool` method. Exporters decide per-span whether `export()` is called; the per-registration sampler closure is gone. + +### Exporter behaviour + +- Built-in exporters now take an optional `sampler` closure as their first constructor argument by convention. +- `Stdout` and `Pretty` default to exporting every span. +- `Sentry` is hard-wired to error spans only. A user-supplied `sampler` is composed (AND) with the error filter, so it can further restrict but not broaden what is sent. +- `None` always returns `false` from `sample()`. + +### Other + +- `Span` now declares `strict_types=1`. +- `Span::finish()` accepts the triggering error directly: `finish(?string $level = null, ?Throwable $error = null)`. The level override is also passed through `finish()` rather than set beforehand. +- Added `Pretty` exporter for colourful, human-readable local development output. +- Added automatic `level` attribute on spans (`error` when an error is captured, `info` otherwise; overridable via `finish(level: ...)`). +- Sentry exporter: added `release`, `server_name`, SDK and runtime metadata; configurable attribute classifier (tag/context/extra); fixes for dropped HTTP attributes and empty extras. +- Dropped PHP 8.1 support. diff --git a/README.md b/README.md index 673cc58..15cbcd7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ use Utopia\Span\Exporter; // Bootstrap once at startup Span::setStorage(new Storage\Auto()); -Span::addExporter(new Exporter\Stdout()); +Span::setExporters(new Exporter\Stdout()); // Create a span $span = Span::init('http.request'); @@ -112,18 +112,21 @@ $span = Span::init('http.request', $request->getHeader('traceparent')); ### Sampling -Add a sampler to control which spans get exported: +Each exporter decides which spans it accepts via its `sample()` method. Most built-in exporters accept a `sampler` closure as their first constructor argument: ```php -Span::addExporter( - new Exporter\Sentry('https://key@sentry.io/123'), - sampler: fn(Span $s) => - $s->getError() !== null || // errors - $s->get('span.duration') > 5.0 || // slow requests (>5s) - $s->get('plan') === 'enterprise' // enterprise customers +Span::setExporters( + new Exporter\Stdout( + sampler: fn(Span $s) => + $s->getError() !== null || // errors + $s->get('span.duration') > 5.0 || // slow requests (>5s) + $s->get('plan') === 'enterprise' // enterprise customers + ), ); ``` +The Sentry exporter is hard-wired to error spans only; a custom sampler is composed with that filter and can further restrict — but not broaden — what is sent. + ## Storage Backends | Backend | Use Case | @@ -144,17 +147,17 @@ Span::addExporter( ### Stdout Exporter ```php -Span::addExporter(new Exporter\Stdout( +Span::setExporters(new Exporter\Stdout( maxTraceFrames: 3 // default, limits error stacktrace length )); ``` -Outputs JSON to stdout (info) or stderr (errors). +Outputs JSON to stdout (info) or stderr (errors). Exports every span by default; pass `sampler:` to filter. ### Pretty Exporter ```php -Span::addExporter(new Exporter\Pretty( +Span::setExporters(new Exporter\Pretty( maxTraceFrames: 3, // default, limits error stacktrace length width: 60 // default, separator line width )); @@ -175,13 +178,13 @@ http.request · 12.3ms · abc12345 ### Sentry Exporter ```php -Span::addExporter(new Exporter\Sentry( +Span::setExporters(new Exporter\Sentry( dsn: 'https://key@sentry.io/123', environment: 'production' // optional )); ``` -Only exports error spans with full stacktraces. Non-error spans are skipped. +Only exports error spans with full stacktraces. Non-error spans are skipped, even if you pass a custom `sampler`. ### Custom Exporter @@ -191,6 +194,11 @@ use Utopia\Span\Span; class MyExporter implements Exporter { + public function sample(Span $span): bool + { + return true; // export every span + } + public function export(Span $span): void { $data = $span->getAttributes(); @@ -206,13 +214,13 @@ Disable or capture spans in tests: ```php // Option 1: Discard all spans -Span::resetExporters(); -Span::addExporter(new Exporter\None()); +Span::setExporters(new Exporter\None()); // Option 2: Capture for assertions $spans = []; -Span::addExporter(new class($spans) implements Exporter { +Span::setExporters(new class($spans) implements Exporter { public function __construct(private array &$spans) {} + public function sample(Span $span): bool { return true; } public function export(Span $span): void { $this->spans[] = $span; } @@ -230,9 +238,8 @@ $this->assertEquals('http.request', $spans[0]->get('action')); | Method | Description | | ---------------------------------------------------- | ------------------------------------- | -| `setStorage(Storage $storage)` | Set the storage backend | -| `addExporter(Exporter $exporter, ?Closure $sampler)` | Add an exporter with optional sampler | -| `resetExporters()` | Remove all exporters | +| `setStorage(?Storage $storage)` | Set the storage backend (null clears) | +| `setExporters(Exporter ...$exporters)` | Replace all exporters | | `init(string $action, ?string $traceparent): Span` | Create and store a new span | | `current(): ?Span` | Get the current span | | `add(string $key, scalar $value)` | Set attribute on current span | diff --git a/src/Span/Exporter/Exporter.php b/src/Span/Exporter/Exporter.php index 7f387ef..0146cb2 100644 --- a/src/Span/Exporter/Exporter.php +++ b/src/Span/Exporter/Exporter.php @@ -16,10 +16,19 @@ interface Exporter /** * Export a finished span. * - * Called after Span::finish() for spans that pass the sampler. + * Called after Span::finish() for spans where {@see self::sample()} returns true. * Use $span->getAttributes() for metadata and $span->getError() for exceptions. * * @param Span $span The finished span to export */ public function export(Span $span): void; + + /** + * Decide whether a span should be exported. + * + * Return false to drop the span. Implementations that always export should return true. + * + * @param Span $span The finished span to consider + */ + public function sample(Span $span): bool; } diff --git a/src/Span/Exporter/None.php b/src/Span/Exporter/None.php index c849288..d643905 100644 --- a/src/Span/Exporter/None.php +++ b/src/Span/Exporter/None.php @@ -12,6 +12,11 @@ */ class None implements Exporter { + public function sample(Span $span): bool + { + return false; + } + public function export(Span $span): void { // Intentionally empty - discards all spans diff --git a/src/Span/Exporter/Pretty.php b/src/Span/Exporter/Pretty.php index ea19ea0..529dce9 100644 --- a/src/Span/Exporter/Pretty.php +++ b/src/Span/Exporter/Pretty.php @@ -2,6 +2,7 @@ namespace Utopia\Span\Exporter; +use Closure; use Utopia\Span\Span; /** @@ -22,14 +23,25 @@ private const CYAN = "\033[36m"; private const WHITE = "\033[37m"; + /** @var Closure(Span): bool */ + private Closure $sampler; + /** + * @param Closure(Span): bool|null $sampler Filter function. Defaults to exporting every span. * @param int $maxTraceFrames Maximum stacktrace frames to include for errors * @param int $width Line width for the separator */ public function __construct( + ?Closure $sampler = null, private int $maxTraceFrames = 3, private int $width = 60, ) { + $this->sampler = $sampler ?? static fn (Span $span): bool => true; + } + + public function sample(Span $span): bool + { + return ($this->sampler)($span); } public function export(Span $span): void diff --git a/src/Span/Exporter/Sentry.php b/src/Span/Exporter/Sentry.php index fd79f5c..6dd21c4 100644 --- a/src/Span/Exporter/Sentry.php +++ b/src/Span/Exporter/Sentry.php @@ -49,9 +49,16 @@ class Sentry implements Exporter /** @var Closure(string): SentryField */ private readonly Closure $classifier; + /** @var Closure(Span): bool */ + private readonly Closure $sampler; + /** * Create a new Sentry exporter. * + * Sentry only ever exports error spans; a custom sampler is composed (AND) with the + * built-in error filter, so it can further restrict — but not broaden — what is sent. + * + * @param Closure(Span): bool|null $sampler Optional additional filter, composed with the error-only filter. * @param string $dsn Sentry DSN (e.g., https://key@sentry.io/123) * @param string|null $environment Optional environment name (e.g., 'production') * @param string|null $release Optional release/version identifier (e.g., commit hash) @@ -59,29 +66,52 @@ class Sentry implements Exporter * @param Closure(string): SentryField|null $classifier Optional callback to classify attributes */ public function __construct( - private readonly string $dsn, + ?Closure $sampler = null, + private readonly string $dsn = '', private readonly ?string $environment = null, private readonly ?string $release = null, private readonly ?string $serverName = null, ?Closure $classifier = null, ) { $this->classifier = $classifier ?? static fn (string $key): SentryField => SentryField::Context; + $this->sampler = static function (Span $span) use ($sampler): bool { + if (!$span->getError() instanceof \Throwable) { + return false; + } + return !$sampler instanceof \Closure || $sampler($span); + }; + if ($dsn === '') { + throw new \InvalidArgumentException('Sentry DSN is required'); + } + $parsed = parse_url($dsn); if ($parsed === false) { throw new \InvalidArgumentException('Invalid Sentry DSN'); } - $this->publicKey = $parsed['user'] ?? ''; - $this->projectId = ltrim($parsed['path'] ?? '', '/'); + $publicKey = $parsed['user'] ?? ''; + $host = $parsed['host'] ?? ''; + $projectId = ltrim($parsed['path'] ?? '', '/'); + + if ($publicKey === '' || $host === '' || $projectId === '') { + throw new \InvalidArgumentException('Invalid Sentry DSN: must include public key, host, and project ID'); + } + + $this->publicKey = $publicKey; + $this->projectId = $projectId; $scheme = $parsed['scheme'] ?? 'https'; - $host = $parsed['host'] ?? ''; $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; $this->endpoint = "{$scheme}://{$host}{$port}/api/{$this->projectId}/envelope/"; } + public function sample(Span $span): bool + { + return ($this->sampler)($span); + } + public function export(Span $span): void { $envelope = $this->buildEnvelope($span); diff --git a/src/Span/Exporter/Stdout.php b/src/Span/Exporter/Stdout.php index f01c6f3..4aefa64 100644 --- a/src/Span/Exporter/Stdout.php +++ b/src/Span/Exporter/Stdout.php @@ -4,6 +4,7 @@ namespace Utopia\Span\Exporter; +use Closure; use Utopia\Span\Span; /** @@ -14,14 +15,25 @@ */ readonly class Stdout implements Exporter { + /** @var Closure(Span): bool */ + private Closure $sampler; + /** * Create a new Stdout exporter. * + * @param Closure(Span): bool|null $sampler Filter function. Defaults to exporting every span. * @param int $maxTraceFrames Maximum stacktrace frames to include for errors */ public function __construct( - private int $maxTraceFrames = 3 + ?Closure $sampler = null, + private int $maxTraceFrames = 3, ) { + $this->sampler = $sampler ?? static fn (Span $span): bool => true; + } + + public function sample(Span $span): bool + { + return ($this->sampler)($span); } public function export(Span $span): void diff --git a/src/Span/Span.php b/src/Span/Span.php index 6ad7f0b..f2aad56 100644 --- a/src/Span/Span.php +++ b/src/Span/Span.php @@ -4,7 +4,6 @@ namespace Utopia\Span; -use Closure; use Throwable; use Utopia\Span\Exporter\Exporter; use Utopia\Span\Storage\Storage; @@ -14,7 +13,7 @@ class Span private static ?Storage $storage = null; /** - * @var array + * @var array */ private static array $exporters = []; @@ -33,56 +32,28 @@ public function __construct(private readonly string $action = 'unknown') } /** - * Set the storage backend for span context. + * Set (or clear) the storage backend for span context. * - * Call once at application startup before creating spans. + * Call once at application startup before creating spans. Pass null to clear. * - * @param Storage $storage Use Storage\Auto for automatic detection + * @param Storage|null $storage Use Storage\Auto for automatic detection, or null to clear */ - public static function setStorage(Storage $storage): void + public static function setStorage(?Storage $storage): void { self::$storage = $storage; } /** - * Add an exporter with optional sampler. + * Replace all exporters. * - * Exporters receive finished spans. Use a sampler to filter which spans are exported. + * Exporters receive finished spans. Each exporter decides whether to export + * via its own {@see Exporter::sample()} method. * - * @param Exporter $exporter The exporter to add - * @param Closure|null $sampler Filter function: fn(Span $s): bool. Return true to export. + * @param Exporter ...$exporters Exporters to register, replacing any previously set */ - public static function addExporter(Exporter $exporter, ?Closure $sampler = null): void + public static function setExporters(Exporter ...$exporters): void { - self::$exporters[] = [ - 'exporter' => $exporter, - 'sampler' => $sampler, - ]; - } - - /** - * Remove all exporters - */ - public static function resetExporters(): void - { - self::$exporters = []; - } - - /** - * Reset storage - */ - public static function resetStorage(): void - { - self::$storage = null; - } - - /** - * Reset all static state - */ - public static function reset(): void - { - self::$storage = null; - self::$exporters = []; + self::$exporters = $exporters; } /** @@ -264,12 +235,9 @@ public function finish(?string $level = null, ?Throwable $error = null): void $this->attributes['level'] = $level ?? ($this->error instanceof \Throwable ? 'error' : 'info'); - foreach (self::$exporters as $config) { + foreach (self::$exporters as $exporter) { try { - $exporter = $config['exporter']; - $sampler = $config['sampler']; - - if ($sampler === null || $sampler($this)) { + if ($exporter->sample($this)) { $exporter->export($this); } } catch (\Throwable) { diff --git a/tests/Exporter/SentryTest.php b/tests/Exporter/SentryTest.php index 4ee1c3c..bbdcc0f 100644 --- a/tests/Exporter/SentryTest.php +++ b/tests/Exporter/SentryTest.php @@ -13,7 +13,7 @@ class SentryTest extends TestCase { public function testConstructorParsesDsn(): void { - $exporter = new Sentry('https://publickey@sentry.io/123456'); + $exporter = new Sentry(dsn: 'https://publickey@sentry.io/123456'); $this->assertInstanceOf(Sentry::class, $exporter); } @@ -23,26 +23,48 @@ public function testConstructorThrowsOnInvalidDsn(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid Sentry DSN'); - new Sentry('http:///invalid'); + new Sentry(dsn: 'http:///invalid'); + } + + public function testConstructorThrowsOnEmptyDsn(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Sentry DSN is required'); + + new Sentry(); + } + + public function testConstructorThrowsOnDsnMissingPublicKey(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Sentry(dsn: 'https://sentry.io/123'); + } + + public function testConstructorThrowsOnDsnMissingProjectId(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Sentry(dsn: 'https://key@sentry.io'); } public function testConstructorHandlesDsnWithPort(): void { - $exporter = new Sentry('https://publickey@sentry.example.com:9000/123'); + $exporter = new Sentry(dsn: 'https://publickey@sentry.example.com:9000/123'); $this->assertInstanceOf(Sentry::class, $exporter); } public function testConstructorHandlesHttpDsn(): void { - $exporter = new Sentry('http://publickey@localhost/123'); + $exporter = new Sentry(dsn: 'http://publickey@localhost/123'); $this->assertInstanceOf(Sentry::class, $exporter); } public function testExportDoesNotThrowWithValidSpan(): void { - $exporter = new Sentry('https://key@sentry.io/123'); + $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); $span = new Span(); $span->set('action', 'test'); $span->finish(); @@ -56,7 +78,7 @@ public function testExportDoesNotThrowWithValidSpan(): void public function testExportHandlesSpanWithParentId(): void { - $exporter = new Sentry('https://key@sentry.io/123'); + $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); $span = new Span(); $span->set('span.parent_id', 'abc123def456'); $span->finish(); @@ -69,7 +91,7 @@ public function testExportHandlesSpanWithParentId(): void public function testExportHandlesSpanWithError(): void { - $exporter = new Sentry('https://key@sentry.io/123'); + $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); $span = new Span(); $span->setError(new \RuntimeException('Test error')); $span->finish(); @@ -82,7 +104,7 @@ public function testExportHandlesSpanWithError(): void public function testExportHandlesSpanWithAllAttributes(): void { - $exporter = new Sentry('https://key@sentry.io/123'); + $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); $span = new Span(); $span->set('action', 'http.request'); $span->set('user.id', '123'); @@ -99,7 +121,7 @@ public function testExportHandlesSpanWithAllAttributes(): void public function testExportHandlesHttpConventionAttributes(): void { - $exporter = new Sentry('https://key@sentry.io/123'); + $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); $span = new Span('http.request'); $span->set('http.url', 'https://api.example.com/users'); $span->set('http.method', 'POST'); @@ -117,7 +139,7 @@ public function testExportHandlesHttpConventionAttributes(): void public function testExportWithClassifier(): void { $exporter = new Sentry( - 'https://key@sentry.io/123', + dsn: 'https://key@sentry.io/123', classifier: fn (string $key): SentryField => match (true) { str_starts_with($key, 'tenant.') => SentryField::Tag, str_starts_with($key, 'user.') => SentryField::Context, diff --git a/tests/SpanTest.php b/tests/SpanTest.php index 43baba2..9acc761 100644 --- a/tests/SpanTest.php +++ b/tests/SpanTest.php @@ -2,6 +2,7 @@ namespace Utopia\Span\Tests; +use Closure; use PHPUnit\Framework\TestCase; use RuntimeException; use Utopia\Span\Exporter\Exporter; @@ -12,7 +13,7 @@ class SpanTest extends TestCase { protected function setUp(): void { - Span::resetExporters(); + Span::setExporters(); Span::setStorage(new Memory()); } @@ -164,8 +165,7 @@ public function testFinishExportsToAllExporters(): void $exporter1 = $this->createExporter($exported1); $exporter2 = $this->createExporter($exported2); - Span::addExporter($exporter1); - Span::addExporter($exporter2); + Span::setExporters($exporter1, $exporter2); $span = Span::init('test'); $span->finish(); @@ -240,7 +240,7 @@ public function testFinishWithErrorExportsErrorSpan(): void $exporter = $this->createExporter($exported); $error = new RuntimeException('Test'); - Span::addExporter($exporter); + Span::setExporters($exporter); $span = Span::init('test'); $span->finish(error: $error); @@ -252,10 +252,12 @@ public function testFinishWithErrorExportsErrorSpan(): void public function testSamplerFiltersExport(): void { $exported = []; - $exporter = $this->createExporter($exported); + $exporter = $this->createExporter( + $exported, + fn (Span $s): bool => $s->getError() instanceof \Throwable, + ); - // Only export spans with errors - Span::addExporter($exporter, fn (Span $s): bool => $s->getError() instanceof \Throwable); + Span::setExporters($exporter); $span1 = Span::init('test'); $span1->finish(); @@ -270,13 +272,16 @@ public function testSamplerFiltersExport(): void public function testSamplerReceivesSpan(): void { $exported = []; - $exporter = $this->createExporter($exported); $sampledSpan = null; + $exporter = $this->createExporter( + $exported, + function (Span $s) use (&$sampledSpan): bool { + $sampledSpan = $s; + return true; + }, + ); - Span::addExporter($exporter, function (Span $s) use (&$sampledSpan): bool { - $sampledSpan = $s; - return true; - }); + Span::setExporters($exporter); $span = Span::init('test'); $span->finish(); @@ -284,61 +289,74 @@ public function testSamplerReceivesSpan(): void $this->assertSame($span, $sampledSpan); } - public function testResetExportersRemovesAllExporters(): void + public function testSetExportersReplacesExistingExporters(): void { - $exported = []; - $exporter = $this->createExporter($exported); + $firstExported = []; + $secondExported = []; + $first = $this->createExporter($firstExported); + $second = $this->createExporter($secondExported); - Span::addExporter($exporter); - Span::resetExporters(); + Span::setExporters($first); + Span::setExporters($second); $span = Span::init('test'); $span->finish(); - $this->assertCount(0, $exported); + $this->assertCount(0, $firstExported); + $this->assertCount(1, $secondExported); } - public function testFluentInterface(): void + public function testSetExportersWithMultipleExporters(): void { - $span = new Span(); + $exportedA = []; + $exportedB = []; + $a = $this->createExporter($exportedA); + $b = $this->createExporter($exportedB); - $result = $span - ->set('key1', 'value1') - ->set('key2', 'value2') - ->setError(new RuntimeException('Error')); + Span::setExporters($a, $b); - $this->assertSame($span, $result); + $span = Span::init('test'); + $span->finish(); + + $this->assertCount(1, $exportedA); + $this->assertCount(1, $exportedB); } - public function testResetClearsStorageAndExporters(): void + public function testSetExportersWithNoArgumentsClearsExporters(): void { $exported = []; $exporter = $this->createExporter($exported); - Span::addExporter($exporter); - $span = Span::init('test'); + Span::setExporters($exporter); + Span::setExporters(); - $this->assertSame($span, Span::current()); + $span = Span::init('test'); + $span->finish(); - Span::reset(); + $this->assertCount(0, $exported); + } - $this->assertNull(Span::current()); + public function testFluentInterface(): void + { + $span = new Span(); - $span2 = Span::init('test'); - $span2->finish(); + $result = $span + ->set('key1', 'value1') + ->set('key2', 'value2') + ->setError(new RuntimeException('Error')); - $this->assertCount(0, $exported); + $this->assertSame($span, $result); } - public function testResetStorageClearsOnlyStorage(): void + public function testSetStorageNullClearsStorage(): void { $exported = []; $exporter = $this->createExporter($exported); - Span::addExporter($exporter); + Span::setExporters($exporter); Span::init('test'); - Span::resetStorage(); + Span::setStorage(null); $this->assertNull(Span::current()); @@ -351,7 +369,7 @@ public function testResetStorageClearsOnlyStorage(): void public function testInitWithoutStorageReturnsSpan(): void { - Span::resetStorage(); + Span::setStorage(null); $span = Span::init('test'); @@ -360,7 +378,7 @@ public function testInitWithoutStorageReturnsSpan(): void public function testCurrentWithoutStorageReturnsNull(): void { - Span::resetStorage(); + Span::setStorage(null); $this->assertNull(Span::current()); } @@ -378,7 +396,7 @@ public function testMultipleSpansInSequence(): void { $exported = []; $exporter = $this->createExporter($exported); - Span::addExporter($exporter); + Span::setExporters($exporter); $span1 = Span::init('test'); $span1->set('name', 'first'); @@ -395,7 +413,7 @@ public function testMultipleSpansInSequence(): void public function testFinishWithoutExportersDoesNotThrow(): void { - Span::resetExporters(); + Span::setExporters(); $span = Span::init('test'); $span->finish(); @@ -462,26 +480,32 @@ public function testAddWithAllScalarTypes(): void $this->assertNull($span->get('null')); } - public function testMultipleSamplersAllMustPass(): void + public function testMultipleExportersWithIndependentSamplers(): void { - $exported = []; - $exporter = $this->createExporter($exported); + $exportedYes = []; + $exportedNo = []; - Span::addExporter($exporter, fn (Span $s): bool => true); - Span::addExporter($exporter, fn (Span $s): bool => false); + $yes = $this->createExporter($exportedYes, fn (Span $s): bool => true); + $no = $this->createExporter($exportedNo, fn (Span $s): bool => false); + + Span::setExporters($yes, $no); $span = Span::init('test'); $span->finish(); - $this->assertCount(1, $exported); + $this->assertCount(1, $exportedYes); + $this->assertCount(0, $exportedNo); } public function testSamplerCanFilterByDuration(): void { $exported = []; - $exporter = $this->createExporter($exported); + $exporter = $this->createExporter( + $exported, + fn (Span $s): bool => $s->get('span.duration') > 0.005, + ); - Span::addExporter($exporter, fn (Span $s): bool => $s->get('span.duration') > 0.005); + Span::setExporters($exporter); $fastSpan = Span::init('test'); $fastSpan->finish(); @@ -617,17 +641,26 @@ public function testLevelNotSetBeforeFinish(): void /** * @param array $exported + * @param Closure(Span): bool|null $sampler */ - private function createExporter(array &$exported): Exporter + private function createExporter(array &$exported, ?Closure $sampler = null): Exporter { - return new class ($exported) implements Exporter { + return new class ($exported, $sampler) implements Exporter { /** @var array */ private array $exported; + private readonly Closure $sampler; + /** @param array $exported */ - public function __construct(array &$exported) + public function __construct(array &$exported, ?Closure $sampler) { $this->exported = &$exported; + $this->sampler = $sampler ?? static fn (Span $span): bool => true; + } + + public function sample(Span $span): bool + { + return ($this->sampler)($span); } public function export(Span $span): void