/** * 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; bodySize: number; fullBody: unknown; }; // 4. API response response: { httpStatus: number | null; httpStatusText: string | null; contentType: string | null; responseHeaders: Record; }; // 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).__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) { 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; 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): Record { 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();