import { isSensitiveKey, REDACTED, redactString } from "@umt/contracts"; import pino, { type Logger } from "pino"; // 键表与 URL query / 请求头 / Bearer 规则与 Agent 的 redactForModel 共用一份(@umt/contracts redact.ts): // 键名规范化后命中 passkey / rsskey / torrent_pass / authkey / downhash / c_secure_pass / cookie / x-api-key / *token … // 即整体替换(数值型如 token 计数除外);其余字符串按 redactString 处理。 export { REDACTED, redactString }; const sensitive = (k: string, v: unknown) => isSensitiveKey(k) && typeof v !== "number" && v != null; /** 递归脱敏:敏感字段整体替换,其余字符串做 URL query 脱敏 */ export function redact(value: unknown, depth = 0): unknown { if (depth > 8) return value; if (typeof value === "string") return redactString(value); if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)); if (value instanceof Error) return redactError(value, depth); if (value && typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value)) { if (sensitive(k, v)) out[k] = REDACTED; else out[k] = redact(v, depth + 1); } return out; } return value; } /** * Error 的 message/stack 是不可枚举属性,按普通对象处理会变成 `{}`; * 这里先按 pino 标准形状(type/message/stack + 自有字段 + cause)展开再脱敏。 */ function redactError(err: Error, depth: number): Record { const out: Record = { type: err.name, message: redactString(err.message) }; if (err.stack) out.stack = redactString(err.stack); for (const [k, v] of Object.entries(err)) { out[k] = sensitive(k, v) ? REDACTED : redact(v, depth + 1); } if (err.cause !== undefined) out.cause = redact(err.cause, depth + 1); return out; } export interface LoggerOptions { level?: string; pretty?: boolean; /** 测试用:自定义输出流 */ destination?: pino.DestinationStream; } export function createLogger(opts: LoggerOptions = {}): Logger { const options: pino.LoggerOptions = { level: opts.level ?? "info", formatters: { // 所有结构化字段(含 err 序列化结果)统一过一遍脱敏 log: (obj) => redact(obj) as Record, // 根 logger 的 base 绑定;child() 的绑定 pino 会跳过这里,由 redactChildBindings 处理 bindings: (bindings) => redact(bindings) as Record, }, // err 已由 formatters.log 按 message/stack 展开并脱敏;默认 err 序列化器会把 type 改写成 "Object" serializers: { err: (v: unknown) => v }, hooks: { // msg 文本也脱敏 logMethod(args, method) { const cleaned = args.map((a) => (typeof a === "string" ? redactString(a) : a)) as typeof args; return method.apply(this, cleaned); }, }, }; if (opts.destination) return redactChildBindings(pino(options, opts.destination)); if (opts.pretty) { return redactChildBindings( pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } }), ); } // 同步写 stdout:进程随后 Deno.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志 return redactChildBindings(pino(options, pino.destination({ dest: 1, sync: true }))); } /** * pino 的 child() 会把 formatters.bindings 重置为恒等函数,绑定字段(如 `child({ password })`) * 原样写进每一行。这里在根实例上覆盖 child:子孙 logger 经原型链继承,绑定先脱敏再交给 pino。 */ function redactChildBindings(logger: Logger): Logger { type ChildFn = (this: Logger, bindings: pino.Bindings, options?: pino.ChildLoggerOptions) => Logger; const original = logger.child as unknown as ChildFn; const child: ChildFn = function (bindings, options) { return original.call(this, redact(bindings) as pino.Bindings, options); }; logger.child = child as unknown as Logger["child"]; return logger; }