diff --git a/README.md b/README.md index 673cc58..9c68793 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,33 @@ Span::add('warning', 'retry scheduled'); Span::add('warning.message', $message); ``` +### Immediate Publishing + +Use `publish()` to send attributes and an optional error to configured exporters without creating or storing a current span: + +```php +Span::publish([ + 'event' => 'job.queued', + 'queue.name' => 'emails', + 'message.id' => $id, +], action: 'job.queued'); +``` + +Published events include the same built-in metadata as finished spans and pass through the same exporter samplers. Storage is not required and the current span is not changed. + +```php +try { + // ... +} catch (Throwable $e) { + Span::publish([ + 'job.name' => 'sync-user', + 'user.id' => $userId, + ], error: $e, action: 'job.failed'); + + throw $e; +} +``` + ### Distributed Tracing Propagate trace context across services using W3C Trace Context headers: @@ -236,6 +263,7 @@ $this->assertEquals('http.request', $spans[0]->get('action')); | `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 | +| `publish(array $attributes = [], ?Throwable $error = null, string $action = 'publish', ?string $level = null): void` | Export an immediate event without storage | | `traceparent(): ?string` | Get traceparent header from current span | ### Span (instance) diff --git a/src/Span/Span.php b/src/Span/Span.php index 6ad7f0b..071e512 100644 --- a/src/Span/Span.php +++ b/src/Span/Span.php @@ -157,6 +157,46 @@ public static function traceparent(): ?string return self::current()?->getTraceparent(); } + /** + * Immediately publish attributes and an optional error to configured exporters. + * + * This does not use or mutate storage, so it can be used without an active span. + * + * @param array $attributes Attributes to publish + * @param Throwable|null $error Exception to publish + * @param string $action What this published event represents + * @param string|null $level Level to export for this event + */ + public static function publish( + array $attributes = [], + ?Throwable $error = null, + string $action = 'publish', + ?string $level = null + ): void { + $span = new self($action); + + foreach ($attributes as $key => $value) { + if (!\is_string($key)) { + continue; + } + + if ( + !\is_string($value) + && !\is_int($value) + && !\is_float($value) + && !\is_bool($value) + && $value !== null + ) { + continue; + } + + $span->set($key, $value); + } + + $span->complete($level, $error); + self::exportSpan($span); + } + /** * Set an attribute on this span. * @@ -250,6 +290,16 @@ public function getAttributes(): array * @param Throwable|null $error Exception that caused the span to fail */ public function finish(?string $level = null, ?Throwable $error = null): void + { + $this->complete($level, $error); + self::exportSpan($this); + + if (self::$storage instanceof \Utopia\Span\Storage\Storage) { + self::$storage->set(null); + } + } + + private function complete(?string $level = null, ?Throwable $error = null): void { if ($error instanceof \Throwable) { $this->setError($error); @@ -263,22 +313,21 @@ public function finish(?string $level = null, ?Throwable $error = null): void $this->attributes['span.duration'] = $finishedAt - $startedAt; $this->attributes['level'] = $level ?? ($this->error instanceof \Throwable ? 'error' : 'info'); + } + private static function exportSpan(self $span): void + { foreach (self::$exporters as $config) { try { $exporter = $config['exporter']; $sampler = $config['sampler']; - if ($sampler === null || $sampler($this)) { - $exporter->export($this); + if ($sampler === null || $sampler($span)) { + $exporter->export($span); } } catch (\Throwable) { // Tracing should never break the application } } - - if (self::$storage instanceof \Utopia\Span\Storage\Storage) { - self::$storage->set(null); - } } } diff --git a/tests/SpanTest.php b/tests/SpanTest.php index 43baba2..918dc4e 100644 --- a/tests/SpanTest.php +++ b/tests/SpanTest.php @@ -403,6 +403,116 @@ public function testFinishWithoutExportersDoesNotThrow(): void $this->assertNull(Span::current()); } + public function testPublishExportsToAllExporters(): void + { + $exported1 = []; + $exported2 = []; + + Span::addExporter($this->createExporter($exported1)); + Span::addExporter($this->createExporter($exported2)); + + Span::publish(['key' => 'value'], action: 'event'); + + $this->assertCount(1, $exported1); + $this->assertCount(1, $exported2); + $this->assertSame('event', $exported1[0]->getAction()); + $this->assertSame('value', $exported1[0]->get('key')); + } + + public function testPublishDoesNotRequireStorage(): void + { + Span::resetStorage(); + $exported = []; + Span::addExporter($this->createExporter($exported)); + + Span::publish(['key' => 'value']); + + $this->assertCount(1, $exported); + $this->assertNull(Span::current()); + } + + public function testPublishDoesNotClearCurrentSpan(): void + { + $exported = []; + Span::addExporter($this->createExporter($exported)); + $current = Span::init('request'); + + Span::publish(['event' => 'queued'], action: 'job.queued'); + + $this->assertSame($current, Span::current()); + $this->assertCount(1, $exported); + $this->assertSame('job.queued', $exported[0]->getAction()); + } + + public function testPublishAcceptsError(): void + { + $exported = []; + $error = new RuntimeException('Test'); + Span::addExporter($this->createExporter($exported)); + + Span::publish(error: $error); + + $this->assertCount(1, $exported); + $this->assertSame($error, $exported[0]->getError()); + $this->assertSame('error', $exported[0]->get('level')); + } + + public function testPublishSetsLevelInfoByDefault(): void + { + $exported = []; + Span::addExporter($this->createExporter($exported)); + + Span::publish(); + + $this->assertSame('info', $exported[0]->get('level')); + } + + public function testPublishAcceptsLevelOverride(): void + { + $exported = []; + Span::addExporter($this->createExporter($exported)); + + Span::publish(level: 'warning'); + + $this->assertSame('warning', $exported[0]->get('level')); + } + + public function testPublishRespectsSampler(): void + { + $exported = []; + Span::addExporter($this->createExporter($exported), fn (Span $s): bool => $s->get('publish') === true); + + Span::publish(['publish' => false]); + Span::publish(['publish' => true]); + + $this->assertCount(1, $exported); + $this->assertTrue($exported[0]->get('publish')); + } + + public function testPublishIgnoresNonStringAttributeKeys(): void + { + $exported = []; + Span::addExporter($this->createExporter($exported)); + + Span::publish([0 => 'value', 'key' => 'kept']); + + $this->assertCount(1, $exported); + $this->assertNull($exported[0]->get('0')); + $this->assertSame('kept', $exported[0]->get('key')); + } + + public function testPublishIgnoresNonScalarAttributeValues(): void + { + $exported = []; + Span::addExporter($this->createExporter($exported)); + + Span::publish(['key' => [], 'valid' => true]); + + $this->assertCount(1, $exported); + $this->assertNull($exported[0]->get('key')); + $this->assertTrue($exported[0]->get('valid')); + } + public function testTraceIdIsUniqueBetweenSpans(): void { $span1 = new Span();