Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 55 additions & 6 deletions src/Span/Span.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array-key, mixed> $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);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

$span->complete($level, $error);
self::exportSpan($span);
}

/**
* Set an attribute on this span.
*
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
}
110 changes: 110 additions & 0 deletions tests/SpanTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading