Signals
Ask a model one question about each request and get the answer back as a column: who broke it, is it worth paging, did the customer get what they came for.

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:

a signal·idle
wide eventPOST · /api/checkout
{
path: "/api/checkout"
status: 502
error: "ECONNRESET api.stripe.com"
durationMs: 2310
signals.fault:
{ value: "upstream", confidence: 0.93 }
}
the signalpick one
ask: "Who is responsible for this failure?"
client
0.03
app
0.04
upstream
0.93
eventquestionprobabilitiescolumn

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=upstream on the Stripe 502, fault=app on the null deref. Two 5xx that look identical on a status chart are two rows in a GROUP 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 keep carries 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

Terminal
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:

server/signals.ts
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)
})

Read the column

The next 500 that reaches your drain carries the answer:

Wide Event
{
  "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:

signals·idle
request · samplingoutcome
POST/api/checkout200dropped
kept
POST/api/checkout502kept
kept
GET/api/orders500kept
kept
POST/webhooks/stripe200dropped
kept
POST/api/agent/turn200dropped
kept
GET/api/products200dropped
dropped
kept by sampling0 / 6
kept with signals0 / 6
model calls0

This is the signal that rescued the silent checkout:

server/signals.ts
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

Yes/no, pick one, or a rubric. Which shape gives you which column, and how to write a question the model can actually answer.

What It Costs

Price per event, the rate limit that binds first, latency on the keep path, and what leaves your process.

Reference

Every option of defineSignal and createSignals, the column format, stats(), and the test helpers, on one page.

Recipes

Three signals most apps want on day one, and nine more to copy from.