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。
包结构
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 和自定义适配器的批量 transportsbrowser 和 node 包会重新导出 core。它们的根入口是便捷预设;每个 transport 和 integration 也都有独立的子路径导出(例如 @loggerjs/browser/transport-http 或 @loggerjs/node/integration-process),构建为独立的入口 bundle,并由 pnpm verify:entry-boundaries 检查。哪些入口属于稳定 API 见 API 稳定性。
设计规则
- core 与平台无关。
@loggerjs/core不导入浏览器、Node、Bun、Deno、worker、文件系统、fetch 或 diagnostics API,公开类型在没有lib.dom时也能编译。运行时能力通过globalThis检测。 - 禁用的日志几乎零成本。 被禁用级别的调用只做一次数值比较,然后在分配 record、计算消息、查找 context 或执行 integration 之前返回。
- 序列化只发生在 transport 边界。 管线内保留原始引用。
resolveMessage(record)是 middleware 唯一允许触发的延迟求值。 - middleware 和 processor 是同步的。 没有 promise,没有 Koa 风格的
next,热路径中也没有异步查找。 - integration 与手动日志走同一条管线。 采集到的 record 只在
source上不同,仍然经过 middleware、processor、路由、codec 和 transport。 - integration 显式且可撤销。 每个 patch 都需要主动开启,带有重入保护,并在
close()时拆除。 - logger 错误永远不会抛到应用代码。 middleware、processor、codec、integration 和 transport 中的失败会计入 logger meta 计数,并通过
onInternalError上报(未设置处理函数时,用未被 patch 的 console 输出console.error)。 - 不使用对象池。 短生命周期的 record 保持为新生代 GC 对象。
- 不生成代码。 脱敏和 codec 都基于解释执行,不使用
eval或new Function。
端到端管线
手动 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:
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:
const log = createLogger({
category: ["app"],
level: "info",
transports: [consoleTransport()],
});库按 category 查找 logger,在应用配置 registry 之前保持静默:
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()],
});调用形式:
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
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
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 所有权:
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
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
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 等)合并在一起。
防止回环分三层:
- 在 patch 之前登记原始的
console、fetch和XMLHttpRequest函数,transport 通过未被 patch 的注册表调用它们。 api.guard()同步丢弃重入的采集,并计入integration.dropped.reentrant。- 采集到的 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 的真实服务测试
每次修改某一层都要带上测试,修改热路径还要附带基准或体积数据。见 贡献 和 测试清单。
