From e0cea19c9fa70e4ea490c3d00ff501c8103043ca Mon Sep 17 00:00:00 2001 From: cthomas Date: Fri, 27 Feb 2026 16:49:26 -0800 Subject: [PATCH] feat: debug log file for diagnostics (#1211) Co-authored-by: Letta --- src/cli/App.tsx | 5 +- src/telemetry/index.ts | 3 + src/utils/debug.ts | 168 +++++++++++++++++++++++++++++++++++------ 3 files changed, 151 insertions(+), 25 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 92949b9..9de5faa 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -112,7 +112,7 @@ import { } from "../tools/manager"; import type { ToolsetName, ToolsetPreference } from "../tools/toolset"; import { formatToolsetName } from "../tools/toolset-labels"; -import { debugLog, debugWarn } from "../utils/debug"; +import { debugLog, debugLogFile, debugWarn } from "../utils/debug"; import { getVersion } from "../version"; import { handleMcpAdd, @@ -1587,6 +1587,7 @@ export default function App({ useEffect(() => { if (agentId && agentId !== "loading") { chunkLog.init(agentId, telemetry.getSessionId()); + debugLogFile.init(agentId, telemetry.getSessionId()); } }, [agentId]); @@ -11522,6 +11523,8 @@ ${SYSTEM_REMINDER_CLOSE} billing_tier: billingTier ?? undefined, // Recent chunk log for diagnostics recent_chunks: chunkLog.getEntries(), + // Debug log tail for diagnostics + debug_log_tail: debugLogFile.getTail(), }), }, ); diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index d665c5b..4d2863b 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -1,5 +1,6 @@ import { getLettaCodeHeaders } from "../agent/http-headers"; import { settingsManager } from "../settings-manager"; +import { debugLogFile } from "../utils/debug"; import { getVersion } from "../version"; export interface TelemetryEvent { @@ -50,6 +51,7 @@ export interface ErrorData { model_id?: string; run_id?: string; recent_chunks?: Record[]; + debug_log_tail?: string; } export interface UserInputData { @@ -382,6 +384,7 @@ class TelemetryManager { model_id: options?.modelId, run_id: options?.runId, recent_chunks: options?.recentChunks, + debug_log_tail: debugLogFile.getTail(), }; this.track("error", data); } diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 6d9f11f..4d5cf29 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,10 +1,27 @@ // src/utils/debug.ts -// Simple debug logging utility - only logs when LETTA_DEBUG env var is set -// Optionally logs to a file when LETTA_DEBUG_FILE is set +// Debug logging utility. +// +// Screen output: controlled by LETTA_DEBUG=1 (or LETTA_DEBUG_FILE for a custom path). +// File output: always written to ~/.letta/logs/debug/{agent-id}/{session-id}.log +// once debugLogFile.init() has been called. Before init, lines are +// silently dropped (no file path yet). -import { appendFileSync } from "node:fs"; +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { format } from "node:util"; +// --------------------------------------------------------------------------- +// Screen-output helpers (unchanged behaviour) +// --------------------------------------------------------------------------- + /** * Check if debug mode is enabled via LETTA_DEBUG env var * Set LETTA_DEBUG=1 or LETTA_DEBUG=true to enable debug logging @@ -19,13 +36,9 @@ function getDebugFile(): string | null { return path && path.trim().length > 0 ? path : null; } -function writeDebugLine( - prefix: string, - message: string, - args: unknown[], -): void { +/** Print to screen (or LETTA_DEBUG_FILE). Only called when LETTA_DEBUG=1. */ +function printDebugLine(line: string): void { const debugFile = getDebugFile(); - const line = `${format(`[${prefix}] ${message}`, ...args)}\n`; if (debugFile) { try { appendFileSync(debugFile, line, { encoding: "utf8" }); @@ -34,38 +47,145 @@ function writeDebugLine( // Fall back to console if file write fails } } - // Default to console output console.log(line.trimEnd()); } +// --------------------------------------------------------------------------- +// Always-on debug log file +// --------------------------------------------------------------------------- + +const DEBUG_LOG_DIR = join(homedir(), ".letta", "logs", "debug"); +const MAX_SESSION_FILES = 5; +const DEFAULT_TAIL_LINES = 50; + +class DebugLogFile { + private logPath: string | null = null; + private agentDir: string | null = null; + private dirCreated = false; + + /** + * Initialize for an agent + session. Call once at session start. + * After this, every debugLog/debugWarn call is persisted to disk. + * Respects LETTA_CODE_TELEM=0 — skips file logging when telemetry is disabled. + */ + init(agentId: string, sessionId: string): void { + const telem = process.env.LETTA_CODE_TELEM; + if (telem === "0" || telem === "false") return; + + this.agentDir = join(DEBUG_LOG_DIR, agentId); + this.logPath = join(this.agentDir, `${sessionId}.log`); + this.dirCreated = false; + this.pruneOldSessions(); + } + + /** Append a single line to the log file (best-effort, sync). */ + appendLine(line: string): void { + if (!this.logPath) return; + this.ensureDir(); + try { + appendFileSync(this.logPath, line, { encoding: "utf8" }); + } catch { + // Best-effort — never crash the app for debug logging + } + } + + /** Read the last N lines from the current log file. */ + getTail(maxLines = DEFAULT_TAIL_LINES): string | undefined { + if (!this.logPath) return undefined; + try { + if (!existsSync(this.logPath)) return undefined; + const content = readFileSync(this.logPath, "utf8"); + const lines = content.trimEnd().split("\n"); + return lines.slice(-maxLines).join("\n"); + } catch { + return undefined; + } + } + + private ensureDir(): void { + if (this.dirCreated || !this.agentDir) return; + try { + if (!existsSync(this.agentDir)) { + mkdirSync(this.agentDir, { recursive: true }); + } + this.dirCreated = true; + } catch { + // Silently ignore — will retry on next append + } + } + + private pruneOldSessions(): void { + if (!this.agentDir) return; + try { + if (!existsSync(this.agentDir)) return; + const files = readdirSync(this.agentDir) + .filter((f) => f.endsWith(".log")) + .sort(); + if (files.length >= MAX_SESSION_FILES) { + const toDelete = files.slice(0, files.length - MAX_SESSION_FILES + 1); + for (const file of toDelete) { + try { + unlinkSync(join(this.agentDir, file)); + } catch { + // best-effort cleanup + } + } + } + } catch { + // best-effort cleanup + } + } +} + +/** Singleton — import and call init() once per session. */ +export const debugLogFile = new DebugLogFile(); + +// --------------------------------------------------------------------------- +// Core write function +// --------------------------------------------------------------------------- + +function writeDebugLine( + prefix: string, + message: string, + args: unknown[], +): void { + const ts = new Date().toISOString(); + const body = format(`[${prefix}] ${message}`, ...args); + const line = `${ts} ${body}\n`; + + // Always persist to the session log file + debugLogFile.appendLine(line); + + // Screen output only when LETTA_DEBUG is on + if (isDebugEnabled()) { + printDebugLine(line); + } +} + +// --------------------------------------------------------------------------- +// Public API (unchanged signatures) +// --------------------------------------------------------------------------- + /** - * Log a debug message (only if LETTA_DEBUG is enabled) - * @param prefix - A prefix/tag for the log message (e.g., "check-approval") - * @param message - The message to log - * @param args - Additional arguments to log + * Log a debug message. Always written to the session log file. + * Only printed to screen when LETTA_DEBUG=1. */ export function debugLog( prefix: string, message: string, ...args: unknown[] ): void { - if (isDebugEnabled()) { - writeDebugLine(prefix, message, args); - } + writeDebugLine(prefix, message, args); } /** - * Log a debug warning (only if LETTA_DEBUG is enabled) - * @param prefix - A prefix/tag for the log message - * @param message - The message to log - * @param args - Additional arguments to log + * Log a debug warning. Always written to the session log file. + * Only printed to screen when LETTA_DEBUG=1. */ export function debugWarn( prefix: string, message: string, ...args: unknown[] ): void { - if (isDebugEnabled()) { - writeDebugLine(prefix, `WARN: ${message}`, args); - } + writeDebugLine(prefix, `WARN: ${message}`, args); }