From 4c387d704394b6f50b419620b482ea8f73223f69 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:55:15 +0000 Subject: [PATCH 1/2] feat: add Pretty exporter for human-readable coloured output Co-Authored-By: Claude Opus 4.6 --- README.md | 32 ++++++- src/Span/Exporter/Pretty.php | 170 ++++++++++++++++++++++++++++++++++ tests/Exporter/PrettyTest.php | 72 ++++++++++++++ 3 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 src/Span/Exporter/Pretty.php create mode 100644 tests/Exporter/PrettyTest.php diff --git a/README.md b/README.md index 12e59a0..48e7052 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/Span/Exporter/Pretty.php b/src/Span/Exporter/Pretty.php new file mode 100644 index 0000000..db50414 --- /dev/null +++ b/src/Span/Exporter/Pretty.php @@ -0,0 +1,170 @@ +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 ($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; + } +} diff --git a/tests/Exporter/PrettyTest.php b/tests/Exporter/PrettyTest.php new file mode 100644 index 0000000..5159d22 --- /dev/null +++ b/tests/Exporter/PrettyTest.php @@ -0,0 +1,72 @@ +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); + } +} From 0ee05afa8745ec973a4c0b4fb5aa371d37262d09 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:57:09 +0000 Subject: [PATCH 2/2] fix: remove unused constants and use array_keys Co-Authored-By: Claude Opus 4.6 --- src/Span/Exporter/Pretty.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Span/Exporter/Pretty.php b/src/Span/Exporter/Pretty.php index db50414..ea19ea0 100644 --- a/src/Span/Exporter/Pretty.php +++ b/src/Span/Exporter/Pretty.php @@ -12,8 +12,6 @@ */ readonly class Pretty implements Exporter { - private const ESC = "\033["; - private const RESET = "\033[0m"; private const BOLD = "\033[1m"; private const DIM = "\033[2m"; @@ -21,7 +19,6 @@ private const RED = "\033[31m"; private const GREEN = "\033[32m"; private const YELLOW = "\033[33m"; - private const BLUE = "\033[34m"; private const CYAN = "\033[36m"; private const WHITE = "\033[37m"; @@ -54,7 +51,7 @@ public function export(Span $span): void } $maxKeyLen = 0; - foreach ($attributes as $key => $_) { + foreach (array_keys($attributes) as $key) { $maxKeyLen = max($maxKeyLen, strlen($key)); }