Your logs tell you a request returned 502. A signal tells you it was Stripe, that a retry would have worked, and that it is not worth waking anyone. Your logs tell you a checkout returned 200. A signal tells you the customer left without an order.
@evlog/signals asks a decision model one question per event and writes the answer onto the event as a column, with a confidence. You query it like any other field.
What you get
A signal is a question in English, attached to the events it applies to. When an event matches, the model reads it, answers with a probability for each option, and the answer is written back onto the event as a typed column:
The question is the whole configuration. There is no prompt to tune and no parser to write: a yes/no gives a boolean, a list of options gives one of them, an ordered rubric gives a level and a position. Every column carries the model's confidence, so WHERE signals.fault.value = 'upstream' AND signals.fault.confidence > 0.9 is a query, not a guess.
Three things that gives you, that a rule on the event's fields could not:
- Every error gets a reason.
fault=upstreamon the Stripe 502,fault=appon the null deref. Two 5xx that look identical on a status chart are two rows in aGROUP BY. Over a week that is a number: how much of your error budget is yours. - A 200 can be a failure. A checkout that answered 200 and created no order, a webhook acknowledged and dropped, an agent looping on the same tool call. No field on those events says anything is wrong; a signal with
keepcarries them past sampling, and the next section shows that. - It costs nothing you would notice. A decision model answers in one short call, every due signal in it: 15 requests in the demo run came to 14 calls, 1,569 input tokens, $0.00007. What It Costs has what that becomes at your traffic.
Add your first signal
Add signals to an evlog app
Install
pnpm add @evlog/signals ai
The default model is typesafe-ai/jev through Vercel AI Gateway. Set AI_GATEWAY_API_KEY, or nothing on Vercel, where OIDC is picked up.
Write the question
A signal is a name, a predicate that decides which events it applies to, and the question. This one labels every failure:
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',
},
})
Register it
createSignals returns an evlog plugin. Nuxt and Nitro expose the two hooks directly; every other integration takes it in a plugins array:
// server/plugins/evlog-signals.ts
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)
})
// lib/evlog.ts
import { initLogger } from 'evlog'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
initLogger({ 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 { initLogger } from 'evlog'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'
initLogger({ plugins: [createSignals({ signals: [fault] })] })
Read the column
The next 500 that reaches your drain carries the answer:
{
"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 is the answer, confidence is its probability (always on a yes/no, on choice and score when the model returns a distribution). Columns are written before the console line, so they show in the dev terminal, in platform logs such as Vercel, and in every drain.
Then keep what sampling drops
Add keep to a signal and a true verdict forces the event past sampling. Six requests on a production rule, info kept at 0%: sampling keeps the two errors, and three of the four 200s come back because a signal said so:
This is the signal that rescued the silent checkout:
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 only promotes. It never drops an event a sampling rule would have kept, and the promoted event carries kept: true on the column so your counts stay honest.
Where to next
What You Can Ask
What It Costs
Node.js request logging
Build a Node.js HTTP handler with structured request logs, explicit outcomes, error context, and a JSON event you can query.
What You Can Ask
Three question shapes, each with the column it produces: a yes/no with a probability, one option out of a list, or a position on a rubric.