使用 LogLayer 在 Next.js 中统一日志:Instrumentation、Console Override 与结构化日志
- 原文:Logging in Next.js with LogLayer: Instrumentation, Console Override, and Structured Logs
- 作者:Yuri Mutti
- 原文发布:2026 年 4 月 12 日
如何用一个 logger 集中管理 server、client、edge 日志,在 instrumentation.ts 中拦截 console.*,并在 Next.js 中使用结构化日志。
Next.js 给了你多个运行时,但没有给你一套跨运行时统一的日志模型。服务端代码、客户端代码和裸 console.* 调用很快就会各走各路:输出格式变了,错误序列化不一致,元数据也容易丢。
这篇文章展示了一个实用方案:用 LogLayer 把这些问题收拢起来。
演示仓库 nextjs-loglayer 使用了单一共享 logger,在 Node.js 运行时通过 instrumentation.ts 重写 console.*,并在服务端与客户端都保留直接的结构化日志能力。
最后的效果是:全应用日志都遵循一套统一心智模型。
为什么 Next.js 的日志常常不一致
如果直接依赖 console.log,体验很快就会变糟。
- 服务端和浏览器输出不一致
- 日志结构弱
- 元数据处理零散
- 错误序列化不统一
- 业务代码和第三方代码无法走同一日志层
- 多运行时并存时调试成本更高
真正的问题不是“日志不够多”,而是“日志行为不一致”。
为什么选 LogLayer
LogLayer 给了你一套统一的应用日志 API,同时允许你按运行时和环境切换 transport。
本文示例中,它提供了:
- 一个共享 logger 实例
- 开发环境可读性更高的
SimplePrettyTerminal - 生产环境服务端结构化日志
PinoTransport - 通过
serialize-error统一序列化错误 - 借助 LogLayer API 做 context、metadata、error 富化
它还提供了清晰的 transport 边界。
你的应用代码可以始终面向 LogLayer,即便输出目标之后变化也不用改业务调用方式。你可以先从终端输出 + Pino 起步,后续再接 Datadog 或基于 OpenTelemetry 的链路。
这篇 demo 没有实现这些外部集成,只把“该接在哪里”这条边界展示清楚。
架构概览
这个示例在应用内共享一个 LogLayer 实例。
在服务端,instrumentation.ts 会拦截 console.*,并把调用导到与你业务代码直接使用的同一个 logger。这样,遗留日志、框架邻近日志和显式 log.info() 都会走同一路径。
在客户端,组件仍用相同 logger API,但开发环境输出落在浏览器控制台,而不是 Node.js 终端。在服务端生产环境里,logger 会从 pretty 输出切到 PinoTransport。
flowchart LR
A["Server code"] --> D["Shared LogLayer logger\n API + enrichment + transport orchestration"]
B["Client code"] --> D
C["console.* on Node.js\n via instrumentation.ts"] --> D
D --> E["SimplePrettyTerminal\n Node terminal in development"]
D --> F["SimplePrettyTerminal\n Browser console in development"]
D --> G["PinoTransport\n Production server runtime"]
H["Datadog transport\n server or browser shipping"]
I["OpenTelemetry plugin\n trace context injection"]
D -.->|optional| H
D -.->|optional| I
仓库当前实现的是实线部分;虚线部分表示将来可插拔的 transport 和可观测性层。
这个仓库刻意保持基础实现小而完整:console 拦截、共享 logger、SimplePrettyTerminal、PinoTransport,以及通过 withContext()、withMetadata()、withError() 做结构化富化。
instrumentation.ts 与 console override
示例把 instrumentation.ts 放在项目根目录,让 Next.js 能发现它。
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { log } = await import('./src/lib/logger');
const { createConsoleMethod } = await import(
'./src/lib/logger/utils/console'
);
console.error = createConsoleMethod(log, 'error');
console.log = createConsoleMethod(log, 'log');
console.info = createConsoleMethod(log, 'info');
console.warn = createConsoleMethod(log, 'warn');
console.debug = createConsoleMethod(log, 'debug');
}
}
这里就是在 Node.js 运行时拦截 console.* 的正确位置。
有两个细节很关键。
第一,文件位置必须正确。Next.js 支持放在项目根目录,或者在使用 src/ 布局时放在 src/ 下。找不到文件就不会执行 override。
第二,运行时守卫很重要。示例只在 process.env.NEXT_RUNTIME === 'nodejs' 时重写 console.*,避免 Node 专属行为泄漏到不支持的运行时。
即便你已经在直接写结构化日志,这个 override 仍然有价值:它能把旧代码和库里依旧使用 console.* 的日志也纳入统一日志层。
主 logger 配置
logger 在 src/lib/logger/index.ts。
import { LogLayer, type PluginBeforeMessageOutParams } from 'loglayer';
import { PinoTransport } from '@loglayer/transport-pino';
import { getSimplePrettyTerminal } from '@loglayer/transport-simple-pretty-terminal';
import { pino } from 'pino';
import { serializeError } from 'serialize-error';
const isServer = typeof window === 'undefined';
const isClient = !isServer;
const pinoLogger = pino({ level: 'trace' });
export const log = new LogLayer({
prefix: '[yurimutti.com]',
errorFieldName: 'error',
errorSerializer: serializeError,
transport: [
getSimplePrettyTerminal({
enabled: process.env.NODE_ENV === 'development',
runtime: isServer ? 'node' : 'browser',
viewMode: isServer ? 'inline' : 'message-only',
includeDataInBrowserConsole: isClient,
}),
new PinoTransport({
enabled: isServer && process.env.NODE_ENV === 'production',
logger: pinoLogger,
}),
],
plugins: [
{
onBeforeMessageOut(params: PluginBeforeMessageOutParams) {
const tag = isServer ? 'Server' : 'Client';
if (params.messages?.length && typeof params.messages[0] === 'string') {
params.messages[0] = `[${tag}] ${params.messages[0]}`;
}
return params.messages;
},
},
],
});
log.withContext({ isServer });
export function getLogger() {
return log;
}
这个文件定义了全应用日志规则。
- 全局单例 logger
- 每条日志前缀:
[yurimutti.com] - 插件注入运行时标签:
[Server]或[Client] - 开发环境用
SimplePrettyTerminal便于阅读 - 服务端生产环境用
PinoTransport serialize-error统一错误载荷
持久化 context 也很重要:
log.withContext({ isServer });
withContext() 是持久性的,会附着在 logger 链上,让稳定字段在多条日志里持续存在。
这和 withMetadata()、withError() 不同,后两者只作用于单条日志。
transport 数组也是未来扩展后端的边界。在这篇 demo 里它刻意简化为:开发环境 SimplePrettyTerminal,服务端生产环境 PinoTransport。之后如果要接 Datadog 或其他后端,就在这里扩展。
console bridge 工具
console override 能工作,是因为 src/lib/logger/utils/console.ts 把 console.* 调用映射到了 LogLayer API。
这个工具不只是转发字符串。
console.log会映射为info- 会剥离终端 ANSI 转义码
Error对象会经由withError()路由- 普通对象会作为 metadata
- 混合参数形态会被显式处理
核心逻辑如下:
if (method === 'log') {
mappedMethod = 'info';
}
let finalMessage = stripAnsiCodes(messages.join(' ')).trim();
if (finalMessage === '⨯' && error) {
finalMessage = error.message || '';
}
if (error && hasData && messages.length > 0) {
log.withError(error).withMetadata(data)[mappedMethod](finalMessage);
} else if (error && messages.length > 0) {
log.withError(error)[mappedMethod](finalMessage);
} else if (hasData && messages.length > 0) {
log.withMetadata(data)[mappedMethod](finalMessage);
} else if (error && hasData && messages.length === 0) {
log.withError(error).withMetadata(data)[mappedMethod]('');
} else if (error && messages.length === 0) {
log.errorOnly(error);
} else if (hasData && messages.length === 0) {
log.metadataOnly(data);
} else {
log[mappedMethod](finalMessage);
}
这段逻辑很关键,因为真实世界里的 console.* 用法本来就很杂乱:有时是“消息 + error”,有时只有对象,有时只有一个 Error。bridge 的作用就是把这些分支统一成稳定结构化输出。
服务端示例
服务端示例位于 src/app/log-demo/server/page.tsx。
一开始有这一行:
export const dynamic = 'force-dynamic';
这行代码有明确目的:强制路由每次请求都执行,让你每次刷新都能看到日志演示。
路由里并排演示了两种写法:
console.log('Server console override demo', { route: '/log-demo/server' });
log.withMetadata({ some: 'data' }).info('Hello, world!');
log
.child()
.withContext({ requestId: 'abc' })
.withMetadata({ duration: 150 })
.withError(new Error('fail'))
.error('Request failed');
观察到的终端输出:
[21:49:12.580] ▶ INFO [Server] [yurimutti.com] Server console override demo isServer=true route=/log-demo/server
[21:49:12.580] ▶ INFO [Server] [yurimutti.com] Hello, world! isServer=true some=data
[21:49:12.581] ▶ ERROR [Server] [yurimutti.com] Request failed isServer=true requestId=abc duration=150 error.name=Error error.message=fail
这部分是 demo 最实用的地方:
console.log被 override 捕获- 共享 logger 上的
withContext({ isServer: true })会出现在所有服务端日志 - 直接 LogLayer 调用仍可用
withContext()在 child logger 链中持续生效withMetadata()追加单条日志字段withError()只把错误附加到当前这条日志
最后那条链式调用就是最值得在生产代码里复用的写法。
客户端示例
客户端示例使用 src/components/client-log-effect.tsx 和 src/app/log-demo/client/page.tsx。
它在 useEffect 中记录日志:
'use client';
useEffect(() => {
console.log('Client console override demo', {
source: 'useEffect',
page: '/log-demo/client',
});
log
.withContext({ requestId: 'client-abc' })
.withMetadata({ source: 'useEffect', page: '/log-demo/client' })
.info('Client mounted');
}, []);
观察到的浏览器控制台输出:
Client console override demo { source: "useEffect", page: "/log-demo/client" }
[21:50:40.595] ▶ INFO [Client] [yurimutti.com] Client mounted { isServer: false, requestId: "client-abc", source: "useEffect", page: "/log-demo/client" }
这让日志心智模型保持一致:
你依然能在浏览器里直接访问同一个 logger API,照样能挂 context 和 metadata;同时也能把原始 console.log 和直接 log.info() 的行为并排对比。
这里还有个细节值得注意:
服务端日志出现在终端,客户端日志出现在浏览器控制台。在开发模式下,Next.js 也可能把一条服务端 console.log 以 Server 标识重放到浏览器。输出目的地会变,但 logger API 和富化模型不变。
预期输出
在开发环境里,你应该看到来自 SimplePrettyTerminal 的易读日志,加上共享前缀与运行时标签。
观察到的输出大致如下:
[21:49:12.580] ▶ INFO [Server] [yurimutti.com] Server console override demo isServer=true route=/log-demo/server
[21:49:12.580] ▶ INFO [Server] [yurimutti.com] Hello, world! isServer=true some=data
[21:49:12.581] ▶ ERROR [Server] [yurimutti.com] Request failed isServer=true requestId=abc duration=150 error.name=Error error.message=fail
[21:50:40.595] ▶ INFO [Client] [yurimutti.com] Client mounted { isServer: "false", requestId: "client-abc", source: "useEffect", page: "/log-demo/client" }
这种一致性就是核心收益:
两端都使用同一个 logger,都能挂 context,都能挂 metadata;在服务端,原始 console.* 也会被 instrumentation override 收敛到同一路径。
未来扩展点
这个仓库刻意把示例收敛在核心路径,但整体形状可扩展性很好。
如果后续要把日志发到 Datadog,那么服务端可接 @loglayer/transport-datadog,浏览器可接 @loglayer/transport-datadog-browser-logs。如果你希望日志挂上 trace 上下文,@loglayer/plugin-opentelemetry 是合适原语,它会注入 trace_id 与 span_id。如果你已经有 OpenTelemetry log processor pipeline,也可以用 @loglayer/transport-opentelemetry。
针对 Edge route,需要单独创建一个 LogLayer 实例。Node.js 侧 logger 会导入 Pino,这会在构建期破坏 Edge bundle。Edge 场景应使用 @loglayer/transport-http 并开启 enableNextJsEdgeCompat: true。
关键原则是:把供应商相关逻辑隔离在 feature 代码之外,让 LogLayer 作为稳定 API 边界。
如何运行示例
npm i
npm run dev
然后打开:
//log-demo/server/log-demo/client
验证生产构建:
npm run build
👉 本文对应代码仓库:GitHub 上的 yurimutti/nextjs-loglayer
关键结论
instrumentation.ts是在 Node.js 运行时拦截console.*的正确位置- 共享一个 LogLayer 实例可以让全应用日志 API 保持一致
withContext()会持续生效,withMetadata()与withError()只作用单条日志- 本示例中服务端生产 transport 是
PinoTransport - Datadog 与 OpenTelemetry 很自然地可以作为后续 transport 与关联层接入
- 这个模式能在接外部日志平台前,先提供一条实用、可落地的统一基线
总结
如果你现在的 Next.js 日志体系是割裂的,修复方式并不复杂:把 console 拦截放到 instrumentation.ts,把服务端 console.* 导进 LogLayer,并保持一个共享 logger 实例用于直接结构化日志。
这样你就得到了一个务实基线:
开发环境日志可读,服务端生产日志结构化,错误处理更干净,全应用 API 一致。同时,你也为后续可选 transport 和可观测性后端保留了清晰扩展路径。