diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e63f10ba80c..59ae94d7630 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -18,18 +18,19 @@ await Log.init({ if (Installation.isLocal()) return "DEBUG" return "INFO" })(), + path: process.env.OPENCODE_LOG_PATH || undefined, + rotate: + process.env.OPENCODE_LOG_ROTATE === "daily" + ? { kind: "daily", retention: Number(process.env.OPENCODE_LOG_RETENTION) || undefined } + : undefined, }) process.on("unhandledRejection", (e) => { - Log.Default.error("rejection", { - e: e instanceof Error ? e.message : e, - }) + Log.Default.error("rejection", { error: e }) }) process.on("uncaughtException", (e) => { - Log.Default.error("exception", { - e: e instanceof Error ? e.message : e, - }) + Log.Default.error("exception", { error: e }) }) // Subscribe to global events and forward them via RPC diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91e..01ca8bf7c20 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,15 +28,11 @@ import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" process.on("unhandledRejection", (e) => { - Log.Default.error("rejection", { - e: e instanceof Error ? e.message : e, - }) + Log.Default.error("rejection", { error: e }) }) process.on("uncaughtException", (e) => { - Log.Default.error("exception", { - e: e instanceof Error ? e.message : e, - }) + Log.Default.error("exception", { error: e }) }) const cli = yargs(hideBin(process.argv)) @@ -65,6 +61,11 @@ const cli = yargs(hideBin(process.argv)) if (Installation.isLocal()) return "DEBUG" return "INFO" })(), + path: process.env.OPENCODE_LOG_PATH || undefined, + rotate: + process.env.OPENCODE_LOG_ROTATE === "daily" + ? { kind: "daily", retention: Number(process.env.OPENCODE_LOG_RETENTION) || undefined } + : undefined, }) process.env.AGENT = "1" diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 6941310bbbd..0ae727fa2aa 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -40,10 +40,22 @@ export namespace Log { export const Default = create({ service: "default" }) + export type Rotate = + | { + kind: "startup" + retention?: number + } + | { + kind: "daily" + retention?: number + } + export interface Options { print: boolean dev?: boolean level?: Level + path?: string + rotate?: Rotate } let logpath = "" @@ -55,43 +67,86 @@ export namespace Log { return msg.length } + let current = "" + let writer: ReturnType["writer"]> | undefined + + function today() { + const d = new Date() + return ( + d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0") + ) + } + + function createWriter(filepath: string, truncate?: boolean) { + if (writer) writer.end() + logpath = filepath + if (truncate) fs.truncate(filepath).catch(() => {}) + writer = Bun.file(filepath).writer() + } + + function flushWriter(msg: any) { + const num = writer!.write(msg) + writer!.flush() + return num + } + + function rotate(dir: string) { + const date = today() + if (date === current && writer) return + current = date + createWriter(path.join(dir, `opencode-${date}.log`)) + } + + const writeStrategies: Record Promise> = { + async startup(options) { + const dir = options.path ?? Global.Path.log + const retention = options.rotate?.retention ?? 10 + cleanup(dir, "????-??-??T??????.log", retention) + const name = options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log" + createWriter(path.join(dir, name), true) + write = flushWriter + }, + async daily(options) { + const dir = options.path ?? Global.Path.log + const retention = options.rotate?.retention ?? 7 + await fs.mkdir(dir, { recursive: true }) + rotate(dir) + cleanup(dir, "opencode-????-??-??.log", retention) + write = (msg: any) => { + rotate(dir) + return flushWriter(msg) + } + }, + } + export async function init(options: Options) { if (options.level) level = options.level - cleanup(Global.Path.log) if (options.print) return - logpath = path.join( - Global.Path.log, - options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", - ) - const logfile = Bun.file(logpath) - await fs.truncate(logpath).catch(() => {}) - const writer = logfile.writer() - write = async (msg: any) => { - const num = writer.write(msg) - writer.flush() - return num - } + return writeStrategies[options.rotate?.kind ?? "startup"](options) } - async function cleanup(dir: string) { - const glob = new Bun.Glob("????-??-??T??????.log") + async function cleanup(dir: string, pattern: string, retention: number) { + const glob = new Bun.Glob(pattern) const files = await Array.fromAsync( glob.scan({ cwd: dir, absolute: true, }), ) - if (files.length <= 5) return - - const filesToDelete = files.slice(0, -10) + if (files.length <= retention) return + files.sort() + const filesToDelete = files.slice(0, -retention) await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {}))) } - function formatError(error: Error, depth = 0): string { - const result = error.message - return error.cause instanceof Error && depth < 10 - ? result + " Caused by: " + formatError(error.cause, depth + 1) - : result + function formatError(error: Error): string[] { + return Object.getOwnPropertyNames(error).flatMap((k) => { + const v = (error as any)[k] + if (v == null) return [] + if (v instanceof Error) return [`${k}=${v.name}: ${v.message}`, ...formatError(v).map((s) => `${k}.${s}`)] + if (typeof v === "object") return [`${k}=${JSON.stringify(v)}`] + return [`${k}=${v}`] + }) } let last = Date.now() @@ -111,12 +166,11 @@ export namespace Log { ...tags, ...extra, }) - .filter(([_, value]) => value !== undefined && value !== null) - .map(([key, value]) => { - const prefix = `${key}=` - if (value instanceof Error) return prefix + formatError(value) - if (typeof value === "object") return prefix + JSON.stringify(value) - return prefix + value + .flatMap(([key, value]) => { + if (value == null) return [] + if (value instanceof Error) return formatError(value) + if (typeof value === "object") return [`${key}=${JSON.stringify(value)}`] + return [`${key}=${value}`] }) .join(" ") const next = new Date()