feat: debug log file for diagnostics (#1211)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
cthomas
2026-02-27 16:49:26 -08:00
committed by GitHub
parent 22c31b3d74
commit e0cea19c9f
3 changed files with 151 additions and 25 deletions

View File

@@ -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(),
}),
},
);

View File

@@ -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<string, unknown>[];
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);
}

View File

@@ -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);
}