日志会告诉你请求返回了 502。signal 会告诉你原因是 Stripe,重试本来可以成功,而且不值得叫醒任何人。日志会告诉你结账请求返回了 200。signal 会告诉你客户离开时并没有创建订单。
@evlog/signals 让决策模型针对每个事件回答一个问题,并将带有置信度的答案作为列写回事件。你可以像查询其他字段一样查询它。
你会得到什么
signal 是一个英文问题,附加到适用的事件上。当事件匹配时,模型读取事件,为每个选项给出概率,并将答案作为类型化列写回事件:
问题本身就是完整配置。无需调整 prompt,也无需编写解析器:是/否问题给出布尔值,选项列表给出其中一个选项,有序评分表给出级别和位置。每列都带有模型置信度,因此 WHERE signals.fault.value = 'upstream' AND signals.fault.confidence > 0.9 是查询,而不是猜测。
它能提供三种事件字段规则无法提供的结果:
- 每个错误都有原因。 Stripe 502 的结果是
fault=upstream,空值解引用的结果是fault=app。状态图上看起来相同的两个 5xx,在GROUP BY中会成为两行。一周后它会变成一个数字:你的错误预算中有多少确实来自你的系统。 - 200 也可能是失败。 结账请求返回了 200 却没有创建订单,webhook 得到确认后被丢弃,agent 在重复调用同一个工具。这些事件没有任何字段说明出错;带有
keep的 signal 会让它们通过采样,下一节会展示这一点。 - 成本低到你不会察觉。 决策模型在一次短调用中回答该事件需要的所有 signal:演示运行中的 15 个请求只产生 14 次调用、1,569 个输入 token 和 0.00007 美元。成本介绍了在你的流量下会是什么结果。
添加第一个 signal
为 evlog 应用添加 signals
安装
pnpm add @evlog/signals ai
默认模型是通过 Vercel AI Gateway 使用的 typesafe-ai/jev。请设置 AI_GATEWAY_API_KEY;在 Vercel 上无需设置,系统会自动使用 OIDC。
编写问题
signal 包含名称、决定适用事件的谓词和问题。下面这个 signal 会标记每个失败:
import { defineSignal } from '@evlog/signals'
export const fault = defineSignal({
name: 'fault',
when: e => (e.status ?? 0) >= 400,
ask: 'Who is responsible for this failure?',
choice: {
client: 'Bad input, expired session, client mistake',
app: 'A bug or misconfiguration in our own code',
upstream: 'A third-party dependency failed',
},
})
注册
createSignals 返回一个 evlog 插件。Nuxt 和 Nitro 直接暴露这两个钩子;其他集成则通过 plugins 数组接收它:
import { createSignals } from '@evlog/signals'
import { fault } from '../signals'
export default defineNitroPlugin((nitroApp) => {
const plugin = createSignals({ signals: [fault] })
nitroApp.hooks.hook('evlog:emit:keep', plugin.keep)
nitroApp.hooks.hook('evlog:enrich', plugin.enrich)
})
import { definePlugin } from 'nitro'
import { createSignals } from '@evlog/signals'
import { fault } from '../signals'
export default definePlugin((nitroApp) => {
const plugin = createSignals({ signals: [fault] })
nitroApp.hooks.hook('evlog:emit:keep', plugin.keep)
nitroApp.hooks.hook('evlog:enrich', plugin.enrich)
})
import { initLogger } from 'evlog'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
initLogger({ plugins: [createSignals({ signals: [fault] })] })
import { createEvlogHooks } from 'evlog/sveltekit'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
export const { handle, handleError } = createEvlogHooks({ plugins: [createSignals({ signals: [fault] })] })
import { definePlugin } from 'nitro'
import { createSignals } from '@evlog/signals'
import { fault } from '../signals'
export default definePlugin((nitroApp) => {
const plugin = createSignals({ signals: [fault] })
nitroApp.hooks.hook('evlog:emit:keep', plugin.keep)
nitroApp.hooks.hook('evlog:enrich', plugin.enrich)
})
import { evlog } from 'evlog/react-router'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
export const middleware: Route.MiddlewareFunction[] = [
evlog({ plugins: [createSignals({ signals: [fault] })] }),
]
import { evlog } from 'evlog/hono'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
app.use(evlog({ plugins: [createSignals({ signals: [fault] })] }))
import { evlog } from 'evlog/express'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
app.use(evlog({ plugins: [createSignals({ signals: [fault] })] }))
import { evlog } from 'evlog/fastify'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
await app.register(evlog, { plugins: [createSignals({ signals: [fault] })] })
import { evlog } from 'evlog/elysia'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
app.use(evlog({ plugins: [createSignals({ signals: [fault] })] }))
import { EvlogModule } from 'evlog/nestjs'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
EvlogModule.forRoot({ plugins: [createSignals({ signals: [fault] })] })
import { withEvlog } from 'evlog/orpc'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
const handler = withEvlog(new RPCHandler(router), { plugins: [createSignals({ signals: [fault] })] })
import { withEvlog } from 'evlog/workers'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
export default withEvlog(handler, { plugins: [createSignals({ signals: [fault] })] })
import { initLogger } from 'evlog'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
initLogger({ plugins: [createSignals({ signals: [fault] })] })
读取列
接下来到达 drain 的 500 个事件会携带答案:
{
"method": "POST",
"path": "/api/checkout",
"status": 502,
"error": { "name": "FetchError", "message": "request to https://api.stripe.com/... failed, reason: read ECONNRESET" },
"signals": {
"fault": { "value": "upstream", "confidence": 0.93 }
}
}
value 是答案,confidence 是答案的概率(是/否始终有该字段;当模型返回分布时,choice 和 score 也有该字段)。列会在控制台行之前写入,因此它们会出现在开发终端、Vercel 等平台日志以及每个 drain 中。
保留采样丢弃的内容
为 signal 添加 keep 后,值为 true 的判定会强制事件通过采样。假设生产规则收到六个请求,info 的保留率为 0%:采样保留两个错误,其中四个 200 响应有三个因为 signal 的判定而返回:
这就是拯救静默结账的 signal:
export const silentFailure = defineSignal({
name: 'silent-failure',
when: e => e.status === 200 && e.path === '/api/checkout',
ask: 'Returned 200, but the customer did not get what they came for',
keep: v => v.value && v.confidence > 0.8,
})
keep 只负责提升保留级别。它不会丢弃采样规则本来会保留的事件,被提升的事件会在列上携带 kept: true,因此统计数字仍然准确。