【翻译】使用 LogLayer 在 Next.js 中统一日志:Instrumentation、Console Override 与结构化日志

6 阅读9分钟

使用 LogLayer 在 Next.js 中统一日志:Instrumentation、Console Override 与结构化日志


如何用一个 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、SimplePrettyTerminalPinoTransport,以及通过 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.tsconsole.* 调用映射到了 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.tsxsrc/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.logServer 标识重放到浏览器。输出目的地会变,但 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_idspan_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

然后打开:

  1. /
  2. /log-demo/server
  3. /log-demo/client

验证生产构建:

npm run build

👉 本文对应代码仓库:GitHub 上的 yurimutti/nextjs-loglayer

关键结论

  1. instrumentation.ts 是在 Node.js 运行时拦截 console.* 的正确位置
  2. 共享一个 LogLayer 实例可以让全应用日志 API 保持一致
  3. withContext() 会持续生效,withMetadata()withError() 只作用单条日志
  4. 本示例中服务端生产 transport 是 PinoTransport
  5. Datadog 与 OpenTelemetry 很自然地可以作为后续 transport 与关联层接入
  6. 这个模式能在接外部日志平台前,先提供一条实用、可落地的统一基线

总结

如果你现在的 Next.js 日志体系是割裂的,修复方式并不复杂:把 console 拦截放到 instrumentation.ts,把服务端 console.* 导进 LogLayer,并保持一个共享 logger 实例用于直接结构化日志。

这样你就得到了一个务实基线:

开发环境日志可读,服务端生产日志结构化,错误处理更干净,全应用 API 一致。同时,你也为后续可选 transport 和可观测性后端保留了清晰扩展路径。