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. 向你的 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',
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 })
})
所有字段会在处理器完成时合并为一个广泛事件并发出:
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"
}
}
在终端中,错误会以彩色输出显示:
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: "支付被拒绝"
// 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() 也适用于 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' })}>
导出
</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:
- Wide Events: 使用上下文分层设计全面的事件
- Adapters: 将日志发送到 Axiom、Sentry、PostHog 等
- Sampling: 使用头部采样和尾部采样控制日志量
- Structured Errors: 抛出包含
why、fix和link字段的错误