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

@@ -2086,4 +2086,259 @@ targets:
"expect.status 是未知字段",
);
});
describe("logging 配置", () => {
test("logging 全部缺省时使用默认值", async () => {
const configPath = join(tempDir, "logging-default.yaml");
await writeFile(
configPath,
`targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.logging.consoleLevel).toBe("info");
expect(config.logging.fileLevel).toBe("info");
expect(config.logging.filePath).toBe(join(config.dataDir, "logs/dial.log"));
expect(config.logging.rotationFrequency).toBe("daily");
expect(config.logging.rotationMaxFiles).toBe(14);
expect(config.logging.rotationSizeRaw).toBe("50MB");
expect(config.logging.rotationSizeBytes).toBe(52428800);
});
test("logging.level 设置全局等级继承到 console 和 file", async () => {
const configPath = join(tempDir, "logging-global-level.yaml");
await writeFile(
configPath,
`logging:
level: "debug"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.logging.consoleLevel).toBe("debug");
expect(config.logging.fileLevel).toBe("debug");
});
test("logging.console.level 覆盖全局等级", async () => {
const configPath = join(tempDir, "logging-console-level.yaml");
await writeFile(
configPath,
`logging:
level: "warn"
console:
level: "trace"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.logging.consoleLevel).toBe("trace");
expect(config.logging.fileLevel).toBe("warn");
});
test("logging.file.level 独立覆盖", async () => {
const configPath = join(tempDir, "logging-file-level.yaml");
await writeFile(
configPath,
`logging:
level: "info"
file:
level: "error"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.logging.consoleLevel).toBe("info");
expect(config.logging.fileLevel).toBe("error");
});
test("logging.file.path 绝对路径保持不变", async () => {
const configPath = join(tempDir, "logging-abs-path.yaml");
await writeFile(
configPath,
`logging:
file:
path: "/var/log/dial/app.log"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.logging.filePath).toBe("/var/log/dial/app.log");
});
test("logging.file.path 相对路径基于配置文件目录解析", async () => {
const configPath = join(tempDir, "logging-rel-path.yaml");
await writeFile(
configPath,
`logging:
file:
path: "custom-logs/app.log"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.logging.filePath).toBe(join(tempDir, "custom-logs/app.log"));
});
test("logging.file.rotation 自定义参数", async () => {
const configPath = join(tempDir, "logging-rotation.yaml");
await writeFile(
configPath,
`logging:
file:
rotation:
size: "100MB"
frequency: "hourly"
maxFiles: 30
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.logging.rotationSizeRaw).toBe("100MB");
expect(config.logging.rotationSizeBytes).toBe(104857600);
expect(config.logging.rotationFrequency).toBe("hourly");
expect(config.logging.rotationMaxFiles).toBe(30);
});
test("logging.level 非法等级抛出错误", async () => {
await expectConfigError(
"logging-bad-level.yaml",
`logging:
level: "verbose"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.level",
);
});
test("logging.console.level 非法等级抛出错误", async () => {
await expectConfigError(
"logging-bad-console-level.yaml",
`logging:
console:
level: "nope"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.console.level",
);
});
test("logging.file.level 非法等级抛出错误", async () => {
await expectConfigError(
"logging-bad-file-level.yaml",
`logging:
file:
level: 123
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.level",
);
});
test("logging.file.rotation.size 非法格式抛出错误", async () => {
await expectConfigError(
"logging-bad-rotation-size.yaml",
`logging:
file:
rotation:
size: "100TB"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"无效的 size 格式",
);
});
test("logging.file.rotation.frequency 非法值抛出错误", async () => {
await expectConfigError(
"logging-bad-frequency.yaml",
`logging:
file:
rotation:
frequency: "monthly"
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.rotation.frequency",
);
});
test("logging.file.rotation.maxFiles 非整数抛出错误", async () => {
await expectConfigError(
"logging-bad-maxfiles.yaml",
`logging:
file:
rotation:
maxFiles: 3.5
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.rotation.maxFiles",
);
});
test("logging.file.path 空字符串抛出错误", async () => {
await expectConfigError(
"logging-empty-path.yaml",
`logging:
file:
path: ""
targets:
- id: "t"
type: http
http:
url: "http://example.com"
`,
"logging.file.path",
);
});
});
});

View File

@@ -9,6 +9,7 @@ import { ProbeEngine } from "../../../src/server/checker/engine";
import { checkerRegistry } from "../../../src/server/checker/runner";
import { CommandChecker } from "../../../src/server/checker/runner/cmd/execute";
import { HttpChecker } from "../../../src/server/checker/runner/http/execute";
import { createMemoryLogger } from "../../../src/server/logger";
const processEnv = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
@@ -20,6 +21,9 @@ function createMockStore(targetNames: string[]) {
return {
_results: results,
getLatestChecksMap() {
return new Map();
},
getTargets() {
return targets.map(({ id, name }) => ({
config: "",
@@ -165,38 +169,32 @@ describe("ProbeEngine", () => {
return originalExecute(target, ctx);
};
const originalWarn = console.warn;
console.warn = () => undefined;
try {
const rejectTarget = makeCommandTarget("reject-cmd");
const goodTarget = makeCommandTarget("good-cmd");
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
const rejectTarget = makeCommandTarget("reject-cmd");
const goodTarget = makeCommandTarget("good-cmd");
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([rejectTarget, goodTarget]);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([rejectTarget, goodTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
expect(results[0]!["targetId"]).toBe("reject-cmd");
expect(results[0]!["matched"]).toBe(false);
expect(results[0]!["durationMs"]).toBeNull();
expect(results[0]!["observation"]).toBeNull();
expect(results[0]!["failure"]).toEqual({
kind: "error",
message: "boom",
path: "engine",
phase: "internal",
});
expect(typeof results[0]!["timestamp"]).toBe("string");
expect(results[1]!["targetId"]).toBe("good-cmd");
expect(results[1]!["matched"]).toBe(true);
} finally {
console.warn = originalWarn;
checker.execute = originalExecute;
}
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
expect(results[0]!["targetId"]).toBe("reject-cmd");
expect(results[0]!["matched"]).toBe(false);
expect(results[0]!["durationMs"]).toBeNull();
expect(results[0]!["observation"]).toBeNull();
expect(results[0]!["failure"]).toEqual({
kind: "error",
message: "boom",
path: "engine",
phase: "internal",
});
expect(typeof results[0]!["timestamp"]).toBe("string");
expect(results[1]!["targetId"]).toBe("good-cmd");
expect(results[1]!["matched"]).toBe(true);
checker.execute = originalExecute;
});
test("并发限制 maxConcurrentChecks", async () => {
@@ -347,4 +345,193 @@ describe("ProbeEngine", () => {
expect(pruneCalled).toBe(false);
engine.stop();
});
describe("日志与状态变化", () => {
test("checker rejected 时 logger 记录 error", async () => {
ensureRegistered();
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target, ctx) => {
if (target.id === "fail-target") throw new Error("explode");
return originalExecute(target, ctx);
};
const logger = createMemoryLogger();
const mockStore = createMockStore(["fail-target", "ok-target"]) as unknown as ProbeStore;
const engine = new ProbeEngine(
mockStore,
[makeCommandTarget("fail-target"), makeCommandTarget("ok-target")],
20,
0,
logger,
);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget("fail-target"), makeCommandTarget("ok-target")]);
const errorLogs = logger.entries.filter((e) => e.level === "error");
expect(errorLogs.length).toBeGreaterThanOrEqual(1);
expect(errorLogs[0]!.msg).toContain("探针执行失败");
checker.execute = originalExecute;
});
test("状态变化 UP→DOWN 记录 warn 日志", async () => {
ensureRegistered();
const logger = createMemoryLogger();
const targetId = "state-test";
const mockStore = {
...createMockStore([targetId]),
getLatestChecksMap() {
return new Map([[targetId, { matched: 1 }]]);
},
} as unknown as ProbeStore;
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target) => {
if (target.id === targetId) {
return {
detail: null,
durationMs: 10,
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
matched: false,
observation: null,
targetId,
timestamp: new Date().toISOString(),
};
}
return originalExecute(target, { signal: new AbortController().signal });
};
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
const stateLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("UP → DOWN"));
expect(stateLogs.length).toBe(1);
expect(stateLogs[0]!.msg).toContain(targetId);
checker.execute = originalExecute;
});
test("状态变化 DOWN→UP 记录 info 日志", async () => {
ensureRegistered();
const logger = createMemoryLogger();
const targetId = "recover-test";
const mockStore = {
...createMockStore([targetId]),
getLatestChecksMap() {
return new Map([[targetId, { matched: 0 }]]);
},
} as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
const recoverLogs = logger.entries.filter((e) => e.level === "info" && e.msg.includes("DOWN → UP"));
expect(recoverLogs.length).toBe(1);
expect(recoverLogs[0]!.msg).toContain(targetId);
});
test("稳态 UP→UP 不产生状态变化日志", async () => {
ensureRegistered();
const logger = createMemoryLogger();
const targetId = "steady-up";
const mockStore = {
...createMockStore([targetId]),
getLatestChecksMap() {
return new Map([[targetId, { matched: 1 }]]);
},
} as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
const stateChangeLogs = logger.entries.filter(
(e) => e.msg.includes("UP → DOWN") || e.msg.includes("DOWN → UP") || e.msg.includes("首次 DOWN"),
);
expect(stateChangeLogs.length).toBe(0);
});
test("首次检查 DOWN 记录 warn 日志", async () => {
ensureRegistered();
const logger = createMemoryLogger();
const targetId = "first-down";
const mockStore = createMockStore([targetId]) as unknown as ProbeStore;
const checker = checkerRegistry.get("cmd");
const originalExecute = checker.execute.bind(checker);
checker.execute = async (target) => {
if (target.id === targetId) {
return {
detail: null,
durationMs: 10,
failure: { kind: "error", message: "fail", path: "exitCode", phase: "body" },
matched: false,
observation: null,
targetId,
timestamp: new Date().toISOString(),
};
}
return originalExecute(target, { signal: new AbortController().signal });
};
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
const firstDownLogs = logger.entries.filter((e) => e.level === "warn" && e.msg.includes("首次 DOWN"));
expect(firstDownLogs.length).toBe(1);
checker.execute = originalExecute;
});
test("每次检查产出 debug 日志", async () => {
ensureRegistered();
const logger = createMemoryLogger();
const targetId = "debug-target";
const mockStore = createMockStore([targetId]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget(targetId)], 20, 0, logger);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget(targetId)]);
const debugLogs = logger.entries.filter((e) => e.level === "debug");
expect(debugLogs.length).toBeGreaterThanOrEqual(1);
const first = debugLogs[0]!;
expect(first.obj).toBeDefined();
expect(first.obj!["targetId"]).toBe(targetId);
});
test("无 logger 时不抛错noop logger 兜底)", async () => {
ensureRegistered();
const mockStore = createMockStore(["no-log"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [makeCommandTarget("no-log")]);
const probeGroup = (
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
).probeGroup.bind(engine);
await probeGroup([makeCommandTarget("no-log")]);
});
});
});