1
0

feat: 运行时日志系统,Pino + pino-pretty + pino-roll,console/file 双输出,敏感信息 redaction

This commit is contained in:
2026-05-21 12:21:59 +08:00
parent 0d709c7681
commit 007d74934d
26 changed files with 1713 additions and 114 deletions

View File

@@ -1,12 +1,15 @@
import { join } from "node:path";
import type { RuntimeMode } from "../shared/api";
import type { ResolvedLoggingConfig } from "./checker/types";
import type { Logger } from "./logger";
import type { StartServerOptions } from "./server";
import type { StaticAssets } from "./static";
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { ProbeStore } from "./checker/store";
import { createRuntimeLogger } from "./logger";
import { startServer } from "./server";
export interface BootstrapDependencies {
@@ -15,7 +18,9 @@ export interface BootstrapDependencies {
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
logger: Logger,
) => BootstrapEngine;
createLogger?: (config: ResolvedLoggingConfig, mode: string, version: string) => Promise<Logger>;
createStore?: (dbPath: string) => ProbeStore;
exit?: (code: number) => never;
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
@@ -39,8 +44,14 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath));
const createEngine =
dependencies.createEngine ??
((store: ProbeStore, targets: ResolvedConfig["targets"], maxConcurrentChecks: number, retentionMs: number) =>
new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs));
((
store: ProbeStore,
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
logger: Logger,
) => new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs, logger));
const buildLogger = dependencies.createLogger ?? createRuntimeLogger;
const serve = dependencies.startServer ?? startServer;
const onSignal =
dependencies.onSignal ??
@@ -52,18 +63,42 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
let store: ProbeStore | undefined;
let engine: BootstrapEngine | undefined;
let logger: Logger | undefined;
try {
const config = await load(options.configPath);
try {
logger = await buildLogger(config.logging, options.mode, options.version);
} catch (logInitError) {
logError("日志初始化失败:", logInitError instanceof Error ? logInitError.message : logInitError);
exit(1);
}
logger!.info({ configPath: options.configPath, mode: options.mode, version: options.version }, "配置加载成功");
store = createStore(join(config.dataDir, "probe.db"));
store.syncTargets(config.targets);
logger!.info({ dataDir: config.dataDir }, "数据库初始化成功");
engine = createEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
engine = createEngine(
store,
config.targets,
config.maxConcurrentChecks,
config.retentionMs,
logger!.child({ component: "engine" }),
);
engine.start();
logger!.info(
{ maxConcurrentChecks: config.maxConcurrentChecks, targetCount: config.targets.length },
"调度引擎启动",
);
const shutdown = () => {
logger?.info("收到退出信号,开始优雅关机");
engine?.stop();
store?.close();
logger?.flush();
exit(0);
};
onSignal("SIGINT", shutdown);
@@ -71,6 +106,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
serve({
config: { host: config.host, port: config.port },
logger: logger!.child({ component: "server" }),
mode: options.mode,
staticAssets: options.staticAssets,
store,
@@ -79,7 +115,12 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
} catch (error) {
engine?.stop();
store?.close();
logError("启动失败:", error instanceof Error ? error.message : error);
if (logger) {
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
logger.flush();
} else {
logError("启动失败:", error instanceof Error ? error.message : error);
}
exit(1);
}
}

View File

@@ -2,13 +2,22 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues";
import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } from "./types";
import type {
DefaultsConfig,
EngineRuntimeConfig,
LoggingConfig,
LogLevel,
RawTargetConfig,
ResolvedLoggingConfig,
ResolvedTargetBase,
RotationFrequency,
} from "./types";
import { checkerRegistry } from "./runner";
import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
import { parseDuration, parseSize } from "./utils";
import { resolveVariables } from "./variables";
const DEFAULT_HOST = "127.0.0.1";
@@ -18,11 +27,19 @@ const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s";
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
const DEFAULT_RETENTION = "7d";
const DEFAULT_LOG_LEVEL: LogLevel = "info";
const DEFAULT_ROTATION_SIZE = "50MB";
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
const DEFAULT_ROTATION_MAX_FILES = 14;
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
export interface ResolvedConfig {
configDir: string;
dataDir: string;
host: string;
logging: ResolvedLoggingConfig;
maxConcurrentChecks: number;
port: number;
retentionMs: number;
@@ -80,7 +97,10 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const retentionMs = resolveRetention(runtime);
const logging = resolveLogging(validated.logging ?? {}, dataDir, configDir);
const allRuntimeIssues = [...allIssues];
validateLoggingConfig(validated.logging, allRuntimeIssues);
if (allRuntimeIssues.length > 0) {
throwConfigIssues(dedupeIssues(allRuntimeIssues));
}
@@ -92,7 +112,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { configDir, dataDir, host, maxConcurrentChecks, port, retentionMs, targets };
return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets };
}
function canRunSemanticValidation(value: unknown): boolean {
@@ -113,6 +133,45 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[]
export { parseDuration } from "./utils";
function isAbsolute(p: string): boolean {
return p.startsWith("/") || /^[A-Za-z]:/.test(p);
}
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
const rawPath = logging.file?.path;
const filePath = rawPath
? isAbsolute(rawPath)
? rawPath
: resolve(configDir, rawPath)
: resolve(dataDir, "logs/dial.log");
const rotationRaw = logging.file?.rotation;
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
const rotationSizeBytes = parseSize(rotationSizeRaw);
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
return {
consoleLevel,
fileLevel,
filePath,
rotationFrequency,
rotationMaxFiles,
rotationSizeBytes,
rotationSizeRaw,
};
}
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
if (!isString(level)) return fallback;
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
return fallback;
}
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
@@ -263,3 +322,69 @@ function validateDurationValue(
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
}
}
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
if (logging === undefined) return;
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
issues.push(
issue("invalid-value", "logging.level", `日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`),
);
}
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
issues.push(
issue(
"invalid-value",
"logging.console.level",
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
issues.push(
issue(
"invalid-value",
"logging.file.level",
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
if (logging.file?.path !== undefined) {
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
issues.push(issue("invalid-value", "logging.file.path", "日志路径不能为空字符串或空白字符串"));
}
}
const rotation = logging.file?.rotation;
if (rotation?.size !== undefined) {
try {
const bytes = parseSize(rotation.size);
if (bytes <= 0) {
issues.push(issue("invalid-value", "logging.file.rotation.size", "滚动大小必须为正整数字节数"));
}
} catch (error) {
issues.push(
issue("invalid-value", "logging.file.rotation.size", error instanceof Error ? error.message : "size 格式非法"),
);
}
}
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
issues.push(
issue(
"invalid-value",
"logging.file.rotation.frequency",
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
),
);
}
if (rotation?.maxFiles !== undefined) {
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
issues.push(issue("invalid-value", "logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
}
}
}

View File

@@ -1,14 +1,18 @@
import { groupBy, isError, Semaphore } from "es-toolkit";
import type { Logger } from "../logger";
import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTargetBase } from "./types";
import { createNoopLogger } from "../logger";
import { errorFailure } from "./expect/failure";
import { checkerRegistry } from "./runner";
const PRUNE_INTERVAL_MS = 3600000;
export class ProbeEngine {
private lastMatched = new Map<string, boolean>();
private logger: Logger;
private retentionMs: number;
private semaphore: Semaphore;
private store: ProbeStore;
@@ -16,12 +20,20 @@ export class ProbeEngine {
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number, retentionMs?: number) {
constructor(
store: ProbeStore,
targets: ResolvedTargetBase[],
maxConcurrentChecks?: number,
retentionMs?: number,
logger?: Logger,
) {
this.store = store;
this.targets = targets;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
this.retentionMs = retentionMs ?? 0;
this.logger = logger ?? createNoopLogger();
this.refreshCache();
this.initStateCache();
}
start(): void {
@@ -53,6 +65,49 @@ export class ProbeEngine {
this.timers = [];
}
private initStateCache(): void {
const latestMap = this.store.getLatestChecksMap();
for (const [id, row] of latestMap) {
this.lastMatched.set(id, row.matched === 1);
}
}
private logCheckDebug(result: CheckResult): void {
this.logger.debug({
durationMs: result.durationMs,
failureMessage: result.failure?.message ?? null,
failurePhase: result.failure?.phase ?? null,
matched: result.matched,
targetId: result.targetId,
});
}
private logStateChange(result: CheckResult): void {
const previous = this.lastMatched.get(result.targetId);
const current = result.matched;
if (previous === undefined) {
if (!current) {
this.logger.warn(
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
`目标首次 DOWN: ${result.targetId}`,
);
}
} else if (previous && !current) {
this.logger.warn(
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
`目标状态变化 UP → DOWN: ${result.targetId}`,
);
} else if (!previous && current) {
this.logger.info(
{ durationMs: result.durationMs, targetId: result.targetId },
`目标恢复 DOWN → UP: ${result.targetId}`,
);
}
this.lastMatched.set(result.targetId, current);
}
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
@@ -68,19 +123,25 @@ export class ProbeEngine {
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
this.writeResult(result.value);
this.logStateChange(result.value);
this.logCheckDebug(result.value);
} else {
const target = targets[index];
console.warn(`探针执行失败: ${formatReason(result.reason)}`);
if (!target) continue;
this.writeResult({
detail: null,
durationMs: null,
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
observation: null,
targetId: target.id,
timestamp: new Date().toISOString(),
});
if (target) {
this.logger.error(
{ reason: formatReason(result.reason), targetId: target.id, targetType: target.type },
`探针执行失败: ${formatReason(result.reason)}`,
);
this.writeResult({
detail: null,
durationMs: null,
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
observation: null,
targetId: target.id,
timestamp: new Date().toISOString(),
});
}
}
}
}

View File

@@ -10,9 +10,13 @@ import {
createRawValueExpectationSchema,
createValueMatcherObjectSchema,
durationSchema,
sizeSchema,
variableValueSchema,
} from "./fragments";
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return {
...cloneSchema(createProbeConfigSchema(checkers, true)),
@@ -31,6 +35,7 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
return Type.Object(
{
defaults: Type.Optional(createDefaultsSchema(checkers)),
logging: Type.Optional(createLoggingSchema()),
runtime: Type.Optional(
Type.Object(
{
@@ -107,3 +112,35 @@ function createDefaultsSchema(checkers: CheckerDefinition[]): TSchema {
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
}
function createLoggingSchema(): TSchema {
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
return Type.Object(
{
console: Type.Optional(Type.Object({ level: Type.Optional(logLevelSchema) }, { additionalProperties: false })),
file: Type.Optional(
Type.Object(
{
level: Type.Optional(logLevelSchema),
path: Type.Optional(Type.String({ minLength: 1 })),
rotation: Type.Optional(
Type.Object(
{
frequency: Type.Optional(
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
),
maxFiles: Type.Optional(Type.Integer({ minimum: 1 })),
size: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
),
level: Type.Optional(logLevelSchema),
},
{ additionalProperties: false },
);
}

View File

@@ -17,8 +17,33 @@ export interface EngineRuntimeConfig {
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
export interface LoggingConfig {
console?: LoggingConsoleConfig;
file?: LoggingFileConfig;
level?: LogLevel;
}
export interface LoggingConsoleConfig {
level?: LogLevel;
}
export interface LoggingFileConfig {
level?: LogLevel;
path?: string;
rotation?: LoggingFileRotationConfig;
}
export interface LoggingFileRotationConfig {
frequency?: RotationFrequency;
maxFiles?: number;
size?: string;
}
export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn";
export interface ProbeConfig {
defaults?: DefaultsConfig;
logging?: LoggingConfig;
runtime?: EngineRuntimeConfig;
server?: ServerConfig;
targets: RawTargetConfig[];
@@ -37,6 +62,16 @@ export interface RawTargetConfig {
type: string;
}
export interface ResolvedLoggingConfig {
consoleLevel: LogLevel;
fileLevel: LogLevel;
filePath: string;
rotationFrequency: RotationFrequency;
rotationMaxFiles: number;
rotationSizeBytes: number;
rotationSizeRaw: string;
}
export interface ResolvedTargetBase {
[key: string]: unknown;
description: null | string;
@@ -50,6 +85,8 @@ export interface ResolvedTargetBase {
type: string;
}
export type RotationFrequency = "daily" | "hourly" | "weekly";
export interface ServerConfig {
dataDir?: string;
host?: string;

View File

@@ -1,7 +1,9 @@
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
import type { StoredCheckResult } from "./checker/types";
import type { Logger } from "./logger";
import { checkerRegistry } from "./checker/runner";
import { createNoopLogger } from "./logger";
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
@@ -47,13 +49,14 @@ export function jsonResponse(
});
}
export function mapCheckResult(row: StoredCheckResult, type: string): CheckResult {
export function mapCheckResult(row: StoredCheckResult, type: string, logger?: Logger): CheckResult {
const log = logger ?? createNoopLogger();
let failure: CheckFailure | null = null;
if (row.failure) {
try {
failure = JSON.parse(row.failure) as CheckFailure;
} catch {
console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
log.warn({ targetId: row.target_id, timestamp: row.timestamp }, "无法解析 failure 数据");
failure = null;
}
}
@@ -63,7 +66,7 @@ export function mapCheckResult(row: StoredCheckResult, type: string): CheckResul
try {
observation = JSON.parse(row.observation) as Record<string, unknown>;
} catch {
console.warn(`无法解析 observation 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
log.warn({ targetId: row.target_id, timestamp: row.timestamp }, "无法解析 observation 数据");
observation = null;
}
}

274
src/server/logger.ts Normal file
View File

@@ -0,0 +1,274 @@
import type pino from "pino";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import type { LogLevel, ResolvedLoggingConfig } from "./checker/types";
export interface Logger {
child(bindings: Record<string, unknown>): Logger;
debug(obj: Record<string, unknown>, msg?: string): void;
debug(msg: string): void;
error(obj: Record<string, unknown>, msg?: string): void;
error(msg: string): void;
fatal(obj: Record<string, unknown>, msg?: string): void;
fatal(msg: string): void;
flush(): void;
info(obj: Record<string, unknown>, msg?: string): void;
info(msg: string): void;
trace(obj: Record<string, unknown>, msg?: string): void;
trace(msg: string): void;
warn(obj: Record<string, unknown>, msg?: string): void;
warn(msg: string): void;
}
export const REDACT_PATHS = [
"authorization",
"cookie",
"set-cookie",
"*.set-cookie",
"authToken",
"key",
"password",
"token",
"apiKey",
"*.authorization",
"*.cookie",
"*.authToken",
"*.key",
"*.password",
"*.token",
"*.apiKey",
];
const LOG_LEVEL_MAP: Record<LogLevel, string> = {
debug: "debug",
error: "error",
fatal: "fatal",
info: "info",
trace: "trace",
warn: "warn",
};
type LogFn = (objOrMsg: Record<string, unknown> | string, msg?: string) => void;
const voidLog: LogFn = () => undefined;
class ConsoleFallbackLogger implements Logger {
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.error(formatMsg(objOrMsg, msg));
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.error(formatMsg(objOrMsg, msg));
}
flush: () => void = () => undefined;
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.warn(formatMsg(objOrMsg, msg));
}
}
class NoopLogger implements Logger {
debug: LogFn = voidLog;
error: LogFn = voidLog;
fatal: LogFn = voidLog;
info: LogFn = voidLog;
trace: LogFn = voidLog;
warn: LogFn = voidLog;
child(_bindings: Record<string, unknown>): Logger {
return this;
}
flush: () => void = () => undefined;
}
class PinoLoggerWrapper implements Logger {
private pino: pino.Logger;
constructor(pinoLogger: pino.Logger) {
this.pino = pinoLogger;
}
child(bindings: Record<string, unknown>): Logger {
return new PinoLoggerWrapper(this.pino.child(bindings));
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.debug(objOrMsg);
else this.pino.debug(objOrMsg, msg);
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.error(objOrMsg);
else this.pino.error(objOrMsg, msg);
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.fatal(objOrMsg);
else this.pino.fatal(objOrMsg, msg);
}
flush(): void {
this.pino.flush();
}
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.info(objOrMsg);
else this.pino.info(objOrMsg, msg);
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.trace(objOrMsg);
else this.pino.trace(objOrMsg, msg);
}
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.warn(objOrMsg);
else this.pino.warn(objOrMsg, msg);
}
}
export class MemoryLogger implements Logger {
entries: Array<{ level: string; msg: string; obj?: Record<string, unknown> }> = [];
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("debug", objOrMsg, msg);
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("error", objOrMsg, msg);
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("fatal", objOrMsg, msg);
}
flush: () => void = () => undefined;
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("info", objOrMsg, msg);
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("trace", objOrMsg, msg);
}
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("warn", objOrMsg, msg);
}
private capture(level: string, objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") {
this.entries.push({ level, msg: objOrMsg });
} else {
this.entries.push({ level, msg: msg ?? "", obj: objOrMsg });
}
}
}
export function createConsoleFallback(): Logger {
return new ConsoleFallbackLogger();
}
export function createMemoryLogger(): MemoryLogger {
return new MemoryLogger();
}
export function createNoopLogger(): Logger {
return new NoopLogger();
}
export async function createRuntimeLogger(
config: ResolvedLoggingConfig,
mode: string,
version: string,
): Promise<Logger> {
const pinoLib = await import("pino");
const pinoPretty = await import("pino-pretty");
mkdirSync(dirname(config.filePath), { recursive: true });
const rootLevel = resolveRootLevel(config.consoleLevel, config.fileLevel);
const prettyStream = pinoPretty.default({
colorize: true,
ignore: "pid,hostname",
singleLine: true,
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
});
const fileStream = await createRollingFileStream(config);
const streams: pino.StreamEntry[] = [
{ level: toPinoLevel(config.consoleLevel) as pino.Level, stream: prettyStream },
{ level: toPinoLevel(config.fileLevel) as pino.Level, stream: fileStream },
];
const logger = pinoLib.default(
{
base: { mode, service: "dial-server", version },
level: rootLevel,
redact: { censor: "[Redacted]", paths: REDACT_PATHS },
timestamp: pinoLib.stdTimeFunctions.isoTime,
},
pinoLib.multistream(streams),
);
return new PinoLoggerWrapper(logger);
}
async function createRollingFileStream(config: ResolvedLoggingConfig): Promise<NodeJS.WritableStream> {
const dir = dirname(config.filePath);
const base = resolve(dir, config.filePath.replace(/^.*[\\/]/, "").replace(/\.log$/, ""));
try {
const buildPinoRoll = (await import("pino-roll")).default;
return await buildPinoRoll({
file: base,
frequency: config.rotationFrequency,
limit: { count: config.rotationMaxFiles },
mkdir: true,
size: config.rotationSizeRaw,
});
} catch {
const fs = await import("node:fs");
return fs.createWriteStream(config.filePath, { flags: "a" });
}
}
function formatMsg(objOrMsg: Record<string, unknown> | string, msg?: string): string {
if (typeof objOrMsg === "string") return objOrMsg;
return msg ? `${msg} ${JSON.stringify(objOrMsg)}` : JSON.stringify(objOrMsg);
}
function resolveRootLevel(consoleLevel: LogLevel, fileLevel: LogLevel): string {
const order: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const ci = order.indexOf(consoleLevel);
const fi = order.indexOf(fileLevel);
return LOG_LEVEL_MAP[order[Math.min(ci, fi)]!] ?? "info";
}
function toPinoLevel(level: LogLevel): string {
return LOG_LEVEL_MAP[level];
}

View File

@@ -1,6 +1,7 @@
import type { RuntimeMode } from "../shared/api";
import type { ProbeStore } from "./checker/store";
import type { RuntimeConfig } from "./config";
import type { Logger } from "./logger";
import type { StaticAssets } from "./static";
import { createApiError, jsonResponse } from "./helpers";
@@ -13,6 +14,7 @@ import { serveStaticAsset } from "./static";
export interface StartServerOptions {
config: RuntimeConfig;
logger: Logger;
mode: RuntimeMode;
staticAssets?: StaticAssets;
store: ProbeStore;
@@ -20,7 +22,7 @@ export interface StartServerOptions {
}
export function startServer(options: StartServerOptions) {
const { config, mode, staticAssets, store, version } = options;
const { config, logger, mode, staticAssets, store, version } = options;
const server = Bun.serve({
fetch(req) {
@@ -51,7 +53,7 @@ export function startServer(options: StartServerOptions) {
},
});
console.log(`DiAL listening on ${server.url}`);
logger.info({ host: config.host, port: config.port, url: server.url.toString() }, "DiAL listening");
return server;
}