format.ts5.2 KBView on GitHub import type { TimelineEntry } from './types';
function fmtTime(ts: number): string {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${hh}:${mm}:${ss}.${ms}`;
}
function safeStringify(v: unknown, indent = 2): string {
try {
return JSON.stringify(v, null, indent);
} catch (e) {
return `[unserializable: ${e instanceof Error ? e.message : 'unknown'}]`;
}
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
}
if (isPlainObject(a) && isPlainObject(b)) {
const ak = Object.keys(a);
const bk = Object.keys(b);
return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k]));
}
return false;
}
/**
* Minimal recursive diff of two bounded-preview values: descends through
* matching object shapes and emits only the leaves that actually changed,
* so a setState touching one field of a 40-key slice yields one line, not
* the whole snapshot.
*/
function minimalDiff(from: unknown, to: unknown): unknown {
if (isPlainObject(from) && isPlainObject(to)) {
const out: Record<string, unknown> = {};
for (const k of new Set([...Object.keys(from), ...Object.keys(to)])) {
if (deepEqual(from[k], to[k])) continue;
out[k] = minimalDiff(from[k], to[k]);
}
return out;
}
return { from, to };
}
/** Per-key minimal diff for a zustand entry — the bulk-copy default. */
export function compactZustandDiff(
diff: Record<string, { from?: unknown; to?: unknown }>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(diff)) {
out[k] = minimalDiff(v.from, v.to);
}
return out;
}
export function entryHeadline(e: TimelineEntry): string {
switch (e.source) {
case 'trpc.request':
return `${e.type.toUpperCase()} ${e.path} req=${e.requestId}${e.serverRequestId ? ` srv=${e.serverRequestId}` : ''}${e.traceId ? ` trace=${e.traceId}` : ''}`;
case 'trpc.response':
return `← ${e.path} req=${e.requestId} ${e.durationMs.toFixed(0)}ms${e.serverRequestId ? ` srv=${e.serverRequestId}` : ''}${e.traceId ? ` trace=${e.traceId}` : ''}`;
case 'trpc.error':
return `✕ ${e.path} req=${e.requestId} ${e.durationMs.toFixed(0)}ms${e.serverRequestId ? ` srv=${e.serverRequestId}` : ''}${e.traceId ? ` trace=${e.traceId}` : ''}`;
case 'zustand':
return e.changedKeys.join(', ');
case 'error':
return `[${e.kind}] ${e.message}`;
case 'perf':
return `${e.name} ${e.durationMs.toFixed(0)}ms`;
case 'sse':
return `${e.stream}/${e.eventType}`;
case 'freeze':
return `FREEZE ${e.reason} ${e.blockedMs.toFixed(0)}ms${e.snapshotId ? ` snap=${e.snapshotId}` : ''}`;
case 'note':
return e.text;
}
}
export function entryBody(e: TimelineEntry, opts: { compact?: boolean } = {}): string {
switch (e.source) {
case 'trpc.request':
return safeStringify(e.input);
case 'trpc.response':
return safeStringify(e.output);
case 'trpc.error':
return safeStringify(e.error);
case 'zustand':
// Compact: only the leaves that changed. Full: the before/after slice
// snapshots (per-row copy button, for when you need surrounding context).
return safeStringify(opts.compact ? compactZustandDiff(e.diff) : e.diff);
case 'error':
return e.stack ?? e.message;
case 'perf':
return safeStringify(e.detail ?? { durationMs: e.durationMs });
case 'sse':
return safeStringify(e.data);
case 'freeze':
return safeStringify({
reason: e.reason,
blockedMs: e.blockedMs,
snapshotId: e.snapshotId,
});
case 'note':
return e.text;
}
}
export function formatAsMarkdown(entries: TimelineEntry[], opts: { mode?: string } = {}): string {
if (entries.length === 0) return '_No timeline entries._';
const first = entries[0]!.ts;
const last = entries[entries.length - 1]!.ts;
const dur = ((last - first) / 1000).toFixed(3);
const lines: string[] = [];
lines.push(`## Cedar Mail Debug Timeline${opts.mode ? ` (${opts.mode} mode)` : ''}`);
lines.push(`Window: ${fmtTime(first)} — ${fmtTime(last)} (${dur}s, ${entries.length} entries)`);
lines.push('');
for (const e of entries) {
lines.push(`### ${fmtTime(e.ts)} [${e.source}] ${entryHeadline(e)}`);
const body = entryBody(e, { compact: true });
if (body && body !== '{}' && body !== 'null') {
lines.push('```json');
lines.push(body);
lines.push('```');
}
lines.push('');
}
return lines.join('\n');
}
export function formatAsJson(entries: TimelineEntry[]): string {
// Mirror the markdown export: collapse zustand entries to their minimal diff.
const compacted = entries.map((e) =>
e.source === 'zustand' ? { ...e, diff: compactZustandDiff(e.diff) } : e,
);
return safeStringify(compacted);
}