From 007d74934dbff38cc40d8d145d7a225284561802 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Thu, 21 May 2026 12:21:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=90=E8=A1=8C=E6=97=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=B3=BB=E7=BB=9F=EF=BC=8CPino=20+=20pino-pretty=20+?= =?UTF-8?q?=20pino-roll=EF=BC=8Cconsole/file=20=E5=8F=8C=E8=BE=93=E5=87=BA?= =?UTF-8?q?=EF=BC=8C=E6=95=8F=E6=84=9F=E4=BF=A1=E6=81=AF=20redaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 76 ++++- README.md | 16 + bun.lock | 61 ++++ eslint.config.js | 2 + openspec/specs/probe-config/spec.md | 50 ++++ openspec/specs/runtime-logging/spec.md | 91 ++++++ openspec/specs/server-bootstrap/spec.md | 18 +- .../specs/test-output-cleanliness/spec.md | 14 +- package.json | 3 + probe-config.schema.json | 143 +++++++++ probes.example.yaml | 12 + src/pino-roll.d.ts | 11 + src/server/bootstrap.ts | 49 +++- src/server/checker/config-loader.ts | 131 ++++++++- src/server/checker/engine.ts | 85 +++++- src/server/checker/schema/builder.ts | 37 +++ src/server/checker/types.ts | 37 +++ src/server/helpers.ts | 9 +- src/server/logger.ts | 274 ++++++++++++++++++ src/server/server.ts | 6 +- tests/server/app.test.ts | 49 ++-- tests/server/bootstrap.test.ts | 22 +- tests/server/checker/config-loader.test.ts | 255 ++++++++++++++++ tests/server/checker/engine.test.ts | 247 ++++++++++++++-- tests/server/helpers.test.ts | 12 +- tests/server/logger.test.ts | 117 ++++++++ 26 files changed, 1713 insertions(+), 114 deletions(-) create mode 100644 openspec/specs/runtime-logging/spec.md create mode 100644 src/pino-roll.d.ts create mode 100644 src/server/logger.ts create mode 100644 tests/server/logger.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 342d00c..8621211 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -48,9 +48,10 @@ DiAL 使用 `package.json.version` 作为应用版本号的唯一来源,遵循 ```text src/ server/ - bootstrap.ts 后端统一启动引导(loadConfig → store → engine → startServer → shutdown) + bootstrap.ts 后端统一启动引导(loadConfig → logger → store → engine → startServer → shutdown) config.ts CLI 参数解析(仅提取配置文件路径) dev.ts 开发模式启动入口(mode: "development",仅 API server) + logger.ts 日志模块(Logger 接口、Pino 运行时封装、NoopLogger、MemoryLogger、ConsoleFallbackLogger) main.ts 生产模式启动入口(mode: "production",安全头启用) server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + fetch fallback 静态资源服务) helpers.ts 共享响应格式化工具(见下方函数清单) @@ -139,11 +140,12 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动 dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath) → bootstrap({ configPath, mode }) → loadConfig(yaml:YAML 解析 → 变量替换 → 契约校验 → 语义校验 → resolve) - → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets } + → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets, logging } + → createRuntimeLogger(logging) → Logger(配置加载失败时使用 ConsoleFallbackLogger) → ProbeStore(db) → store.syncTargets(targets) - → ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start() - → startServer({ config, mode, store }) - → 注册 SIGINT/SIGTERM shutdown(engine.stop + store.close) + → ProbeEngine(store, targets, maxConcurrentChecks, retentionMs, logger) → engine.start() + → startServer({ config, mode, store, logger }) + → 注册 SIGINT/SIGTERM shutdown(engine.stop + store.close + logger.flush) 运行时: 定时器(tick) → ProbeEngine.probeGroup() @@ -257,6 +259,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: | `configDir` | 配置文件所在目录 | — | | `dataDir` | `server.dataDir`(基于配置文件目录解析为绝对路径) | `configDir/data` | | `host` | `server.host` | `127.0.0.1` | +| `logging` | `logging`(等级继承、路径解析、滚动参数) | 见 logging 配置 | | `port` | `server.port` | `3000` | | `maxConcurrentChecks` | `runtime.maxConcurrentChecks` | `20` | | `retentionMs` | `runtime.retention` | `7d` | @@ -566,8 +569,63 @@ if (r.body) { - **异常可观测**:`probeGroup()` 对 `Promise.allSettled` 的 rejected 结果通过索引关联 target,并写入 `phase:"internal"` 的失败记录 - **数据清理**:当 `retentionMs > 0` 时,engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据 - **生命周期**:`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval` +- **日志集成**:engine 构造时接收 `Logger` 实例(可选,默认 NoopLogger),通过 `initStateCache()` 从 store 加载最新状态;状态变化时记录日志(UP→DOWN `warn`、DOWN→UP `info`、首次检查 DOWN `warn`、稳态无日志);每次检查产出 `debug` 级别结构化摘要 -### 1.10 expect 断言系统 +### 1.10 日志模块 + +日志模块位于 `src/server/logger.ts`,定义项目内部最小 `Logger` 接口,后端运行时代码统一通过此接口输出日志。 + +**Logger 接口**: + +| 方法 | 说明 | +| ------- | ---------------------------------------- | +| `trace` | 级别 trace(开发调试) | +| `debug` | 级别 debug(检查摘要、状态详情) | +| `info` | 级别 info(启动、恢复、正常操作) | +| `warn` | 级别 warn(状态变化 UP→DOWN、首次 DOWN) | +| `error` | 级别 error(checker 执行异常) | +| `fatal` | 级别 fatal(启动失败) | +| `child` | 创建子 logger(附加 bindings 上下文) | +| `flush` | 刷新缓冲(用于关机前确保日志落盘) | + +每个方法支持两种签名:`(msg: string)` 和 `(obj: Record, msg?: string)`。 + +**实现**: + +| 实现 | 用途 | +| ----------------------- | ----------------------------------------------- | +| `PinoLoggerWrapper` | 生产运行时,封装 Pino + pino-pretty + pino-roll | +| `NoopLogger` | 静默丢弃所有日志,用于不需要日志输出的场景 | +| `MemoryLogger` | 测试替身,将日志条目收集到 `entries` 数组供断言 | +| `ConsoleFallbackLogger` | 配置加载失败前的降级日志,直接输出到 console | + +**日志输出**: + +- **控制台**:始终开启,使用 pino-pretty 格式化(彩色、单行、时间戳 `yyyy-mm-dd HH:MM:ss.l`) +- **文件**:始终开启,JSONL 格式,通过 pino-roll 支持按大小和频率滚动 +- **根等级**:取 console 和 file 中的最低等级,确保两个流都能收到所需日志 +- **敏感信息**:自动 redact `authorization`、`cookie`、`set-cookie`、`authToken`、`key`、`password`、`token`、`apiKey` 及其嵌套路径,替换为 `[Redacted]` + +**测试用法**: + +```typescript +import { createMemoryLogger } from "../logger"; + +const logger = createMemoryLogger(); +const engine = new ProbeEngine(store, targets, 20, 0, logger); + +// 断言日志 +expect(logger.entries.filter((e) => e.level === "warn")).toHaveLength(1); +expect(logger.entries[0]!.msg).toContain("UP → DOWN"); +``` + +**运行时规范**: + +- `src/server/` 下的运行时代码禁止直接使用 `console.*`,必须通过注入的 `Logger` 实例输出 +- 配置加载失败(logger 尚未初始化)时使用 `ConsoleFallbackLogger` +- `bootstrap.ts` 在 shutdown 时调用 `logger.flush()` 确保缓冲日志写入磁盘 + +### 1.11 expect 断言系统 两层模型:**观测值收集** → **规则校验**。共享断言基础设施位于 `checker/expect/`,checker 专属状态断言位于各自目录。 @@ -645,14 +703,14 @@ expect 字段 7. **实现时参考 [1.7.5 五层管线](#175-步骤四实现-checker-类) 中的对应表**。决策树解决"选哪种模型",五层管线表解决"每种模型从类型定义到执行分别调哪个函数"。 -### 1.11 错误模式 +### 1.12 错误模式 - **API 错误**:`{ error: "描述", status: }`,状态码 400/404/503 - **CheckFailure**:`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }` - **错误处理**:expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`,body 超限/解码/解析错误归属 `phase:"body"` -- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)` +- **日志**:运行时日志通过 `Logger` 接口统一输出(Pino 运行时、Noop/Memory/ConsoleFallback 测试替身),配置加载失败前使用 ConsoleFallbackLogger;禁止在 `src/server/` 运行时代码中直接使用 `console.*` -### 1.12 测试规范 +### 1.13 测试规范 - 测试目录 `tests/` 镜像 `src/` 目录结构,但共享 expect 模块的测试集中放在 `tests/server/checker/runner/shared/` 下,覆盖 `failure.ts`、`value.ts`(operator)、`content.ts`(body/text)、`keyed.ts`(headers/duplicate-key)、`validate.ts`(shorthand)和 `redos.ts` - 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()` diff --git a/README.md b/README.md index 837e040..7bc4f83 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,22 @@ targets: # 拨测目标列表(必填) | `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` | | `retention` | 历史数据保留时长,支持 `ms`/`s`/`m`/`h`/`d` 单位 | 否 | `7d` | +### logging — 日志配置 + +| 字段 | 说明 | 必填 | 默认值 | +| ------------------------- | ---------------------------------------------- | ---- | ------------------------- | +| `level` | 全局日志等级,console 和 file 未指定时继承此值 | 否 | `info` | +| `console.level` | 控制台日志等级 | 否 | 继承 `level` | +| `file.level` | 文件日志等级 | 否 | 继承 `level` | +| `file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `/logs/dial.log` | +| `file.rotation.size` | 按大小滚动,支持 `KB`/`MB`/`GB` 单位 | 否 | `50MB` | +| `file.rotation.frequency` | 按时间滚动:`hourly`、`daily`、`weekly` | 否 | `daily` | +| `file.rotation.maxFiles` | 保留的归档文件数量(不含活跃日志) | 否 | `14` | + +日志等级支持:`trace`、`debug`、`info`、`warn`、`error`、`fatal`。 + +控制台始终输出(pretty 格式),文件始终输出 JSONL 格式并支持滚动。`rotation.size` 和 `rotation.frequency` 任一条件触发即滚动。 + ### defaults — 全局默认值 | 字段 | 说明 | 必填 | 默认值 | diff --git a/bun.lock b/bun.lock index 779460b..69eb704 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,9 @@ "ajv": "^8.20.0", "cheerio": "^1.2.0", "es-toolkit": "^1.46.1", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "pino-roll": "^4.0.0", "react": "^19.2.6", "react-dom": "^19.2.6", "recharts": "^3.8.1", @@ -208,6 +211,8 @@ "@oxc-project/types": ["@oxc-project/types@0.130.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.130.0.tgz", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], "@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], @@ -418,6 +423,8 @@ "async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "b4a": ["b4a@1.8.1", "https://registry.npmmirror.com/b4a/-/b4a-1.8.1.tgz", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], @@ -472,6 +479,8 @@ "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "colorette": ["colorette@2.0.20", "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "compare-func": ["compare-func@2.0.0", "https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -528,6 +537,10 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@4.2.1", "https://registry.npmmirror.com/date-fns/-/date-fns-4.2.1.tgz", {}, "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA=="], + + "dateformat": ["dateformat@4.6.3", "https://registry.npmmirror.com/dateformat/-/dateformat-4.6.3.tgz", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + "dayjs": ["dayjs@1.11.10", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz", {}, "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="], "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -570,6 +583,8 @@ "encoding-sniffer": ["encoding-sniffer@0.2.1", "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + "end-of-stream": ["end-of-stream@1.4.5", "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@8.0.0", "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -642,6 +657,8 @@ "eventsource-parser": ["eventsource-parser@3.0.8", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "fast-copy": ["fast-copy@4.0.3", "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.3.tgz", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -652,6 +669,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -712,6 +731,8 @@ "hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "help-me": ["help-me@5.0.0", "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], @@ -806,6 +827,8 @@ "jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "joycon": ["joycon@3.1.1", "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -920,6 +943,10 @@ "object.values": ["object.values@1.2.1", "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -952,6 +979,16 @@ "picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pino": ["pino@10.3.1", "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "https://registry.npmmirror.com/pino-pretty/-/pino-pretty-13.1.3.tgz", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-roll": ["pino-roll@4.0.0", "https://registry.npmmirror.com/pino-roll/-/pino-roll-4.0.0.tgz", { "dependencies": { "date-fns": "^4.1.0", "sonic-boom": "^4.0.1" } }, "sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], @@ -964,10 +1001,16 @@ "pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process-warning": ["process-warning@5.0.0", "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "raf": ["raf@3.4.1", "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], "react": ["react@19.2.6", "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], @@ -982,6 +1025,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "real-require": ["real-require@0.2.0", "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="], "redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], @@ -1016,12 +1061,16 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "saxes": ["saxes@6.0.0", "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "set-function-length": ["set-function-length@1.2.2", "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1046,10 +1095,14 @@ "slice-ansi": ["slice-ansi@8.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="], "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -1070,6 +1123,8 @@ "strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "symbol-tree": ["symbol-tree@3.2.4", "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], @@ -1086,6 +1141,8 @@ "text-decoder": ["text-decoder@1.2.7", "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "thread-stream": ["thread-stream@4.2.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyexec": ["tinyexec@1.1.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -1164,6 +1221,8 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xmlchars": ["xmlchars@2.2.0", "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], @@ -1282,6 +1341,8 @@ "tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="], + "thread-stream/real-require": ["real-require@1.0.0", "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/eslint.config.js b/eslint.config.js index 2c9e2bf..2099c00 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,8 @@ export default tseslint.config( "@typescript-eslint/array-type": ["error", { default: "array-simple" }], "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }], "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], + "@typescript-eslint/no-empty-function": ["error", { allow: ["private-constructors", "protected-constructors"] }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/only-throw-error": "error", "@typescript-eslint/prefer-nullish-coalescing": "error", "@typescript-eslint/prefer-optional-chain": "error", diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 5a47716..c4b8a6f 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -632,3 +632,53 @@ #### Scenario: llm expect stream firstTokenMs 非法 - **WHEN** YAML 中 llm target 的 `expect.stream.firstTokenMs` 不是合法 `RawValueExpectation` - **THEN** 系统 SHALL 以配置错误退出,提示 expect.stream.firstTokenMs 格式错误 + +### Requirement: 日志配置格式 +系统 SHALL 支持可选的顶层 `logging` 配置,用于定义运行时日志等级、命令行日志等级、文件日志等级、文件路径和滚动策略。`logging` 未配置时 SHALL 使用内置默认值。系统 SHALL NOT 支持 `logging.console.enabled`、`logging.console.format`、`logging.file.enabled`、`logging.file.format` 或 `logging.file.rotation.enabled` 字段。 + +#### Scenario: 未配置 logging 使用默认值 +- **WHEN** 配置文件未声明 `logging` +- **THEN** 系统 SHALL 使用 `logging.level=info`、`logging.console.level=info`、`logging.file.level=info`、`logging.file.path=/logs/dial.log`、`logging.file.rotation.size=50MB`、`logging.file.rotation.frequency=daily` 和 `logging.file.rotation.maxFiles=14` + +#### Scenario: console 和 file level 继承全局 level +- **WHEN** 配置声明 `logging.level: warn` 且未声明 `logging.console.level` 和 `logging.file.level` +- **THEN** 系统 SHALL 将 console 和 file 的日志等级均解析为 `warn` + +#### Scenario: 显式配置文件日志路径 +- **WHEN** 配置声明 `logging.file.path` +- **THEN** 系统 SHALL 使用该路径作为文件日志路径,而不是默认 `/logs/dial.log` + +#### Scenario: 相对日志路径 +- **WHEN** `logging.file.path` 是相对路径 +- **THEN** 系统 SHALL 基于配置文件所在目录解析为绝对路径 + +#### Scenario: 绝对日志路径 +- **WHEN** `logging.file.path` 是绝对路径 +- **THEN** 系统 SHALL 原样使用该绝对路径,并允许该路径位于 `dataDir` 之外 + +#### Scenario: 不支持日志开关和格式字段 +- **WHEN** 配置声明 `logging.console.enabled`、`logging.console.format`、`logging.file.enabled`、`logging.file.format` 或 `logging.file.rotation.enabled` +- **THEN** 系统 SHALL 以配置错误退出并提示存在未知字段 + +### Requirement: 日志配置校验 +系统 SHALL 在启动期校验 `logging` 配置。日志等级 SHALL 只能是 `trace`、`debug`、`info`、`warn`、`error` 或 `fatal`。`rotation.size` SHALL 使用有效 size 格式且解析为正整数字节数。`rotation.frequency` SHALL 只能是 `hourly`、`daily` 或 `weekly`。`rotation.maxFiles` SHALL 是正整数。 + +#### Scenario: 非法日志等级 +- **WHEN** 配置声明 `logging.level: verbose` +- **THEN** 系统 SHALL 以配置错误退出并提示日志等级非法 + +#### Scenario: 非法滚动大小 +- **WHEN** 配置声明 `logging.file.rotation.size: "large"` +- **THEN** 系统 SHALL 以配置错误退出并提示 size 格式非法 + +#### Scenario: 非法滚动频率 +- **WHEN** 配置声明 `logging.file.rotation.frequency: monthly` +- **THEN** 系统 SHALL 以配置错误退出并提示 frequency 非法 + +#### Scenario: 非法归档数量 +- **WHEN** 配置声明 `logging.file.rotation.maxFiles: 0` +- **THEN** 系统 SHALL 以配置错误退出并提示 maxFiles 必须为正整数 + +#### Scenario: 非法日志路径 +- **WHEN** 配置声明 `logging.file.path` 为空字符串或空白字符串 +- **THEN** 系统 SHALL 以配置错误退出并提示日志路径非法 diff --git a/openspec/specs/runtime-logging/spec.md b/openspec/specs/runtime-logging/spec.md new file mode 100644 index 0000000..e539cd6 --- /dev/null +++ b/openspec/specs/runtime-logging/spec.md @@ -0,0 +1,91 @@ +## Purpose + +定义运行时日志输出、日志等级、命令行输出、文件 JSONL 输出、滚动策略和敏感信息保护。 + +## Requirements + +### Requirement: 运行时 logger 输出 +系统 SHALL 在配置解析成功后初始化统一运行时 logger。logger SHALL 同时输出命令行 pretty 日志和文件 JSONL 日志。命令行输出、文件输出和文件滚动 SHALL 始终启用,不提供关闭开关。 + +#### Scenario: 默认初始化 logger +- **WHEN** 配置文件未声明 `logging` +- **THEN** 系统 SHALL 使用默认等级 `info` 初始化 console pretty 输出和 `/logs/dial.log` 文件 JSONL 输出 + +#### Scenario: 模块 child logger +- **WHEN** bootstrap 创建 engine、server 或其他运行时模块 +- **THEN** 系统 SHALL 为模块创建带 `component` 字段的 child logger + +#### Scenario: 配置成功后的启动失败 +- **WHEN** 配置解析成功后数据库、logger、engine 或 HTTP server 初始化失败 +- **THEN** 系统 SHALL 通过正式 logger 输出 `fatal` 日志并以非零状态退出 + +### Requirement: 日志等级语义 +系统 SHALL 支持 `trace`、`debug`、`info`、`warn`、`error` 和 `fatal` 六个日志等级。`logging.level` SHALL 作为全局默认等级,`logging.console.level` 和 `logging.file.level` SHALL 在省略时继承全局等级。 + +#### Scenario: 目的地等级继承 +- **WHEN** 配置只声明 `logging.level: warn` +- **THEN** console 和 file 输出均 SHALL 使用 `warn` 作为最低输出等级 + +#### Scenario: 目的地等级覆盖 +- **WHEN** 配置声明 `logging.level: info`、`logging.console.level: warn` 和 `logging.file.level: debug` +- **THEN** console SHALL 输出 `warn` 及以上日志,file SHALL 输出 `debug` 及以上日志 + +#### Scenario: 默认不输出 debug 检查摘要 +- **WHEN** 系统使用默认 `info` 日志等级执行拨测 +- **THEN** 每次检查的 debug 摘要 SHALL NOT 输出到 console 或 file + +### Requirement: 文件日志滚动 +系统 SHALL 对文件日志启用滚动策略。滚动 SHALL 在 `logging.file.rotation.size` 或 `logging.file.rotation.frequency` 任一条件满足时触发。`logging.file.rotation.maxFiles` SHALL 表示最多保留的归档文件数量,不包含当前正在写入的日志文件。 + +#### Scenario: 按大小滚动 +- **WHEN** 当前日志文件达到配置的 `rotation.size` +- **THEN** 系统 SHALL 滚动到新的日志文件继续写入 + +#### Scenario: 按频率滚动 +- **WHEN** 当前时间达到配置的 `rotation.frequency` 周期边界 +- **THEN** 系统 SHALL 滚动到新的日志文件继续写入 + +#### Scenario: 限制归档数量 +- **WHEN** 归档日志文件数量超过 `rotation.maxFiles` +- **THEN** 系统 SHALL 删除最旧的归档日志文件并保留当前正在写入的文件 + +### Requirement: 日志事件内容边界 +系统 SHALL 将运行日志作为运行时事件记录,而不是将每次拨测结果完整复制到日志文件。`info` SHALL 记录生命周期事件,`warn` SHALL 记录需要关注但进程可继续的异常和目标 DOWN 状态变化,`error` SHALL 记录内部异常,`debug` SHALL 记录每次检查摘要。 + +#### Scenario: 成功检查默认不产生日志噪音 +- **WHEN** target 连续检查成功且日志等级为默认 `info` +- **THEN** 系统 SHALL NOT 为每次成功检查输出 info 日志 + +#### Scenario: 目标首次 DOWN +- **WHEN** target 没有历史状态且本次检查结果为 DOWN +- **THEN** 系统 SHALL 输出 `warn` 日志,包含 targetId、targetType、durationMs 和 failure 摘要 + +#### Scenario: 目标恢复 UP +- **WHEN** target 最近状态为 DOWN 且本次检查结果为 UP +- **THEN** 系统 SHALL 输出 `info` 日志,包含 targetId、targetType 和 durationMs + +#### Scenario: checker rejected +- **WHEN** checker 执行抛出未捕获异常导致 Promise rejected +- **THEN** 系统 SHALL 输出 `error` 日志并写入 `matched: false` 的 check_result + +### Requirement: 敏感信息保护 +系统 SHALL 避免在日志中输出敏感配置和运行时数据。日志事件 SHALL 优先记录白名单字段,并通过日志库 redaction 对常见敏感字段进行兜底保护。 + +#### Scenario: HTTP 敏感 header 不输出 +- **WHEN** target 配置包含 `Authorization`、`Cookie` 或 `Set-Cookie` 相关值 +- **THEN** 系统 SHALL NOT 在 console 或 file 日志中输出这些原始值 + +#### Scenario: LLM 敏感字段不输出 +- **WHEN** LLM target 配置包含 `key`、`authToken`、`prompt` 或 providerOptions +- **THEN** 系统 SHALL NOT 在 console 或 file 日志中输出这些原始值 + +#### Scenario: 命令环境变量不输出 +- **WHEN** cmd target 配置包含 `env` +- **THEN** 系统 SHALL NOT 在 console 或 file 日志中输出完整环境变量表 + +### Requirement: 单可执行文件兼容 +运行时日志能力 SHALL 在 Bun 单可执行文件构建产物中可用。生产构建后的 executable SHALL 不依赖目标机器安装 Bun、Node.js 或 node_modules 即可输出 console 日志和滚动文件日志。 + +#### Scenario: standalone executable 写入文件日志 +- **WHEN** 开发者运行 `bun run build` 并启动生成的 `dist/dial-server` +- **THEN** executable SHALL 在配置的数据目录下创建并写入滚动文件日志 diff --git a/openspec/specs/server-bootstrap/spec.md b/openspec/specs/server-bootstrap/spec.md index fcdd890..6be3b91 100644 --- a/openspec/specs/server-bootstrap/spec.md +++ b/openspec/specs/server-bootstrap/spec.md @@ -5,23 +5,27 @@ TBD - 统一服务启动引导函数,封装开发和生产模式的完整启 ## Requirements ### Requirement: 统一启动引导函数 -系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。 +系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、初始化正式 logger、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。配置加载失败发生在正式 logger 初始化之前,系统 SHALL 使用 console fallback 输出启动失败信息。配置加载成功后的启动失败 SHALL 使用正式 logger 输出 `fatal` 后退出。 #### Scenario: 开发模式启动 - **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })` -- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets +- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets,并初始化运行时 logger #### Scenario: 生产模式启动(带静态资源) - **WHEN** code-generated entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })` -- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer +- **THEN** 系统 SHALL 完成完整启动序列,将 staticAssets 传递给 startServer,并初始化运行时 logger -#### Scenario: 启动失败处理 -- **WHEN** 启动过程中任何步骤抛出异常 -- **THEN** 系统 SHALL 输出错误信息并以非零退出码退出进程 +#### Scenario: 配置加载失败处理 +- **WHEN** 配置文件读取、YAML 解析或配置校验失败 +- **THEN** 系统 SHALL 通过 console fallback 输出错误信息并以非零退出码退出进程 + +#### Scenario: 配置加载后的启动失败处理 +- **WHEN** logger、store、engine 或 HTTP server 初始化失败 +- **THEN** 系统 SHALL 通过正式 logger 输出 `fatal` 日志并以非零退出码退出进程 #### Scenario: 优雅关机 - **WHEN** 进程收到 SIGINT 或 SIGTERM 信号 -- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出 +- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop()、store.close() 和 logger.flush() 后退出 ### Requirement: BootstrapOptions 接口 `bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string`、`mode: RuntimeMode` 和可选的 `staticAssets?: StaticAssets`。 diff --git a/openspec/specs/test-output-cleanliness/spec.md b/openspec/specs/test-output-cleanliness/spec.md index 5729a4e..93e8abb 100644 --- a/openspec/specs/test-output-cleanliness/spec.md +++ b/openspec/specs/test-output-cleanliness/spec.md @@ -7,22 +7,22 @@ ## Requirements ### Requirement: 测试不应产生无关 console 输出 -测试运行时,由测试用例预期的容错行为(如 JSON 解析失败、checker rejected)触发的 `console.warn` 输出 SHALL 在测试代码中被抑制,不污染测试报告。 +测试运行时,由测试用例预期的容错行为(如 JSON 解析失败、checker rejected)触发的 logger 输出 SHALL 在测试代码中被抑制,不污染测试报告。测试 SHALL 优先通过注入 no-op logger 或 memory logger 抑制预期日志,而不是直接覆盖全局 `console.warn`。 -#### Scenario: 容错测试抑制 console.warn +#### Scenario: 容错测试抑制 logger 输出 - **WHEN** 测试用例故意注入损坏数据或触发异常以验证系统容错行为 -- **THEN** 测试 SHALL 在执行前临时覆盖 `console.warn` 为空函数,在 finally 块中恢复原始函数 +- **THEN** 测试 SHALL 注入 no-op logger 或 memory logger,并在断言完成后恢复默认测试上下文 -#### Scenario: 非预期 console.warn 不被抑制 +#### Scenario: 非预期 logger 输出不被抑制 - **WHEN** 测试用例并非专门测试容错行为 -- **THEN** 测试 SHALL NOT 抑制 `console.warn`,确保意外 warn 可被观测 +- **THEN** 测试 SHALL NOT 抑制 logger 输出,确保意外 warn 或 error 可被观测 ### Requirement: 探针执行失败日志输出单行消息 -ProbeEngine 在捕获 checker rejected 时,`console.warn` SHALL 输出单行错误消息文本,MUST NOT 输出 Error 对象(会导致多行堆栈噪音)。 +ProbeEngine 在捕获 checker rejected 时,logger SHALL 输出单行错误消息文本,MUST NOT 输出 Error 对象(会导致多行堆栈噪音)。该日志 SHALL 使用 `error` 等级,并包含 targetId、targetType 和 `formatReason()` 提取的错误消息。 #### Scenario: checker rejected 输出单行日志 - **WHEN** checker 执行抛出未捕获异常(Promise rejected) -- **THEN** `console.warn` SHALL 输出格式为 `探针执行失败: ` 的单行文本,其中 message 使用 `formatReason()` 提取 +- **THEN** logger SHALL 输出格式为 `探针执行失败: ` 的单行消息,其中 message 使用 `formatReason()` 提取 #### Scenario: formatReason 复用 - **WHEN** 构建失败日志消息和写入 CheckFailure diff --git a/package.json b/package.json index 7c379c3..223ca86 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,9 @@ "ajv": "^8.20.0", "cheerio": "^1.2.0", "es-toolkit": "^1.46.1", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "pino-roll": "^4.0.0", "react": "^19.2.6", "react-dom": "^19.2.6", "recharts": "^3.8.1", diff --git a/probe-config.schema.json b/probe-config.schema.json index e1e6323..73ebb1e 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -211,6 +211,149 @@ } } }, + "logging": { + "additionalProperties": false, + "type": "object", + "properties": { + "console": { + "additionalProperties": false, + "type": "object", + "properties": { + "level": { + "anyOf": [ + { + "const": "trace", + "type": "string" + }, + { + "const": "debug", + "type": "string" + }, + { + "const": "info", + "type": "string" + }, + { + "const": "warn", + "type": "string" + }, + { + "const": "error", + "type": "string" + }, + { + "const": "fatal", + "type": "string" + } + ] + } + } + }, + "file": { + "additionalProperties": false, + "type": "object", + "properties": { + "level": { + "anyOf": [ + { + "const": "trace", + "type": "string" + }, + { + "const": "debug", + "type": "string" + }, + { + "const": "info", + "type": "string" + }, + { + "const": "warn", + "type": "string" + }, + { + "const": "error", + "type": "string" + }, + { + "const": "fatal", + "type": "string" + } + ] + }, + "path": { + "minLength": 1, + "type": "string" + }, + "rotation": { + "additionalProperties": false, + "type": "object", + "properties": { + "frequency": { + "anyOf": [ + { + "const": "hourly", + "type": "string" + }, + { + "const": "daily", + "type": "string" + }, + { + "const": "weekly", + "type": "string" + } + ] + }, + "maxFiles": { + "minimum": 1, + "type": "integer" + }, + "size": { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + } + } + } + } + }, + "level": { + "anyOf": [ + { + "const": "trace", + "type": "string" + }, + { + "const": "debug", + "type": "string" + }, + { + "const": "info", + "type": "string" + }, + { + "const": "warn", + "type": "string" + }, + { + "const": "error", + "type": "string" + }, + { + "const": "fatal", + "type": "string" + } + ] + } + } + }, "runtime": { "additionalProperties": false, "type": "object", diff --git a/probes.example.yaml b/probes.example.yaml index 0fd1006..39ad7ef 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -8,6 +8,18 @@ server: runtime: maxConcurrentChecks: 20 +# logging: +# level: "info" +# console: +# level: "info" +# file: +# level: "info" +# path: "/var/log/dial/dial.log" +# rotation: +# size: "50MB" +# frequency: "daily" +# maxFiles: 14 + defaults: interval: "30s" timeout: "10s" diff --git a/src/pino-roll.d.ts b/src/pino-roll.d.ts new file mode 100644 index 0000000..0ef7d38 --- /dev/null +++ b/src/pino-roll.d.ts @@ -0,0 +1,11 @@ +declare module "pino-roll" { + interface RollingStreamOptions { + file: string; + frequency?: string; + limit?: { count?: number }; + mkdir?: boolean; + size?: string; + } + + export default function build(options: RollingStreamOptions): Promise; +} diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 1a981e2..b2999e4 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -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; createStore?: (dbPath: string) => ProbeStore; exit?: (code: number) => never; loadConfig?: (configPath: string) => Promise; @@ -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); } } diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 1c6afec..092805a 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -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 { 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 { 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 必须为正整数")); + } + } +} diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index 6898ad9..594f0af 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -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(); + 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> = []; - 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 { 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(), + }); + } } } } diff --git a/src/server/checker/schema/builder.ts b/src/server/checker/schema/builder.ts index a21049c..65a8b97 100644 --- a/src/server/checker/schema/builder.ts +++ b/src/server/checker/schema/builder.ts @@ -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 { 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 }, + ); +} diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index ba151b1..404abeb 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -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; diff --git a/src/server/helpers.ts b/src/server/helpers.ts index 913ccf8..272c19a 100644 --- a/src/server/helpers.ts +++ b/src/server/helpers.ts @@ -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; } catch { - console.warn(`无法解析 observation 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`); + log.warn({ targetId: row.target_id, timestamp: row.timestamp }, "无法解析 observation 数据"); observation = null; } } diff --git a/src/server/logger.ts b/src/server/logger.ts new file mode 100644 index 0000000..f691f7a --- /dev/null +++ b/src/server/logger.ts @@ -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): Logger; + debug(obj: Record, msg?: string): void; + debug(msg: string): void; + error(obj: Record, msg?: string): void; + error(msg: string): void; + fatal(obj: Record, msg?: string): void; + fatal(msg: string): void; + flush(): void; + info(obj: Record, msg?: string): void; + info(msg: string): void; + trace(obj: Record, msg?: string): void; + trace(msg: string): void; + warn(obj: Record, 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 = { + debug: "debug", + error: "error", + fatal: "fatal", + info: "info", + trace: "trace", + warn: "warn", +}; + +type LogFn = (objOrMsg: Record | string, msg?: string) => void; + +const voidLog: LogFn = () => undefined; + +class ConsoleFallbackLogger implements Logger { + child(_bindings: Record): Logger { + return this; + } + + debug(objOrMsg: Record | string, msg?: string): void { + console.log(formatMsg(objOrMsg, msg)); + } + + error(objOrMsg: Record | string, msg?: string): void { + console.error(formatMsg(objOrMsg, msg)); + } + + fatal(objOrMsg: Record | string, msg?: string): void { + console.error(formatMsg(objOrMsg, msg)); + } + + flush: () => void = () => undefined; + + info(objOrMsg: Record | string, msg?: string): void { + console.log(formatMsg(objOrMsg, msg)); + } + + trace(objOrMsg: Record | string, msg?: string): void { + console.log(formatMsg(objOrMsg, msg)); + } + + warn(objOrMsg: Record | 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): Logger { + return this; + } + flush: () => void = () => undefined; +} + +class PinoLoggerWrapper implements Logger { + private pino: pino.Logger; + + constructor(pinoLogger: pino.Logger) { + this.pino = pinoLogger; + } + + child(bindings: Record): Logger { + return new PinoLoggerWrapper(this.pino.child(bindings)); + } + + debug(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.debug(objOrMsg); + else this.pino.debug(objOrMsg, msg); + } + + error(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.error(objOrMsg); + else this.pino.error(objOrMsg, msg); + } + + fatal(objOrMsg: Record | 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, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.info(objOrMsg); + else this.pino.info(objOrMsg, msg); + } + + trace(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.trace(objOrMsg); + else this.pino.trace(objOrMsg, msg); + } + + warn(objOrMsg: Record | 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 }> = []; + + child(_bindings: Record): Logger { + return this; + } + + debug(objOrMsg: Record | string, msg?: string): void { + this.capture("debug", objOrMsg, msg); + } + + error(objOrMsg: Record | string, msg?: string): void { + this.capture("error", objOrMsg, msg); + } + + fatal(objOrMsg: Record | string, msg?: string): void { + this.capture("fatal", objOrMsg, msg); + } + + flush: () => void = () => undefined; + + info(objOrMsg: Record | string, msg?: string): void { + this.capture("info", objOrMsg, msg); + } + + trace(objOrMsg: Record | string, msg?: string): void { + this.capture("trace", objOrMsg, msg); + } + + warn(objOrMsg: Record | string, msg?: string): void { + this.capture("warn", objOrMsg, msg); + } + + private capture(level: string, objOrMsg: Record | 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 { + 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 { + 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, 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]; +} diff --git a/src/server/server.ts b/src/server/server.ts index e05c79c..0a11e36 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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; } diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index b407097..aa3fd57 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -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(); }); }); diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index 464e68b..cbb053e 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -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", diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 701c3e8..ccb80ec 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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", + ); + }); + }); }); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index c9fa025..6d3a0ea 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -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 } - ).probeGroup.bind(engine); - await probeGroup([rejectTarget, goodTarget]); + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); + await probeGroup([rejectTarget, goodTarget]); - const results = (mockStore as unknown as { _results: Array> })._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> })._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 } + ).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 } + ).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 } + ).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 } + ).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 } + ).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 } + ).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 } + ).probeGroup.bind(engine); + await probeGroup([makeCommandTarget("no-log")]); + }); + }); }); diff --git a/tests/server/helpers.test.ts b/tests/server/helpers.test.ts index ec02a04..c55bbdb 100644 --- a/tests/server/helpers.test.ts +++ b/tests/server/helpers.test.ts @@ -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(); }); }); diff --git a/tests/server/logger.test.ts b/tests/server/logger.test.ts new file mode 100644 index 0000000..150c31b --- /dev/null +++ b/tests/server/logger.test.ts @@ -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}`); + } + }); +});