feat(web): add opt-in KAP debug panel tracing REST and WS traffic

Enable with ?debug=1 or localStorage kimi-web.debug=1. A ring buffer
(1000 entries, redacted secrets, truncated payloads) records every REST
call (method/path/requestId/status/envelope code/duration) and WS frame
(lifecycle, outbound control frames, inbound events with session/seq/
offset) as a side channel — request ordering and error handling are
unchanged. The panel offers a filterable timeline, per-entry JSON with
copy, JSONL export, and per-session/event-type aggregation.
This commit is contained in:
qer 2026-06-12 12:40:05 +08:00
parent f71ef6d466
commit 719ee4ac76
6 changed files with 997 additions and 1 deletions

View file

@ -20,6 +20,8 @@ import MobileSwitcherSheet from './components/MobileSwitcherSheet.vue';
import MobileSettingsSheet from './components/MobileSettingsSheet.vue';
import Onboarding from './components/Onboarding.vue';
import GlobalLoading from './components/GlobalLoading.vue';
import DebugPanel from './debug/DebugPanel.vue';
import { isTraceEnabled } from './debug/trace';
import { useKimiWebClient } from './composables/useKimiWebClient';
import { useIsMobile } from './composables/useIsMobile';
import type { ThinkingLevel } from './api/types';
@ -29,6 +31,9 @@ const client = useKimiWebClient();
provide('resolveImage', client.resolveImageUrl);
const { t } = useI18n();
// KAP/daemon debug panel opt-in via ?debug=1 or localStorage kimi-web.debug=1.
const debugEnabled = isTraceEnabled();
// Narrow viewports (640px) render the single-column mobile shell; desktop is
// unchanged. jsdom defaults to false (desktop) so component tests are unaffected.
const isMobile = useIsMobile();
@ -803,6 +808,9 @@ function handleCreateSessionInWorkspace(workspaceId: string): void {
<!-- Floating warnings / agent errors (e.g. a 403 from the model provider) -->
<WarningToasts :warnings="client.warnings.value" @dismiss="client.dismissWarning" />
<!-- KAP/daemon debug panel (opt-in, ?debug=1) -->
<DebugPanel v-if="debugEnabled" />
<!-- Mobile switcher bottom-sheet: workspace groups + sessions (mirrors the
desktop sidebar) -->
<MobileSwitcherSheet

View file

@ -3,6 +3,7 @@
import { buildRestUrl } from '../config';
import { DaemonApiError, DaemonNetworkError } from '../errors';
import { traceRestFailure, traceRestRequest, traceRestResponse } from '../../debug/trace';
import type { WireEnvelope } from './wire';
/** Per-request timeout. Without one, a hung connection (half-open TCP after a
@ -48,6 +49,23 @@ function createRequestId(): string {
return `${encodeBase32(Date.now(), 10)}${randomBase32(16)}`;
}
/** Trace-only FormData summary: field names + file name/size/type, never content. */
function describeFormData(formData: FormData): unknown {
try {
const fields: Array<Record<string, unknown>> = [];
formData.forEach((value, field) => {
if (typeof value === 'string') {
fields.push({ field, value });
} else {
fields.push({ field, file: value.name, size: value.size, type: value.type });
}
});
return { formData: fields };
} catch {
return '[FormData]';
}
}
async function readResponsePreview(response: Response): Promise<string | undefined> {
try {
const text = await response.text();
@ -76,10 +94,13 @@ export class DaemonHttpClient {
const headers: Record<string, string> = {
'X-Request-Id': requestId,
};
const startedAt = Date.now();
traceRestRequest({ method: 'POST', path, url, requestId, body: describeFormData(formData) });
let response: Response;
try {
response = await fetch(url, { method: 'POST', headers, body: formData, signal: timeoutSignal() });
} catch (err) {
traceRestFailure({ method: 'POST', path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err });
throw new DaemonNetworkError({
message: `Network error calling POST ${path}`,
cause: err,
@ -96,6 +117,7 @@ export class DaemonHttpClient {
try {
envelope = (await response.json()) as WireEnvelope<T>;
} catch (err) {
traceRestFailure({ method: 'POST', path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err });
throw new DaemonNetworkError({
message: `Failed to parse JSON response from POST ${path}`,
cause: err,
@ -111,6 +133,17 @@ export class DaemonHttpClient {
bodyPreview: await readResponsePreview(responseForDiagnostics),
});
}
traceRestResponse({
method: 'POST',
path,
requestId,
status: response.status,
durationMs: Date.now() - startedAt,
code: envelope.code,
msg: envelope.msg,
envelopeRequestId: envelope.request_id,
data: envelope.data,
});
if (envelope.code !== 0) {
throw new DaemonApiError({
code: envelope.code,
@ -159,6 +192,9 @@ export class DaemonHttpClient {
headers['Content-Type'] = 'application/json; charset=utf-8';
}
const startedAt = Date.now();
traceRestRequest({ method, path, url, requestId, body });
// Execute fetch
let response: Response;
try {
@ -169,6 +205,7 @@ export class DaemonHttpClient {
signal: timeoutSignal(),
});
} catch (err) {
traceRestFailure({ method, path, requestId, phase: 'fetch', durationMs: Date.now() - startedAt, error: err });
throw new DaemonNetworkError({
message: `Network error calling ${method} ${path}`,
cause: err,
@ -187,6 +224,7 @@ export class DaemonHttpClient {
try {
envelope = (await response.json()) as WireEnvelope<T>;
} catch (err) {
traceRestFailure({ method, path, requestId, phase: 'parse', durationMs: Date.now() - startedAt, status: response.status, error: err });
throw new DaemonNetworkError({
message: `Failed to parse JSON response from ${method} ${path}`,
cause: err,
@ -203,6 +241,18 @@ export class DaemonHttpClient {
});
}
traceRestResponse({
method,
path,
requestId,
status: response.status,
durationMs: Date.now() - startedAt,
code: envelope.code,
msg: envelope.msg,
envelopeRequestId: envelope.request_id,
data: envelope.data,
});
// Unwrap: code 0 = success; allowed non-zero = return data; else throw
if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) {
throw new DaemonApiError({

View file

@ -3,6 +3,7 @@
// Handles: server_hello / client_hello handshake, subscribe/unsubscribe,
// ping/pong heartbeat, resync_required, error frames, event.* dispatch.
import { traceWsIn, traceWsLifecycle, traceWsOut } from '../../debug/trace';
import { classifyFrame } from './agentEventProjector';
import type { WireEvent, WireServerFrame } from './wire';
@ -78,18 +79,22 @@ export class DaemonEventSocket {
connect(): void {
if (this.ws !== null || this.closed) return;
traceWsLifecycle('connect', { url: this.wsUrl, attempt: this.reconnectAttempts });
const ws = new WebSocket(this.wsUrl);
this.ws = ws;
ws.onopen = () => {
// Don't mark as connected yet — wait for server_hello
traceWsLifecycle('open');
};
ws.onmessage = (ev: MessageEvent) => {
try {
const frame = JSON.parse(String(ev.data)) as WireServerFrame;
traceWsIn(frame);
this.handleFrame(frame);
} catch (err) {
traceWsLifecycle('parse-error', { error: String(err) });
this.handlers.onError(0, `Failed to parse WS frame: ${String(err)}`, false);
}
};
@ -97,10 +102,12 @@ export class DaemonEventSocket {
ws.onerror = () => {
// The error details are not exposed by the browser WS API; the close
// event with a reason code follows immediately.
traceWsLifecycle('error');
this.handlers.onError(0, 'WebSocket error', false);
};
ws.onclose = () => {
ws.onclose = (ev?: CloseEvent) => {
traceWsLifecycle('close', ev ? { code: ev.code, reason: ev.reason, wasClean: ev.wasClean } : undefined);
this.connected = false;
this.ws = null;
this.handlers.onConnectionState(false);
@ -117,6 +124,7 @@ export class DaemonEventSocket {
const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts);
const delay = base + Math.floor(Math.random() * 250); // jitter
this.reconnectAttempts += 1;
traceWsLifecycle('reconnect-scheduled', { delayMs: delay, attempt: this.reconnectAttempts });
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
@ -347,6 +355,7 @@ export class DaemonEventSocket {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
try {
this.ws.send(JSON.stringify(msg));
traceWsOut(msg);
} catch {
// Ignore send errors (socket closing races)
}

View file

@ -0,0 +1,408 @@
<!-- apps/kimi-web/src/debug/DebugPanel.vue
KAP/daemon debug panel opt-in (?debug=1 or localStorage kimi-web.debug=1).
Timeline of REST calls + WS frames with filters, per-entry JSON detail,
copy, JSONL export, and a per-session/per-event-type aggregate view.
Dev tooling: labels are intentionally not localized. -->
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import {
clearTrace,
tracePaused,
traceEntries,
traceToJsonl,
traceVersion,
type TraceEntry,
} from './trace';
const open = ref(false);
// ---------------------------------------------------------------------------
// Filters
// ---------------------------------------------------------------------------
const sourceFilter = ref<'all' | 'rest' | 'ws'>('all');
const textFilter = ref('');
const sessionFilter = ref<string>('');
const errorsOnly = ref(false);
const view = ref<'timeline' | 'aggregate'>('timeline');
const all = computed<readonly TraceEntry[]>(() => {
void traceVersion.value; // re-read the buffer on every push
return [...traceEntries()];
});
const sessionIds = computed<string[]>(() => {
const ids = new Set<string>();
for (const e of all.value) if (e.sessionId) ids.add(e.sessionId);
return [...ids].sort();
});
function isError(e: TraceEntry): boolean {
return e.kind === 'rest:error' || (e.code !== undefined && e.code !== 0)
|| e.eventType === 'error' || e.eventType === 'parse-error';
}
const filtered = computed<TraceEntry[]>(() => {
const text = textFilter.value.trim().toLowerCase();
return all.value.filter((e) => {
if (sourceFilter.value !== 'all' && e.source !== sourceFilter.value) return false;
if (sessionFilter.value && e.sessionId !== sessionFilter.value) return false;
if (errorsOnly.value && !isError(e)) return false;
if (text) {
const hay = `${e.label} ${e.kind} ${e.eventType ?? ''} ${e.sessionId ?? ''} ${e.requestId ?? ''}`.toLowerCase();
if (!hay.includes(text)) return false;
}
return true;
});
});
// ---------------------------------------------------------------------------
// Aggregate: WS events by session/type, REST by method+path
// ---------------------------------------------------------------------------
interface WsAggRow { key: string; sessionId: string; eventType: string; dir: string; count: number; lastSeq?: number }
interface RestAggRow { key: string; count: number; errors: number; avgMs: number }
const wsAgg = computed<WsAggRow[]>(() => {
const map = new Map<string, WsAggRow>();
for (const e of filtered.value) {
if (e.kind !== 'ws:in' && e.kind !== 'ws:out') continue;
const dir = e.kind === 'ws:in' ? '←' : '→';
const key = `${dir} ${e.eventType ?? '?'} @ ${e.sessionId ?? '-'}`;
const row = map.get(key) ?? { key, sessionId: e.sessionId ?? '-', eventType: e.eventType ?? '?', dir, count: 0 };
row.count++;
if (e.seq !== undefined) row.lastSeq = e.seq;
map.set(key, row);
}
return [...map.values()].sort((a, b) => b.count - a.count);
});
const restAgg = computed<RestAggRow[]>(() => {
const map = new Map<string, { count: number; errors: number; totalMs: number; timed: number }>();
for (const e of filtered.value) {
if (e.source !== 'rest' || e.kind === 'rest:request') continue;
const key = `${e.method ?? '?'} ${e.path ?? '?'}`;
const row = map.get(key) ?? { count: 0, errors: 0, totalMs: 0, timed: 0 };
row.count++;
if (isError(e)) row.errors++;
if (e.durationMs !== undefined) { row.totalMs += e.durationMs; row.timed++; }
map.set(key, row);
}
return [...map.entries()]
.map(([key, r]) => ({ key, count: r.count, errors: r.errors, avgMs: r.timed > 0 ? Math.round(r.totalMs / r.timed) : 0 }))
.sort((a, b) => b.count - a.count);
});
// ---------------------------------------------------------------------------
// Timeline: detail expansion, follow-bottom, copy, export
// ---------------------------------------------------------------------------
const expandedId = ref<number | null>(null);
const follow = ref(true);
const listRef = ref<HTMLElement | null>(null);
const copiedId = ref<number | null>(null);
watch([() => filtered.value.length, open], async () => {
if (!follow.value || !open.value || view.value !== 'timeline') return;
await nextTick();
const el = listRef.value;
if (el) el.scrollTop = el.scrollHeight;
});
function toggleDetail(id: number): void {
expandedId.value = expandedId.value === id ? null : id;
}
function fmtTime(ts: number): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
function entryJson(e: TraceEntry): string {
return JSON.stringify(e, null, 2);
}
async function copyEntry(e: TraceEntry): Promise<void> {
try {
await navigator.clipboard.writeText(entryJson(e));
copiedId.value = e.id;
setTimeout(() => { if (copiedId.value === e.id) copiedId.value = null; }, 1500);
} catch {
// clipboard unavailable
}
}
function exportJsonl(): void {
const blob = new Blob([traceToJsonl(filtered.value)], { type: 'application/x-ndjson' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kap-trace-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`;
a.click();
URL.revokeObjectURL(url);
}
function badgeClass(e: TraceEntry): string {
if (isError(e)) 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';
}
</script>
<template>
<!-- floating toggle -->
<button v-if="!open" class="kap-fab" type="button" title="KAP debug panel" @click="open = true">
KAP
</button>
<aside v-else class="kap-panel">
<header class="kap-head">
<strong>KAP debug</strong>
<span class="kap-count">{{ filtered.length }}/{{ all.length }}</span>
<div class="kap-head-actions">
<button type="button" :class="{ on: tracePaused }" @click="tracePaused = !tracePaused">
{{ tracePaused ? 'resume' : 'pause' }}
</button>
<button type="button" @click="clearTrace()">clear</button>
<button type="button" @click="exportJsonl()">export jsonl</button>
<button type="button" @click="open = false"></button>
</div>
</header>
<div class="kap-filters">
<select v-model="sourceFilter" aria-label="Source filter">
<option value="all">rest + ws</option>
<option value="rest">rest</option>
<option value="ws">ws</option>
</select>
<select v-model="sessionFilter" aria-label="Session filter">
<option value="">all sessions</option>
<option v-for="sid in sessionIds" :key="sid" :value="sid">{{ sid }}</option>
</select>
<input v-model="textFilter" type="text" placeholder="filter (type / path / id)" aria-label="Text filter" />
<label class="kap-check"><input v-model="errorsOnly" type="checkbox" /> errors</label>
<label class="kap-check"><input v-model="follow" type="checkbox" /> follow</label>
<div class="kap-view-toggle" role="group">
<button type="button" :class="{ on: view === 'timeline' }" @click="view = 'timeline'">timeline</button>
<button type="button" :class="{ on: view === 'aggregate' }" @click="view = 'aggregate'">aggregate</button>
</div>
</div>
<div v-if="view === 'timeline'" ref="listRef" class="kap-list">
<div v-if="filtered.length === 0" class="kap-empty">
No trace entries yet. REST calls and WS frames will appear here.
</div>
<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-label">{{ e.label }}</span>
</button>
<div v-if="expandedId === e.id" class="kap-detail">
<div class="kap-detail-actions">
<button type="button" @click="copyEntry(e)">{{ copiedId === e.id ? 'copied ' : 'copy json' }}</button>
</div>
<pre>{{ entryJson(e) }}</pre>
</div>
</div>
</div>
<div v-else class="kap-agg">
<h4>WS frames by session / type</h4>
<table>
<thead><tr><th>dir</th><th>type</th><th>session</th><th>count</th><th>last seq</th></tr></thead>
<tbody>
<tr v-for="r in wsAgg" :key="r.key">
<td>{{ r.dir }}</td>
<td class="mono">{{ r.eventType }}</td>
<td class="mono">{{ r.sessionId }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">{{ r.lastSeq ?? '—' }}</td>
</tr>
<tr v-if="wsAgg.length === 0"><td colspan="5" class="kap-empty">no ws frames</td></tr>
</tbody>
</table>
<h4>REST by endpoint</h4>
<table>
<thead><tr><th>endpoint</th><th>count</th><th>errors</th><th>avg ms</th></tr></thead>
<tbody>
<tr v-for="r in restAgg" :key="r.key">
<td class="mono">{{ r.key }}</td>
<td class="num">{{ r.count }}</td>
<td class="num" :class="{ err: r.errors > 0 }">{{ r.errors }}</td>
<td class="num">{{ r.avgMs }}</td>
</tr>
<tr v-if="restAgg.length === 0"><td colspan="4" class="kap-empty">no rest calls</td></tr>
</tbody>
</table>
</div>
</aside>
</template>
<style scoped>
.kap-fab {
position: fixed;
right: 10px;
bottom: 10px;
z-index: 240;
padding: 5px 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
opacity: 0.75;
}
.kap-fab:hover { opacity: 1; color: var(--blue); }
.kap-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 240;
width: min(560px, 100vw);
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--line);
box-shadow: -8px 0 28px rgba(0, 0, 0, 0.18);
font-family: var(--mono);
font-size: 11.5px;
color: var(--ink);
}
.kap-head {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
}
.kap-count { color: var(--muted); }
.kap-head-actions { margin-left: auto; display: flex; gap: 6px; }
.kap-head-actions button,
.kap-view-toggle button {
padding: 3px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
color: var(--muted);
font: inherit;
cursor: pointer;
}
.kap-head-actions button:hover,
.kap-view-toggle button:hover { color: var(--ink); }
.kap-head-actions button.on,
.kap-view-toggle button.on { color: var(--blue2); border-color: var(--bd); background: var(--soft); }
.kap-filters {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 7px 10px;
border-bottom: 1px solid var(--line);
}
.kap-filters select,
.kap-filters input[type='text'] {
padding: 3px 6px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
color: var(--ink);
font: inherit;
min-width: 0;
}
.kap-filters input[type='text'] { flex: 1; min-width: 120px; }
.kap-check { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); white-space: nowrap; }
.kap-view-toggle { display: flex; gap: 0; }
.kap-view-toggle button:first-child { border-radius: 6px 0 0 6px; border-right: none; }
.kap-view-toggle button:last-child { border-radius: 0 6px 6px 0; }
.kap-list { flex: 1; min-height: 0; overflow-y: auto; }
.kap-empty { padding: 18px 12px; color: var(--muted); text-align: center; }
.kap-row {
display: flex;
align-items: baseline;
gap: 7px;
width: 100%;
padding: 3px 10px;
border: none;
border-bottom: 1px solid var(--line);
background: transparent;
color: var(--ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.kap-row:hover { background: var(--panel2); }
.kap-row.expanded { background: var(--soft); }
.kap-ts { flex: none; color: var(--muted); }
.kap-badge {
flex: none;
padding: 0 5px;
border-radius: 5px;
font-size: 9.5px;
font-weight: 700;
line-height: 1.7;
}
.b-rest { background: var(--soft); color: var(--blue2); }
.b-in { background: var(--soft); color: var(--ok, #2da44e); }
.b-out { background: var(--soft); color: var(--warn); }
.b-life { background: var(--panel2); color: var(--muted); }
.b-err { background: var(--warn); color: var(--bg); }
.kap-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kap-detail {
border-bottom: 1px solid var(--line);
background: var(--bg);
padding: 6px 10px 10px;
}
.kap-detail-actions { display: flex; justify-content: flex-end; margin-bottom: 4px; }
.kap-detail-actions button {
padding: 2px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--muted);
font: inherit;
cursor: pointer;
}
.kap-detail-actions button:hover { color: var(--ink); }
.kap-detail pre {
margin: 0;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 11px;
line-height: 1.45;
}
.kap-agg { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 10px; }
.kap-agg h4 { margin: 8px 0 4px; font-size: 11.5px; color: var(--muted); }
.kap-agg table { width: 100%; border-collapse: collapse; }
.kap-agg th, .kap-agg td {
padding: 3px 6px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.kap-agg th { color: var(--muted); font-weight: 600; }
.kap-agg .num { text-align: right; }
.kap-agg .err { color: var(--warn); font-weight: 700; }
.kap-agg .mono { word-break: break-all; }
</style>

View file

@ -0,0 +1,308 @@
// apps/kimi-web/src/debug/trace.ts
// KAP/daemon debug trace — a side-channel recording of REST calls and WS
// frames, kept in a bounded ring buffer for the opt-in debug panel.
//
// Opt-in: `?debug=1` in the URL or `localStorage["kimi-web.debug"]="1"`.
// When not enabled every record call is a single boolean check, and nothing
// is stored — normal use pays (almost) nothing. Recording NEVER changes the
// request/WS behavior: callers pass data in, errors here must not propagate.
import { ref, shallowRef } from 'vue';
export type TraceSource = 'rest' | 'ws';
export interface TraceEntry {
id: number;
/** Epoch ms when recorded. */
ts: number;
source: TraceSource;
/**
* rest:request | rest:response | rest:error
* ws:lifecycle (connect/open/close/error/reconnect) | ws:in | ws:out
*/
kind: string;
/** One-line summary for the timeline. */
label: string;
sessionId?: string;
/** REST method + path (for filtering/aggregation). */
method?: string;
path?: string;
/** WS frame type (server_hello, ping, event.* / raw agent type, …). */
eventType?: string;
seq?: number;
offset?: number;
/** HTTP status (REST). */
status?: number;
/** Envelope code (REST) — 0 is success. */
code?: number;
requestId?: string;
durationMs?: number;
/** Sanitized + truncated payload for the detail view. */
detail?: unknown;
}
const MAX_ENTRIES = 1000;
/** A single entry's detail JSON is capped so one giant frame (e.g. a snapshot
with full scrollback) can't dominate the buffer's memory. */
const MAX_DETAIL_JSON_CHARS = 16_384;
const MAX_STRING = 500;
const MAX_ARRAY_ITEMS = 50;
const MAX_DEPTH = 6;
const SENSITIVE_KEY_RE = /api[_-]?key|authorization|token|secret|password|cookie|credential/i;
/** Long unbroken base64-ish runs (uploads, inlined images) are size, not signal. */
const BASE64ISH_RE = /^[A-Za-z0-9+/=_-]{200,}$/;
// ---------------------------------------------------------------------------
// Enablement — resolved lazily on first use so tests (and a user flipping the
// localStorage flag before load) are honored without module-import ordering.
// ---------------------------------------------------------------------------
let enabledCache: boolean | null = null;
export function isTraceEnabled(): boolean {
if (enabledCache !== null) return enabledCache;
let enabled = false;
try {
if (typeof location !== 'undefined') {
const v = new URLSearchParams(location.search).get('debug');
if (v === '1' || v === 'true') enabled = true;
}
} catch {
// location unavailable
}
if (!enabled) {
try {
enabled = localStorage.getItem('kimi-web.debug') === '1';
} catch {
// localStorage unavailable
}
}
enabledCache = enabled;
return enabled;
}
// ---------------------------------------------------------------------------
// Ring buffer + reactivity
// ---------------------------------------------------------------------------
const entries: TraceEntry[] = [];
let nextId = 1;
/** Bumped on every push; the panel re-reads the buffer when it changes. */
export const traceVersion = ref(0);
/** While true new records are dropped (panel "pause" button). */
export const tracePaused = shallowRef(false);
export function traceEntries(): readonly TraceEntry[] {
return entries;
}
export function clearTrace(): void {
entries.length = 0;
traceVersion.value++;
}
function push(entry: Omit<TraceEntry, 'id' | 'ts'>): void {
if (tracePaused.value) return;
entries.push({ id: nextId++, ts: Date.now(), ...entry });
if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES);
traceVersion.value++;
}
// ---------------------------------------------------------------------------
// Sanitization — redact sensitive keys, truncate long strings/arrays/depth.
// ---------------------------------------------------------------------------
export function sanitizeForTrace(value: unknown, depth = 0): unknown {
if (value === null || value === undefined) return value;
const t = typeof value;
if (t === 'number' || t === 'boolean') return value;
if (t === 'string') {
const s = value as string;
if (BASE64ISH_RE.test(s)) return `[base64-like, ${s.length} chars omitted]`;
if (s.length > MAX_STRING) return `${s.slice(0, MAX_STRING)}… [+${s.length - MAX_STRING} chars]`;
return s;
}
if (t !== 'object') return String(value);
if (depth >= MAX_DEPTH) return '[max depth]';
if (Array.isArray(value)) {
const out: unknown[] = value
.slice(0, MAX_ARRAY_ITEMS)
.map((v) => sanitizeForTrace(v, depth + 1));
if (value.length > MAX_ARRAY_ITEMS) out.push(`[+${value.length - MAX_ARRAY_ITEMS} more items]`);
return out;
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = SENSITIVE_KEY_RE.test(k) ? '[redacted]' : sanitizeForTrace(v, depth + 1);
}
return out;
}
/** Sanitize, then hard-cap the serialized size of one entry's detail. */
function detailOf(value: unknown): unknown {
if (value === undefined) return undefined;
const sanitized = sanitizeForTrace(value);
try {
const json = JSON.stringify(sanitized);
if (json !== undefined && json.length > MAX_DETAIL_JSON_CHARS) {
return {
_truncated: `detail JSON was ${json.length} chars; first ${MAX_DETAIL_JSON_CHARS} kept`,
preview: json.slice(0, MAX_DETAIL_JSON_CHARS),
};
}
} catch {
return '[unserializable detail]';
}
return sanitized;
}
// ---------------------------------------------------------------------------
// REST recording — called from DaemonHttpClient
// ---------------------------------------------------------------------------
export function traceRestRequest(info: {
method: string;
path: string;
url: string;
requestId: string;
body?: unknown;
}): void {
if (!isTraceEnabled()) return;
push({
source: 'rest',
kind: 'rest:request',
label: `${info.method} ${info.path}`,
method: info.method,
path: info.path,
requestId: info.requestId,
detail: { url: info.url, body: detailOf(info.body) },
});
}
export function traceRestResponse(info: {
method: string;
path: string;
requestId: string;
status: number;
durationMs: number;
code: number;
msg: string;
envelopeRequestId?: string;
data?: unknown;
}): void {
if (!isTraceEnabled()) return;
const failed = info.code !== 0;
push({
source: 'rest',
kind: failed ? 'rest:error' : 'rest:response',
label: `${info.method} ${info.path} ${info.status} code=${info.code}${failed ? ` "${info.msg}"` : ''} ${Math.round(info.durationMs)}ms`,
method: info.method,
path: info.path,
requestId: info.requestId,
status: info.status,
code: info.code,
durationMs: info.durationMs,
detail: {
envelope: { code: info.code, msg: info.msg, request_id: info.envelopeRequestId },
data: detailOf(info.data),
},
});
}
export function traceRestFailure(info: {
method: string;
path: string;
requestId: string;
phase: 'fetch' | 'parse';
durationMs: number;
status?: number;
error: unknown;
}): void {
if (!isTraceEnabled()) return;
push({
source: 'rest',
kind: 'rest:error',
label: `${info.method} ${info.path} ${info.phase} error${info.status !== undefined ? ` (HTTP ${info.status})` : ''} ${Math.round(info.durationMs)}ms`,
method: info.method,
path: info.path,
requestId: info.requestId,
status: info.status,
durationMs: info.durationMs,
detail: { phase: info.phase, error: String(info.error) },
});
}
// ---------------------------------------------------------------------------
// WS recording — called from DaemonEventSocket
// ---------------------------------------------------------------------------
export function traceWsLifecycle(event: string, detail?: unknown): void {
if (!isTraceEnabled()) return;
push({
source: 'ws',
kind: 'ws:lifecycle',
eventType: event,
label: `ws ${event}`,
detail: detailOf(detail),
});
}
/** Outbound client frame (client_hello / subscribe / unsubscribe / abort / pong). */
export function traceWsOut(frame: unknown): void {
if (!isTraceEnabled()) return;
const f = (frame ?? {}) as Record<string, unknown>;
const type = typeof f['type'] === 'string' ? (f['type'] as string) : '(unknown)';
const payload = f['payload'] as Record<string, unknown> | undefined;
const sessionId =
typeof payload?.['session_id'] === 'string' ? (payload['session_id'] as string) : undefined;
push({
source: 'ws',
kind: 'ws:out',
eventType: type,
sessionId,
label: `${type}`,
detail: detailOf(frame),
});
}
/** Inbound server frame — control frames and event frames alike. */
export function traceWsIn(frame: unknown): void {
if (!isTraceEnabled()) return;
const f = (frame ?? {}) as Record<string, unknown>;
const type = typeof f['type'] === 'string' ? (f['type'] as string) : '(unknown)';
const sessionId =
typeof f['session_id'] === 'string'
? (f['session_id'] as string)
: typeof (f['payload'] as Record<string, unknown> | undefined)?.['session_id'] === 'string'
? ((f['payload'] as Record<string, unknown>)['session_id'] as string)
: undefined;
const seq = typeof f['seq'] === 'number' ? (f['seq'] as number) : undefined;
const offset = typeof f['offset'] === 'number' ? (f['offset'] as number) : undefined;
const bits = [
sessionId,
seq !== undefined ? `seq=${seq}` : undefined,
offset !== undefined ? `offset=${offset}` : undefined,
f['volatile'] === true ? 'volatile' : undefined,
].filter(Boolean);
push({
source: 'ws',
kind: 'ws:in',
eventType: type,
sessionId,
seq,
offset,
label: `${type}${bits.length > 0 ? ` (${bits.join(' ')})` : ''}`,
detail: detailOf(f['payload']),
});
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
/** Serialize the given entries (default: all) as JSONL for download. */
export function traceToJsonl(list: readonly TraceEntry[] = entries): string {
return list.map((e) => JSON.stringify(e)).join('\n');
}

View file

@ -0,0 +1,213 @@
// apps/kimi-web/test/debug-trace.test.ts
//
// KAP debug trace: the side-channel recording of REST calls and WS frames.
// Drives the REAL DaemonHttpClient (stubbed fetch) and DaemonEventSocket
// (stubbed WebSocket) and asserts what a user would see in the debug panel:
// request/response/error entries, redacted secrets, truncated payloads,
// bounded buffer, JSONL export.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DaemonHttpClient } from '../src/api/daemon/http';
import { DaemonEventSocket, type DaemonEventSocketHandlers } from '../src/api/daemon/ws';
import {
clearTrace,
sanitizeForTrace,
traceEntries,
traceToJsonl,
traceWsIn,
} from '../src/debug/trace';
function okEnvelope(data: unknown): Response {
return new Response(
JSON.stringify({ code: 0, msg: 'ok', data, request_id: 'req_env_1' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
function errEnvelope(code: number, msg: string): Response {
return new Response(
JSON.stringify({ code, msg, data: null, request_id: 'req_env_2' }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
beforeEach(() => {
// Opt the trace in the way a user would (the localStorage switch).
localStorage.setItem('kimi-web.debug', '1');
clearTrace();
});
afterEach(() => {
vi.unstubAllGlobals();
});
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' })));
const http = new DaemonHttpClient('http://example.test:7878');
await http.post('/sessions', { metadata: { cwd: '/repo' } });
const entries = traceEntries();
const request = entries.find((e) => e.kind === 'rest:request');
const response = entries.find((e) => e.kind === 'rest:response');
expect(request).toBeDefined();
expect(request!.method).toBe('POST');
expect(request!.path).toBe('/sessions');
expect(request!.requestId).toMatch(/./);
expect(response).toBeDefined();
expect(response!.status).toBe(200);
expect(response!.code).toBe(0);
expect(typeof response!.durationMs).toBe('number');
expect(response!.requestId).toBe(request!.requestId);
const detail = response!.detail as { envelope: { request_id: string } };
expect(detail.envelope.request_id).toBe('req_env_1');
});
it('redacts sensitive request fields (api_key / authorization)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => okEnvelope({})));
const http = new DaemonHttpClient('http://example.test:7878');
await http.post('/providers', { api_key: 'YOUR_API_KEY', authorization: 'Bearer x' });
const request = traceEntries().find((e) => e.kind === 'rest:request');
const body = (request!.detail as { body: Record<string, unknown> }).body;
expect(body['api_key']).toBe('[redacted]');
expect(body['authorization']).toBe('[redacted]');
});
it('records a daemon API error (non-zero envelope code) as rest:error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => errEnvelope(40401, 'session does not exist')));
const http = new DaemonHttpClient('http://example.test:7878');
await expect(http.get('/sessions/ses_x')).rejects.toThrow();
const entry = traceEntries().find((e) => e.kind === 'rest:error');
expect(entry).toBeDefined();
expect(entry!.code).toBe(40401);
expect(entry!.label).toContain('session does not exist');
});
it('records a network failure with its phase', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Promise.reject(new TypeError('Failed to fetch'))));
const http = new DaemonHttpClient('http://example.test:7878');
await expect(http.get('/healthz')).rejects.toThrow();
const entry = traceEntries().find((e) => e.kind === 'rest:error');
expect(entry).toBeDefined();
expect((entry!.detail as { phase: string }).phase).toBe('fetch');
});
it('records a JSON parse failure with HTTP status', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>busy</html>', { status: 502 })));
const http = new DaemonHttpClient('http://example.test:7878');
await expect(http.get('/healthz')).rejects.toThrow();
const entry = traceEntries().find((e) => e.kind === 'rest:error');
expect(entry).toBeDefined();
expect(entry!.status).toBe(502);
expect((entry!.detail as { phase: string }).phase).toBe('parse');
});
});
describe('WS tracing via DaemonEventSocket', () => {
class FakeWebSocket {
static OPEN = 1;
static last: FakeWebSocket | null = null;
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev?: { code: number; reason: string; wasClean: boolean }) => void) | null = null;
readyState = 1;
sent: string[] = [];
constructor(public url: string) {
FakeWebSocket.last = this;
}
send(data: string): void {
this.sent.push(data);
}
close(): void {}
}
const handlers: DaemonEventSocketHandlers = {
onWireEvent: () => {},
onRawAgentEvent: () => {},
onResync: () => {},
onConnectionState: () => {},
onError: () => {},
};
it('records lifecycle, handshake frames and event frames with session/seq/offset', () => {
vi.stubGlobal('WebSocket', FakeWebSocket);
const socket = new DaemonEventSocket('ws://example.test/ws', 'client_1', handlers);
socket.subscribe('ses_1', { seq: 0 });
socket.connect();
const fake = FakeWebSocket.last!;
fake.onopen?.();
fake.onmessage?.({ data: JSON.stringify({ type: 'server_hello', payload: {} }) });
fake.onmessage?.({
data: JSON.stringify({
type: 'message.delta',
session_id: 'ses_1',
seq: 7,
offset: 3,
timestamp: '2026-06-12T00:00:00Z',
payload: { delta: 'hi' },
}),
});
fake.onclose?.({ code: 1006, reason: 'gone', wasClean: false });
socket.close();
const entries = traceEntries();
const kinds = entries.map((e) => `${e.kind}:${e.eventType ?? ''}`);
expect(kinds).toContain('ws:lifecycle:connect');
expect(kinds).toContain('ws:lifecycle:open');
expect(kinds).toContain('ws:in:server_hello');
expect(kinds).toContain('ws:out:client_hello');
expect(kinds).toContain('ws:lifecycle:close');
expect(kinds).toContain('ws:lifecycle:reconnect-scheduled');
const event = entries.find((e) => e.eventType === 'message.delta');
expect(event).toBeDefined();
expect(event!.sessionId).toBe('ses_1');
expect(event!.seq).toBe(7);
expect(event!.offset).toBe(3);
const hello = entries.find((e) => e.kind === 'ws:out' && e.eventType === 'client_hello');
const helloDetail = hello!.detail as { payload: { subscriptions: string[] } };
expect(helloDetail.payload.subscriptions).toContain('ses_1');
});
});
describe('sanitization + buffer bounds + export', () => {
it('truncates long strings and elides base64-like blobs', () => {
const long = 'lorem ipsum '.repeat(200); // 2400 chars, with spaces (not base64-like)
const b64 = 'A'.repeat(300);
const out = sanitizeForTrace({ text: long, image: b64 }) as Record<string, string>;
expect(out['text']!.length).toBeLessThan(600);
expect(out['text']).toContain('[+1900 chars]');
expect(out['image']).toContain('base64-like');
});
it('keeps at most 1000 entries (ring buffer)', () => {
for (let i = 0; i < 1100; i++) {
traceWsIn({ type: 'ping', payload: { nonce: i } });
}
expect(traceEntries().length).toBe(1000);
// Oldest entries dropped — the first kept nonce is 100.
const first = traceEntries()[0]!.detail as { nonce: number };
expect(first.nonce).toBe(100);
});
it('exports JSONL that parses back into entries', () => {
traceWsIn({ type: 'ping', payload: { nonce: 1 } });
const jsonl = traceToJsonl();
const lines = jsonl.split('\n');
expect(lines.length).toBe(traceEntries().length);
const parsed = JSON.parse(lines[0]!) as { kind: string };
expect(parsed.kind).toBe('ws:in');
});
});