feat(daemon): split console/file logging defaults with rotation (#88)

This commit is contained in:
Mohamed Boudra
2026-03-02 12:18:30 +07:00
committed by GitHub
parent 3a6a8cfa38
commit 01faa3467d
10 changed files with 643 additions and 149 deletions
+1 -1
View File
@@ -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);
+193 -69
View File
@@ -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);
}
});
+224 -25
View File
@@ -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);
});
});
+28 -3
View File
@@ -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();