From 01faa3467d841291757bc7f943c0a909d83ab3eb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Mar 2026 12:18:30 +0700 Subject: [PATCH] feat(daemon): split console/file logging defaults with rotation (#88) --- package-lock.json | 13 + .../cli/src/commands/daemon/local-daemon.ts | 96 +++---- packages/server/package.json | 1 + packages/server/src/server/index.ts | 2 +- packages/server/src/server/logger.test.ts | 262 +++++++++++++----- packages/server/src/server/logger.ts | 249 +++++++++++++++-- .../src/server/persisted-config.test.ts | 50 ++++ .../server/src/server/persisted-config.ts | 31 ++- .../public/schemas/paseo.config.v1.json | 39 +++ .../website/src/routes/docs/configuration.tsx | 49 ++++ 10 files changed, 643 insertions(+), 149 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e13bee64..4de9630ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18241,6 +18241,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/rotating-file-stream": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.9.tgz", + "integrity": "sha512-i9i0KkHh12ryl4xtELg+0gyoFre2PJ9RcQQLzquWsiqygyYsrZLckrqqYrthhnJZGZb4g+KUHtcoWYVq34gaug==", + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN" + } + }, "node_modules/rou3": { "version": "0.7.12", "license": "MIT" @@ -21767,6 +21779,7 @@ "pino": "^10.2.0", "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", + "rotating-file-stream": "^3.2.9", "sherpa-onnx": "^1.12.23", "sherpa-onnx-node": "^1.12.23", "strip-ansi": "^7.1.2", diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 3072de7dd..54d8c2f04 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { closeSync, existsSync, openSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { loadConfig, resolvePaseoHome } from '@getpaseo/server' @@ -347,64 +347,58 @@ export async function startLocalDaemonDetached( const paseoHome = resolvePaseoHome(childEnv) const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME) const daemonRunnerEntry = resolveDaemonRunnerEntry() - const logFd = openSync(logPath, 'a') + const child = spawn( + process.execPath, + [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], + { + detached: true, + env: childEnv, + stdio: ['ignore', 'ignore', 'ignore'], + } + ) - try { - const child = spawn( - process.execPath, - [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], - { - detached: true, - env: childEnv, - stdio: ['ignore', logFd, logFd], - } - ) + child.unref() - child.unref() + const startup = await new Promise((resolve) => { + let settled = false - const startup = await new Promise((resolve) => { - let settled = false + const finish = (value: DetachedStartupResult) => { + if (settled) return + settled = true + resolve(value) + } - const finish = (value: DetachedStartupResult) => { - if (settled) return - settled = true - resolve(value) - } + const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS) - const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS) - - child.once('error', (error) => { - clearTimeout(timer) - finish(startupExited({ code: null, signal: null, error })) - }) - - child.once('exit', (code, signal) => { - clearTimeout(timer) - finish(startupExited({ code, signal })) - }) + child.once('error', (error) => { + clearTimeout(timer) + finish(startupExited({ code: null, signal: null, error })) }) - if (startup.exitedEarly) { - const reason = startup.error - ? startup.error.message - : `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}` - const recentLogs = tailFile(logPath) - throw new Error( - [ - `Daemon failed to start in background (${reason}).`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join('\n\n') - ) - } + child.once('exit', (code, signal) => { + clearTimeout(timer) + finish(startupExited({ code, signal })) + }) + }) - return { - pid: child.pid ?? null, - logPath, - } - } finally { - closeSync(logFd) + if (startup.exitedEarly) { + const reason = startup.error + ? startup.error.message + : `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}` + const recentLogs = tailFile(logPath) + throw new Error( + [ + `Daemon failed to start in background (${reason}).`, + recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, + ] + .filter(Boolean) + .join('\n\n') + ) + } + + return { + pid: child.pid ?? null, + logPath, } } diff --git a/packages/server/package.json b/packages/server/package.json index f15f2d59d..e41de30db 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -93,6 +93,7 @@ "sherpa-onnx-node": "^1.12.23", "strip-ansi": "^7.1.2", "tiny-invariant": "^1.3.3", + "rotating-file-stream": "^3.2.9", "uuid": "^9.0.1", "ws": "^8.14.2", "zod": "^3.23.8", diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 8fe3a3b58..36b711536 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -26,7 +26,7 @@ async function main() { try { paseoHome = resolvePaseoHome(); const persistedConfig = loadPersistedConfig(paseoHome); - logger = createRootLogger(persistedConfig); + logger = createRootLogger(persistedConfig, { paseoHome }); config = loadConfig(paseoHome); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/server/src/server/logger.test.ts b/packages/server/src/server/logger.test.ts index 1d53a52b3..d8c090dbb 100644 --- a/packages/server/src/server/logger.test.ts +++ b/packages/server/src/server/logger.test.ts @@ -1,108 +1,232 @@ +import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { resolveLogConfig } from "./logger.js"; import type { PersistedConfig } from "./persisted-config.js"; describe("resolveLogConfig", () => { const originalEnv = process.env; + const paseoHome = "/tmp/paseo-logger-tests"; beforeEach(() => { process.env = { ...originalEnv }; delete process.env.PASEO_LOG; delete process.env.PASEO_LOG_FORMAT; + delete process.env.PASEO_LOG_CONSOLE_LEVEL; + delete process.env.PASEO_LOG_FILE_LEVEL; + delete process.env.PASEO_LOG_FILE_PATH; + delete process.env.PASEO_LOG_FILE_ROTATE_SIZE; + delete process.env.PASEO_LOG_FILE_ROTATE_COUNT; }); afterEach(() => { process.env = originalEnv; }); - it("returns defaults when no config or env vars", () => { - const result = resolveLogConfig(undefined); - expect(result).toEqual({ level: "debug", format: "pretty" }); - }); - - it("uses config.json values over defaults", () => { - const config: PersistedConfig = { - log: { - level: "debug", - format: "json", - }, - }; - const result = resolveLogConfig(config); - expect(result).toEqual({ level: "debug", format: "json" }); - }); - - it("uses env PASEO_LOG over config.json level", () => { - process.env.PASEO_LOG = "warn"; - const config: PersistedConfig = { - log: { - level: "debug", - format: "json", - }, - }; - const result = resolveLogConfig(config); - expect(result).toEqual({ level: "warn", format: "json" }); - }); - - it("uses env PASEO_LOG_FORMAT over config.json format", () => { - process.env.PASEO_LOG_FORMAT = "pretty"; - const config: PersistedConfig = { - log: { - level: "debug", - format: "json", - }, - }; - const result = resolveLogConfig(config); - expect(result).toEqual({ level: "debug", format: "pretty" }); - }); - - it("env vars override both config.json and defaults", () => { - process.env.PASEO_LOG = "error"; - process.env.PASEO_LOG_FORMAT = "json"; - const config: PersistedConfig = { - log: { + it("returns dual-sink defaults when no config or env vars", () => { + const result = resolveLogConfig(undefined, { paseoHome }); + expect(result).toEqual({ + level: "trace", + console: { level: "info", format: "pretty", }, - }; - const result = resolveLogConfig(config); - expect(result).toEqual({ level: "error", format: "json" }); + file: { + level: "trace", + path: path.join(paseoHome, "daemon.log"), + rotate: { + maxSize: "10m", + maxFiles: 2, + }, + }, + }); }); - it("handles partial config - level only", () => { + it("uses config.json destination-specific values over defaults", () => { + const config: PersistedConfig = { + log: { + console: { + level: "warn", + format: "json", + }, + file: { + level: "debug", + path: "/tmp/custom.log", + rotate: { + maxSize: "25m", + maxFiles: 5, + }, + }, + }, + }; + const result = resolveLogConfig(config, { paseoHome }); + + expect(result).toEqual({ + level: "debug", + console: { + level: "warn", + format: "json", + }, + file: { + level: "debug", + path: "/tmp/custom.log", + rotate: { + maxSize: "25m", + maxFiles: 5, + }, + }, + }); + }); + + it("uses env vars over config.json values", () => { + process.env.PASEO_LOG_CONSOLE_LEVEL = "error"; + process.env.PASEO_LOG_FILE_LEVEL = "fatal"; + process.env.PASEO_LOG_FORMAT = "json"; + process.env.PASEO_LOG_FILE_PATH = "logs/daemon-custom.log"; + process.env.PASEO_LOG_FILE_ROTATE_SIZE = "15m"; + process.env.PASEO_LOG_FILE_ROTATE_COUNT = "4"; + + const config: PersistedConfig = { + log: { + console: { + level: "info", + format: "pretty", + }, + file: { + level: "trace", + path: "/tmp/will-be-overridden.log", + rotate: { + maxSize: "30m", + maxFiles: 8, + }, + }, + }, + }; + + const result = resolveLogConfig(config, { paseoHome }); + expect(result).toEqual({ + level: "error", + console: { + level: "error", + format: "json", + }, + file: { + level: "fatal", + path: path.resolve(paseoHome, "logs/daemon-custom.log"), + rotate: { + maxSize: "15m", + maxFiles: 4, + }, + }, + }); + }); + + it("keeps backwards compatibility for legacy log.level and log.format", () => { const config: PersistedConfig = { log: { level: "warn", - }, - }; - const result = resolveLogConfig(config); - expect(result).toEqual({ level: "warn", format: "pretty" }); - }); - - it("handles partial config - format only", () => { - const config: PersistedConfig = { - log: { format: "json", }, }; - const result = resolveLogConfig(config); - expect(result).toEqual({ level: "debug", format: "json" }); + + const result = resolveLogConfig(config, { paseoHome }); + expect(result).toEqual({ + level: "warn", + console: { + level: "warn", + format: "json", + }, + file: { + level: "warn", + path: path.join(paseoHome, "daemon.log"), + rotate: { + maxSize: "10m", + maxFiles: 2, + }, + }, + }); }); - it("handles empty log object in config", () => { + it("keeps backwards compatibility for legacy env vars", () => { + process.env.PASEO_LOG = "error"; + process.env.PASEO_LOG_FORMAT = "json"; + + const result = resolveLogConfig(undefined, { paseoHome }); + expect(result).toEqual({ + level: "error", + console: { + level: "error", + format: "json", + }, + file: { + level: "error", + path: path.join(paseoHome, "daemon.log"), + rotate: { + maxSize: "10m", + maxFiles: 2, + }, + }, + }); + }); + + it("supports partial destination config and retains defaults", () => { const config: PersistedConfig = { - log: {}, + log: { + console: { + level: "warn", + }, + }, }; - const result = resolveLogConfig(config); - expect(result).toEqual({ level: "debug", format: "pretty" }); + + const result = resolveLogConfig(config, { paseoHome }); + expect(result).toEqual({ + level: "trace", + console: { + level: "warn", + format: "pretty", + }, + file: { + level: "trace", + path: path.join(paseoHome, "daemon.log"), + rotate: { + maxSize: "10m", + maxFiles: 2, + }, + }, + }); }); - it("supports all log levels", () => { - const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> = - ["trace", "debug", "info", "warn", "error", "fatal"]; + it("ignores invalid rotate count env var and falls back to config value", () => { + process.env.PASEO_LOG_FILE_ROTATE_COUNT = "0"; + const config: PersistedConfig = { + log: { + file: { + rotate: { + maxFiles: 7, + }, + }, + }, + }; + + const result = resolveLogConfig(config, { paseoHome }); + expect(result.file.rotate.maxFiles).toBe(7); + }); + + it("supports all log levels for destination-specific env vars", () => { + const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> = [ + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ]; for (const level of levels) { - process.env.PASEO_LOG = level; - const result = resolveLogConfig(undefined); + process.env.PASEO_LOG_CONSOLE_LEVEL = level; + process.env.PASEO_LOG_FILE_LEVEL = level; + const result = resolveLogConfig(undefined, { paseoHome }); + expect(result.console.level).toBe(level); + expect(result.file.level).toBe(level); expect(result.level).toBe(level); } }); diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index f9171badf..46da3d0d3 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -1,49 +1,248 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; import pino from "pino"; +import pretty from "pino-pretty"; +import { createStream as createRotatingFileStream } from "rotating-file-stream"; import type { PersistedConfig } from "./persisted-config.js"; +import { resolvePaseoHome } from "./paseo-home.js"; export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; export type LogFormat = "pretty" | "json"; export interface ResolvedLogConfig { level: LogLevel; - format: LogFormat; + console: { + level: LogLevel; + format: LogFormat; + }; + file: { + level: LogLevel; + path: string; + rotate: { + maxSize: string; + maxFiles: number; + }; + }; +} + +type LegacyLogConfig = { + level?: LogLevel; + format?: LogFormat; +}; + +type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined; + +type ResolveLogConfigOptions = { + paseoHome?: string; + env?: NodeJS.ProcessEnv; +}; + +const LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; +const LOG_FORMATS: LogFormat[] = ["pretty", "json"]; +const LOG_LEVEL_PRIORITIES: Record = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, +}; + +const DEFAULT_CONSOLE_LEVEL: LogLevel = "info"; +const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty"; +const DEFAULT_FILE_LEVEL: LogLevel = "trace"; +const DEFAULT_FILE_ROTATE_SIZE = "10m"; +const DEFAULT_FILE_ROTATE_MAX_FILES = 2; +const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log"; + +function parseLogLevel(value: string | undefined): LogLevel | undefined { + if (!value || !LOG_LEVELS.includes(value as LogLevel)) { + return undefined; + } + return value as LogLevel; +} + +function parseLogFormat(value: string | undefined): LogFormat | undefined { + if (!value || !LOG_FORMATS.includes(value as LogFormat)) { + return undefined; + } + return value as LogFormat; +} + +function parsePositiveInteger(value: string | undefined): number | undefined { + if (!value || value.trim().length === 0) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return undefined; + } + + return parsed; +} + +function resolveFilePath(paseoHome: string, configuredPath: string | undefined): string { + const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME); + if (!configuredPath) { + return fallback; + } + + if (path.isAbsolute(configuredPath)) { + return configuredPath; + } + + return path.resolve(paseoHome, configuredPath); +} + +function minLogLevel(levels: LogLevel[]): LogLevel { + let minLevel = levels[0]; + + for (const level of levels) { + if (LOG_LEVEL_PRIORITIES[level] < LOG_LEVEL_PRIORITIES[minLevel]) { + minLevel = level; + } + } + + return minLevel; +} + +function resolveConfiguredPaseoHome( + options: ResolveLogConfigOptions | undefined +): string { + if (options?.paseoHome) { + return options.paseoHome; + } + return resolvePaseoHome(options?.env ?? process.env); +} + +function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined { + if (!config) { + return undefined; + } + + if ("log" in config) { + return config as PersistedConfig; + } + + if ("level" in config || "format" in config) { + const legacy = config as LegacyLogConfig; + return { + log: { + ...(legacy.level ? { level: legacy.level } : {}), + ...(legacy.format ? { format: legacy.format } : {}), + }, + }; + } + + return config as PersistedConfig; +} + +function toRotatingFileStreamSize(size: string): string { + const trimmed = size.trim(); + const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/); + if (!match) { + return trimmed; + } + + const value = match[1]; + const unit = (match[2] ?? "M").toUpperCase(); + return `${value}${unit}`; } export function resolveLogConfig( - persistedConfig: PersistedConfig | undefined + configInput: LoggerConfigInput, + options?: ResolveLogConfigOptions ): ResolvedLogConfig { - const envLevel = process.env.PASEO_LOG as LogLevel | undefined; - const envFormat = process.env.PASEO_LOG_FORMAT as LogFormat | undefined; + const persistedConfig = normalizeLoggerConfigInput(configInput); + const env = options?.env ?? process.env; + const paseoHome = resolveConfiguredPaseoHome(options); + const persistedLog = persistedConfig?.log; - const level: LogLevel = - envLevel ?? persistedConfig?.log?.level ?? "debug"; - const format: LogFormat = - envFormat ?? persistedConfig?.log?.format ?? "pretty"; + const envGlobalLevel = parseLogLevel(env.PASEO_LOG); + const persistedGlobalLevel = persistedLog?.level; + const consoleLevel: LogLevel = + parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ?? + envGlobalLevel ?? + persistedLog?.console?.level ?? + persistedGlobalLevel ?? + DEFAULT_CONSOLE_LEVEL; - return { level, format }; + const fileLevel: LogLevel = + parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ?? + envGlobalLevel ?? + persistedLog?.file?.level ?? + persistedGlobalLevel ?? + DEFAULT_FILE_LEVEL; + + const consoleFormat: LogFormat = + parseLogFormat(env.PASEO_LOG_FORMAT) ?? + persistedLog?.console?.format ?? + persistedLog?.format ?? + DEFAULT_CONSOLE_FORMAT; + + const filePath = resolveFilePath( + paseoHome, + env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path + ); + + const rotateMaxSize = + env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() || + persistedLog?.file?.rotate?.maxSize || + DEFAULT_FILE_ROTATE_SIZE; + + const rotateMaxFiles = + parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ?? + persistedLog?.file?.rotate?.maxFiles ?? + DEFAULT_FILE_ROTATE_MAX_FILES; + + return { + level: minLogLevel([consoleLevel, fileLevel]), + console: { + level: consoleLevel, + format: consoleFormat, + }, + file: { + level: fileLevel, + path: filePath, + rotate: { + maxSize: rotateMaxSize, + maxFiles: rotateMaxFiles, + }, + }, + }; } export function createRootLogger( - persistedConfig: PersistedConfig | undefined + configInput: LoggerConfigInput, + options?: ResolveLogConfigOptions ): pino.Logger { - const config = resolveLogConfig(persistedConfig); + const config = resolveLogConfig(configInput, options); - const transport = - config.format === "pretty" - ? { - target: "pino-pretty", - options: { - colorize: true, - singleLine: true, - ignore: "pid,hostname", - }, - } - : undefined; + mkdirSync(path.dirname(config.file.path), { recursive: true }); - return pino({ - level: config.level, - transport, + const consoleStream = + config.console.format === "pretty" + ? pretty({ + colorize: true, + singleLine: true, + ignore: "pid,hostname", + }) + : pino.destination({ dest: 1, sync: false }); + + const fileStream = createRotatingFileStream(path.basename(config.file.path), { + path: path.dirname(config.file.path), + size: toRotatingFileStreamSize(config.file.rotate.maxSize), + maxFiles: config.file.rotate.maxFiles, }); + + return pino( + { level: config.level }, + pino.multistream([ + { level: config.console.level, stream: consoleStream }, + { level: config.file.level, stream: fileStream }, + ]) + ); } export function createChildLogger(parent: pino.Logger, name: string): pino.Logger { diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 2e9c2625b..e062eab33 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -57,3 +57,53 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { expect(result.success).toBe(false); }); }); + +describe("PersistedConfigSchema logging config", () => { + test("accepts destination-specific logging config", () => { + const parsed = PersistedConfigSchema.parse({ + log: { + console: { + level: "info", + format: "pretty", + }, + file: { + level: "trace", + path: "daemon.log", + rotate: { + maxSize: "10m", + maxFiles: 2, + }, + }, + }, + }); + + expect(parsed.log?.console?.level).toBe("info"); + expect(parsed.log?.file?.level).toBe("trace"); + expect(parsed.log?.file?.rotate?.maxFiles).toBe(2); + }); + + test("accepts legacy logging config fields", () => { + const parsed = PersistedConfigSchema.parse({ + log: { + level: "debug", + format: "json", + }, + }); + + expect(parsed.log?.level).toBe("debug"); + expect(parsed.log?.format).toBe("json"); + }); + + test("rejects unknown logging config fields", () => { + const result = PersistedConfigSchema.safeParse({ + log: { + console: { + level: "info", + color: "red", + }, + }, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 90ee10c33..861dfa485 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -4,12 +4,37 @@ import { z } from "zod"; import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js"; +const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); +const LogFormatSchema = z.enum(["pretty", "json"]); + const LogConfigSchema = z .object({ - level: z - .enum(["trace", "debug", "info", "warn", "error", "fatal"]) + // Legacy global log settings (kept for backwards compatibility). + level: LogLevelSchema.optional(), + format: LogFormatSchema.optional(), + + console: z + .object({ + level: LogLevelSchema.optional(), + format: LogFormatSchema.optional(), + }) + .strict() + .optional(), + + file: z + .object({ + level: LogLevelSchema.optional(), + path: z.string().min(1).optional(), + rotate: z + .object({ + maxSize: z.string().min(1).optional(), + maxFiles: z.number().int().positive().optional(), + }) + .strict() + .optional(), + }) + .strict() .optional(), - format: z.enum(["pretty", "json"]).optional(), }) .strict(); diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 5afb19a64..85d61fee2 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -320,6 +320,45 @@ "pretty", "json" ] + }, + "console": { + "type": "object", + "properties": { + "level": { + "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" + }, + "format": { + "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/format" + } + }, + "additionalProperties": false + }, + "file": { + "type": "object", + "properties": { + "level": { + "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "rotate": { + "type": "object", + "properties": { + "maxSize": { + "type": "string", + "minLength": 1 + }, + "maxFiles": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/packages/website/src/routes/docs/configuration.tsx b/packages/website/src/routes/docs/configuration.tsx index dd5276134..74711be51 100644 --- a/packages/website/src/routes/docs/configuration.tsx +++ b/packages/website/src/routes/docs/configuration.tsx @@ -168,12 +168,61 @@ docker run --rm -i \\

+
+

Logging

+

+ Daemon logging uses separate console and file sinks by default: +

+
    +
  • + Console: info and above +
  • +
  • + File ($PASEO_HOME/daemon.log):{' '} + trace and above +
  • +
  • + File rotation: 10m max file size,{' '} + 2 retained files total (active + 1 rotated) +
  • +
+
+{`{
+  "log": {
+    "console": {
+      "level": "info",
+      "format": "pretty"
+    },
+    "file": {
+      "level": "trace",
+      "path": "daemon.log",
+      "rotate": {
+        "maxSize": "10m",
+        "maxFiles": 2
+      }
+    }
+  }
+}`}
+        
+

+ Legacy fields log.level and{' '} + log.format are still supported and map to the new + destination settings. +

+
+

Common env vars

  • PASEO_HOME — set Paseo home directory
  • PASEO_LISTEN — override daemon.listen
  • PASEO_ALLOWED_HOSTS — override/extend daemon.allowedHosts
  • +
  • PASEO_LOG_CONSOLE_LEVEL — override log.console.level
  • +
  • PASEO_LOG_FILE_LEVEL — override log.file.level
  • +
  • PASEO_LOG_FILE_PATH — override log.file.path
  • +
  • PASEO_LOG_FILE_ROTATE_SIZE — override log.file.rotate.maxSize
  • +
  • PASEO_LOG_FILE_ROTATE_COUNT — override log.file.rotate.maxFiles
  • +
  • PASEO_LOG, PASEO_LOG_FORMAT — legacy log overrides (still supported)
  • OPENAI_API_KEY — override OpenAI provider key
  • PASEO_VOICE_LLM_PROVIDER — override voice LLM provider (claude, codex, opencode)
  • PASEO_DICTATION_STT_PROVIDER, PASEO_VOICE_STT_PROVIDER, PASEO_VOICE_TTS_PROVIDER — override voice provider selection (local or openai)