feat(web): capture front-end errors in the troubleshooting log export

The KAP debug panel already exported REST + WS traffic as JSONL, but a broken
page is usually explained by a JS error the network log doesn't show. Fold
window error/unhandledrejection and console.error/warn into the trace buffer
(opt-in, install-once, behavior-preserving) as a 'client' source, surface them
in the panel with an 'app errors' filter + APP badge, so 'export jsonl' yields a
complete troubleshooting log.
This commit is contained in:
qer 2026-06-13 12:28:08 +08:00
parent 4b0eae5ab3
commit 481bfa0dfc
4 changed files with 108 additions and 4 deletions

View file

@ -19,7 +19,7 @@ const open = ref(false);
// ---------------------------------------------------------------------------
// Filters
// ---------------------------------------------------------------------------
const sourceFilter = ref<'all' | 'rest' | 'ws'>('all');
const sourceFilter = ref<'all' | 'rest' | 'ws' | 'client'>('all');
const textFilter = ref('');
const sessionFilter = ref<string>('');
const errorsOnly = ref(false);
@ -142,10 +142,17 @@ function exportJsonl(): void {
function badgeClass(e: TraceEntry): string {
if (isError(e)) return 'b-err';
if (e.source === 'client') return 'b-err';
if (e.source === 'rest') return 'b-rest';
if (e.kind === 'ws:lifecycle') return 'b-life';
return e.kind === 'ws:out' ? 'b-out' : 'b-in';
}
function badgeLabel(e: TraceEntry): string {
if (e.source === 'rest') return 'REST';
if (e.source === 'client') return 'APP';
return 'WS';
}
</script>
<template>
@ -170,9 +177,10 @@ function badgeClass(e: TraceEntry): string {
<div class="kap-filters">
<select v-model="sourceFilter" aria-label="Source filter">
<option value="all">rest + ws</option>
<option value="all">rest + ws + app</option>
<option value="rest">rest</option>
<option value="ws">ws</option>
<option value="client">app errors</option>
</select>
<select v-model="sessionFilter" aria-label="Session filter">
<option value="">all sessions</option>
@ -194,7 +202,7 @@ function badgeClass(e: TraceEntry): string {
<div v-for="e in filtered" :key="e.id" class="kap-row-wrap">
<button type="button" class="kap-row" :class="{ expanded: expandedId === e.id }" @click="toggleDetail(e.id)">
<span class="kap-ts">{{ fmtTime(e.ts) }}</span>
<span class="kap-badge" :class="badgeClass(e)">{{ e.source === 'rest' ? 'REST' : 'WS' }}</span>
<span class="kap-badge" :class="badgeClass(e)">{{ badgeLabel(e) }}</span>
<span class="kap-label">{{ e.label }}</span>
</button>
<div v-if="expandedId === e.id" class="kap-detail">

View file

@ -9,7 +9,7 @@
import { ref, shallowRef } from 'vue';
export type TraceSource = 'rest' | 'ws';
export type TraceSource = 'rest' | 'ws' | 'client';
export interface TraceEntry {
id: number;
@ -298,6 +298,77 @@ export function traceWsIn(frame: unknown): void {
});
}
// ---------------------------------------------------------------------------
// Client-side error capture — so the exported troubleshooting log includes the
// front-end errors (uncaught exceptions, rejected promises, console.error/warn)
// that explain a broken page, not just network traffic. Install-once, opt-in
// (only when tracing is enabled), and never alters runtime behavior: the
// original console methods and default error handling still run.
// ---------------------------------------------------------------------------
function traceClientLog(level: 'error' | 'warn', label: string, detail?: unknown): void {
if (!isTraceEnabled()) return;
push({
source: 'client',
kind: `client:${level}`,
label: `${level === 'error' ? '✕' : '⚠'} ${label}`,
detail: detailOf(detail),
});
}
let clientCaptureInstalled = false;
/** Wire up window error + console.error/warn capture into the trace buffer. */
export function installClientErrorCapture(): void {
if (clientCaptureInstalled || !isTraceEnabled()) return;
clientCaptureInstalled = true;
try {
if (typeof window !== 'undefined') {
window.addEventListener('error', (e: ErrorEvent) => {
traceClientLog('error', e.message || 'window error', {
source: e.filename,
line: e.lineno,
col: e.colno,
stack: e.error instanceof Error ? e.error.stack : undefined,
});
});
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const reason = e.reason;
traceClientLog('error', 'unhandled promise rejection', {
reason: reason instanceof Error ? `${reason.name}: ${reason.message}` : reason,
stack: reason instanceof Error ? reason.stack : undefined,
});
});
}
} catch {
// window unavailable
}
for (const level of ['error', 'warn'] as const) {
const original = console[level];
if (typeof original !== 'function') continue;
console[level] = (...args: unknown[]): void => {
try {
traceClientLog(level, args.map(stringifyArg).join(' '), args.length > 1 ? args : args[0]);
} catch {
// never let tracing break logging
}
original.apply(console, args);
};
}
}
function stringifyArg(a: unknown): string {
if (typeof a === 'string') return a;
if (a instanceof Error) return `${a.name}: ${a.message}`;
try {
return JSON.stringify(a);
} catch {
return String(a);
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------

View file

@ -1,7 +1,13 @@
import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';
import { installClientErrorCapture } from './debug/trace';
import '@fontsource-variable/jetbrains-mono/wght.css';
import './style.css';
// Opt-in (only with ?debug=1 / the debug flag): fold front-end errors and
// console.error/warn into the trace buffer so the panel's "export jsonl" gives
// a complete troubleshooting log, not just network traffic.
installClientErrorCapture();
createApp(App).use(i18n).mount('#app');

View file

@ -12,6 +12,7 @@ import { DaemonHttpClient } from '../src/api/daemon/http';
import { DaemonEventSocket, type DaemonEventSocketHandlers } from '../src/api/daemon/ws';
import {
clearTrace,
installClientErrorCapture,
sanitizeForTrace,
traceEntries,
traceToJsonl,
@ -42,6 +43,24 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe('client-side error capture', () => {
it('folds console.error into the trace so the export includes app errors', () => {
const original = console.error;
installClientErrorCapture();
try {
console.error('render failed', new Error('boom'));
} finally {
console.error = original; // undo the install-once wrap for other tests
}
const entry = traceEntries().find((e) => e.kind === 'client:error');
expect(entry).toBeDefined();
expect(entry!.source).toBe('client');
expect(entry!.label).toContain('render failed');
// The exported JSONL carries the client entry alongside network traffic.
expect(traceToJsonl().includes('"client:error"')).toBe(true);
});
});
describe('REST tracing via DaemonHttpClient', () => {
it('records request + response with envelope code, status, duration and requestId', async () => {
vi.stubGlobal('fetch', vi.fn(async () => okEnvelope({ id: 'ses_1' })));