From dd7cfd97e68385dc32795bc196414b7c7857b495 Mon Sep 17 00:00:00 2001 From: Michael Schiemer Date: Tue, 25 Nov 2025 04:13:25 +0100 Subject: [PATCH] feat: improve stack trace rendering --- .../Renderers/ConsoleErrorRenderer.php | 25 +++- .../Renderers/ResponseErrorRenderer.php | 131 +++++++++++++++++- .../ValueObjects/StackItem.php | 62 ++++++++- .../ValueObjects/StackTrace.php | 33 +++++ 4 files changed, 234 insertions(+), 17 deletions(-) diff --git a/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php b/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php index d53224eb..aa2de668 100644 --- a/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php +++ b/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php @@ -55,18 +55,28 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer ConsoleColor::RED ); - if ($exception->getPrevious()) { + $previous = $exception->getPrevious(); + while ($previous !== null) { $this->output->writeErrorLine( - " Caused by: " . $exception->getPrevious()->getMessage(), + " Caused by: " . get_class($previous) . ": " . $previous->getMessage(), ConsoleColor::YELLOW ); + $previous = $previous->getPrevious(); } $this->output->writeErrorLine(" Stack trace:", ConsoleColor::GRAY); $stackTrace = StackTrace::fromThrowable($exception); - foreach ($stackTrace->getItems() as $index => $item) { - $formattedLine = $this->formatStackTraceLine($index, $item); - $this->output->writeErrorLine(" " . $formattedLine, ConsoleColor::GRAY); + $items = $stackTrace->getItems(); + $indexWidth = strlen((string) max(count($items) - 1, 0)); + + foreach ($items as $index => $item) { + $formattedLine = $this->formatStackTraceLine($index, $item, $indexWidth); + $color = $item->isVendorFrame() ? ConsoleColor::GRAY : ConsoleColor::WHITE; + if ($index === 0) { + $color = ConsoleColor::BRIGHT_WHITE; + } + + $this->output->writeErrorLine(" " . $formattedLine, $color); } // Context information if available @@ -110,9 +120,10 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer /** * Format stack trace line with clickable file links */ - private function formatStackTraceLine(int $index, \App\Framework\ExceptionHandling\ValueObjects\StackItem $item): string + private function formatStackTraceLine(int $index, \App\Framework\ExceptionHandling\ValueObjects\StackItem $item, int $indexWidth): string { - $baseFormat = sprintf('#%d %s', $index, $item->formatForDisplay()); + $indexLabel = str_pad((string) $index, $indexWidth, ' ', STR_PAD_LEFT); + $baseFormat = sprintf('#%s %s', $indexLabel, $item->formatForDisplay()); // If PhpStorm is detected, replace file:line with clickable link if (!$this->isPhpStorm()) { diff --git a/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php b/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php index 3b95c820..52f482de 100644 --- a/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php +++ b/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php @@ -69,6 +69,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer ?ExceptionContextProvider $contextProvider ): HttpResponse { $statusCode = $this->getHttpStatusCode($exception); + $stackTrace = StackTrace::fromThrowable($exception); // Get user-friendly message if translator is available $context = $contextProvider?->get($exception); @@ -98,7 +99,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer if ($this->isDebugMode) { $errorData['error']['file'] = $exception->getFile(); $errorData['error']['line'] = $exception->getLine(); - $errorData['error']['trace'] = StackTrace::fromThrowable($exception)->toArray(); + $errorData['error']['trace'] = $stackTrace->toArray(); + $errorData['error']['trace_short'] = $stackTrace->formatShort(); // Add context from WeakMap if available if ($contextProvider !== null) { @@ -236,6 +238,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer 'file' => $exception->getFile(), 'line' => $exception->getLine(), 'trace' => $stackTrace->formatForHtml(), + 'trace_short' => $stackTrace->formatShort(), + 'trace_frames' => $stackTrace->toArray(), ]; // Add context from WeakMap if available @@ -332,6 +336,65 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer font-weight: bold; color: #666; } + .stack-trace { + margin-top: 1rem; + } + .stack-trace__header { + display: flex; + align-items: center; + gap: 1rem; + } + .stack-trace__list { + margin-top: 0.5rem; + border: 1px solid #e1e3e6; + border-radius: 6px; + background: #fff; + } + .stack-frame { + border-bottom: 1px solid #f0f0f0; + padding: 0.5rem 0.75rem; + } + .stack-frame:last-child { + border-bottom: none; + } + .stack-frame__index { + display: inline-block; + min-width: 32px; + color: #666; + } + .stack-frame__call { + font-weight: 600; + } + .stack-frame__location { + color: #666; + margin-left: 0.5rem; + } + .stack-frame__code { + margin-top: 0.5rem; + } + .stack-frame[open] summary { + font-weight: 600; + } + .stack-frame summary { + list-style: none; + cursor: pointer; + } + .stack-frame summary::-webkit-details-marker { display:none; } + .stack-frame--vendor { + opacity: 0.8; + background: #fafbfc; + } + .btn-copy { + border: 1px solid #d0d4da; + background: #fff; + padding: 0.25rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + } + .btn-copy:hover { + background: #eef1f5; + } @@ -360,6 +423,9 @@ HTML; $line = $exception->getLine(); $stackTrace = StackTrace::fromThrowable($exception); $trace = $stackTrace->formatForHtml(); // Already HTML-encoded in formatForHtml + $traceShort = htmlspecialchars($stackTrace->formatShort(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $tracePlain = htmlspecialchars($stackTrace->formatForConsole(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $renderedFrames = $this->renderStackTraceList($stackTrace); $contextHtml = ''; if ($contextProvider !== null) { @@ -399,11 +465,41 @@ HTML;
File: {$file}:{$line}
+
+ Short trace: {$traceShort} +
{$contextHtml} {$codeSnippet} -

Stack Trace:

-
{$trace}
+
+
+

Stack Trace

+ +
+
+ {$renderedFrames} +
+
{$trace}
+ +
+ HTML; } @@ -512,4 +608,33 @@ HTML; return ''; } } + + private function renderStackTraceList(StackTrace $stackTrace): string + { + $frames = []; + foreach ($stackTrace->getItems() as $index => $item) { + $call = $item->getCall() !== '' ? $item->getCall() : '{main}'; + $callEscaped = htmlspecialchars($call, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $locationEscaped = htmlspecialchars($item->getShortFile() . ':' . $item->line, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $isVendor = $item->isVendorFrame(); + $expanded = !$isVendor && $index < 3; + $codeSnippet = ''; + + if (!$isVendor && $index < 3) { + $codeSnippet = $this->getCodeSnippet($item->file, $item->line); + } + + $frames[] = sprintf( + '
#%d %s %s%s
', + $isVendor ? ' stack-frame--vendor' : '', + $expanded ? ' open' : '', + $index, + $callEscaped, + $locationEscaped, + $codeSnippet !== '' ? '
' . $codeSnippet . '
' : '' + ); + } + + return implode("\n", $frames); + } } diff --git a/src/Framework/ExceptionHandling/ValueObjects/StackItem.php b/src/Framework/ExceptionHandling/ValueObjects/StackItem.php index d5b55737..495a4e4b 100644 --- a/src/Framework/ExceptionHandling/ValueObjects/StackItem.php +++ b/src/Framework/ExceptionHandling/ValueObjects/StackItem.php @@ -12,6 +12,10 @@ namespace App\Framework\ExceptionHandling\ValueObjects; */ final readonly class StackItem { + private const MAX_STRING_LENGTH = 80; + private const MAX_ARGS = 6; + private const MAX_PARAMS_LENGTH = 200; + public function __construct( public string $file, public int $line, @@ -166,7 +170,7 @@ final readonly class StackItem */ public function getShortFile(): string { - $projectRoot = dirname(__DIR__, 4); // Von src/Framework/ExceptionHandling/ValueObjects nach root + $projectRoot = self::projectRoot(); if (str_starts_with($this->file, $projectRoot)) { return substr($this->file, strlen($projectRoot) + 1); @@ -175,6 +179,17 @@ final readonly class StackItem return $this->file; } + /** + * True wenn Frame im vendor/ Verzeichnis liegt + */ + public function isVendorFrame(): bool + { + $projectRoot = self::projectRoot(); + $vendorPath = $projectRoot . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR; + + return str_starts_with($this->file, $vendorPath); + } + /** * Gibt vollständigen Method/Function Call zurück (ohne Namespace) */ @@ -200,15 +215,28 @@ final readonly class StackItem /** * Formatiert Parameter für Display (kompakte Darstellung) */ - public function formatParameters(): string + public function formatParameters(int $maxArgs = self::MAX_ARGS, int $maxTotalLength = self::MAX_PARAMS_LENGTH): string { if (empty($this->args)) { return ''; } $formatted = []; - foreach ($this->args as $arg) { - $formatted[] = $this->formatParameterForDisplay($arg); + $length = 0; + foreach ($this->args as $index => $arg) { + if ($index >= $maxArgs) { + $formatted[] = '…'; + break; + } + + $param = $this->formatParameterForDisplay($arg); + $length += strlen($param); + $formatted[] = $param; + + if ($length > $maxTotalLength) { + $formatted[] = '…'; + break; + } } return implode(', ', $formatted); @@ -266,8 +294,8 @@ final readonly class StackItem } // Lange Strings kürzen - if (strlen($value) > 50) { - return sprintf("'%s...'", substr($value, 0, 50)); + if (strlen($value) > self::MAX_STRING_LENGTH) { + return sprintf("'%s...'", substr($value, 0, self::MAX_STRING_LENGTH)); } return sprintf("'%s'", $value); @@ -349,6 +377,16 @@ final readonly class StackItem return end($parts); } + /** + * Kurzform für kompaktes Logging / JSON + */ + public function formatShort(): string + { + $call = $this->getCall() !== '' ? $this->getCall() : '{main}'; + + return sprintf('%s @ %s:%d', $call, $this->getShortFile(), $this->line); + } + /** * Formatiert für Display (HTML/Console) * Verwendet Standard PHP Stack Trace Format: ClassName->methodName($param1, $param2, ...) in file.php:line @@ -437,5 +475,15 @@ final readonly class StackItem default => $value, }; } -} + private static function projectRoot(): string + { + static $projectRoot = null; + if ($projectRoot !== null) { + return $projectRoot; + } + + $projectRoot = dirname(__DIR__, 4); + return $projectRoot; + } +} diff --git a/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php b/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php index 9b119055..b7227225 100644 --- a/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php +++ b/src/Framework/ExceptionHandling/ValueObjects/StackTrace.php @@ -39,6 +39,21 @@ final readonly class StackTrace implements IteratorAggregate, Countable return new self($items); } + /** + * Liefert nur App-Frames (keine vendor Frames) + */ + public function appFrames(): self + { + return new self( + array_values( + array_filter( + $this->items, + fn(StackItem $item) => !$item->isVendorFrame() + ) + ) + ); + } + /** * Begrenzt Anzahl der Frames */ @@ -82,6 +97,24 @@ final readonly class StackTrace implements IteratorAggregate, Countable return implode("\n", $lines); } + /** + * Kompakte, einzeilige Darstellung für Logs/JSON + */ + public function formatShort(int $maxFrames = 5): string + { + $frames = array_slice($this->items, 0, $maxFrames); + $formatted = array_map( + fn(StackItem $item) => $item->formatShort(), + $frames + ); + + $suffix = count($this->items) > $maxFrames + ? sprintf(' … +%d frames', count($this->items) - $maxFrames) + : ''; + + return implode(' | ', $formatted) . $suffix; + } + /** * Konvertiert zu Array für JSON-Serialisierung *