Signals

Recipes

Three signals to start with, then nine more to copy: failures explained, 200s that lied, agent turns, audit actions, flaky jobs.

Every signal here is a question a rule cannot answer from the fields alone. Change the when to your paths and they are yours.

Start with these three

One file, three columns, and a week later you have numbers you did not have before.

server/signals.ts
import { defineSignal } from '@evlog/signals'

const errorName = (e: Record<string, unknown>) => (e.error as { name?: string } | undefined)?.name ?? 'none'

/** Every 4xx/5xx. Splits the error budget between the client, the code and the dependencies. */
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, missing permission, client mistake',
    app: 'A bug, a misconfiguration or a validation error in our own code',
    upstream: 'A third-party dependency failed, timed out or rate-limited us',
  },
  cacheKey: e => e.error ? `${e.method} ${e.path} ${e.status} ${errorName(e)}` : undefined,
})

/** Every 5xx. Tells a retry policy which failures are worth a second attempt. */
export const retryable = defineSignal({
  name: 'retryable',
  when: e => (e.status ?? 0) >= 500,
  ask: 'Would the same request most likely succeed if retried in a few seconds?',
  criteria: { true: 'Timeout, connection reset, rate limit, transient upstream error', false: 'Bug, bad data, missing resource' },
  cacheKey: e => e.error ? `${e.path} ${errorName(e)}` : undefined,
})

/** Successful checkouts. The 200 that sampling deletes and nobody notices. */
export const silentFailure = defineSignal({
  name: 'silent-failure',
  when: e => e.status === 200 && e.path === '/api/checkout',
  ask: 'The request returned 200, but the customer did not get what they came for',
  criteria: { true: 'No order or confirmation, a fallback path, an empty or partial result', false: 'Order created and confirmed' },
  keep: v => v.value && v.confidence > 0.8,
})

What each one gives you:

  • GROUP BY signals.fault.value over a week says whether the next sprint goes to validation messages, your own bugs, or a dependency's SLA. cacheKey on the error name makes an outage one call, not one per request.
  • signals.retryable.value = true on a 502 is the difference between a retry policy and a guess.
  • silent-failure promotes the checkouts that returned 200 with no order. Point when at your own money path.

All twelve

SignalHookQuestionWhy a rule cannot
faultenrich, cachedWho is responsible: user, us, upstreamA Stripe timeout and a null deref are both a 500
severityenrichnoise, watch or pageUrgency depends on what failed and for whom
retryableenrich, cachedWould a retry succeedTransient and permanent errors share status codes
slow-causeenrichdatabase, upstream, computeReading the timings takes a human
silent-failurekeep200, but the customer left empty-handedSampling only keeps what a predicate can name
webhook-ignoredkeepAcknowledged but not acted onThe provider only sees the 200
validation-bugkeepThe rejected input was validLooks identical to bad input
turn-resolvedenrichThe agent did what was askedA sampled LLM-as-judge misses the rest
turn-loopingkeepRepeated a tool call without progressLoops hide in successful turns
turn-off-scriptkeepDid something nobody asked forSame
audit-reviewkeepThis action deserves a reviewerRisk is in the combination, not one field
job-flakyenrichSucceeded only because it retriedThe retry hides the cause

More that fit the shape

first-seen (is this error new for this route), user-impact (none, degraded, blocked), owner (which team should look), known-error (which catalog entry is this, from your own why and fix), frustration on support conversations, did-succeed on session events.

Not a fit

  • Secret or PII detection. Sending a value to a model to ask whether it is a secret is the leak. Use redaction.
  • Paging. Never page on a probability. severity ranks what to read first; an alert on status and durationMs decides who wakes up.
  • Anything adversarial as a single verdict. A model that can be asked can be prompted.
  • Anything numeric. A decision model returns a distribution over options, not a number. Compute it in the handler and log it.