1
0

feat: 运行时日志系统,Pino + pino-pretty + pino-roll,console/file 双输出,敏感信息 redaction

This commit is contained in:
2026-05-21 12:21:59 +08:00
parent 0d709c7681
commit 007d74934d
26 changed files with 1713 additions and 114 deletions

View File

@@ -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(yamlYAML 解析 → 变量替换 → 契约校验 → 语义校验 → 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 shutdownengine.stop + store.close
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs, logger) → engine.start()
→ startServer({ config, mode, store, logger })
→ 注册 SIGINT/SIGTERM shutdownengine.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` | 级别 errorchecker 执行异常) |
| `fatal` | 级别 fatal启动失败 |
| `child` | 创建子 logger附加 bindings 上下文) |
| `flush` | 刷新缓冲(用于关机前确保日志落盘) |
每个方法支持两种签名:`(msg: string)``(obj: Record<string, unknown>, 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: <code> }`,状态码 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()`

View File

@@ -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` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/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 — 全局默认值
| 字段 | 说明 | 必填 | 默认值 |

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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=<resolved dataDir>/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 使用该路径作为文件日志路径,而不是默认 `<resolved dataDir>/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 以配置错误退出并提示日志路径非法

View File

@@ -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 输出和 `<resolved dataDir>/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 在配置的数据目录下创建并写入滚动文件日志

View File

@@ -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`

View File

@@ -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>` 的单行文本,其中 message 使用 `formatReason()` 提取
- **THEN** logger SHALL 输出格式为 `探针执行失败: <message>` 的单行消息,其中 message 使用 `formatReason()` 提取
#### Scenario: formatReason 复用
- **WHEN** 构建失败日志消息和写入 CheckFailure

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

11
src/pino-roll.d.ts vendored Normal file
View File

@@ -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<NodeJS.WritableStream>;
}

View File

@@ -1,12 +1,15 @@
import { join } from "node:path";
import type { RuntimeMode } from "../shared/api";
import type { ResolvedLoggingConfig } from "./checker/types";
import type { Logger } from "./logger";
import type { StartServerOptions } from "./server";
import type { StaticAssets } from "./static";
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { ProbeStore } from "./checker/store";
import { createRuntimeLogger } from "./logger";
import { startServer } from "./server";
export interface BootstrapDependencies {
@@ -15,7 +18,9 @@ export interface BootstrapDependencies {
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
logger: Logger,
) => BootstrapEngine;
createLogger?: (config: ResolvedLoggingConfig, mode: string, version: string) => Promise<Logger>;
createStore?: (dbPath: string) => ProbeStore;
exit?: (code: number) => never;
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
@@ -39,8 +44,14 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath));
const createEngine =
dependencies.createEngine ??
((store: ProbeStore, targets: ResolvedConfig["targets"], maxConcurrentChecks: number, retentionMs: number) =>
new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs));
((
store: ProbeStore,
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
logger: Logger,
) => new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs, logger));
const buildLogger = dependencies.createLogger ?? createRuntimeLogger;
const serve = dependencies.startServer ?? startServer;
const onSignal =
dependencies.onSignal ??
@@ -52,18 +63,42 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
let store: ProbeStore | undefined;
let engine: BootstrapEngine | undefined;
let logger: Logger | undefined;
try {
const config = await load(options.configPath);
try {
logger = await buildLogger(config.logging, options.mode, options.version);
} catch (logInitError) {
logError("日志初始化失败:", logInitError instanceof Error ? logInitError.message : logInitError);
exit(1);
}
logger!.info({ configPath: options.configPath, mode: options.mode, version: options.version }, "配置加载成功");
store = createStore(join(config.dataDir, "probe.db"));
store.syncTargets(config.targets);
logger!.info({ dataDir: config.dataDir }, "数据库初始化成功");
engine = createEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
engine = createEngine(
store,
config.targets,
config.maxConcurrentChecks,
config.retentionMs,
logger!.child({ component: "engine" }),
);
engine.start();
logger!.info(
{ maxConcurrentChecks: config.maxConcurrentChecks, targetCount: config.targets.length },
"调度引擎启动",
);
const shutdown = () => {
logger?.info("收到退出信号,开始优雅关机");
engine?.stop();
store?.close();
logger?.flush();
exit(0);
};
onSignal("SIGINT", shutdown);
@@ -71,6 +106,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
serve({
config: { host: config.host, port: config.port },
logger: logger!.child({ component: "server" }),
mode: options.mode,
staticAssets: options.staticAssets,
store,
@@ -79,7 +115,12 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
} catch (error) {
engine?.stop();
store?.close();
if (logger) {
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
logger.flush();
} else {
logError("启动失败:", error instanceof Error ? error.message : error);
}
exit(1);
}
}

View File

@@ -2,13 +2,22 @@ import { isNumber, isPlainObject, isString } from "es-toolkit";
import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues";
import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } from "./types";
import type {
DefaultsConfig,
EngineRuntimeConfig,
LoggingConfig,
LogLevel,
RawTargetConfig,
ResolvedLoggingConfig,
ResolvedTargetBase,
RotationFrequency,
} from "./types";
import { checkerRegistry } from "./runner";
import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
import { parseDuration, parseSize } from "./utils";
import { resolveVariables } from "./variables";
const DEFAULT_HOST = "127.0.0.1";
@@ -18,11 +27,19 @@ const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s";
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
const DEFAULT_RETENTION = "7d";
const DEFAULT_LOG_LEVEL: LogLevel = "info";
const DEFAULT_ROTATION_SIZE = "50MB";
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
const DEFAULT_ROTATION_MAX_FILES = 14;
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
export interface ResolvedConfig {
configDir: string;
dataDir: string;
host: string;
logging: ResolvedLoggingConfig;
maxConcurrentChecks: number;
port: number;
retentionMs: number;
@@ -80,7 +97,10 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const retentionMs = resolveRetention(runtime);
const logging = resolveLogging(validated.logging ?? {}, dataDir, configDir);
const allRuntimeIssues = [...allIssues];
validateLoggingConfig(validated.logging, allRuntimeIssues);
if (allRuntimeIssues.length > 0) {
throwConfigIssues(dedupeIssues(allRuntimeIssues));
}
@@ -92,7 +112,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { configDir, dataDir, host, maxConcurrentChecks, port, retentionMs, targets };
return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets };
}
function canRunSemanticValidation(value: unknown): boolean {
@@ -113,6 +133,45 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[]
export { parseDuration } from "./utils";
function isAbsolute(p: string): boolean {
return p.startsWith("/") || /^[A-Za-z]:/.test(p);
}
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
const rawPath = logging.file?.path;
const filePath = rawPath
? isAbsolute(rawPath)
? rawPath
: resolve(configDir, rawPath)
: resolve(dataDir, "logs/dial.log");
const rotationRaw = logging.file?.rotation;
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
const rotationSizeBytes = parseSize(rotationSizeRaw);
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
return {
consoleLevel,
fileLevel,
filePath,
rotationFrequency,
rotationMaxFiles,
rotationSizeBytes,
rotationSizeRaw,
};
}
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
if (!isString(level)) return fallback;
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
return fallback;
}
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
@@ -263,3 +322,69 @@ function validateDurationValue(
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
}
}
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
if (logging === undefined) return;
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
issues.push(
issue("invalid-value", "logging.level", `日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`),
);
}
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
issues.push(
issue(
"invalid-value",
"logging.console.level",
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
issues.push(
issue(
"invalid-value",
"logging.file.level",
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
if (logging.file?.path !== undefined) {
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
issues.push(issue("invalid-value", "logging.file.path", "日志路径不能为空字符串或空白字符串"));
}
}
const rotation = logging.file?.rotation;
if (rotation?.size !== undefined) {
try {
const bytes = parseSize(rotation.size);
if (bytes <= 0) {
issues.push(issue("invalid-value", "logging.file.rotation.size", "滚动大小必须为正整数字节数"));
}
} catch (error) {
issues.push(
issue("invalid-value", "logging.file.rotation.size", error instanceof Error ? error.message : "size 格式非法"),
);
}
}
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
issues.push(
issue(
"invalid-value",
"logging.file.rotation.frequency",
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
),
);
}
if (rotation?.maxFiles !== undefined) {
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
issues.push(issue("invalid-value", "logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
}
}
}

View File

@@ -1,14 +1,18 @@
import { groupBy, isError, Semaphore } from "es-toolkit";
import type { Logger } from "../logger";
import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTargetBase } from "./types";
import { createNoopLogger } from "../logger";
import { errorFailure } from "./expect/failure";
import { checkerRegistry } from "./runner";
const PRUNE_INTERVAL_MS = 3600000;
export class ProbeEngine {
private lastMatched = new Map<string, boolean>();
private logger: Logger;
private retentionMs: number;
private semaphore: Semaphore;
private store: ProbeStore;
@@ -16,12 +20,20 @@ export class ProbeEngine {
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number, retentionMs?: number) {
constructor(
store: ProbeStore,
targets: ResolvedTargetBase[],
maxConcurrentChecks?: number,
retentionMs?: number,
logger?: Logger,
) {
this.store = store;
this.targets = targets;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
this.retentionMs = retentionMs ?? 0;
this.logger = logger ?? createNoopLogger();
this.refreshCache();
this.initStateCache();
}
start(): void {
@@ -53,6 +65,49 @@ export class ProbeEngine {
this.timers = [];
}
private initStateCache(): void {
const latestMap = this.store.getLatestChecksMap();
for (const [id, row] of latestMap) {
this.lastMatched.set(id, row.matched === 1);
}
}
private logCheckDebug(result: CheckResult): void {
this.logger.debug({
durationMs: result.durationMs,
failureMessage: result.failure?.message ?? null,
failurePhase: result.failure?.phase ?? null,
matched: result.matched,
targetId: result.targetId,
});
}
private logStateChange(result: CheckResult): void {
const previous = this.lastMatched.get(result.targetId);
const current = result.matched;
if (previous === undefined) {
if (!current) {
this.logger.warn(
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
`目标首次 DOWN: ${result.targetId}`,
);
}
} else if (previous && !current) {
this.logger.warn(
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
`目标状态变化 UP → DOWN: ${result.targetId}`,
);
} else if (!previous && current) {
this.logger.info(
{ durationMs: result.durationMs, targetId: result.targetId },
`目标恢复 DOWN → UP: ${result.targetId}`,
);
}
this.lastMatched.set(result.targetId, current);
}
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
@@ -68,10 +123,15 @@ 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;
if (target) {
this.logger.error(
{ reason: formatReason(result.reason), targetId: target.id, targetType: target.type },
`探针执行失败: ${formatReason(result.reason)}`,
);
this.writeResult({
detail: null,
durationMs: null,
@@ -84,6 +144,7 @@ export class ProbeEngine {
}
}
}
}
private refreshCache(): void {
this.targetIds.clear();

View File

@@ -10,9 +10,13 @@ import {
createRawValueExpectationSchema,
createValueMatcherObjectSchema,
durationSchema,
sizeSchema,
variableValueSchema,
} from "./fragments";
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const;
const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const;
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return {
...cloneSchema(createProbeConfigSchema(checkers, true)),
@@ -31,6 +35,7 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
return Type.Object(
{
defaults: Type.Optional(createDefaultsSchema(checkers)),
logging: Type.Optional(createLoggingSchema()),
runtime: Type.Optional(
Type.Object(
{
@@ -107,3 +112,35 @@ function createDefaultsSchema(checkers: CheckerDefinition[]): TSchema {
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
}
function createLoggingSchema(): TSchema {
const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]);
return Type.Object(
{
console: Type.Optional(Type.Object({ level: Type.Optional(logLevelSchema) }, { additionalProperties: false })),
file: Type.Optional(
Type.Object(
{
level: Type.Optional(logLevelSchema),
path: Type.Optional(Type.String({ minLength: 1 })),
rotation: Type.Optional(
Type.Object(
{
frequency: Type.Optional(
Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]),
),
maxFiles: Type.Optional(Type.Integer({ minimum: 1 })),
size: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
),
level: Type.Optional(logLevelSchema),
},
{ additionalProperties: false },
);
}

View File

@@ -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;

View File

@@ -1,7 +1,9 @@
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
import type { StoredCheckResult } from "./checker/types";
import type { Logger } from "./logger";
import { checkerRegistry } from "./checker/runner";
import { createNoopLogger } from "./logger";
export function createApiError(error: string, status: number): ApiErrorResponse {
return { error, status };
@@ -47,13 +49,14 @@ export function jsonResponse(
});
}
export function mapCheckResult(row: StoredCheckResult, type: string): CheckResult {
export function mapCheckResult(row: StoredCheckResult, type: string, logger?: Logger): CheckResult {
const log = logger ?? createNoopLogger();
let failure: CheckFailure | null = null;
if (row.failure) {
try {
failure = JSON.parse(row.failure) as CheckFailure;
} catch {
console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
log.warn({ targetId: row.target_id, timestamp: row.timestamp }, "无法解析 failure 数据");
failure = null;
}
}
@@ -63,7 +66,7 @@ export function mapCheckResult(row: StoredCheckResult, type: string): CheckResul
try {
observation = JSON.parse(row.observation) as Record<string, unknown>;
} catch {
console.warn(`无法解析 observation 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
log.warn({ targetId: row.target_id, timestamp: row.timestamp }, "无法解析 observation 数据");
observation = null;
}
}

274
src/server/logger.ts Normal file
View File

@@ -0,0 +1,274 @@
import type pino from "pino";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import type { LogLevel, ResolvedLoggingConfig } from "./checker/types";
export interface Logger {
child(bindings: Record<string, unknown>): Logger;
debug(obj: Record<string, unknown>, msg?: string): void;
debug(msg: string): void;
error(obj: Record<string, unknown>, msg?: string): void;
error(msg: string): void;
fatal(obj: Record<string, unknown>, msg?: string): void;
fatal(msg: string): void;
flush(): void;
info(obj: Record<string, unknown>, msg?: string): void;
info(msg: string): void;
trace(obj: Record<string, unknown>, msg?: string): void;
trace(msg: string): void;
warn(obj: Record<string, unknown>, msg?: string): void;
warn(msg: string): void;
}
export const REDACT_PATHS = [
"authorization",
"cookie",
"set-cookie",
"*.set-cookie",
"authToken",
"key",
"password",
"token",
"apiKey",
"*.authorization",
"*.cookie",
"*.authToken",
"*.key",
"*.password",
"*.token",
"*.apiKey",
];
const LOG_LEVEL_MAP: Record<LogLevel, string> = {
debug: "debug",
error: "error",
fatal: "fatal",
info: "info",
trace: "trace",
warn: "warn",
};
type LogFn = (objOrMsg: Record<string, unknown> | string, msg?: string) => void;
const voidLog: LogFn = () => undefined;
class ConsoleFallbackLogger implements Logger {
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.error(formatMsg(objOrMsg, msg));
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.error(formatMsg(objOrMsg, msg));
}
flush: () => void = () => undefined;
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.warn(formatMsg(objOrMsg, msg));
}
}
class NoopLogger implements Logger {
debug: LogFn = voidLog;
error: LogFn = voidLog;
fatal: LogFn = voidLog;
info: LogFn = voidLog;
trace: LogFn = voidLog;
warn: LogFn = voidLog;
child(_bindings: Record<string, unknown>): Logger {
return this;
}
flush: () => void = () => undefined;
}
class PinoLoggerWrapper implements Logger {
private pino: pino.Logger;
constructor(pinoLogger: pino.Logger) {
this.pino = pinoLogger;
}
child(bindings: Record<string, unknown>): Logger {
return new PinoLoggerWrapper(this.pino.child(bindings));
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.debug(objOrMsg);
else this.pino.debug(objOrMsg, msg);
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.error(objOrMsg);
else this.pino.error(objOrMsg, msg);
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.fatal(objOrMsg);
else this.pino.fatal(objOrMsg, msg);
}
flush(): void {
this.pino.flush();
}
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.info(objOrMsg);
else this.pino.info(objOrMsg, msg);
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.trace(objOrMsg);
else this.pino.trace(objOrMsg, msg);
}
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.warn(objOrMsg);
else this.pino.warn(objOrMsg, msg);
}
}
export class MemoryLogger implements Logger {
entries: Array<{ level: string; msg: string; obj?: Record<string, unknown> }> = [];
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("debug", objOrMsg, msg);
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("error", objOrMsg, msg);
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("fatal", objOrMsg, msg);
}
flush: () => void = () => undefined;
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("info", objOrMsg, msg);
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("trace", objOrMsg, msg);
}
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("warn", objOrMsg, msg);
}
private capture(level: string, objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") {
this.entries.push({ level, msg: objOrMsg });
} else {
this.entries.push({ level, msg: msg ?? "", obj: objOrMsg });
}
}
}
export function createConsoleFallback(): Logger {
return new ConsoleFallbackLogger();
}
export function createMemoryLogger(): MemoryLogger {
return new MemoryLogger();
}
export function createNoopLogger(): Logger {
return new NoopLogger();
}
export async function createRuntimeLogger(
config: ResolvedLoggingConfig,
mode: string,
version: string,
): Promise<Logger> {
const pinoLib = await import("pino");
const pinoPretty = await import("pino-pretty");
mkdirSync(dirname(config.filePath), { recursive: true });
const rootLevel = resolveRootLevel(config.consoleLevel, config.fileLevel);
const prettyStream = pinoPretty.default({
colorize: true,
ignore: "pid,hostname",
singleLine: true,
translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l",
});
const fileStream = await createRollingFileStream(config);
const streams: pino.StreamEntry[] = [
{ level: toPinoLevel(config.consoleLevel) as pino.Level, stream: prettyStream },
{ level: toPinoLevel(config.fileLevel) as pino.Level, stream: fileStream },
];
const logger = pinoLib.default(
{
base: { mode, service: "dial-server", version },
level: rootLevel,
redact: { censor: "[Redacted]", paths: REDACT_PATHS },
timestamp: pinoLib.stdTimeFunctions.isoTime,
},
pinoLib.multistream(streams),
);
return new PinoLoggerWrapper(logger);
}
async function createRollingFileStream(config: ResolvedLoggingConfig): Promise<NodeJS.WritableStream> {
const dir = dirname(config.filePath);
const base = resolve(dir, config.filePath.replace(/^.*[\\/]/, "").replace(/\.log$/, ""));
try {
const buildPinoRoll = (await import("pino-roll")).default;
return await buildPinoRoll({
file: base,
frequency: config.rotationFrequency,
limit: { count: config.rotationMaxFiles },
mkdir: true,
size: config.rotationSizeRaw,
});
} catch {
const fs = await import("node:fs");
return fs.createWriteStream(config.filePath, { flags: "a" });
}
}
function formatMsg(objOrMsg: Record<string, unknown> | string, msg?: string): string {
if (typeof objOrMsg === "string") return objOrMsg;
return msg ? `${msg} ${JSON.stringify(objOrMsg)}` : JSON.stringify(objOrMsg);
}
function resolveRootLevel(consoleLevel: LogLevel, fileLevel: LogLevel): string {
const order: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const ci = order.indexOf(consoleLevel);
const fi = order.indexOf(fileLevel);
return LOG_LEVEL_MAP[order[Math.min(ci, fi)]!] ?? "info";
}
function toPinoLevel(level: LogLevel): string {
return LOG_LEVEL_MAP[level];
}

View File

@@ -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;
}

View File

@@ -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,9 +427,6 @@ describe("API 路由", () => {
});
test("损坏的 failure JSON 返回 null 而不崩溃", async () => {
const originalWarn = console.warn;
console.warn = () => undefined;
try {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
@@ -451,8 +451,5 @@ describe("API 路由", () => {
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(body.items[0]!.failure).toBeNull();
} finally {
console.warn = originalWarn;
}
});
});

View File

@@ -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",

View File

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

View File

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

View File

@@ -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;
}
});
});

117
tests/server/logger.test.ts Normal file
View File

@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test";
import type { Logger } from "../../src/server/logger";
import { createConsoleFallback, createMemoryLogger, createNoopLogger, REDACT_PATHS } from "../../src/server/logger";
describe("NoopLogger", () => {
test("所有方法不抛异常", () => {
const logger = createNoopLogger();
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.flush();
const child = logger.child({ component: "test" });
expect(child).toBeDefined();
});
});
describe("MemoryLogger", () => {
test("记录所有等级日志", () => {
const logger = createMemoryLogger();
logger.trace("trace-msg");
logger.debug("debug-msg");
logger.info("info-msg");
logger.warn("warn-msg");
logger.error("error-msg");
logger.fatal("fatal-msg");
expect(logger.entries).toHaveLength(6);
expect(logger.entries[0]).toEqual({ level: "trace", msg: "trace-msg" });
expect(logger.entries[5]).toEqual({ level: "fatal", msg: "fatal-msg" });
});
test("记录结构化日志", () => {
const logger = createMemoryLogger();
logger.info({ matched: true, targetId: "abc" }, "check complete");
expect(logger.entries).toHaveLength(1);
expect(logger.entries[0]!.level).toBe("info");
expect(logger.entries[0]!.msg).toBe("check complete");
expect(logger.entries[0]!.obj).toEqual({ matched: true, targetId: "abc" });
});
test("child 返回自身", () => {
const logger = createMemoryLogger();
const child = logger.child({ component: "engine" });
child.info("child-msg");
expect(logger.entries).toHaveLength(1);
});
test("flush 不抛异常", () => {
const logger = createMemoryLogger();
logger.flush();
});
});
describe("ConsoleFallbackLogger", () => {
test("不抛异常", () => {
const logger = createConsoleFallback();
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.flush();
const child = logger.child({ component: "test" });
expect(child).toBeDefined();
});
});
describe("Logger 接口契约", () => {
function assertLogger(logger: Logger): void {
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.info({ key: "value" }, "structured");
logger.child({ component: "test" }).info("child");
logger.flush();
}
test("NoopLogger 满足 Logger 接口", () => {
expect(() => assertLogger(createNoopLogger())).not.toThrow();
});
test("MemoryLogger 满足 Logger 接口", () => {
expect(() => assertLogger(createMemoryLogger())).not.toThrow();
});
test("ConsoleFallbackLogger 满足 Logger 接口", () => {
expect(() => assertLogger(createConsoleFallback())).not.toThrow();
});
});
describe("redaction 敏感信息保护", () => {
test("MemoryLogger 不做 redaction测试用途仅 Pino 运行时 redact", () => {
const logger = createMemoryLogger();
logger.info({ authorization: "Bearer secret", password: "hunter2" }, "test");
const entry = logger.entries[0]!;
expect(entry.obj!["authorization"]).toBe("Bearer secret");
expect(entry.obj!["password"]).toBe("hunter2");
});
test("REDACT_PATHS 覆盖所有敏感字段键名", () => {
const sensitiveKeys = ["authorization", "cookie", "set-cookie", "authToken", "key", "password", "token", "apiKey"];
for (const key of sensitiveKeys) {
expect(REDACT_PATHS).toContain(key);
expect(REDACT_PATHS).toContain(`*.${key}`);
}
});
});