Skip to content
Merged
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 26 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 |
Expand All @@ -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
));
Expand All @@ -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

Expand All @@ -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();
Expand All @@ -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;
}
Expand All @@ -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 |
Expand Down
11 changes: 10 additions & 1 deletion src/Span/Exporter/Exporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions src/Span/Exporter/None.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/Span/Exporter/Pretty.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Utopia\Span\Exporter;

use Closure;
use Utopia\Span\Span;

/**
Expand All @@ -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
Expand Down
38 changes: 34 additions & 4 deletions src/Span/Exporter/Sentry.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,39 +49,69 @@ 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)
* @param string|null $serverName Optional server name/identifier
* @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);
Expand Down
14 changes: 13 additions & 1 deletion src/Span/Exporter/Stdout.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Utopia\Span\Exporter;

use Closure;
use Utopia\Span\Span;

/**
Expand All @@ -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
Expand Down
Loading
Loading