feat: 运行时日志系统,Pino + pino-pretty + pino-roll,console/file 双输出,敏感信息 redaction
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 必须为正整数"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
274
src/server/logger.ts
Normal 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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user