Skip to content

LoggerJS 架构 ​

LoggerJS 是一个同构的结构化日志库,可运行在浏览器、Node.js、Bun、Deno、worker 和 edge 运行时中。本页说明当前各个包如何组织、实现遵守的规则,以及热路径背后的设计决策。精确类型以各包的声明文件和 API 报告 为准。

产品围绕三个面向用户的概念构建:

  • Integration(集成):可选的自动采集,例如浏览器 console 捕获、全局脚本错误、fetch/XHR 失败、页面生命周期 flush、Node 进程错误、HTTP 框架、队列和数据库客户端。
  • Middleware 与 processor:同步的转换与过滤,例如脱敏、采样、补充字段、去重、指纹、路由和 fingers-crossed 缓冲。
  • Transport(传输):投递边界,例如 console、stdout、文件、HTTP、IndexedDB、OTLP、Sentry、数据库、worker 线程或自定义 sink。

另有一个同样重要的技术边界:codec(编解码器)。codec 属于 transport,负责序列化。middleware 和 processor 从不序列化;console transport 保留原始值;HTTP、文件和 OTLP transport 各自选择需要的 codec。

包结构 ​

txt
packages/core        Logger、LogRecord/LogEvent 模型、registry、context、typed/semantic events、
                     middleware 内核、integration API、console/memory/test/batch/retry/fallback
                     transports、json/safe-json/ndjson/metrics codecs、payload transforms
packages/browser     HTTP、IndexedDB、WebSocket、service worker、BroadcastChannel 和 offline-first
                     transports,离线队列,ZIP 导出,19 个浏览器 integrations
packages/node        stdout/stderr/file/rotating-file/HTTP/syslog/worker transports、
                     AsyncLocalStorage context、diagnostics_channel 桥接、16 个 Node integrations
packages/pretty      浏览器 DevTools 与终端显示 transports,以及共享 formatter
packages/processors  与运行时无关的 middleware 与 processor 目录
packages/codecs      fast-event-json、Pino 兼容、msgpackr 和 projector codecs
packages/otel        OTLP JSON 映射、OTLP/HTTP transport、log bridge、活跃 span trace processor
packages/sentry      Sentry 结构化日志、breadcrumbs、异常/消息捕获
packages/datadog|elastic|loki|cloudwatch   厂商协议 transports
packages/database    SQLite、Postgres 和自定义适配器的批量 transports

browser 和 node 包会重新导出 core。它们的根入口是便捷预设;每个 transport 和 integration 也都有独立的子路径导出(例如 @loggerjs/browser/transport-http 或 @loggerjs/node/integration-process),构建为独立的入口 bundle,并由 pnpm verify:entry-boundaries 检查。哪些入口属于稳定 API 见 API 稳定性。

设计规则 ​

  1. core 与平台无关。 @loggerjs/core 不导入浏览器、Node、Bun、Deno、worker、文件系统、fetch 或 diagnostics API,公开类型在没有 lib.dom 时也能编译。运行时能力通过 globalThis 检测。
  2. 禁用的日志几乎零成本。 被禁用级别的调用只做一次数值比较,然后在分配 record、计算消息、查找 context 或执行 integration 之前返回。
  3. 序列化只发生在 transport 边界。 管线内保留原始引用。resolveMessage(record) 是 middleware 唯一允许触发的延迟求值。
  4. middleware 和 processor 是同步的。 没有 promise,没有 Koa 风格的 next,热路径中也没有异步查找。
  5. integration 与手动日志走同一条管线。 采集到的 record 只在 source 上不同,仍然经过 middleware、processor、路由、codec 和 transport。
  6. integration 显式且可撤销。 每个 patch 都需要主动开启,带有重入保护,并在 close() 时拆除。
  7. logger 错误永远不会抛到应用代码。 middleware、processor、codec、integration 和 transport 中的失败会计入 logger meta 计数,并通过 onInternalError 上报(未设置处理函数时,用未被 patch 的 console 输出 console.error)。
  8. 不使用对象池。 短生命周期的 record 保持为新生代 GC 对象。
  9. 不生成代码。 脱敏和 codec 都基于解释执行,不使用 eval 或 new Function。

端到端管线 ​

txt
手动 API / integration 采集
        |
        v
  级别判断                        一次数值比较
        |
        v
  createRecord()                 包含原始消息、错误、props 和 context 的 LogRecord
        |
        v
  middleware                     同步、有序;可以替换字段或丢弃(返回 null)
        |
        +-- 没有 processor ------> record 快速路径:transport 直接收到 record
        |
        v
  投影为 LogEvent                 分配 id、解析消息、规范化错误
        |
        v
  processors                     同步、有序;可以返回新 event 或丢弃(返回 false)
        |
        v
  transport 分发                  按 transport 的 minLevel 和 processor 路由过滤
        |
        v
  transport                      自行负责队列、批量、重试、codec 和投递

在选定的 transport 准备发送之前,record 不会被字符串化。正因如此,console 能保留可交互的对象,HTTP 可以选择 JSON 或二进制,文件写入 NDJSON,OTLP 使用自己的映射,而不会拖慢其他目的地。

Record 模型 ​

LogRecord 是热路径使用的结构。它只通过一个 createRecord() 入口构造,每个字段(包括 null 字段)都按相同顺序赋值,因此所有 record 共享同一个 hidden class:

ts
interface LogRecord {
  time: number;
  level: number;
  category: readonly string[];
  type: string | null;
  tags: Tags | null;
  trace: TraceContext | null;
  msg: string | null;
  lazy: (() => string) | null;
  props: Record<string, unknown> | null;
  err: unknown;
  ctx: BoundContext | null;
  source: string;
  stack: string | null;
  seq: number;
}
  • 不删除字段,也不附加临时属性;额外数据放在 props 或不可变的 ctx 中。
  • time 来自 logger 的时钟;时间戳相同时按 seq 排序。
  • err 与 props 分开存放,因为错误规范化、堆栈截断、cause 处理和去重都需要专门逻辑。
  • ctx 和 logger 级别的 tags 被冻结并共享。middleware 应替换它们(record.tags = { ...record.tags, extra }),而不是原地修改。
  • record 上没有 id。id 在 record 投影为 LogEvent 时分配;直接编码 record 的 codec 使用 defaultRecordId。

LogEvent 是面向 transport 的信封结构(id、time、seq、level、levelName、logger、message、type、tags、data、error、context、trace、source)。recordToEvent() 和 eventToRecord() 在两者之间转换;有损转换的情况见 核心概念。

Logger API ​

LoggerJS 支持两种获取 logger 的方式。应用直接创建 logger:

ts
const log = createLogger({
  category: ["app"],
  level: "info",
  transports: [consoleTransport()],
});

库按 category 查找 logger,在应用配置 registry 之前保持静默:

ts
const log = getLogger(["library", "parser"]);

await configure({
  processors: [redactProcessor({ keys: ["password", /token/i] })],
  transports: {
    console: consoleTransport(),
    http: browserHttpTransport({ url: "/v1/logs", codec: jsonCodec() }),
  },
  loggers: [
    { category: ["app"], level: "debug", transports: ["console", "http"] },
    { category: ["library"], level: "warn", transports: ["http"] },
  ],
  integrations: [captureConsoleIntegration(), captureBrowserErrorsIntegration()],
});

调用形式:

ts
log.info("user logged in", { userId: 42 });
log.error(err, "save failed", { orderId });
log.debug(() => expensiveDebugMessage());
log.event(CheckoutCompleted, { orderId, amountCents });
log.child({ bindings: { requestId } }).warn("retrying");
await log.flush();

重载规则保持简单:

  • 第一个参数是 string:消息
  • 第一个参数是 function:延迟消息
  • 其他情况:作为错误,后面可以跟可选的消息和 props

core 不提供 printf 风格的格式化。结构化字段是一等公民;格式化属于展示层,由 @loggerjs/pretty 负责。

Registry 与配置 ​

getLogger(category) 返回 RegistryLogger。在 configure() 运行之前它不做任何事;之后每次调用都会解析到根据当前配置快照构建并缓存的运行时 logger。

  • 路由按 category 前缀匹配,最具体的路由优先:["app"] 适用于 ["app", "checkout"],除非有更长的路由匹配。
  • transport 是具名的(传对象,或传数组并以各 transport 的 name 为键);路由按名称选择 transport,没有指定时使用全部 transport。
  • 全局 processor 先于路由 processor 运行。
  • 通过 configure() 配置的 integration 只在一个内部宿主 logger 上安装一次。
  • 再次调用 configure() 会替换上一份快照:先拆除旧的 integration 再安装新的,并关闭新配置不再引用的 transport;再次传入的 transport 保持打开。
  • configure({ reset: true }) 会在安装新快照之前关闭所有旧的 integration 和 transport,包括被复用的。
  • 配置以快照形式保存,日志调用不需要遍历可变的配置结构。

有了 registry,第三方库可以记录日志,而不必绑定某个后端,也不会强迫应用做配置。

Context ​

context 有两种模式:

  • 显式 context:通过 logger.child({ bindings }) 提供。bindings 在创建 child 时被展平并冻结。
  • 环境 context:通过 withContext(bindings, fn) 提供。默认的 context 管理器覆盖同步作用域;@loggerjs/node 的 installAsyncLocalStorageContext() 让 context 在 Node 中跨越 await。浏览器在 TC39 AsyncContext 可用之前仍只支持同步作用域。

addContextProvider() 让 integration 在不替换应用自身 provider 的前提下补充环境字段(trace、session、request id)。没有注册任何 context provider 时,getContext() 直接返回当前管理的 context,不做任何分配。

Middleware 与 Processor ​

ts
interface Middleware {
  readonly name: string;
  process(record: LogRecord, context: MiddlewareContext): LogRecord | null;
}

type Processor = (event: LogEvent, context: ProcessorContext) => LogEvent | false | void;
  • middleware 在分配 id、解析消息或处理错误之前运行在 record 上。返回 null 即丢弃该 record。
  • processor 运行在投影后的 event 上。返回 false 丢弃 event;返回 event 则替换原 event。
  • 两者都做了错误隔离:异常会被上报并计数,管线继续运行。
  • 只要配置了任意 processor,该 logger 就不再走 record 快速路径,因为每条日志都必须投影成 event。

middleware 不得调用 JSON.stringify、String(record.props),也不得递归规范化整个 record。如果需要消息,应显式调用 resolveMessage(record)。与运行时无关的目录位于 @loggerjs/processors,见 处理器。

Transport ​

ts
interface Transport {
  name?: string;
  minLevel?: LoggerLevel;
  ready?(): void | Promise<void>;
  write?(record: LogRecord, context: TransportContext): void | Promise<void>;
  writeBatch?(records: LogRecord[], context: TransportContext): void | Promise<void>;
  log?(event: LogEvent, context: TransportContext): void | Promise<void>;
  logBatch?(events: LogEvent[], context: TransportContext): void | Promise<void>;
  flush?(): void | Promise<void>;
  flushSync?(): void;
  close?(): void | Promise<void>;
}
  • 在 record 路径上,core 依次优先使用 write、writeBatch,否则用 context.toEvent(record) 转换一次后调用 log/logBatch。转换结果按 record 缓存,多个 transport 共享同一次投影和同一个 id。
  • 在 event 路径上(经过 processor 之后),core 优先使用 log/logBatch,只为仅支持 record 的 transport 派生 record。
  • minLevel 按 transport 过滤;processor 路由(routeProcessor、withLogEventRoute)把 event 固定到具名 transport。
  • 同步抛出的异常和被拒绝的 promise 都会上报到 logger meta;一个 transport 失败不会阻塞其他 transport。
  • ready() 需要显式调用。普通日志调用从不等待 transport 启动。
  • close() 自己负责尽力 flush。存在 close() 时 core 调用它,只有没有 close() 的 transport 才回退到调用 flush()。

transport 负责队列与背压、批量、重试与熔断、codec 选择、目的地投递、丢弃与错误计数,以及 flush 和 close 的语义。

批量 ​

batchTransport(inner, options) 是共享的可靠性层,HTTP 和 OTLP transport 都基于它,也推荐用它包装原始的厂商 transport。它提供:

  • maxRecords(默认 50)、maxBytes 和 maxWaitMs(默认 1000 毫秒)三种 flush 触发条件
  • maxQueueSize,以及 drop-oldest、drop-newest 或 throw 三种丢弃策略
  • concurrency,控制并行发送的批次数
  • 带抖动的指数退避重试
  • 支持半开恢复的熔断器
  • transport.dropped.<reason> 计数器和可选的 onDrop 回调
  • 只有 maxBytes 为有限值时才估算字节数;队列为空时不保留空闲定时器

retryTransport() 和 fallbackTransport() 适用于已经自带批量、或需要本地备份 sink 的 transport。每个内置 transport 的投递特性见 传输。

Console ​

console transport 在 pretty 模式下不做序列化,而是把原始的消息、数据和错误引用传给原生 console 方法,让浏览器 DevTools 保留对象检查能力。它通过未被 patch 的 console 注册表写入,因此可以与 console 捕获同时启用而不会形成回环,并且默认过滤掉从 console 捕获来的 record。

HTTP ​

  • 浏览器: 使用带 keepalive 的 fetch,在 pagehide 或页面隐藏时使用 sendBeacon,可选内存或 IndexedDB 离线队列,并按条数和 Beacon 字节数限制每次请求(maxBatchSize、beaconMaxBytes)。
  • Node: 使用包装在 batchTransport 中的 fetch,支持重试和熔断选项。远程 HTTP 不是同步的崩溃 flush 路径。

隐私默认值:fetch/XHR integration 不采集请求或响应 body,除非加入允许列表,否则也不采集 header;浏览器离线队列只有在显式传入时才启用。

文件与 stdout ​

Node 的 stdout、stderr 和文件 transport 通过一个共享的目的地实现写入 NDJSON:跟踪写入回调和 drain,支持可选的 minLength 缓冲,把 EPIPE 当作正常关闭,并用同步写入实现 flushSync() 以覆盖致命错误路径。fileTransport({ sync: true }) 让每次写入都同步完成。

Worker ​

workerTransport() 把 I/O 移出主线程:用 codec 编码批次后发送给 worker,并可选择转移 buffer 所有权:

txt
main thread batch -> codec.encode(batch) -> Uint8Array -> postMessage(buffer, [buffer])

可选的 ready 和 ack 协议让 worker 是否接收成功可以被观测;worker 失败时,待处理的批次会交给 fallback transport 或计为丢弃。flushSync() 无法跨越 worker 边界。

OTLP 与 Sentry ​

OTLP/HTTP JSON 是一方 transport,因为 LoggerJS 选择接入现有的可观测性后端,而不是另造一套协议。Sentry 支持以适配包形式提供:使用应用已经初始化的 SDK,把 record 映射为 Sentry 结构化日志,并可把错误 record 捕获为 Sentry 事件。

Codec ​

ts
interface Codec<TPayload = string | Uint8Array> {
  name: string;
  contentType: string;
  encode(input: LogEvent | LogRecord | readonly (LogEvent | LogRecord)[], context?: EncodeContext): TPayload;
  decode?(payload: TPayload): LogEvent | LogEvent[];
  prepareRecordEncoder?(hints: RecordEncoderHints): PreparedRecordEncoder<TPayload>;
}
  • ndjsonCodec() 和 fastEventJsonCodec() 在快速路径上使用原生 JSON.stringify 语义,只有原生序列化抛错时(循环引用、BigInt)才改用安全序列化重新编码,并计入 codec.fallback。
  • safeJsonCodec() 对每一条都做完整规范化。
  • msgpackrCodec() 通过 msgpackr 生成二进制批次;projectorCodec() 适配自定义线上格式;pinoCompatCodec() 输出 Pino 形状的 NDJSON;otlpJsonCodec() 输出 OTLP JSON。
  • EncodeContext 提供级别名查找和 WeakMap 缓存,codec 可以为不可变的 context 和 tags 缓存已编码的片段。
  • createPreparedRecordEncoder(codec) 让支持 record 的 transport 复用 codec 持有的 logger 和 tag 片段,而不必把序列化挪进 logger。

完整契约见 编解码。

Integration ​

ts
interface Integration {
  name: string;
  setup(api: IntegrationSetupContext): void | Teardown;
}

interface IntegrationAPI {
  capture(input: CaptureInput): void;
  getLogger(category: LoggerCategory): LoggerLike;
  readonly unpatched: UnpatchedRegistry;
  guard<T extends (...args: never[]) => unknown>(fn: T): T;
}

IntegrationSetupContext 把上面的 API 与 logger 方法(info、error、flush 等)合并在一起。

防止回环分三层:

  1. 在 patch 之前登记原始的 console、fetch 和 XMLHttpRequest 函数,transport 通过未被 patch 的注册表调用它们。
  2. api.guard() 同步丢弃重入的采集,并计入 integration.dropped.reentrant。
  3. 采集到的 record 保留 source: "integration:<name>",transport 可以据此过滤自身产生的 record。

每个 integration 实例只 setup 一次,并在 close() 时按相反顺序拆除。Node 的崩溃处理如实执行:启用 exitOnUncaught 时,captureProcessIntegration() 先记录一条 fatal record,对支持同步 flush 的 transport 调用 flushSync(),在有限时间内等待异步 flush(),然后退出进程,而不是留下僵尸进程。

路由 ​

路由依据 category、级别、source 和显式指定的 transport:

  • registry 中按 category 前缀匹配的路由
  • 每个 transport 的 minLevel
  • 按 source 排除,例如 console transport 跳过 integration:console 的 record
  • processor 附加的路由,把 event 固定到具名 transport

record 快速路径不做路由过滤:路由只能由 processor 附加,而 record 路径只在 logger 没有 processor 时运行。

可靠性 ​

默认的投递语义是尽力而为、至多一次。LoggerJS 不会为了保证投递而无限期阻塞应用。每一条丢失路径都可以通过 logger meta 计数(getLoggerMetaStats()、getLoggerSelfMetrics())和 transport 统计观测到:

  • 队列溢出
  • 批次过大
  • 重试耗尽
  • 熔断器打开
  • Beacon 被拒绝
  • 离线队列超出配额
  • 超过 flush 截止时间
  • integration 重入被丢弃
  • middleware、processor、codec 或 transport 抛出异常

隐私与安全 ​

  • redactProcessor() 默认遮盖常见敏感键:password、passwd、secret、token、authorization、cookie、set-cookie、apiKey 和 api_key。
  • fetch 和 XHR integration 不采集 body,除非加入允许列表也不采集 header。
  • 除非配置了离线队列或 IndexedDB transport,否则不会在浏览器中持久化日志。
  • 不使用 eval 或生成代码,core 没有运行时依赖。

任何把日志写入浏览器持久存储的功能都必须显式开启,因为这会改变应用的隐私属性。另见 运维 和 SECURITY.md。

性能 ​

预算 ​

路径目标当前
禁用级别的调用一次数值比较,零分配参考机器上约 3 ns
record 分配每条日志一个 record 对象;除非 middleware 显式克隆,否则不复制数据已达成
Node lean NDJSON 路径等价输出下与 pino 同一量级M1 Max 参考机器上约为 pino 吞吐量的 1.19 倍,M4 Pro 行上慢于 pino
core 体积在平台无关的功能集允许范围内尽量小完整 barrel 连同共享 chunk 约 22 KB gzip;经过 tree-shaking 和压缩后,createLogger 加 consoleTransport 约 5.5 KB gzip,加 browserHttpTransport 约 7.8 KB,加 stdoutTransport 约 6.2 KB

最小 bundle 中剩下的大部分是稳定的 Logger 本身:child logger、bindings 和环境 context、middleware 和 processor、integration 安装、event() 和 capture()、错误规范化、安全序列化,以及 onDrop 背后的投递记账。这些都属于 1.x API,所以要做到 3–4 KB,需要一个 API 更小的独立入口,而不是继续裁剪这一个。诊断在安装 sink 之前没有开销:Logger 直接检查 sink 绑定来守护埋点代码,没有任何代码给它赋值时,打包工具会把这部分去掉。

pnpm size:check 对每个包入口以及上述三个最小应用 bundle 强制执行原始体积和 gzip 预算,并检查它们不包含 codec 的 decode 校验,pnpm bench:gate 对禁用级别、入队、lean、prepared 和完整信封路径强制执行相对 pino 的配对 A/B 比值。基准同时在 Node 和真实浏览器中运行;见 基准 和 基准矩阵。

决策:保留 record 管线,通过 codec 持有的预处理来优化 ​

LoggerJS 为每条日志分配一个 LogRecord,让 middleware、processor、integration 和多个 transport 观察同一个共享值,codec 也保持“永不抛错、安全回退”的契约。在这一架构下,lean Node NDJSON 路径与 pino 处于同一量级:在 M1 Max 参考机器上,配对 A/B 测试框架测得 lean 路径约为 pino 吞吐量的 1.19 倍,codec 持有的 prepared 路径约为 1.28 倍,而额外输出 id、seq 和 levelName 的完整信封约为 0.93 倍。排名取决于 CPU 和 Node/V8 版本;在 M4 Pro 那一行上 pino 更快。

热路径在不把序列化挪进 logger 的前提下做到了这一点:

  • 没有注册 context provider 时,getContext() 直接返回,不会在每次调用时合并空的 provider。
  • fastEventJsonCodec 在创建时一次性确定 include* 选项,并用单个模板输出头部字段。
  • prepared record encoder 让 transport 复用 codec 持有的 logger、category 和 tag 片段。

“当 logger 只有一个同步 transport 且没有 middleware 时绕过 record”的融合快速路径不会作为默认方案,因为它会:

  • 形成性能断崖:加入第一个 middleware 就会悄悄损失一大截吞吐量;
  • 把序列化挪进 logger,破坏“codec 属于 transport”的边界;
  • 让每次语义变更都必须同步维护的热路径翻倍,而 id 漂移、source 往返转换这类缺陷正是出在这种双路径上。

性能工作集中在默认路径(批量入队、默认 codec、prepared codec 契约)和回归门禁上。只有当生产场景证明单独的语义热路径确有必要时,才重新考虑融合路径。

测试策略 ​

  • 单元测试覆盖 record 构造、级别判断、重载、child context、middleware、路由和 transport 错误
  • codec 测试覆盖安全 JSON 回退和往返转换
  • 同时启用 console transport 和 console 捕获的防回环测试
  • 在 Chromium、Firefox 和 WebKit 中运行的 Playwright 测试,覆盖 pagehide/Beacon flush、fetch/XHR 捕获、全局错误和离线队列
  • Node 子进程测试,覆盖未捕获异常时的 flush 和退出行为
  • 在 Node、Bun、Deno 和 workerd/Miniflare 上对打包产物做运行时冒烟测试
  • 每个包入口的体积预算和配对基准回归门禁
  • 针对 Elasticsearch、Loki、Datadog 和 CloudWatch 的真实服务测试

每次修改某一层都要带上测试,修改热路径还要附带基准或体积数据。见 贡献 和 测试清单。

相关链接 ​

基于 MIT License 发布。