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

@@ -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;
}
</style>
</head>
<body>
@@ -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;
<div class="context-item">
<span class="context-label">File:</span> {$file}:{$line}
</div>
<div class="context-item">
<span class="context-label">Short trace:</span> {$traceShort}
</div>
{$contextHtml}
{$codeSnippet}
<h4>Stack Trace:</h4>
<pre>{$trace}</pre>
<div class="stack-trace">
<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 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;
}
@@ -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(
'<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);
}
}