feat(daemon): split console/file logging defaults with rotation (#88)
This commit is contained in:
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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<DetachedStartupResult>((resolve) => {
|
||||
let settled = false
|
||||
|
||||
const startup = await new Promise<DetachedStartupResult>((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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<LogLevel, number> = {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -168,12 +168,61 @@ docker run --rm -i \\
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-medium">Logging</h2>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Daemon logging uses separate console and file sinks by default:
|
||||
</p>
|
||||
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||
<li>
|
||||
Console: <code className="font-mono">info</code> and above
|
||||
</li>
|
||||
<li>
|
||||
File (<code className="font-mono">$PASEO_HOME/daemon.log</code>):{' '}
|
||||
<code className="font-mono">trace</code> and above
|
||||
</li>
|
||||
<li>
|
||||
File rotation: <code className="font-mono">10m</code> max file size,{' '}
|
||||
<code className="font-mono">2</code> retained files total (active + 1 rotated)
|
||||
</li>
|
||||
</ul>
|
||||
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
|
||||
{`{
|
||||
"log": {
|
||||
"console": {
|
||||
"level": "info",
|
||||
"format": "pretty"
|
||||
},
|
||||
"file": {
|
||||
"level": "trace",
|
||||
"path": "daemon.log",
|
||||
"rotate": {
|
||||
"maxSize": "10m",
|
||||
"maxFiles": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Legacy fields <code className="font-mono">log.level</code> and{' '}
|
||||
<code className="font-mono">log.format</code> are still supported and map to the new
|
||||
destination settings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-medium">Common env vars</h2>
|
||||
<ul className="text-white/60 space-y-2 list-disc list-inside">
|
||||
<li><code className="font-mono">PASEO_HOME</code> — set Paseo home directory</li>
|
||||
<li><code className="font-mono">PASEO_LISTEN</code> — override <code className="font-mono">daemon.listen</code></li>
|
||||
<li><code className="font-mono">PASEO_ALLOWED_HOSTS</code> — override/extend <code className="font-mono">daemon.allowedHosts</code></li>
|
||||
<li><code className="font-mono">PASEO_LOG_CONSOLE_LEVEL</code> — override <code className="font-mono">log.console.level</code></li>
|
||||
<li><code className="font-mono">PASEO_LOG_FILE_LEVEL</code> — override <code className="font-mono">log.file.level</code></li>
|
||||
<li><code className="font-mono">PASEO_LOG_FILE_PATH</code> — override <code className="font-mono">log.file.path</code></li>
|
||||
<li><code className="font-mono">PASEO_LOG_FILE_ROTATE_SIZE</code> — override <code className="font-mono">log.file.rotate.maxSize</code></li>
|
||||
<li><code className="font-mono">PASEO_LOG_FILE_ROTATE_COUNT</code> — override <code className="font-mono">log.file.rotate.maxFiles</code></li>
|
||||
<li><code className="font-mono">PASEO_LOG</code>, <code className="font-mono">PASEO_LOG_FORMAT</code> — legacy log overrides (still supported)</li>
|
||||
<li><code className="font-mono">OPENAI_API_KEY</code> — override OpenAI provider key</li>
|
||||
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — override voice LLM provider (<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, <code className="font-mono">opencode</code>)</li>
|
||||
<li><code className="font-mono">PASEO_DICTATION_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_TTS_PROVIDER</code> — override voice provider selection (<code className="font-mono">local</code> or <code className="font-mono">openai</code>)</li>
|
||||
|
||||
Reference in New Issue
Block a user