From 7531a2d61fb3fd9c19b03717e16b1f4985609eb9 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 13 May 2026 11:27:02 +0100 Subject: [PATCH 1/4] Refactor exporter API: drop addExporter/reset helpers - Replace Span::addExporter() with variadic Span::setExporters() - Move sampling onto the Exporter interface (sample(): bool) - Built-in exporters take an optional sampler closure as first ctor arg - Sentry composes user sampler with its error-only filter - Span::setStorage() now accepts ?Storage (null clears); drop resetStorage/reset Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 26 +++++++ README.md | 43 ++++++----- src/Span/Exporter/Exporter.php | 11 ++- src/Span/Exporter/None.php | 5 ++ src/Span/Exporter/Pretty.php | 12 +++ src/Span/Exporter/Sentry.php | 21 ++++- src/Span/Exporter/Stdout.php | 14 +++- src/Span/Span.php | 58 ++++---------- tests/Exporter/SentryTest.php | 20 ++--- tests/SpanTest.php | 137 ++++++++++++++++++++------------- 10 files changed, 219 insertions(+), 128 deletions(-) create mode 100644 CHANGELOG.md 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..36fe1a6 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; } @@ -231,8 +239,7 @@ $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 | +| `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..f93c149 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,13 +66,20 @@ 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 === null || $sampler($span); + }; $parsed = parse_url($dsn); if ($parsed === false) { @@ -82,6 +96,11 @@ public function __construct( $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..20ff753 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,26 @@ public function testConstructorThrowsOnInvalidDsn(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid Sentry DSN'); - new Sentry('http:///invalid'); + new Sentry(dsn: 'http:///invalid'); } 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 +56,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 +69,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 +82,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 +99,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 +117,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..44ad002 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 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 From 107ab50e0a3c858bbb46106c0061c35ccc970025 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 13 May 2026 11:46:22 +0100 Subject: [PATCH 2/4] Fix setStorage signature in README API table Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 36fe1a6..15cbcd7 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ $this->assertEquals('http.request', $spans[0]->get('action')); | Method | Description | | ---------------------------------------------------- | ------------------------------------- | -| `setStorage(Storage $storage)` | Set the storage backend | +| `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 | From cea1fda2f64f4bd388e35bd9ab1828dee2233b48 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 13 May 2026 11:48:07 +0100 Subject: [PATCH 3/4] Validate Sentry DSN components on construction parse_url('') returns ['path' => ''] rather than false, so an empty or incomplete DSN previously slipped through and only failed later as an opaque cURL error. Reject empty DSNs and DSNs missing the public key, host, or project ID at construction time. Co-Authored-By: Claude Opus 4.7 --- src/Span/Exporter/Sentry.php | 17 ++++++++++++++--- tests/Exporter/SentryTest.php | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/Span/Exporter/Sentry.php b/src/Span/Exporter/Sentry.php index f93c149..f8b3ce7 100644 --- a/src/Span/Exporter/Sentry.php +++ b/src/Span/Exporter/Sentry.php @@ -80,17 +80,28 @@ public function __construct( } return $sampler === null || $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/"; diff --git a/tests/Exporter/SentryTest.php b/tests/Exporter/SentryTest.php index 20ff753..bbdcc0f 100644 --- a/tests/Exporter/SentryTest.php +++ b/tests/Exporter/SentryTest.php @@ -26,6 +26,28 @@ public function testConstructorThrowsOnInvalidDsn(): void 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(dsn: 'https://publickey@sentry.example.com:9000/123'); From 9a8e403c61714527861b739aae0bc5123076fda5 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 13 May 2026 11:51:19 +0100 Subject: [PATCH 4/4] Apply rector suggestions Co-Authored-By: Claude Opus 4.7 --- src/Span/Exporter/Sentry.php | 2 +- tests/SpanTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Span/Exporter/Sentry.php b/src/Span/Exporter/Sentry.php index f8b3ce7..6dd21c4 100644 --- a/src/Span/Exporter/Sentry.php +++ b/src/Span/Exporter/Sentry.php @@ -78,7 +78,7 @@ public function __construct( if (!$span->getError() instanceof \Throwable) { return false; } - return $sampler === null || $sampler($span); + return !$sampler instanceof \Closure || $sampler($span); }; if ($dsn === '') { throw new \InvalidArgumentException('Sentry DSN is required'); diff --git a/tests/SpanTest.php b/tests/SpanTest.php index 44ad002..9acc761 100644 --- a/tests/SpanTest.php +++ b/tests/SpanTest.php @@ -649,7 +649,7 @@ private function createExporter(array &$exported, ?Closure $sampler = null): Exp /** @var array */ private array $exported; - private Closure $sampler; + private readonly Closure $sampler; /** @param array $exported */ public function __construct(array &$exported, ?Closure $sampler)