eve
eve 内置了可观测性:Vercel 上的 Agent Runs,以及通过 agent/instrumentation.ts 提供的可选 OpenTelemetry spans。evlog/eve 增加了第三层:每个轮次都生成可导出的宽事件,并接入你完整的 evlog 管道(drains、enrichers、尾部采样、审计)。
evlog/eve 需要 eve 0.30 或更高版本。eve 仍处于 beta 阶段,流事件结构可能会在正式发布前发生变化,因此请在生产环境中的 agent 内固定 eve 和 evlog 的版本。为我的 eve agent 添加 evlog 宽事件
何时使用什么
| 需求 | 使用 |
|---|---|
| 在 Vercel 中调试会话 | eve Agent Runs(自动) |
| 在 Datadog / Honeycomb 中查看跨度级追踪 | eve agent/instrumentation.ts + OTel 导出器 |
| 将宽事件发送到 Axiom / Better Stack / FS,用于计费、审计和尾部采样 | evlog/eve |
| 在跨度与其宽事件之间相互跳转 | defineEvlogInstrumentation() — 见下文 |
快速开始
1. 安装
pnpm add evlog eve
bun add evlog eve
yarn add evlog eve
npm install evlog eve
2. 添加 hook
创建 agent/hooks/evlog.ts:
import { defineEvlogHook } from 'evlog/eve'
import { createAxiomDrain } from 'evlog/axiom'
export default defineEvlogHook({
init: { env: { service: 'my-agent' } },
drain: createAxiomDrain(),
enrich: (ctx) => {
ctx.event.region = process.env.VERCEL_REGION
},
})
eve 会自动发现 agent/hooks/ 下的 hook 文件。不需要 HTTP 中间件,因为工作单元是 agent 轮次,而不是请求。
3. 从工具中记录业务上下文
defineEvlogHook() 会在 turn.started 上通过 AsyncLocalStorage 绑定轮次 logger。在工具的 execute() 处理器内,不带参数调用 useLogger(),其使用方式与 Nuxt 或 Hono 中的 useLogger(event) 相同。
在示例 agent 中,支持工具会在 agent 处理退款时附加客户和订单上下文:
import { defineTool } from 'eve/tools'
import { useLogger } from 'evlog/eve'
import { z } from 'zod'
export default defineTool({
description: '按 ID 查找订单。',
inputSchema: z.object({ orderId: z.string() }),
async execute({ orderId }) {
const order = await fetchOrder(orderId)
const log = useLogger()
log.set({ order: { id: order.id, amount: order.amount, status: order.status } })
return order
},
})
ctx — useLogger(ctx)。使用标准 eve agent 布局并注册了 agent/hooks/evlog.ts 后,通常不需要传入它。useLogger() 从不抛出异常:当它无法访问轮次 logger 时,会发出一次警告,并返回一个接受所有调用但不产生任何输出的 logger。在步骤边界之后于另一个进程中恢复轮次是最常见的情况,丢失 enrich 信息绝不能导致正在进行 enrich 的工具失败。轮次会继续运行;只有其额外字段会被丢弃。4. Next.js Web 聊天(可选)
使用来自 eve/next 的 withEve() 包装 next.config.ts,并在应用中使用来自 eve/react 的 useEveAgent()。eve 会与 next dev 一起启动,并在同一源上代理 /eve/v1/*,因此工具调用、审批和 ask_question 都可以直接使用。请参阅示例 agent。
宽事件形态
每个已完成的轮次都会发出一个事件:
{
"method": "EVE",
"path": "/sessions/sess_abc/turns/turn_0",
"status": 200,
"duration": "7.9s",
"durationMs": 7903,
"service": "clearbill-support-agent",
"eve": {
"sessionId": "sess_abc",
"turnId": "turn_0",
"turnSequence": 0,
"phase": "awaiting-approval",
"sessionTurns": 1
},
"customer": { "slug": "acme-corp", "plan": "enterprise" },
"order": { "id": "4821", "amount": 890, "currency": "USD" },
"approval": { "status": "pending", "tool": "issue_refund" },
"ai": {
"model": "deepseek/deepseek-v4-flash",
"provider": "deepseek",
"calls": 2,
"steps": 2,
"inputTokens": 11724,
"outputTokens": 282,
"finishReason": "tool-calls",
"tools": [
{ "name": "lookup_customer", "durationMs": 26, "success": true, "inputTokens": 8900 },
{ "name": "lookup_order", "durationMs": 13, "success": true }
]
}
}
在审批通过后,下一个轮次会携带结果:
{
"path": "/sessions/sess_abc/turns/turn_1",
"eve": {
"sessionId": "sess_abc",
"turnId": "turn_1",
"turnSequence": 1,
"sessionTurns": 2
},
"refund": { "orderId": "4821", "amount": 890, "status": "refunded" },
"audit": { "action": "refund.issued", "target": { "type": "order", "id": "4821" } },
"approval": { "status": "approved", "tool": "issue_refund" },
"ai": {
"tools": [{ "name": "issue_refund", "durationMs": 993, "success": true }],
"finishReason": "stop"
}
}
Token 使用量和工具执行情况会从 eve 流事件(step.completed、actions.requested、action.result)中累积。通过 useLogger() 设置的业务字段会在同一会话的各轮次之间延续。在分析中使用 eve.sessionId + eve.turnSequence 关联各轮次,并且每个事件都保持自包含。eve.phase 仅会在非例行结束时设置:awaiting-approval、awaiting-authorization、rejected、cancelled、failed。
轮次可以报告的所有信息
除了上述标识符之外,轮次还会记录 eve 报告的相关信息。每个字段都是可选的,只有在轮次产生该字段时才会出现。
| 字段 | 含义 |
|---|---|
eve.runtime | eve 版本、agent ID、模型,以及部署的 gitSha / gitBranch / deployedAt |
eve.caller | 触发轮次的调用方:principalId、principalType 和 authenticator。在多用户频道中,你可以据此对成本和数量进行分组。绝不会记录 subject 和 attributes — 频道可能会将姓名或电子邮件放入其中 |
eve.parent | 子 agent 运行的父会话 ID 和根会话 ID — 使用 rootSessionId 重建委派树 |
eve.authorizations | 连接登录信息,包括其 outcome、reason 和持续时间 |
eve.compaction | 执行了多少次压缩、使用了哪个模型,以及 inputTokensAtTrigger — 第一次压缩触发时上下文有多满 |
eve.stepFailures / eve.failedSteps | 失败的模型调用,包括随后通过重试成功的轮次中的失败调用 |
eve.subagents | 委派信息,包括其状态和持续时间 |
eve.reasoning | blocks 和 chars — 模型思考的大小,但绝不会记录其内容 |
eve.result | 结构化结果,适用于具有输出 schema 的 agent |
eve.contextCleared | 持久化历史记录在此轮次期间被清除 |
message.responseChars | agent 响应的长度,无论使用哪种 message 模式都会记录 |
ai.costUsd | eve 报告的成本。ai.estimatedCost 是根据 cost 计算出的备用值 |
ai.model / ai.provider | 为轮次提供服务的网关 slug(deepseek/deepseek-v4-flash)和解析后的 provider(deepseek)。ai.provider 是从 eve 在 session.started 上报告的模型 ID 中拆分出来的 |
ai.tools[].inputTokens | 工具结果为下一次模型调用增加的输入 token 数:调用工具的步骤与使用其结果的步骤之间的增量。如果轮次在结果被使用前结束,或者压缩重置了上下文,则不设置该字段 |
关联宽事件与追踪
一个宽事件和一个 Agent Runs span 描述的是同一轮交互,但它们不会自动关联。defineEvlogInstrumentation() 会将 evlog 的轮次标识写入 eve 的 AI SDK spans:
import { defineEvlogInstrumentation } from 'evlog/eve'
export default defineEvlogInstrumentation()
随后,每个模型调用 span 及其子 span 都会携带 evlog.request_id 和 evlog.session_id,其值与宽事件报告的 requestId 和 eve.sessionId 相同。你可以从 Braintrust、Datadog 或 Agent Runs 中的追踪直接跳转到 drain 中的事件,也可以反向跳转。
不使用 setup 时,OpenTelemetry 导出不会受到影响:eve 仍会写入本地追踪,可通过 eve traces 查看。传入 setup 即可将其导出到其他位置:
import { defineEvlogInstrumentation } from 'evlog/eve'
import { registerOTel } from '@vercel/otel'
export default defineEvlogInstrumentation({
setup: ({ agentName }) => registerOTel({ serviceName: agentName }),
})
functionId、recordInputs、recordOutputs 和 traceChannelRequests 会直接传递给 eve 的 defineInstrumentation。
与其他可观测性后端并用
defineEvlogInstrumentation() 是仅使用 evlog 进行 agent instrumentation 时的快捷方式。它拥有该文件,而一个 agent 恰好只有一个 agent/instrumentation.ts,因此 eve 注册表中的每个可观测性项目都会写入同一个路径。这也是为什么在 eve add instrumentation/posthog 之后执行 eve add instrumentation/sentry 会拒绝执行,而不是覆盖前者。
一旦启用了其他后端,请使用 eve 自己的 defineInstrumentation 手动编写该文件,并将 evlogRuntimeContext 展开到运行时上下文中。evlog 会为你的 instrumentation 提供属性,而不是对其进行包装:
import { BatchSpanProcessor, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { PostHogTraceExporter } from '@posthog/ai/otel'
import { OTLPHttpProtoTraceExporter, registerOTel } from '@vercel/otel'
import { defineInstrumentation } from 'eve/instrumentation'
import { evlogRuntimeContext } from 'evlog/eve'
export default defineInstrumentation({
// 每个后端使用一个 provider 和一个 span processor。生成
// `traceExporter` 的项目(Sentry、Datadog、Arize、Jaeger、Braintrust、Honeycomb)
// 会成为此数组中的一项。只调用一次 registerOTel,不要为每个后端各调用一次。
setup: ({ agentName }) => registerOTel({
serviceName: agentName,
spanProcessors: [
new SimpleSpanProcessor(new PostHogTraceExporter({
projectToken: process.env.POSTHOG_PROJECT_TOKEN!,
})),
new BatchSpanProcessor(new OTLPHttpProtoTraceExporter({
url: process.env.SENTRY_OTLP_TRACES_ENDPOINT!,
headers: { 'x-sentry-auth': `sentry sentry_key=${process.env.SENTRY_PUBLIC_KEY}` },
})),
],
}),
events: {
'step.started': (input) => {
const principalId = input.session.auth.initiator?.principalId
?? input.session.auth.current?.principalId
return {
runtimeContext: {
...evlogRuntimeContext(input),
// 省略而不是留空:空属性会显示为空 ID。
...(principalId ? { posthog_distinct_id: principalId } : {}),
},
}
},
},
})
evlogRuntimeContext 在受追踪轮次之外会返回 undefined,展开它不会添加任何内容。PostHog 是唯一一个同时使用 events 的注册表项目;其余项目只需要它们的 span processor。在合并时,请保持每个生成的文件处于打开状态:环境变量和导出器选项是值得原样复制的部分。
跨度和宽事件通过属性而不是 trace ID 保持关联:agent turn 没有可继承的入站 traceparent,因此由 evlog.request_id 负责传递关联信息。
evlog.*。**它的导出器会转发完整的 span,但服务端将其转换为 $ai_generation / $ai_span 事件时,只保留以 posthog_ 为前缀的属性,并去掉该前缀。要在 PostHog 中进行关联,请在能够保留下来的名称下重复这些 ID,因为 posthog_evlog_request_id 会作为 evlog_request_id 到达。其他 OTLP 后端会原样接收 evlog.*。eve 只会将运行时上下文应用到步骤 span,因此其下的生成 span 不会继承任何内容。在 PostHog 中,这意味着成本会出现在没有身份和环境信息的事件上。需要使用一个 span processor,将当前 turn 的 posthog_* 属性复制到每个子 span 上,才能弥合这一缺口。
要反向完成关联,请将 agent 自身的宽事件发送到 PostHog Logs,并将 drain 指向同一个主体:
import { createPostHogDrain } from 'evlog/posthog'
import { defineEvlogHook } from 'evlog/eve'
export default defineEvlogHook({
drain: createPostHogDrain({ distinctIdField: 'eve.caller.principalId' }),
})
这样,轮次就会进入与其生成的 LLM 追踪相同的 PostHog 用户。请参阅 PostHog 适配器。
生产环境
长时间运行的 eve agent 应关闭终端美化打印,并使用非阻塞的 drain:
import { defineEvlogHook } from 'evlog/eve'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'
const drain = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 } })(
createAxiomDrain(),
)
export default defineEvlogHook({
init: {
env: { service: 'my-agent', environment: 'production' },
pretty: false,
sampling: { rates: { info: 10 } },
},
drain,
maxSessions: 256,
})
| 关注点 | 建议 |
|---|---|
| 终端输出 | init.pretty: false — 美化打印仅适用于本地开发 |
| Drain 延迟 | 批处理或异步 HTTP drains;绝不要因为 I/O 阻塞 turn |
| Head 采样 | init.sampling.rates — eve 每个 turn 只发出一个事件,而不是每个 token |
| 内存 | maxSessions(默认 256)会驱逐最久未使用的空闲会话状态 |
| 负载 | Hook 处理器对每个流事件的复杂度为 O(1);成本主要取决于你的 drain |
选项
| 选项 | 描述 |
|---|---|
init | 在首次调用钩子时传递给 initLogger() |
drain / enrich / keep / plugins | 与 HTTP 集成相同(插件) |
message | 'omit'(默认)、'preview' 或 'full' — 记录多少用户消息和代理响应内容 |
messagePreviewLength | 'preview' 模式下保留的字符数(默认值为 500) |
sessionEvent | 设为 true 时,每个会话额外生成一个事件,汇总该会话的各轮交互 |
cost / model | 备用令牌定价(来自 evlog/ai 的 ModelCost)→ ai.estimatedCost,仅在 eve 未报告成本时使用 |
maxSessions | 用于上下文传递的内存中会话上限(默认值为 256) |
include / exclude | 对轮次路径(/sessions/*/turns/*)应用类似路由的过滤器 |
尾部采样
使用 keep 强制保留带有失败工具或高 token 使用量的轮次:
export default defineEvlogHook({
drain: createAxiomDrain(),
keep: (ctx) => {
const ai = ctx.context.ai as {
inputTokens?: number
outputTokens?: number
tools?: Array<{ success: boolean }>
} | undefined
const totalTokens = (ai?.inputTokens ?? 0) + (ai?.outputTokens ?? 0)
if (totalTokens > 10_000) ctx.shouldKeep = true
if (ai?.tools?.some(t => !t.success)) ctx.shouldKeep = true
},
})
审计日志
结合 审计日志:通过 init.plugins 或全局插件注册 auditEnricher(),并在人类审批门触发时在工具内调用 log.audit()。工具拒绝会在 action.result 中显示,status: "rejected"。
本地运行
git clone https://github.com/evloghq/evlog
cd evlog
pnpm install
pnpm example eve
examples/eve 项目是一个客服退款助手(Clearbill SaaS):查询客户 → 查询订单 → 发起退款,当金额超过 100 美元时需要 eve 审批。运行 pnpm example eve,打开 **
接下来阅读什么
- eve hooks 指南:流事件词汇
- eve instrumentation:OTel spans(互补功能)
- AI SDK 用例:当你直接拥有模型循环时
- 适配器概览:drain 目标站点。