框架

Next.js

Source Code
广泛事件、结构化错误、排水管道、尾部采样、基于路由的服务、错误处理以及 Next.js 应用程序中的客户端日志记录。

evlog 通过一个 createEvlog() 工厂函数与 Next.js App Router 集成,提供 withEvlog() 处理器包装器、useLogger() 以及类型化导出。一个文件,零全局状态。

提示
在 Next.js 应用中设置 evlog,支持广泛事件和结构化错误。

- 安装 evlog:pnpm add evlog
- 创建 lib/evlog.ts,使用 createEvlog() 导出 withEvlog、useLogger、createError
- 设置服务名称以及可选的采样/排水配置
- 使用 withEvlog() 包装 API 路由处理器
- 在处理器内部使用 useLogger() 构建带有 log.set() 的广泛事件
- 使用 createError({ message, status, why, fix }) 抛出错误
- 广泛事件会在每个请求完成时自动发出

文档:https://www.evlog.dev/frameworks/nextjs
适配器:https://www.evlog.dev/adapters

快速开始

1. 安装

终端
bun add evlog

2. 创建你的 evlog 实例

lib/evlog.ts
import { createEvlog } from 'evlog/next'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
})

3. 包装路由处理器

app/api/hello/route.ts
import { withEvlog, useLogger } from '@/lib/evlog'

export const GET = withEvlog(async () => {
  const log = useLogger()
  log.set({ action: 'hello' })
  return Response.json({ message: 'Hello!' })
})

仪表化

Next.js 支持在项目根目录下使用 instrumentation.ts 文件来处理服务器启动钩子和错误上报。evlog 提供 createInstrumentation() 来集成这一模式。

这两个 API 用途不同,可以独立或一起使用:
  • createEvlog():通过 withEvlog() 实现每个请求的广泛事件
  • createInstrumentation():服务器启动(register())+ 全局未处理错误上报(onRequestError()),适用于所有路由,包括 SSR 和 RSC
  • 两者可以共存:register() 初始化并锁定日志记录器,因此 createEvlog() 会遵循其配置。每个可以拥有自己的 drain

1. 向你的 evlog 实例添加 instrumentation 导出

lib/evlog.ts
import { createInstrumentation } from 'evlog/next/instrumentation'
import { createFsDrain } from 'evlog/fs'

export const { register, onRequestError } = createInstrumentation({
  service: 'my-app',
  drain: createFsDrain(),
  captureOutput: true,
})

2. 连接 instrumentation.ts

Next.js 在 Node.js 和 Edge 运行时都会执行 instrumentation.ts。仅在 NEXT_RUNTIME === 'nodejs' 时加载真实的 lib/evlog.ts,以防止 Edge 包引入仅 Node.js 所需的排水器(fs、适配器等)。

推荐方式:使用 defineNodeInstrumentation 拦截 Node 运行时,动态导入(缓存一次)你的模块,并转发 register / onRequestError

instrumentation.ts
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))

手动方式:使用显式处理器实现相同行为;如果你希望在根文件中拥有完全控制权(额外分支、每错误逻辑或不同的导入策略),可以使用此方式。无需共享辅助函数时,每个 onRequestError 通常会重新执行 import('./lib/evlog'),除非你添加了自己的缓存。

instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { register } = await import('./lib/evlog')
    await register()
  }
}

export async function onRequestError(
  error: { digest?: string } & Error,
  request: { path: string; method: string; headers: Record<string, string> },
  context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { onRequestError } = await import('./lib/evlog')
    await onRequestError(error, request, context)
  }
}

两种风格均受支持:辅助函数是可选的糖衣,不会接管整个文件。defineNodeInstrumentation 仅将 Next 的两个钩子转发到 lib/evlog 中导出的内容。它不会阻止应用中的其他工作。

自定义行为(evlog + 你的代码)

  • instrumentation.ts:Next 在此处的稳定接口是 registeronRequestError。evlog 辅助函数导出的正是这些;它不会预留整个文件。如果后续需要额外的顶级导出(当 Next 文档化它们时),请使用手动接线并自行组合,或保持 evlog 钩子最小化,将其他内容放在 lib/evlog.ts 中。
  • lib/evlog.ts(推荐用于组合):包装 evlog 的处理器,以便你可以自由添加启动工作、指标或其他日志,而不会与辅助函数冲突:
lib/evlog.ts
import { createInstrumentation } from 'evlog/next/instrumentation'

const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
  service: 'my-app',
  drain: myDrain,
})

export async function register() {
  await evlogRegister()
  // 例如:OpenTelemetry、特性标志、一次性初始化
}

export function onRequestError(
  error: { digest?: string } & Error,
  request: { path: string; method: string; headers: Record<string, string> },
  context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
  evlogOnRequestError(error, request, context)
  // 可选:你自己的副作用(指标等)
}

然后让 instrumentation.ts 作为一个薄层导入(defineNodeInstrumentation 或手动),仅在 Node 环境中加载 ./lib/evlog。你的自定义代码可以放在 createEvlog() 同一位置。

Next.js 会自动调用这些导出:

  • register():服务器启动时运行一次。使用配置的排水器、采样和选项初始化 evlog 日志记录器。如果启用了 captureOutput,则 stdoutstderr 的写入会被捕获为结构化日志事件。
  • onRequestError():每个未处理的请求错误都会调用。发出包含错误消息、摘要、堆栈跟踪、请求路径/方法以及路由上下文(routerKindroutePathrouteTyperenderSource)的结构化错误日志。
captureOutput 仅在 Node.js 运行时生效(NEXT_RUNTIME === 'nodejs')。它会修补 process.stdout.writeprocess.stderr.write,以同时发出结构化的 log.info / log.error 事件和原始输出。

配置

createInstrumentation() 工厂接受全局日志选项(enabledserviceenvprettysilentsamplingstringifydrain)以及:

选项类型默认值说明
captureOutputbooleanfalse将 stdout/stderr 捕获为结构化日志事件

生产配置

一个真实的 lib/evlog.ts,包含增强器、分批排水、尾部采样和基于路由的服务名称:

lib/evlog.ts
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'

// 1. 增强器 - 为每个事件添加派生上下文
const enrichers = [createUserAgentEnricher(), createRequestSizeEnricher()]

// 2. 管道 - 在发送前批量处理事件
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 50, intervalMs: 5000 } })

// 3. 排水器 - 将批量事件发送到 Axiom
const drain = pipeline(createAxiomDrain({
  dataset: 'logs',
  token: process.env.AXIOM_TOKEN!,
}))

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',

  // 4. 头部采样 - 保留 10% 的 info 日志
  sampling: {
    rates: { info: 10 },
    keep: [
      { status: 400 },              // 始终保留 4xx/5xx 错误
      { duration: 1000 },           // 始终保留缓慢请求
      { path: '/api/critical/**' }, // 始终保留关键路径
    ],
  },

  // 5. 基于路由的服务名称
  routes: {
    '/api/auth/**': { service: 'auth-service' },
    '/api/payment/**': { service: 'payment-service' },
    '/api/booking/**': { service: 'booking-service' },
  },

  // 6. 自定义尾部采样 - 业务逻辑
  keep: (ctx) => {
    const user = ctx.context.user as { premium?: boolean } | undefined
    if (user?.premium) ctx.shouldKeep = true
  },

  // 7. 使用增强器为每个事件添加用户代理、请求大小和部署信息
  enrich: (ctx) => {
    for (const enricher of enrichers) enricher(ctx)
    ctx.event.deploymentId = process.env.VERCEL_DEPLOYMENT_ID
    ctx.event.region = process.env.VERCEL_REGION
  },

  drain,
})

广泛事件

通过处理器逐步构建上下文。一次请求 = 一个广泛事件:

app/api/checkout/route.ts
import { withEvlog, useLogger } from '@/lib/evlog'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()
  const body = await request.json()

  // 阶段 1:用户上下文
  log.set({
    user: { id: body.userId, plan: 'enterprise' },
  })

  // 阶段 2:购物车上下文
  log.set({
    cart: { items: body.items.length, total: body.total, currency: 'USD' },
  })

  // 阶段 3:支付上下文
  const payment = await processPayment(body)
  log.set({
    payment: { method: payment.method, cardLast4: payment.last4 },
  })

  return Response.json({ success: true, orderId: payment.orderId })
})

所有字段会在处理器完成时合并为一个广泛事件并发出:

输出(美化)
10:23:45.612 INFO [my-app] POST /api/checkout 200 in 145ms
  ├─ user: id=usr_123 plan=enterprise
  ├─ cart: items=3 total=14999 currency=USD
  ├─ payment: method=card cardLast4=4242
  └─ requestId: a1b2c3d4-...

错误处理

使用 createError 抛出带有 whyfixlink 字段的结构化错误,以帮助开发人员在日志和 API 响应中调试:

app/api/payment/process/route.ts
import { withEvlog, useLogger, createError } from '@/lib/evlog'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()
  const body = await request.json()

  log.set({ payment: { amount: body.amount } })

  if (body.amount <= 0) {
    throw createError({
      status: 400,
      message: 'Invalid payment amount',
      why: 'The amount must be a positive number',
      fix: 'Pass a positive integer in cents (e.g. 4999 for $49.99)',
      link: 'https://docs.example.com/api/payments#amount',
    })
  }

  const result = await chargeCard(body)

  if (!result.success) {
    log.error(new Error(`Payment declined: ${result.reason}`))
    throw createError({
      status: 402,
      message: 'Payment declined',
      why: `Card declined by issuer: ${result.reason}`,
      fix: 'Try a different payment method or contact your bank',
    })
  }

  return Response.json({ success: true })
})

withEvlog() 会捕获 EvlogError 并返回结构化的 JSON 响应(类似于 Nitro 对 Nuxt 的处理):

响应(402)
{
  "name": "EvlogError",
  "message": "Payment declined",
  "status": 402,
  "data": {
    "why": "Card declined by issuer: insufficient_funds",
    "fix": "Try a different payment method or contact your bank"
  }
}

在终端中,错误会以彩色输出显示:

终端输出
Error: Payment declined
Why: Card declined by issuer: insufficient_funds
Fix: Try a different payment method or contact your bank

在客户端解析错误

使用 parseError 从任意错误中提取结构化字段,无论是 fetch 响应、EvlogError 还是普通的 Error 对象:

app/components/PaymentForm.tsx
'use client'
import { parseError } from 'evlog'

async function handleSubmit(formData: FormData) {
  try {
    const res = await fetch('/api/payment/process', {
      method: 'POST',
      body: JSON.stringify({ amount: Number(formData.get('amount')) }),
    })
    if (!res.ok) throw { data: await res.json(), status: res.status }
  } catch (error) {
    const { message, status, why, fix, link } = parseError(error)
    // message: "Payment declined"
    // why: "Card declined by issuer: insufficient_funds"
    // fix: "Try a different payment method or contact your bank"
  }
}

parseError 会将任意错误格式规范化为扁平的 { message, status, why?, fix?, link? } 对象,这样你的 UI 代码就无需处理嵌套的 data.data 或检查不同的错误格式。

配置

请参阅配置参考了解完整的可共享选项列表(enabledprettysilentsampling、中间件选项等)。

createEvlog() 工厂接受以下选项:

选项类型默认值说明
servicestring'app'日志中显示的服务名称
environmentstring自动检测环境名称
includestring[]undefined要记录的路劲模式
excludestring[]undefined要排除的路劲模式
routesRecord<string, RouteConfig>undefined路由特定的服务配置
sampling.ratesobjectundefined每种日志级别的头部采样率
sampling.keeparrayundefined尾部采样条件
keep(ctx: TailSamplingContext) => voidundefined自定义尾部采样回调
drainDrainFunctionundefined用于外部服务的排水适配器
enrich(ctx: EnrichContext) => voidundefined事件增强回调

尾部采样

结合基于规则的尾部采样和自定义尾部采样,确保记录重要内容,即使头部采样丢弃了大多数日志:

lib/evlog.ts
export const { withEvlog, useLogger } = createEvlog({
  service: 'my-app',
  sampling: {
    rates: { info: 10 }, // 仅保留 10% 的 info 日志
    keep: [
      { status: 400 },              // 始终保留 4xx/5xx
      { duration: 1000 },           // 始终保留缓慢请求
      { path: '/api/critical/**' }, // 始终保留关键路径
    ],
  },
  // 自定义:始终保留 premium 用户请求
  keep: (ctx) => {
    const user = ctx.context.user as { premium?: boolean } | undefined
    if (user?.premium) ctx.shouldKeep = true
  },
})

keep 规则采用 OR 逻辑:任何匹配都会强制事件通过,无论头部采样如何。

中间件

设置 x-request-idx-evlog-start 标头,以便 withEvlog() 可以在中间件 -> 处理器链中关联计时:

proxy.ts
import { evlogMiddleware } from 'evlog/next'

export const proxy = evlogMiddleware()

export const config = {
  matcher: ['/api/:path*'],
}
旧版本的 Next.js 使用 middleware.ts 而不是 proxy.ts。evlog 中间件与两者都兼容,因此无论哪种情况都应从 evlog/next 导入。

服务器行为

withEvlog() 也适用于 Server Actions。包装你的行为以获得完整的请求范围日志记录:

app/actions/checkout.ts
'use server'
import { withEvlog, useLogger } from '@/lib/evlog'

export const checkout = withEvlog(async (formData: FormData) => {
  const log = useLogger()
  log.set({ action: 'checkout', cartId: formData.get('cartId') })
  // ...
})

客户端提供者

EvlogProvider 包装在根布局中以启用客户端日志记录和传输:

app/layout.tsx
import { EvlogProvider } from 'evlog/next/client'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <EvlogProvider service="my-app" transport={{ enabled: true }}>
          {children}
        </EvlogProvider>
      </body>
    </html>
  )
}

客户端日志记录

在任何客户端组件中使用 log。身份信息会保留在所有日志中并传输到服务器:

app/components/Dashboard.tsx
'use client'
import { log, setIdentity, clearIdentity } from 'evlog/next/client'

export function Dashboard({ user }: { user: { id: string } }) {
  // 一次性设置身份 - 后续所有日志均包含该身份
  useEffect(() => {
    setIdentity({ userId: user.id })
    return () => clearIdentity()
  }, [user.id])

  return (
    <button onClick={() => log.info({ action: 'export_clicked', format: 'csv' })}>
      Export
    </button>
  )
}

HTTP 排水

对于高级用例,可直接从浏览器向自定义端点发送结构化的 DrainContext 事件:

lib/http-drain.ts
import { createHttpLogDrain } from 'evlog/http'

const drain = createHttpLogDrain({
  drain: { endpoint: '/api/evlog/http-ingest' },
  pipeline: { batch: { size: 10, intervalMs: 5000 } },
})

drain(drainEvent)
await drain.flush()

服务器端点接收批量事件:

app/api/evlog/http-ingest/route.ts
export async function POST(request: Request) {
  const events = await request.json()
  // 转发到你的排水管道、Axiom 等
  return new Response(null, { status: 204 })
}

本地运行

终端
git clone https://github.com/hugorcd/evlog.git
cd evlog/examples/nextjs
bun install
bun run dev

打开 http://localhost:3000 探索示例。

源代码

在 GitHub 上浏览完整的 Next.js 示例源代码。

后续步骤

深入集成 Next.js

  • 广泛事件:设计带有上下文分层的综合性事件
  • 适配器:将日志发送到 Axiom、Sentry、PostHog 等
  • 采样:通过头部和尾部采样控制日志量
  • 结构化错误:使用 whyfixlink 字段抛出错误