Files
ForgeLM/tauri-app/src/lib/devLogger.ts
T

659 lines
20 KiB
TypeScript

/**
* DevLogger — Development-only comprehensive interaction logger.
*
* Captures the FULL lifecycle of every chat interaction:
* 1. User input + context assembly
* 2. API request (headers, body, model, params)
* 3. SSE stream chunks (raw + parsed)
* 4. Response parsing (thinking extraction, transcript cleanup)
* 5. DB writes (what was persisted)
* 6. Error states + recovery actions
*
* All data is held in memory and can be exported as JSON or viewed
* in the browser console via `window.__devLog`.
*
* Only active when import.meta.env.DEV is true.
*/
// ─── Types ───────────────────────────────────────────────────────
export interface InteractionLog {
id: string;
chatId: number | null;
startedAt: string;
finishedAt: string | null;
durationMs: number | null;
status: "pending" | "streaming" | "complete" | "error" | "aborted";
// 1. User input
userInput: {
rawText: string;
charCount: number;
estimatedTokens: number;
};
// 2. Context sent to API
context: {
messageCount: number;
messages: Array<{ role: string; contentPreview: string; contentLength: number }>;
totalContextChars: number;
memoryEnabled: boolean;
agenticMode: boolean;
};
// 3. API request
request: {
url: string;
method: string;
model: string;
stream: boolean;
maxTokens: number;
stopSequences: string[];
headers: Record<string, string>;
bodySize: number;
fullBody: unknown;
};
// 4. API response
response: {
httpStatus: number | null;
httpStatusText: string | null;
contentType: string | null;
responseHeaders: Record<string, string>;
};
// 5. Streaming data
streaming: {
chunksReceived: number;
rawChunks: Array<{
index: number;
timestamp: string;
raw: string;
parsed: unknown | null;
deltaContent: string | null;
error: string | null;
}>;
totalDeltaChars: number;
tokenCount: number;
firstTokenAt: string | null;
lastTokenAt: string | null;
timeToFirstTokenMs: number | null;
};
// 6. Parsed result
parsed: {
rawContent: string;
visibleContent: string;
reasoningContent: string;
thinkingOpen: boolean;
isCorrupted: boolean;
transcriptLeakageDetected: boolean;
contentLength: number;
};
// 7. Usage stats from API
usage: {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
tokensPerSecond: number | null;
};
// 8. DB persistence
dbWrites: Array<{
timestamp: string;
operation: string;
table: string;
params: unknown[];
resultId: number | null;
}>;
// 9. Errors
errors: Array<{
timestamp: string;
phase: string;
message: string;
stack: string | null;
raw: unknown;
}>;
// 10. Sanitization / encoding diagnostics
sanitization: Array<{
timestamp: string;
stage: string;
changed: boolean;
repairedEncoding: boolean;
beforePreview: string;
afterPreview: string;
}>;
// 11. Timeline of phases
timeline: Array<{
timestamp: string;
phase: string;
detail: string;
}>;
}
// ─── Logger Class ────────────────────────────────────────────────
const IS_DEV = Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
const MAX_LOGS = 50;
class DevLogger {
private interactions: InteractionLog[] = [];
private current: InteractionLog | null = null;
private enabled: boolean;
private startTime: number = 0;
constructor() {
this.enabled = IS_DEV;
if (this.enabled && typeof window !== "undefined") {
(window as unknown as Record<string, unknown>).__devLog = this;
console.log(
"%c[DevLogger] 🔍 Development logging ACTIVE — access via window.__devLog",
"color: #22c55e; font-weight: bold; font-size: 13px",
);
console.log(
"%c .current → current interaction\n .interactions → all logged interactions\n .export() → JSON export\n .dump() → console table\n .dumpLast() → last interaction detail\n .clear() → reset",
"color: #999; font-size: 11px",
);
}
}
// ── Interaction Lifecycle ──
startInteraction(chatId: number | null, userText: string, opts: {
memoryEnabled: boolean;
agenticMode: boolean;
}): string {
if (!this.enabled) return "";
const id = `int_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
this.startTime = performance.now();
this.current = {
id,
chatId,
startedAt: new Date().toISOString(),
finishedAt: null,
durationMs: null,
status: "pending",
userInput: {
rawText: userText,
charCount: userText.length,
estimatedTokens: Math.ceil(userText.length / 4),
},
context: {
messageCount: 0,
messages: [],
totalContextChars: 0,
memoryEnabled: opts.memoryEnabled,
agenticMode: opts.agenticMode,
},
request: {
url: "",
method: "POST",
model: "",
stream: true,
maxTokens: 0,
stopSequences: [],
headers: {},
bodySize: 0,
fullBody: null,
},
response: {
httpStatus: null,
httpStatusText: null,
contentType: null,
responseHeaders: {},
},
streaming: {
chunksReceived: 0,
rawChunks: [],
totalDeltaChars: 0,
tokenCount: 0,
firstTokenAt: null,
lastTokenAt: null,
timeToFirstTokenMs: null,
},
parsed: {
rawContent: "",
visibleContent: "",
reasoningContent: "",
thinkingOpen: false,
isCorrupted: false,
transcriptLeakageDetected: false,
contentLength: 0,
},
usage: {
promptTokens: null,
completionTokens: null,
totalTokens: null,
tokensPerSecond: null,
},
dbWrites: [],
errors: [],
sanitization: [],
timeline: [],
};
this.addTimeline("init", `Interaction started — chatId=${chatId}, text="${userText.slice(0, 60)}…"`);
this.logPhase("📝 User Input", { text: userText.slice(0, 200), chars: userText.length });
return id;
}
logContext(messages: Array<{ role: string; content: string }>) {
if (!this.enabled || !this.current) return;
const ctx = this.current.context;
ctx.messageCount = messages.length;
ctx.messages = messages.map((m) => ({
role: m.role,
contentPreview: m.content.slice(0, 100),
contentLength: m.content.length,
}));
ctx.totalContextChars = messages.reduce((sum, m) => sum + m.content.length, 0);
this.addTimeline("context", `${messages.length} messages, ${ctx.totalContextChars} chars total`);
this.logPhase("📦 Context Assembly", {
messageCount: messages.length,
totalChars: ctx.totalContextChars,
roles: messages.map((m) => m.role).join(", "),
messages: ctx.messages,
});
}
logRequest(url: string, body: unknown, headers: Record<string, string>) {
if (!this.enabled || !this.current) return;
const bodyStr = JSON.stringify(body);
const req = this.current.request;
req.url = url;
req.fullBody = body;
req.bodySize = bodyStr.length;
req.headers = sanitizeHeaders(headers);
if (typeof body === "object" && body !== null) {
const b = body as Record<string, unknown>;
req.model = String(b.model ?? "");
req.stream = Boolean(b.stream);
req.maxTokens = Number(b.max_tokens ?? 0);
req.stopSequences = Array.isArray(b.stop) ? (b.stop as string[]) : [];
}
this.addTimeline("request", `POST ${url} — model=${req.model}, stream=${req.stream}, body=${bodyStr.length}B`);
this.logPhase("🚀 API Request", {
url,
model: req.model,
stream: req.stream,
maxTokens: req.maxTokens,
bodySize: bodyStr.length,
stopSequences: req.stopSequences,
fullBody: body,
});
}
logResponse(res: Response) {
if (!this.enabled || !this.current) return;
const resp = this.current.response;
resp.httpStatus = res.status;
resp.httpStatusText = res.statusText;
resp.contentType = res.headers.get("content-type");
res.headers.forEach((value, key) => {
resp.responseHeaders[key] = value;
});
this.addTimeline("response", `HTTP ${res.status} ${res.statusText} — ${resp.contentType}`);
this.logPhase("📡 API Response", {
status: res.status,
statusText: res.statusText,
contentType: resp.contentType,
headers: resp.responseHeaders,
});
}
logStreamChunk(raw: string, parsed: unknown | null, deltaContent: string | null, error: string | null = null) {
if (!this.enabled || !this.current) return;
const s = this.current.streaming;
const now = new Date().toISOString();
s.chunksReceived++;
// Store first 100 chunks in full + every 50th after that for inspection
if (s.chunksReceived <= 100 || s.chunksReceived % 50 === 0) {
s.rawChunks.push({
index: s.chunksReceived,
timestamp: now,
raw: raw.slice(0, 500),
parsed,
deltaContent,
error,
});
}
if (deltaContent) {
s.totalDeltaChars += deltaContent.length;
s.tokenCount++;
s.lastTokenAt = now;
if (!s.firstTokenAt) {
s.firstTokenAt = now;
s.timeToFirstTokenMs = performance.now() - this.startTime;
this.addTimeline("first_token", `TTFT=${s.timeToFirstTokenMs.toFixed(0)}ms, delta="${deltaContent}"`);
}
}
if (error) {
this.current.errors.push({ timestamp: now, phase: "stream_chunk", message: error, stack: null, raw });
}
// Log first 3, then every 50th chunk to console
if (s.chunksReceived <= 3 || s.chunksReceived % 50 === 0) {
console.debug(
`%c[DevLogger] 🔄 Chunk #${s.chunksReceived}`,
"color: #60a5fa",
{ delta: deltaContent, totalChars: s.totalDeltaChars, raw: raw.slice(0, 120) },
);
}
}
logNonStreamResponse(data: unknown) {
if (!this.enabled || !this.current) return;
this.addTimeline("non_stream_response", "Full response received (non-streaming)");
this.logPhase("📩 Non-Stream Response", data);
}
logParsedResult(result: {
rawContent: string;
visibleContent: string;
reasoningContent: string;
thinkingOpen: boolean;
isCorrupted: boolean;
transcriptLeakageDetected: boolean;
}) {
if (!this.enabled || !this.current) return;
const p = this.current.parsed;
Object.assign(p, result);
p.contentLength = result.visibleContent.length;
this.addTimeline("parsed", `visible=${result.visibleContent.length}ch, reasoning=${result.reasoningContent.length}ch, corrupted=${result.isCorrupted}`);
const logStyle = result.isCorrupted
? "%c[DevLogger] ⚠️ PARSED RESULT (CORRUPTED)"
: "%c[DevLogger] ✅ Parsed Result";
const logColor = result.isCorrupted ? "color: #ef4444; font-weight: bold" : "color: #22c55e";
console.groupCollapsed(logStyle, logColor);
console.log("Raw content:", result.rawContent);
console.log("Visible content:", result.visibleContent);
console.log("Reasoning:", result.reasoningContent || "(none)");
console.log("Thinking still open:", result.thinkingOpen);
console.log("Corrupted:", result.isCorrupted);
console.log("Transcript leak:", result.transcriptLeakageDetected);
console.groupEnd();
}
logUsage(usage: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
tokens_per_second?: number;
} | null) {
if (!this.enabled || !this.current || !usage) return;
this.current.usage = {
promptTokens: usage.prompt_tokens ?? null,
completionTokens: usage.completion_tokens ?? null,
totalTokens: usage.total_tokens ?? null,
tokensPerSecond: usage.tokens_per_second ?? null,
};
this.addTimeline("usage", `prompt=${usage.prompt_tokens}, completion=${usage.completion_tokens}, tps=${usage.tokens_per_second?.toFixed(1)}`);
this.logPhase("📊 Usage Stats", usage);
}
logDBWrite(operation: string, table: string, params: unknown[], resultId: number | null = null) {
if (!this.enabled || !this.current) return;
this.current.dbWrites.push({
timestamp: new Date().toISOString(),
operation,
table,
params: params.map((p) => typeof p === "string" && p.length > 200 ? p.slice(0, 200) + "…" : p),
resultId,
});
this.addTimeline("db_write", `${operation} → ${table}${resultId ? ` (id=${resultId})` : ""}`);
console.debug(`%c[DevLogger] 💾 DB ${operation}`, "color: #a855f7", { table, params: params.map((p) => typeof p === "string" && p.length > 100 ? p.slice(0, 100) + "…" : p), resultId });
}
logSanitization(stage: string, payload: {
before: string;
after: string;
changed: boolean;
repairedEncoding?: boolean;
}) {
if (!this.enabled || !this.current) return;
this.current.sanitization.push({
timestamp: new Date().toISOString(),
stage,
changed: payload.changed,
repairedEncoding: Boolean(payload.repairedEncoding),
beforePreview: payload.before.slice(0, 240),
afterPreview: payload.after.slice(0, 240),
});
if (payload.changed || payload.repairedEncoding) {
this.addTimeline(
"sanitize",
`${stage}: changed=${payload.changed} repairedEncoding=${Boolean(payload.repairedEncoding)}`,
);
console.warn(`%c[DevLogger] 🧼 Sanitization @ ${stage}`, "color: #f59e0b; font-weight: bold", {
repairedEncoding: Boolean(payload.repairedEncoding),
before: payload.before,
after: payload.after,
});
}
}
logError(phase: string, error: unknown) {
if (!this.enabled || !this.current) return;
const err = error instanceof Error ? error : new Error(String(error));
this.current.errors.push({
timestamp: new Date().toISOString(),
phase,
message: err.message,
stack: err.stack ?? null,
raw: error,
});
this.current.status = "error";
this.addTimeline("error", `[${phase}] ${err.message}`);
console.error(`%c[DevLogger] ❌ Error in ${phase}`, "color: #ef4444; font-weight: bold", err);
}
logAbort(partialContent: string) {
if (!this.enabled || !this.current) return;
this.current.status = "aborted";
this.addTimeline("abort", `User aborted — partial content: ${partialContent.length} chars`);
this.logPhase("🛑 Aborted", { partialContentLength: partialContent.length, preview: partialContent.slice(0, 200) });
}
finishInteraction() {
if (!this.enabled || !this.current) return;
const elapsed = performance.now() - this.startTime;
this.current.durationMs = Math.round(elapsed);
this.current.finishedAt = new Date().toISOString();
if (this.current.status === "pending" || this.current.status === "streaming") {
this.current.status = "complete";
}
this.addTimeline("finish", `Duration=${elapsed.toFixed(0)}ms, status=${this.current.status}`);
// Summary console output
const c = this.current;
console.groupCollapsed(
`%c[DevLogger] 🏁 Interaction ${c.id} — ${c.status} in ${elapsed.toFixed(0)}ms`,
c.status === "error" ? "color: #ef4444; font-weight: bold" : "color: #22c55e; font-weight: bold",
);
console.table({
"User Input": `${c.userInput.charCount} chars`,
"Context Messages": c.context.messageCount,
"Context Chars": c.context.totalContextChars,
"Model": c.request.model,
"Stream": c.request.stream,
"HTTP Status": c.response.httpStatus,
"Chunks Received": c.streaming.chunksReceived,
"TTFT (ms)": c.streaming.timeToFirstTokenMs?.toFixed(0) ?? "n/a",
"Stream Tokens": c.streaming.tokenCount,
"Stream Chars": c.streaming.totalDeltaChars,
"Final Content": `${c.parsed.contentLength} chars`,
"Reasoning": c.parsed.reasoningContent ? `${c.parsed.reasoningContent.length} chars` : "none",
"Corrupted": c.parsed.isCorrupted,
"Prompt Tokens": c.usage.promptTokens,
"Completion Tokens": c.usage.completionTokens,
"TPS": c.usage.tokensPerSecond?.toFixed(1) ?? "n/a",
"DB Writes": c.dbWrites.length,
"Sanitizations": c.sanitization.length,
"Errors": c.errors.length,
"Duration (ms)": elapsed.toFixed(0),
});
if (c.errors.length > 0) {
console.warn("Errors:", c.errors);
}
if (c.parsed.isCorrupted) {
console.warn("⚠️ CORRUPTED RESPONSE — raw content:", c.parsed.rawContent);
}
// Log response content comparison for debugging "response 1" issue
if (c.parsed.visibleContent.trim().length <= 5) {
console.warn(
"⚠️ SUSPICIOUSLY SHORT RESPONSE:",
{
rawFromStream: c.streaming.totalDeltaChars,
rawContent: c.parsed.rawContent,
visibleContent: c.parsed.visibleContent,
allChunks: c.streaming.rawChunks,
},
);
}
console.groupEnd();
// Archive
this.interactions.push(this.current);
if (this.interactions.length > MAX_LOGS) {
this.interactions = this.interactions.slice(-MAX_LOGS);
}
this.current = null;
}
// ── Public Inspection API ──
/** Export all logs as JSON (for file download / clipboard). */
export(): string {
return JSON.stringify({
exportedAt: new Date().toISOString(),
interactionCount: this.interactions.length,
interactions: this.interactions,
}, null, 2);
}
/** Console table of all interactions. */
dump() {
if (this.interactions.length === 0) {
console.log("[DevLogger] No interactions recorded yet.");
return;
}
console.table(
this.interactions.map((i) => ({
id: i.id,
chatId: i.chatId,
status: i.status,
model: i.request.model,
userInput: i.userInput.rawText.slice(0, 40),
responseLen: i.parsed.contentLength,
corrupted: i.parsed.isCorrupted,
chunks: i.streaming.chunksReceived,
ttft: i.streaming.timeToFirstTokenMs?.toFixed(0),
tps: i.usage.tokensPerSecond?.toFixed(1),
durationMs: i.durationMs,
errors: i.errors.length,
})),
);
}
/** Detailed dump of the last interaction. */
dumpLast() {
const last = this.interactions[this.interactions.length - 1];
if (!last) {
console.log("[DevLogger] No interactions recorded yet.");
return;
}
console.log(`[DevLogger] Last interaction: ${last.id}`);
console.log("Timeline:");
last.timeline.forEach((t) => console.log(` ${t.timestamp} [${t.phase}] ${t.detail}`));
console.log("\nFull data:", last);
}
/** Return the latest interaction, optionally filtered by chatId. */
getLatestInteraction(chatId?: number | null): InteractionLog | null {
if (this.interactions.length === 0) return null;
if (chatId === undefined) return this.interactions[this.interactions.length - 1] ?? null;
for (let index = this.interactions.length - 1; index >= 0; index -= 1) {
const candidate = this.interactions[index];
if (candidate.chatId === chatId) return candidate;
}
return this.interactions[this.interactions.length - 1] ?? null;
}
/** Clear all logs. */
clear() {
this.interactions = [];
this.current = null;
console.log("[DevLogger] Cleared all logs.");
}
// ── Internal helpers ──
private addTimeline(phase: string, detail: string) {
this.current?.timeline.push({
timestamp: new Date().toISOString(),
phase,
detail,
});
}
private logPhase(label: string, data: unknown) {
console.groupCollapsed(`%c[DevLogger] ${label}`, "color: #60a5fa; font-weight: bold");
console.log(data);
console.groupEnd();
}
}
// ── Helpers ──
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const safe = { ...headers };
// Mask authorization tokens
for (const key of Object.keys(safe)) {
if (key.toLowerCase().includes("auth") || key.toLowerCase().includes("key")) {
safe[key] = safe[key].slice(0, 10) + "…[MASKED]";
}
}
return safe;
}
// ── Singleton Export ──
export const devLog = new DevLogger();