feat: add developer error page with request ID tracking and structured logging
Custom-built error handler for the web channel that shows a tabbed debug dashboard (exception, request context, environment, SQL queries) in dev mode and a clean branded error page with copyable request ID in prod mode. All errors are logged as JSON lines to storage/logs/errors/ for lookup by request ID. Uses the project's design tokens for visual consistency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
432
web/css/pages/app-error-debug.css
Normal file
432
web/css/pages/app-error-debug.css
Normal file
@@ -0,0 +1,432 @@
|
||||
/*
|
||||
* Error Debug Page
|
||||
*
|
||||
* Self-contained: inlined by ErrorRenderer so it works even when the app's
|
||||
* stylesheet fails to load. Uses the project's token names with hardcoded
|
||||
* fallbacks so the page looks native but remains independent.
|
||||
*
|
||||
* Excluded from TypographyTokenContractTest (self-contained error page).
|
||||
*/
|
||||
|
||||
/* ── Fallback tokens (only apply when the app's CSS is NOT loaded) ────── */
|
||||
:root {
|
||||
/* typography — mirrors typography.tokens.css */
|
||||
--text-2xs: 0.6875rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.875rem;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.125rem;
|
||||
--text-3xl: clamp(1.375rem, 1.2rem + 0.75vw, 1.75rem);
|
||||
--text-5xl: clamp(2rem, 1.7rem + 1.5vw, 2.75rem);
|
||||
--leading-none: 1;
|
||||
--leading-tight: 1.2;
|
||||
--leading-normal: 1.5;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
--tracking-wider: 0.05em;
|
||||
|
||||
/* layout — mirrors variables.base.css */
|
||||
--app-font-family: system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
Helvetica, Arial, sans-serif;
|
||||
--app-font-family-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo,
|
||||
Consolas, "Liberation Mono", monospace;
|
||||
--app-border-radius: 0.5rem;
|
||||
--app-spacing: 1rem;
|
||||
--app-transition: 0.2s ease-in-out;
|
||||
|
||||
/* light theme fallbacks — mirrors [data-theme="light"] */
|
||||
--app-background-color: #fff;
|
||||
--app-color: #373c44;
|
||||
--app-muted-color: #646b79;
|
||||
--app-muted-border-color: rgb(231, 234, 239.5);
|
||||
--app-card-background-color: #fff;
|
||||
--app-card-border-color: rgb(231, 234, 239.5);
|
||||
--app-card-sectioning-background-color: rgb(251, 251.5, 252.25);
|
||||
--app-form-element-background-color: rgb(251, 251.5, 252.25);
|
||||
--app-form-element-border-color: #cfd5e2;
|
||||
--app-border: var(--app-form-element-border-color);
|
||||
--app-primary: hsl(180.75 37.38% 58.04%);
|
||||
--badge-danger-bg: #fdecec;
|
||||
--badge-danger-color: #8f2c2c;
|
||||
}
|
||||
|
||||
/* dark mode fallback (prefers-color-scheme or data-theme) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--app-background-color: rgb(19, 22.5, 30.5);
|
||||
--app-color: #c2c7d0;
|
||||
--app-muted-color: #7b8495;
|
||||
--app-muted-border-color: #202632;
|
||||
--app-card-background-color: #181c25;
|
||||
--app-card-border-color: #181c25;
|
||||
--app-card-sectioning-background-color: rgb(28, 33, 43.5);
|
||||
--app-form-element-background-color: rgb(28, 33, 43.5);
|
||||
--app-form-element-border-color: #2a3140;
|
||||
--app-border: var(--app-form-element-border-color);
|
||||
--badge-danger-bg: #3a1b1b;
|
||||
--badge-danger-color: #f2a3a3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reset (scoped to error debug page) ──────────────────────────────── */
|
||||
|
||||
body.app-error-debug {
|
||||
margin: 0;
|
||||
font-family: var(--app-font-family);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--app-color);
|
||||
background: var(--app-background-color);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-error-debug *,
|
||||
.app-error-debug *::before,
|
||||
.app-error-debug *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Container ───────────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-container {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--app-spacing);
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-header {
|
||||
background: var(--app-card-background-color);
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
padding: calc(var(--app-spacing) * 1.25) 0;
|
||||
}
|
||||
|
||||
.app-error-debug-header__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--app-spacing);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-error-debug-header__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
}
|
||||
|
||||
.app-error-debug-header__code {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-bold);
|
||||
line-height: var(--leading-none);
|
||||
color: var(--badge-danger-color);
|
||||
}
|
||||
|
||||
.app-error-debug-header__label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.app-error-debug-request-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--app-muted-color);
|
||||
background: var(--app-form-element-background-color);
|
||||
padding: calc(var(--app-spacing) * 0.375) calc(var(--app-spacing) * 0.75);
|
||||
border-radius: var(--app-border-radius);
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-error-debug-copy-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--app-muted-color);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color var(--app-transition);
|
||||
}
|
||||
|
||||
.app-error-debug-copy-button:hover { color: var(--app-color); }
|
||||
.app-error-debug-copy-button.copied { color: var(--app-primary); }
|
||||
|
||||
/* ── Exception Summary ───────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-summary {
|
||||
padding: calc(var(--app-spacing) * 1.5) 0;
|
||||
}
|
||||
|
||||
.app-error-debug-summary__class {
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-primary);
|
||||
margin-bottom: calc(var(--app-spacing) * 0.375);
|
||||
}
|
||||
|
||||
.app-error-debug-summary__message {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--app-color);
|
||||
word-break: break-word;
|
||||
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||
}
|
||||
|
||||
.app-error-debug-summary__location {
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
/* ── Tabs ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
margin-bottom: calc(var(--app-spacing) * 1.5);
|
||||
}
|
||||
|
||||
.app-error-debug-tab {
|
||||
padding: calc(var(--app-spacing) * 0.625) var(--app-spacing);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--app-muted-color);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color var(--app-transition), border-color var(--app-transition);
|
||||
}
|
||||
|
||||
.app-error-debug-tab:hover { color: var(--app-color); }
|
||||
|
||||
.app-error-debug-tab.active {
|
||||
color: var(--app-primary);
|
||||
border-bottom-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.app-error-debug-tab__count {
|
||||
display: inline-block;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
line-height: var(--leading-tight);
|
||||
text-align: center;
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: var(--font-semibold);
|
||||
background: var(--app-muted-border-color);
|
||||
color: var(--app-muted-color);
|
||||
border-radius: 9999px;
|
||||
margin-left: calc(var(--app-spacing) * 0.375);
|
||||
padding: 0 calc(var(--app-spacing) * 0.375);
|
||||
}
|
||||
|
||||
.app-error-debug-panel { display: none; padding-bottom: calc(var(--app-spacing) * 2); }
|
||||
.app-error-debug-panel.active { display: block; }
|
||||
|
||||
/* ── Stack Trace ──────────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-trace { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.app-error-debug-frame {
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-border-radius);
|
||||
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-error-debug-frame__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: calc(var(--app-spacing) * 0.5);
|
||||
padding: calc(var(--app-spacing) * 0.625) calc(var(--app-spacing) * 0.875);
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
background: var(--app-card-background-color);
|
||||
transition: background var(--app-transition);
|
||||
}
|
||||
|
||||
.app-error-debug-frame__header:hover {
|
||||
background: var(--app-card-sectioning-background-color);
|
||||
}
|
||||
|
||||
.app-error-debug-frame__index {
|
||||
color: var(--app-muted-color);
|
||||
font-size: var(--text-xs);
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.app-error-debug-frame__file { color: var(--app-color); }
|
||||
.app-error-debug-frame__line { color: var(--badge-danger-color); }
|
||||
|
||||
.app-error-debug-frame__function {
|
||||
color: var(--app-muted-color);
|
||||
font-size: var(--text-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.app-error-debug-frame__code { display: none; overflow-x: auto; }
|
||||
.app-error-debug-frame.open .app-error-debug-frame__code { display: block; }
|
||||
|
||||
/* ── Code Table ───────────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-code-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
.app-error-debug-code-table td {
|
||||
padding: 0 calc(var(--app-spacing) * 0.875);
|
||||
white-space: pre;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.app-error-debug-code-table__line-no {
|
||||
width: 3.5rem;
|
||||
text-align: right;
|
||||
color: var(--app-muted-color);
|
||||
user-select: none;
|
||||
padding-right: var(--app-spacing);
|
||||
border-right: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-error-debug-code-table__code {
|
||||
padding-left: var(--app-spacing);
|
||||
}
|
||||
|
||||
.app-error-debug-code-table tr.highlight {
|
||||
background: var(--badge-danger-bg);
|
||||
}
|
||||
|
||||
.app-error-debug-code-table tr.highlight .app-error-debug-code-table__line-no {
|
||||
color: var(--badge-danger-color);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
/* ── Info Tables ──────────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-section {
|
||||
margin-bottom: calc(var(--app-spacing) * 1.5);
|
||||
}
|
||||
|
||||
.app-error-debug-section__title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
color: var(--app-muted-color);
|
||||
margin-bottom: calc(var(--app-spacing) * 0.625);
|
||||
}
|
||||
|
||||
.app-error-debug-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
background: var(--app-card-background-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-error-debug-info-table tr + tr {
|
||||
border-top: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-error-debug-info-table th {
|
||||
text-align: left;
|
||||
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.875);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--app-muted-color);
|
||||
width: 12rem;
|
||||
vertical-align: top;
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--app-card-sectioning-background-color);
|
||||
}
|
||||
|
||||
.app-error-debug-info-table td {
|
||||
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.875);
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--app-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.app-error-debug-redacted {
|
||||
color: var(--app-muted-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Query Timeline ───────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-query {
|
||||
background: var(--app-card-background-color);
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-border-radius);
|
||||
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-error-debug-query__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.875);
|
||||
cursor: pointer;
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.app-error-debug-query__duration {
|
||||
color: var(--app-muted-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-error-debug-query__sql {
|
||||
display: none;
|
||||
padding: calc(var(--app-spacing) * 0.75) calc(var(--app-spacing) * 0.875);
|
||||
background: var(--app-card-sectioning-background-color);
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-xs);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border-top: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-error-debug-query.open .app-error-debug-query__sql { display: block; }
|
||||
|
||||
/* ── Empty State ──────────────────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-empty {
|
||||
text-align: center;
|
||||
padding: calc(var(--app-spacing) * 2);
|
||||
color: var(--app-muted-color);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ── Previous Exception Chain ─────────────────────────────────────────── */
|
||||
|
||||
.app-error-debug-chain {
|
||||
margin-top: calc(var(--app-spacing) * 1.5);
|
||||
padding-top: calc(var(--app-spacing) * 1.5);
|
||||
border-top: 1px dashed var(--app-border);
|
||||
}
|
||||
|
||||
.app-error-debug-chain__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
color: var(--app-muted-color);
|
||||
margin-bottom: calc(var(--app-spacing) * 0.5);
|
||||
}
|
||||
Reference in New Issue
Block a user