Compare commits

..

2 Commits

Author SHA1 Message Date
26f87060d5 fix(deploy): add build parameter to ensure Docker images are rebuilt
All checks were successful
Test Runner / test-php (push) Successful in 41s
Deploy Application / deploy (push) Successful in 2m58s
Test Runner / test-basic (push) Successful in 7s
The deployment was only pulling code via git but not rebuilding the
Docker images, causing containers to run with stale code from the
registry image. This fixes the debug error pages still showing on
staging despite APP_DEBUG=false.
2025-11-25 04:23:38 +01:00
dd7cfd97e6 feat: improve stack trace rendering 2025-11-25 04:13:25 +01:00
5 changed files with 236 additions and 19 deletions

View File

@@ -55,8 +55,8 @@ jobs:
git fetch origin ${{ github.ref_name }} git fetch origin ${{ github.ref_name }}
git reset --hard origin/${{ github.ref_name }} git reset --hard origin/${{ github.ref_name }}
# Run deployment script # Run deployment script with image build
./deployment/scripts/deploy.sh ${{ steps.env.outputs.environment }} ./deployment/scripts/deploy.sh ${{ steps.env.outputs.environment }} build
EOF EOF
rm -f /tmp/ssh_key rm -f /tmp/ssh_key

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 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);
}
} }

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
* *