Next.js
evlog 通过一个 createEvlog() 工厂函数与 Next.js App Router 集成,提供 withEvlog() 处理器包装器、useLogger() 以及类型化导出。一个文件,零全局状态。
在我的 Next.js 应用中设置 evlog
快速开始
1. 安装
pnpm add evlog
bun add evlog
yarn add evlog
npm install 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. 将仪表化与路由配置分离
将仅限 Node 的导入(evlog/fs、重量级适配器)移出根 instrumentation.ts。使用带选项对象的 defineNodeInstrumentation——evlog 仅在 Node.js 上加载 createInstrumentation,而不会在你的文件中显式出现 import()。
- 根
instrumentation.ts→defineNodeInstrumentation({ service, ... }) lib/evlog.ts→createEvlog()和用于 API 路由的仅限 Node 的 drain
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'
export const { register, onRequestError } = defineNodeInstrumentation({
service: 'my-app',
captureOutput: true,
})
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.ts。defineNodeInstrumentation 会根据 NEXT_RUNTIME === 'nodejs' 进行判断,并在内部加载仅限 Node 的工厂。
自定义行为(evlog + 你的代码)
当你需要在 evlog 之外再执行额外的启动工作时,请传入一个 加载器回调:
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,则stdout和stderr的写入会被捕获为结构化日志事件。onRequestError():每个未处理的请求错误都会调用。发出包含错误消息、摘要、堆栈跟踪、请求路径/方法以及路由上下文(routerKind、routePath、routeType、renderSource)的结构化错误日志。
captureOutput 仅在 Node.js 运行时(NEXT_RUNTIME === 'nodejs')中生效。它会补丁化 process.stdout.write 和 process.stderr.write,以发出结构化的 log.info / log.error 事件。当 silent 为 false(默认值)时,捕获的输出会以结构化终端输出的形式显示一次——原始写入不会重复。将 silent: true 设为 true,可在排水器交付的同时保留原始透传。默认会过滤已知的 Next.js Edge 打包器警告,因此它们不会作为 evlog 错误重新发出。配置
defineNodeInstrumentation() 和 createInstrumentation() 接受全局日志记录器选项(enabled、service、env、pretty、silent、sampling、stringify、drain)以及:
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
captureOutput | boolean | CaptureOutputOptions | false | 将 stdout/stderr 捕获为结构化日志事件 |
CaptureOutputOptions 字段:
| 字段 | 类型 | 默认值 | 描述 |
|---|---|---|---|
stdout | boolean | true | 捕获 stdout 写入 |
stderr | boolean | true | 捕获 stderr 写入 |
ignore | (string | RegExp)[] | Next.js Edge 打包器警告 | 跳过将匹配的块重新发作为日志事件 |
defineNodeInstrumentation({
captureOutput: {
stderr: true,
ignore: [/my-noisy-dep/, 'benign warning'],
},
})
生产配置
一个真实的 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,
})
广泛事件
通过处理器逐步构建上下文。一次请求 = 一个广泛事件:
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 的日志记录器。参见 广泛事件 — 发送后。
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 抛出带有 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"
}
}
在终端中,错误会显示在较宽的事件中——先显示错误块,然后是请求上下文。颜色和树状连接线会在终端中渲染;下面的示例为了便于阅读省略了 ANSI。
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 对象:
'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 或检查不同的错误格式。
配置
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() 也适用于服务器操作。包装你的行为以获得完整的请求范围日志记录:
'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' })}>
导出
</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
pnpm install
pnpm run dev
打开 http://localhost:3000 探索示例。
后续步骤
深入集成 Next.js: