feat: 运行时日志系统,Pino + pino-pretty + pino-roll,console/file 双输出,敏感信息 redaction
This commit is contained in:
@@ -15,6 +15,7 @@ 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 { ProbeStore } from "../../src/server/checker/store";
|
||||
import { createNoopLogger } from "../../src/server/logger";
|
||||
import { startServer } from "../../src/server/server";
|
||||
import { rmRetry } from "../helpers";
|
||||
|
||||
@@ -173,6 +174,7 @@ describe("API 路由", () => {
|
||||
|
||||
server = startServer({
|
||||
config: { host: "127.0.0.1", port: 0 },
|
||||
logger: createNoopLogger(),
|
||||
mode: "test",
|
||||
store,
|
||||
version: "0.1.0",
|
||||
@@ -410,6 +412,7 @@ describe("API 路由", () => {
|
||||
test("生产响应包含安全 headers", async () => {
|
||||
const prodServer = startServer({
|
||||
config: { host: "127.0.0.1", port: 0 },
|
||||
logger: createNoopLogger(),
|
||||
mode: "production",
|
||||
store,
|
||||
version: "0.1.0",
|
||||
@@ -424,35 +427,29 @@ describe("API 路由", () => {
|
||||
});
|
||||
|
||||
test("损坏的 failure JSON 返回 null 而不崩溃", async () => {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => undefined;
|
||||
try {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
store.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "test", path: "$", phase: "body" },
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-06-01T00:00:00.000Z",
|
||||
});
|
||||
store.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "test", path: "$", phase: "body" },
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-06-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
||||
.prepare("UPDATE check_results SET failure = ? WHERE target_id = ? AND timestamp = ?")
|
||||
.run("{invalid json!!!", t1Id, "2025-06-01T00:00:00.000Z");
|
||||
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
||||
.prepare("UPDATE check_results SET failure = ? WHERE target_id = ? AND timestamp = ?")
|
||||
.run("{invalid json!!!", t1Id, "2025-06-01T00:00:00.000Z");
|
||||
|
||||
const from = "2025-06-01T00:00:00.000Z";
|
||||
const to = "2025-06-01T23:59:59.999Z";
|
||||
const response = await fetch(`${baseUrl}/api/targets/${t1Id}/history?from=${from}&to=${to}`);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
const from = "2025-06-01T00:00:00.000Z";
|
||||
const to = "2025-06-01T23:59:59.999Z";
|
||||
const response = await fetch(`${baseUrl}/api/targets/${t1Id}/history?from=${from}&to=${to}`);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0]!.failure).toBeNull();
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0]!.failure).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ProbeStore } from "../../src/server/checker/store";
|
||||
import type { ResolvedTargetBase } from "../../src/server/checker/types";
|
||||
|
||||
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
|
||||
import { createNoopLogger } from "../../src/server/logger";
|
||||
|
||||
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||
|
||||
@@ -27,6 +28,15 @@ function createHarness(overrides: BootstrapDependencies = {}) {
|
||||
configDir: "/tmp",
|
||||
dataDir: "/tmp/dial-data",
|
||||
host: "127.0.0.1",
|
||||
logging: {
|
||||
consoleLevel: "info",
|
||||
fileLevel: "info",
|
||||
filePath: "/tmp/dial-data/logs/dial.log",
|
||||
rotationFrequency: "daily",
|
||||
rotationMaxFiles: 14,
|
||||
rotationSizeBytes: 52428800,
|
||||
rotationSizeRaw: "50MB",
|
||||
},
|
||||
maxConcurrentChecks: 3,
|
||||
port: 3000,
|
||||
retentionMs: 1000,
|
||||
@@ -49,12 +59,19 @@ function createHarness(overrides: BootstrapDependencies = {}) {
|
||||
},
|
||||
} as unknown as ProbeEngine;
|
||||
|
||||
const noopLogger = createNoopLogger();
|
||||
|
||||
const dependencies: BootstrapDependencies = {
|
||||
createEngine(actualStore, targets, maxConcurrentChecks, retentionMs) {
|
||||
createEngine(actualStore, targets, maxConcurrentChecks, retentionMs, logger) {
|
||||
expect(actualStore).toBe(store);
|
||||
expect(logger).toBe(noopLogger);
|
||||
calls.push(`createEngine:${targets.length}:${maxConcurrentChecks}:${retentionMs}`);
|
||||
return engine;
|
||||
},
|
||||
createLogger() {
|
||||
calls.push("createLogger");
|
||||
return Promise.resolve(noopLogger);
|
||||
},
|
||||
createStore(dbPath) {
|
||||
calls.push(`createStore:${dbPath}`);
|
||||
return store;
|
||||
@@ -83,7 +100,7 @@ function createHarness(overrides: BootstrapDependencies = {}) {
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { calls, dependencies, shutdownHandlers };
|
||||
return { calls, dependencies, noopLogger, shutdownHandlers };
|
||||
}
|
||||
|
||||
describe("bootstrap", () => {
|
||||
@@ -94,6 +111,7 @@ describe("bootstrap", () => {
|
||||
|
||||
expect(calls).toEqual([
|
||||
"loadConfig:/tmp/probes.yaml",
|
||||
"createLogger",
|
||||
`createStore:${join("/tmp/dial-data", "probe.db")}`,
|
||||
"syncTargets:1",
|
||||
"createEngine:1:3:1000",
|
||||
|
||||
@@ -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")]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,14 +143,8 @@ describe("mapCheckResult", () => {
|
||||
});
|
||||
|
||||
test("损坏 observation JSON 返回 null observation", () => {
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => undefined;
|
||||
try {
|
||||
const result = mapCheckResult(makeRow({ observation: "{invalid json" }), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
const result = mapCheckResult(makeRow({ observation: "{invalid json" }), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
117
tests/server/logger.test.ts
Normal file
117
tests/server/logger.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { Logger } from "../../src/server/logger";
|
||||
|
||||
import { createConsoleFallback, createMemoryLogger, createNoopLogger, REDACT_PATHS } from "../../src/server/logger";
|
||||
|
||||
describe("NoopLogger", () => {
|
||||
test("所有方法不抛异常", () => {
|
||||
const logger = createNoopLogger();
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.flush();
|
||||
const child = logger.child({ component: "test" });
|
||||
expect(child).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryLogger", () => {
|
||||
test("记录所有等级日志", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.trace("trace-msg");
|
||||
logger.debug("debug-msg");
|
||||
logger.info("info-msg");
|
||||
logger.warn("warn-msg");
|
||||
logger.error("error-msg");
|
||||
logger.fatal("fatal-msg");
|
||||
|
||||
expect(logger.entries).toHaveLength(6);
|
||||
expect(logger.entries[0]).toEqual({ level: "trace", msg: "trace-msg" });
|
||||
expect(logger.entries[5]).toEqual({ level: "fatal", msg: "fatal-msg" });
|
||||
});
|
||||
|
||||
test("记录结构化日志", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.info({ matched: true, targetId: "abc" }, "check complete");
|
||||
|
||||
expect(logger.entries).toHaveLength(1);
|
||||
expect(logger.entries[0]!.level).toBe("info");
|
||||
expect(logger.entries[0]!.msg).toBe("check complete");
|
||||
expect(logger.entries[0]!.obj).toEqual({ matched: true, targetId: "abc" });
|
||||
});
|
||||
|
||||
test("child 返回自身", () => {
|
||||
const logger = createMemoryLogger();
|
||||
const child = logger.child({ component: "engine" });
|
||||
child.info("child-msg");
|
||||
expect(logger.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("flush 不抛异常", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConsoleFallbackLogger", () => {
|
||||
test("不抛异常", () => {
|
||||
const logger = createConsoleFallback();
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.flush();
|
||||
const child = logger.child({ component: "test" });
|
||||
expect(child).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Logger 接口契约", () => {
|
||||
function assertLogger(logger: Logger): void {
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.info({ key: "value" }, "structured");
|
||||
logger.child({ component: "test" }).info("child");
|
||||
logger.flush();
|
||||
}
|
||||
|
||||
test("NoopLogger 满足 Logger 接口", () => {
|
||||
expect(() => assertLogger(createNoopLogger())).not.toThrow();
|
||||
});
|
||||
|
||||
test("MemoryLogger 满足 Logger 接口", () => {
|
||||
expect(() => assertLogger(createMemoryLogger())).not.toThrow();
|
||||
});
|
||||
|
||||
test("ConsoleFallbackLogger 满足 Logger 接口", () => {
|
||||
expect(() => assertLogger(createConsoleFallback())).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redaction 敏感信息保护", () => {
|
||||
test("MemoryLogger 不做 redaction(测试用途,仅 Pino 运行时 redact)", () => {
|
||||
const logger = createMemoryLogger();
|
||||
logger.info({ authorization: "Bearer secret", password: "hunter2" }, "test");
|
||||
const entry = logger.entries[0]!;
|
||||
expect(entry.obj!["authorization"]).toBe("Bearer secret");
|
||||
expect(entry.obj!["password"]).toBe("hunter2");
|
||||
});
|
||||
|
||||
test("REDACT_PATHS 覆盖所有敏感字段键名", () => {
|
||||
const sensitiveKeys = ["authorization", "cookie", "set-cookie", "authToken", "key", "password", "token", "apiKey"];
|
||||
for (const key of sensitiveKeys) {
|
||||
expect(REDACT_PATHS).toContain(key);
|
||||
expect(REDACT_PATHS).toContain(`*.${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user