mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-27 09:34:43 +00:00
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:
parent
f71ef6d466
commit
719ee4ac76
6 changed files with 997 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
408
apps/kimi-web/src/debug/DebugPanel.vue
Normal file
408
apps/kimi-web/src/debug/DebugPanel.vue
Normal 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>
|
||||
308
apps/kimi-web/src/debug/trace.ts
Normal file
308
apps/kimi-web/src/debug/trace.ts
Normal 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');
|
||||
}
|
||||
213
apps/kimi-web/test/debug-trace.test.ts
Normal file
213
apps/kimi-web/test/debug-trace.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue