feat: improve stack trace rendering

This commit is contained in:
2025-11-25 04:13:25 +01:00
parent 57eabe30a5
commit dd7cfd97e6
4 changed files with 234 additions and 17 deletions

View File

@@ -55,18 +55,28 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
ConsoleColor::RED ConsoleColor::RED
); );
if ($exception->getPrevious()) { $previous = $exception->getPrevious();
while ($previous !== null) {
$this->output->writeErrorLine( $this->output->writeErrorLine(
" Caused by: " . $exception->getPrevious()->getMessage(), " Caused by: " . get_class($previous) . ": " . $previous->getMessage(),
ConsoleColor::YELLOW ConsoleColor::YELLOW
); );
$previous = $previous->getPrevious();
} }
$this->output->writeErrorLine(" Stack trace:", ConsoleColor::GRAY); $this->output->writeErrorLine(" Stack trace:", ConsoleColor::GRAY);
$stackTrace = StackTrace::fromThrowable($exception); $stackTrace = StackTrace::fromThrowable($exception);
foreach ($stackTrace->getItems() as $index => $item) { $items = $stackTrace->getItems();
$formattedLine = $this->formatStackTraceLine($index, $item); $indexWidth = strlen((string) max(count($items) - 1, 0));
$this->output->writeErrorLine(" " . $formattedLine, ConsoleColor::GRAY);
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 // Context information if available
@@ -110,9 +120,10 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer
/** /**
* Format stack trace line with clickable file links * 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 PhpStorm is detected, replace file:line with clickable link
if (!$this->isPhpStorm()) { if (!$this->isPhpStorm()) {

View File

@@ -69,6 +69,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
?ExceptionContextProvider $contextProvider ?ExceptionContextProvider $contextProvider
): HttpResponse { ): HttpResponse {
$statusCode = $this->getHttpStatusCode($exception); $statusCode = $this->getHttpStatusCode($exception);
$stackTrace = StackTrace::fromThrowable($exception);
// Get user-friendly message if translator is available // Get user-friendly message if translator is available
$context = $contextProvider?->get($exception); $context = $contextProvider?->get($exception);
@@ -98,7 +99,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
if ($this->isDebugMode) { if ($this->isDebugMode) {
$errorData['error']['file'] = $exception->getFile(); $errorData['error']['file'] = $exception->getFile();
$errorData['error']['line'] = $exception->getLine(); $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 // Add context from WeakMap if available
if ($contextProvider !== null) { if ($contextProvider !== null) {
@@ -236,6 +238,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
'file' => $exception->getFile(), 'file' => $exception->getFile(),
'line' => $exception->getLine(), 'line' => $exception->getLine(),
'trace' => $stackTrace->formatForHtml(), 'trace' => $stackTrace->formatForHtml(),
'trace_short' => $stackTrace->formatShort(),
'trace_frames' => $stackTrace->toArray(),
]; ];
// Add context from WeakMap if available // Add context from WeakMap if available
@@ -332,6 +336,65 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer
font-weight: bold; font-weight: bold;
color: #666; 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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -360,6 +423,9 @@ HTML;
$line = $exception->getLine(); $line = $exception->getLine();
$stackTrace = StackTrace::fromThrowable($exception); $stackTrace = StackTrace::fromThrowable($exception);
$trace = $stackTrace->formatForHtml(); // Already HTML-encoded in formatForHtml $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 = ''; $contextHtml = '';
if ($contextProvider !== null) { if ($contextProvider !== null) {
@@ -399,11 +465,41 @@ HTML;
<div class="context-item"> <div class="context-item">
<span class="context-label">File:</span> {$file}:{$line} <span class="context-label">File:</span> {$file}:{$line}
</div> </div>
<div class="context-item">
<span class="context-label">Short trace:</span> {$traceShort}
</div>
{$contextHtml} {$contextHtml}
{$codeSnippet} {$codeSnippet}
<h4>Stack Trace:</h4> <div class="stack-trace">
<pre>{$trace}</pre> <div class="stack-trace__header">
<h4 style="margin: 0;">Stack Trace</h4>
<button type="button" class="btn-copy" data-copy-target="full-trace-text">Copy stack</button>
</div> </div>
<div class="stack-trace__list">
{$renderedFrames}
</div>
<pre id="full-trace-text" class="stack-trace__raw" aria-label="Stack trace">{$trace}</pre>
<pre id="full-trace-plain" style="display:none">{$tracePlain}</pre>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const btn = document.querySelector('[data-copy-target="full-trace-text"]');
if (!btn) return;
btn.addEventListener('click', async function () {
const raw = document.getElementById('full-trace-plain');
if (!raw) return;
try {
await navigator.clipboard.writeText(raw.textContent || '');
btn.textContent = 'Copied';
setTimeout(() => btn.textContent = 'Copy stack', 1200);
} catch (e) {
btn.textContent = 'Copy failed';
setTimeout(() => btn.textContent = 'Copy stack', 1200);
}
});
});
</script>
HTML; HTML;
} }
@@ -512,4 +608,33 @@ HTML;
return ''; 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(
'<details class="stack-frame%s"%s><summary><span class="stack-frame__index">#%d</span> <span class="stack-frame__call">%s</span> <span class="stack-frame__location">%s</span></summary>%s</details>',
$isVendor ? ' stack-frame--vendor' : '',
$expanded ? ' open' : '',
$index,
$callEscaped,
$locationEscaped,
$codeSnippet !== '' ? '<div class="stack-frame__code">' . $codeSnippet . '</div>' : ''
);
}
return implode("\n", $frames);
}
} }

View File

@@ -12,6 +12,10 @@ namespace App\Framework\ExceptionHandling\ValueObjects;
*/ */
final readonly class StackItem 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 function __construct(
public string $file, public string $file,
public int $line, public int $line,
@@ -166,7 +170,7 @@ final readonly class StackItem
*/ */
public function getShortFile(): string 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)) { if (str_starts_with($this->file, $projectRoot)) {
return substr($this->file, strlen($projectRoot) + 1); return substr($this->file, strlen($projectRoot) + 1);
@@ -175,6 +179,17 @@ final readonly class StackItem
return $this->file; 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) * 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) * 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)) { if (empty($this->args)) {
return ''; return '';
} }
$formatted = []; $formatted = [];
foreach ($this->args as $arg) { $length = 0;
$formatted[] = $this->formatParameterForDisplay($arg); 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); return implode(', ', $formatted);
@@ -266,8 +294,8 @@ final readonly class StackItem
} }
// Lange Strings kürzen // Lange Strings kürzen
if (strlen($value) > 50) { if (strlen($value) > self::MAX_STRING_LENGTH) {
return sprintf("'%s...'", substr($value, 0, 50)); return sprintf("'%s...'", substr($value, 0, self::MAX_STRING_LENGTH));
} }
return sprintf("'%s'", $value); return sprintf("'%s'", $value);
@@ -349,6 +377,16 @@ final readonly class StackItem
return end($parts); 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) * Formatiert für Display (HTML/Console)
* Verwendet Standard PHP Stack Trace Format: ClassName->methodName($param1, $param2, ...) in file.php:line * Verwendet Standard PHP Stack Trace Format: ClassName->methodName($param1, $param2, ...) in file.php:line
@@ -437,5 +475,15 @@ final readonly class StackItem
default => $value, default => $value,
}; };
} }
private static function projectRoot(): string
{
static $projectRoot = null;
if ($projectRoot !== null) {
return $projectRoot;
} }
$projectRoot = dirname(__DIR__, 4);
return $projectRoot;
}
}

View File

@@ -39,6 +39,21 @@ final readonly class StackTrace implements IteratorAggregate, Countable
return new self($items); 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 * Begrenzt Anzahl der Frames
*/ */
@@ -82,6 +97,24 @@ final readonly class StackTrace implements IteratorAggregate, Countable
return implode("\n", $lines); 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 * Konvertiert zu Array für JSON-Serialisierung
* *