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)