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
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,12 @@ Span::addExporter(

## Exporters

| Exporter | Description |
| ----------------- | ------------------------------------ |
| `Exporter\Stdout` | JSON to stdout/stderr |
| `Exporter\Sentry` | Sentry events (Issues) |
| `Exporter\None` | Discard (for testing) |
| Exporter | Description |
| ------------------ | ------------------------------------ |
| `Exporter\Stdout` | JSON to stdout/stderr |
| `Exporter\Pretty` | Colourful human-readable output |
| `Exporter\Sentry` | Sentry events (Issues) |
| `Exporter\None` | Discard (for testing) |

### Stdout Exporter

Expand All @@ -132,6 +133,27 @@ Span::addExporter(new Exporter\Stdout(

Outputs JSON to stdout (info) or stderr (errors).

### Pretty Exporter

```php
Span::addExporter(new Exporter\Pretty(
maxTraceFrames: 3, // default, limits error stacktrace length
width: 60 // default, separator line width
));
```

Colourful, multi-line output for local development. Attributes are displayed with aligned values, duration is colour-coded (green < 100ms, yellow < 1s, red >= 1s), and errors are highlighted in red. Writes to stdout (info) or stderr (errors).

```
http.request · 12.3ms · abc12345

http.method GET
http.url /api/users
user.id 42

────────────────────────────────────────────────────────────
```

### Sentry Exporter

```php
Expand Down
167 changes: 167 additions & 0 deletions src/Span/Exporter/Pretty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

namespace Utopia\Span\Exporter;

use Utopia\Span\Span;

/**
* Exports spans as colourful, human-readable multi-line output.
*
* Designed for local development. Writes error spans to stderr
* and non-error spans to stdout.
*/
readonly class Pretty implements Exporter
{
private const RESET = "\033[0m";
private const BOLD = "\033[1m";
private const DIM = "\033[2m";

private const RED = "\033[31m";
private const GREEN = "\033[32m";
private const YELLOW = "\033[33m";
private const CYAN = "\033[36m";
private const WHITE = "\033[37m";

/**
* @param int $maxTraceFrames Maximum stacktrace frames to include for errors
* @param int $width Line width for the separator
*/
public function __construct(
private int $maxTraceFrames = 3,
private int $width = 60,
) {
}

public function export(Span $span): void
{
$error = $span->getError();
$hasError = $error instanceof \Throwable;
$stream = $hasError ? STDERR : STDOUT;

$lines = [];

$lines[] = $this->header($span, $hasError);
$lines[] = '';

$attributes = [];
foreach ($span->getAttributes() as $key => $value) {
if (!str_starts_with($key, 'span.')) {
$attributes[$key] = $value;
}
}

$maxKeyLen = 0;
foreach (array_keys($attributes) as $key) {
$maxKeyLen = max($maxKeyLen, strlen($key));
}

foreach ($attributes as $key => $value) {
$lines[] = $this->attribute($key, $value, $maxKeyLen);
}

if ($hasError) {
if (count($attributes) > 0) {
$lines[] = '';
}

$lines[] = $this->error($error);

$trace = $error->getTrace();
$limited = array_slice($trace, 0, $this->maxTraceFrames);

foreach ($limited as $frame) {
$file = $frame['file'] ?? 'unknown';
$line = $frame['line'] ?? '?';
$lines[] = self::DIM . " at {$file}:{$line}" . self::RESET;
}

$remaining = count($trace) - $this->maxTraceFrames;
if ($remaining > 0) {
$lines[] = self::DIM . " ... {$remaining} more" . self::RESET;
}
}

$lines[] = '';
$lines[] = self::DIM . str_repeat('─', $this->width) . self::RESET;

fwrite($stream, implode(PHP_EOL, $lines) . PHP_EOL);
}

private function header(Span $span, bool $hasError): string
{
$action = $span->getAction();
$duration = $span->get('span.duration');
$traceId = $span->get('span.trace_id');

$actionColor = $hasError ? self::RED : self::GREEN;
$actionStr = self::BOLD . $actionColor . $action . self::RESET;

$parts = [$actionStr];

if (is_float($duration)) {
$durationStr = $this->formatDuration($duration);
$durationColor = $this->durationColor($duration);
$parts[] = $durationColor . $durationStr . self::RESET;
}

if (is_string($traceId)) {
$short = substr($traceId, 0, 8);
$parts[] = self::DIM . $short . self::RESET;
}

return implode(self::DIM . ' · ' . self::RESET, $parts);
}

private function attribute(string $key, string|int|float|bool|null $value, int $padTo): string
{
$padded = str_pad($key, $padTo);
$keyStr = self::CYAN . $padded . self::RESET;
$valStr = self::WHITE . $this->formatValue($value) . self::RESET;

return " {$keyStr} {$valStr}";
}

private function error(\Throwable $error): string
{
$type = $error::class;
$message = $error->getMessage();
$file = $error->getFile();
$line = $error->getLine();

return self::RED . self::BOLD . " {$type}" . self::RESET
. self::RED . ": {$message}" . self::RESET . PHP_EOL
. self::DIM . " at {$file}:{$line}" . self::RESET;
}

private function formatValue(string|int|float|bool|null $value): string
{
return match (true) {
$value === null => 'null',
$value === true => 'true',
$value === false => 'false',
default => (string) $value,
};
}

private function formatDuration(float $seconds): string
{
if ($seconds >= 1.0) {
return round($seconds, 2) . 's';
}

return round($seconds * 1000, 1) . 'ms';
}

private function durationColor(float $seconds): string
{
if ($seconds >= 1.0) {
return self::RED;
}

if ($seconds >= 0.1) {
return self::YELLOW;
}

return self::GREEN;
}
}
72 changes: 72 additions & 0 deletions tests/Exporter/PrettyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace Utopia\Span\Tests\Exporter;

use PHPUnit\Framework\TestCase;
use Utopia\Span\Exporter\Pretty;
use Utopia\Span\Span;

class PrettyTest extends TestCase
{
public function testExportWritesOutput(): void
{
$exporter = new Pretty();
$span = new Span('test.action');
$span->finish();

$exporter->export($span);

$this->assertTrue(true);
}

public function testExportDoesNotThrow(): void
{
$exporter = new Pretty();
$span = new Span();

$exporter->export($span);

$this->assertTrue(true);
}

public function testExportHandlesAllAttributeTypes(): void
{
$exporter = new Pretty();
$span = new Span('test.types');
$span->set('string', 'value');
$span->set('int', 42);
$span->set('float', 3.14);
$span->set('bool', true);
$span->set('null', null);

$exporter->export($span);

$this->assertTrue(true);
}

public function testExportHandlesError(): void
{
$exporter = new Pretty();
$span = new Span('test.error');
$span->setError(new \RuntimeException('Test error', 42));

$exporter->export($span);

$this->assertTrue(true);
}

public function testExportIncludesSpanMetadata(): void
{
new Pretty();
$span = new Span('test.meta');
$span->finish();

$attributes = $span->getAttributes();

$this->assertArrayHasKey('span.trace_id', $attributes);
$this->assertArrayHasKey('span.id', $attributes);
$this->assertArrayHasKey('span.started_at', $attributes);
$this->assertArrayHasKey('span.finished_at', $attributes);
$this->assertArrayHasKey('span.duration', $attributes);
}
}