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