feat: improve stack trace rendering
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user