框架

Next.js

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

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

在我的 Next.js 应用中设置 evlog

快速开始

1. 安装

pnpm 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. 将仪表化与路由配置分离

将仅限 Node 的导入(evlog/fs、重量级适配器)移出根 instrumentation.ts。使用带选项对象的 defineNodeInstrumentation——evlog 仅在 Node.js 上加载 createInstrumentation,而不会在你的文件中显式出现 import()

  • instrumentation.tsdefineNodeInstrumentation({ service, ... })
  • lib/evlog.tscreateEvlog() 和用于 API 路由的仅限 Node 的 drain
instrumentation.ts
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation({
  service: 'my-app',
  captureOutput: true,
})
lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createFsDrain } from 'evlog/fs'

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

2. 连接 instrumentation.ts

Next.js 会在 Node.js 和 Edge 运行时中同时评估 instrumentation.tsdefineNodeInstrumentation 会根据 NEXT_RUNTIME === 'nodejs' 进行判断,并在内部加载仅限 Node 的工厂。

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

当你需要在 evlog 之外再执行额外的启动工作时,请传入一个 加载器回调

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

export const { register, onRequestError } = defineNodeInstrumentation(async () => {
  const { createInstrumentation } = await import('evlog/next/instrumentation/create')
  const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
    service: 'my-app',
    captureOutput: true,
  })

  return {
    async register() {
      await evlogRegister()
      // e.g. OpenTelemetry, feature flags, custom one-off init
    },
    onRequestError: evlogOnRequestError,
  }
})

保留 lib/evlog.ts 用于 createEvlog() 和仅 Node 的排水器。路由处理程序导入 @/lib/evlog

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

  • register():服务器启动时运行一次。使用配置的排水器、采样和选项初始化 evlog 日志记录器。如果启用了 captureOutput,则 stdoutstderr 的写入会被捕获为结构化日志事件。
  • onRequestError():每个未处理的请求错误都会调用。发出包含错误消息、摘要、堆栈跟踪、请求路径/方法以及路由上下文(routerKindroutePathrouteTyperenderSource)的结构化错误日志。
captureOutput 仅在 Node.js 运行时(NEXT_RUNTIME === 'nodejs')中生效。它会补丁化 process.stdout.writeprocess.stderr.write,以发出结构化的 log.info / log.error 事件。当 silentfalse(默认值)时,捕获的输出会以结构化终端输出的形式显示一次——原始写入不会重复。将 silent: true 设为 true,可在排水器交付的同时保留原始透传。默认会过滤已知的 Next.js Edge 打包器警告,因此它们不会作为 evlog 错误重新发出。

配置

defineNodeInstrumentation()createInstrumentation() 接受全局日志记录器选项(enabledserviceenvprettysilentsamplingstringifydrain)以及:

选项类型默认值描述
captureOutputboolean | CaptureOutputOptionsfalse将 stdout/stderr 捕获为结构化日志事件

CaptureOutputOptions 字段:

字段类型默认值描述
stdoutbooleantrue捕获 stdout 写入
stderrbooleantrue捕获 stderr 写入
ignore(string | RegExp)[]Next.js Edge 打包器警告跳过将匹配的块重新发作为日志事件
instrumentation.ts
defineNodeInstrumentation({
  captureOutput: {
    stderr: true,
    ignore: [/my-noisy-dep/, 'benign warning'],
  },
})

生产配置

一个真实的 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',
  apiKey: process.env.AXIOM_API_KEY!,
}))

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 })
})

所有字段都会合并为一个宽泛的事件,并在处理程序完成时发出(或者在流式响应体结束时发出,因此会包含 AI SDK 元数据):

输出(美化)
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-...

后台工作 (log.fork)

withEvlog 中,useLogger() 会返回一个带有用于子广泛事件的 fork 的日志记录器。参见 广泛事件 — 发送后

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

export const POST = withEvlog(async () => {
  const log = useLogger()
  log.fork!('enqueue', async () => {
    const child = useLogger()
    child.set({ job: 'queued' })
  })
  return Response.json({ ok: true })
})

错误处理

使用 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"
  }
}

在终端中,错误会显示在较宽的事件中——先显示错误块,然后是请求上下文。颜色和树状连接线会在终端中渲染;下面的示例为了便于阅读省略了 ANSI。

Terminal output
ERROR [app] POST /api/payment/process 402 in 12ms
  ├─ error: Payment declined
     at app/api/payment/process/route.ts:336
 336 throw createError({ message: 'Payment declined', ... })
     Why: Card declined by issuer: insufficient_funds
     Fix: Try a different payment method or contact your bank
     stack (3 frames hidden in node_modules)
  └─ payment: amount=4999

在客户端解析错误

使用 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: "支付被拒绝"
    // why: "发卡行拒绝卡片:资金不足"
    // fix: "尝试其他支付方式或联系您的银行"
  }
}

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() 也适用于服务器操作。包装你的行为以获得完整的请求范围日志记录:

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' })}>
      导出
    </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
pnpm install
pnpm run dev

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

源代码

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

后续步骤

深入集成 Next.js

  • 宽事件: 使用上下文分层设计全面的事件
  • 适配器: 将日志发送到 Axiom、Sentry、PostHog 等
  • 采样: 使用头部采样和尾部采样控制日志量
  • 结构化错误: 抛出包含 whyfixlink 字段的错误