feat: 运行时日志系统,Pino + pino-pretty + pino-roll,console/file 双输出,敏感信息 redaction
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user