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 实例
import { createEvlog } from 'evlog/next'
export const { withEvlog, useLogger, log, createError } = createEvlog({
service: 'my-app',
})
3. 包装路由处理器
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() 来集成这一模式。
createEvlog():通过withEvlog()实现每个请求的广泛事件createInstrumentation():服务器启动(register())+ 全局未处理错误上报(onRequestError()),适用于所有路由,包括 SSR 和 RSC- 两者可以共存:
register()初始化并锁定日志记录器,因此createEvlog()会遵循其配置。每个可以拥有自己的drain。
1. 向你的 evlog 实例添加 instrumentation 导出
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:
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'
export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
手动方式:使用显式处理器实现相同行为;如果你希望在根文件中拥有完全控制权(额外分支、每错误逻辑或不同的导入策略),可以使用此方式。无需共享辅助函数时,每个 onRequestError 通常会重新执行 import('./lib/evlog'),除非你添加了自己的缓存。
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 在此处的稳定接口是register和onRequestError。evlog 辅助函数导出的正是这些;它不会预留整个文件。如果后续需要额外的顶级导出(当 Next 文档化它们时),请使用手动接线并自行组合,或保持 evlog 钩子最小化,将其他内容放在lib/evlog.ts中。 lib/evlog.ts(推荐用于组合):包装 evlog 的处理器,以便你可以自由添加启动工作、指标或其他日志,而不会与辅助函数冲突:
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,则stdout和stderr的写入会被捕获为结构化日志事件。onRequestError():每个未处理的请求错误都会调用。发出包含错误消息、摘要、堆栈跟踪、请求路径/方法以及路由上下文(routerKind、routePath、routeType、renderSource)的结构化错误日志。
captureOutput 仅在 Node.js 运行时生效(NEXT_RUNTIME === 'nodejs')。它会修补 process.stdout.write 和 process.stderr.write,以同时发出结构化的 log.info / log.error 事件和原始输出。配置
createInstrumentation() 工厂接受全局日志选项(enabled、service、env、pretty、silent、sampling、stringify、drain)以及:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
captureOutput | boolean | false | 将 stdout/stderr 捕获为结构化日志事件 |
生产配置
一个真实的 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,
})
广泛事件
通过处理器逐步构建上下文。一次请求 = 一个广泛事件:
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 抛出带有 why、fix 和 link 字段的结构化错误,以帮助开发人员在日志和 API 响应中调试:
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 的处理):
{
"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 对象:
'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 或检查不同的错误格式。
配置
createEvlog() 工厂接受以下选项:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
service | string | 'app' | 日志中显示的服务名称 |
environment | string | 自动检测 | 环境名称 |
include | string[] | undefined | 要记录的路劲模式 |
exclude | string[] | undefined | 要排除的路劲模式 |
routes | Record<string, RouteConfig> | undefined | 路由特定的服务配置 |
sampling.rates | object | undefined | 每种日志级别的头部采样率 |
sampling.keep | array | undefined | 尾部采样条件 |
keep | (ctx: TailSamplingContext) => void | undefined | 自定义尾部采样回调 |
drain | DrainFunction | undefined | 用于外部服务的排水适配器 |
enrich | (ctx: EnrichContext) => void | undefined | 事件增强回调 |
尾部采样
结合基于规则的尾部采样和自定义尾部采样,确保记录重要内容,即使头部采样丢弃了大多数日志:
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-id 和 x-evlog-start 标头,以便 withEvlog() 可以在中间件 -> 处理器链中关联计时:
import { evlogMiddleware } from 'evlog/next'
export const proxy = evlogMiddleware()
export const config = {
matcher: ['/api/:path*'],
}
middleware.ts 而不是 proxy.ts。evlog 中间件与两者都兼容,因此无论哪种情况都应从 evlog/next 导入。服务器行为
withEvlog() 也适用于 Server Actions。包装你的行为以获得完整的请求范围日志记录:
'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 包装在根布局中以启用客户端日志记录和传输:
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。身份信息会保留在所有日志中并传输到服务器:
'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 事件:
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()
服务器端点接收批量事件:
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 探索示例。
后续步骤
深入集成 Next.js: