Skip to content

Commit 555daf2

Browse files
ccchowclaude
andcommitted
feat: persist logs to disk with rotation
Logs now write to .clawui/logs/server.log in addition to console output. Rotates at 10 MB, keeps up to 5 rotated files (server.log.1 through .5). File logging is non-fatal — falls back to console-only if directory creation or writes fail. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 483fba1 commit 555daf2

2 files changed

Lines changed: 89 additions & 9 deletions

File tree

backend/src/__tests__/logger.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,11 @@ describe("logger module", () => {
6969
expect(msg).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
7070
});
7171

72-
it("passes extra arguments through to console", () => {
72+
it("inlines extra arguments into formatted message", () => {
7373
const log = createLogger("test");
74-
const extra = { key: "value" };
75-
log.error("msg", extra);
76-
expect(consoleSpy.error).toHaveBeenCalledWith(expect.any(String), extra);
74+
log.error("value is %s", "hello");
75+
expect(consoleSpy.error).toHaveBeenCalledWith(
76+
expect.stringContaining("value is hello"),
77+
);
7778
});
7879
});

backend/src/logger.ts

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { LOG_LEVEL } from "./config.js";
1+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { LOG_LEVEL, CLAWUI_DB_DIR } from "./config.js";
24

35
type LogLevel = "debug" | "info" | "warn" | "error";
46

@@ -16,6 +18,57 @@ const LEVEL_LABELS: Record<LogLevel, string> = {
1618
error: "ERROR",
1719
};
1820

21+
// ─── File logging ────────────────────────────────────────────
22+
23+
const LOG_DIR = join(CLAWUI_DB_DIR, "logs");
24+
const LOG_FILE = join(LOG_DIR, "server.log");
25+
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
26+
const MAX_ROTATED_FILES = 5;
27+
28+
let fileLoggingEnabled = false;
29+
30+
try {
31+
if (!existsSync(LOG_DIR)) {
32+
mkdirSync(LOG_DIR, { recursive: true });
33+
}
34+
fileLoggingEnabled = true;
35+
} catch {
36+
// If we can't create the log dir, fall back to console-only
37+
console.warn(`[logger] Could not create log directory ${LOG_DIR}, file logging disabled`);
38+
}
39+
40+
function rotateIfNeeded(): void {
41+
try {
42+
if (!existsSync(LOG_FILE)) return;
43+
const stat = statSync(LOG_FILE);
44+
if (stat.size < MAX_LOG_SIZE) return;
45+
46+
// Rotate: server.log.4 → delete, server.log.3 → .4, ... server.log → .1
47+
for (let i = MAX_ROTATED_FILES - 1; i >= 1; i--) {
48+
const from = `${LOG_FILE}.${i}`;
49+
const to = `${LOG_FILE}.${i + 1}`;
50+
if (existsSync(from)) {
51+
renameSync(from, to);
52+
}
53+
}
54+
renameSync(LOG_FILE, `${LOG_FILE}.1`);
55+
} catch {
56+
// Rotation failure is non-fatal
57+
}
58+
}
59+
60+
function writeToFile(formatted: string): void {
61+
if (!fileLoggingEnabled) return;
62+
try {
63+
rotateIfNeeded();
64+
appendFileSync(LOG_FILE, formatted + "\n");
65+
} catch {
66+
// File write failure is non-fatal
67+
}
68+
}
69+
70+
// ─── Logger ──────────────────────────────────────────────────
71+
1972
function shouldLog(level: LogLevel): boolean {
2073
const threshold = LEVEL_ORDER[LOG_LEVEL as LogLevel] ?? LEVEL_ORDER.info;
2174
return LEVEL_ORDER[level] >= threshold;
@@ -25,6 +78,16 @@ function formatMessage(level: LogLevel, module: string, msg: string): string {
2578
return `[${new Date().toISOString()}] [${LEVEL_LABELS[level]}] [${module}] ${msg}`;
2679
}
2780

81+
function formatArgs(msg: string, args: unknown[]): string {
82+
if (args.length === 0) return msg;
83+
// Simple %s substitution like console.log does
84+
let result = msg;
85+
for (const arg of args) {
86+
result = result.replace("%s", String(arg));
87+
}
88+
return result;
89+
}
90+
2891
export interface Logger {
2992
debug: (msg: string, ...args: unknown[]) => void;
3093
info: (msg: string, ...args: unknown[]) => void;
@@ -35,16 +98,32 @@ export interface Logger {
3598
export function createLogger(module: string): Logger {
3699
return {
37100
debug(msg: string, ...args: unknown[]) {
38-
if (shouldLog("debug")) console.debug(formatMessage("debug", module, msg), ...args);
101+
if (shouldLog("debug")) {
102+
const formatted = formatMessage("debug", module, formatArgs(msg, args));
103+
console.debug(formatted);
104+
writeToFile(formatted);
105+
}
39106
},
40107
info(msg: string, ...args: unknown[]) {
41-
if (shouldLog("info")) console.log(formatMessage("info", module, msg), ...args);
108+
if (shouldLog("info")) {
109+
const formatted = formatMessage("info", module, formatArgs(msg, args));
110+
console.log(formatted);
111+
writeToFile(formatted);
112+
}
42113
},
43114
warn(msg: string, ...args: unknown[]) {
44-
if (shouldLog("warn")) console.warn(formatMessage("warn", module, msg), ...args);
115+
if (shouldLog("warn")) {
116+
const formatted = formatMessage("warn", module, formatArgs(msg, args));
117+
console.warn(formatted);
118+
writeToFile(formatted);
119+
}
45120
},
46121
error(msg: string, ...args: unknown[]) {
47-
if (shouldLog("error")) console.error(formatMessage("error", module, msg), ...args);
122+
if (shouldLog("error")) {
123+
const formatted = formatMessage("error", module, formatArgs(msg, args));
124+
console.error(formatted);
125+
writeToFile(formatted);
126+
}
48127
},
49128
};
50129
}

0 commit comments

Comments
 (0)