---
title: "Recipes"
description: "Three signals to start with, then nine more to copy: failures explained, 200s that lied, agent turns, audit actions, flaky jobs."
canonical_url: "https://www.evlog.dev/use-cases/signals/recipes"
---
# 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.

```typescript [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

<table>
<thead>
  <tr>
    <th>
      Signal
    </th>
    
    <th>
      Hook
    </th>
    
    <th>
      Question
    </th>
    
    <th>
      Why a rule cannot
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        fault
      </code>
    </td>
    
    <td>
      enrich, cached
    </td>
    
    <td>
      Who is responsible: user, us, upstream
    </td>
    
    <td>
      A Stripe timeout and a null deref are both a 500
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        severity
      </code>
    </td>
    
    <td>
      enrich
    </td>
    
    <td>
      noise, watch or page
    </td>
    
    <td>
      Urgency depends on what failed and for whom
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retryable
      </code>
    </td>
    
    <td>
      enrich, cached
    </td>
    
    <td>
      Would a retry succeed
    </td>
    
    <td>
      Transient and permanent errors share status codes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        slow-cause
      </code>
    </td>
    
    <td>
      enrich
    </td>
    
    <td>
      database, upstream, compute
    </td>
    
    <td>
      Reading the timings takes a human
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        silent-failure
      </code>
    </td>
    
    <td>
      keep
    </td>
    
    <td>
      200, but the customer left empty-handed
    </td>
    
    <td>
      Sampling only keeps what a predicate can name
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhook-ignored
      </code>
    </td>
    
    <td>
      keep
    </td>
    
    <td>
      Acknowledged but not acted on
    </td>
    
    <td>
      The provider only sees the 200
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        validation-bug
      </code>
    </td>
    
    <td>
      keep
    </td>
    
    <td>
      The rejected input was valid
    </td>
    
    <td>
      Looks identical to bad input
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        turn-resolved
      </code>
    </td>
    
    <td>
      enrich
    </td>
    
    <td>
      The agent did what was asked
    </td>
    
    <td>
      A sampled LLM-as-judge misses the rest
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        turn-looping
      </code>
    </td>
    
    <td>
      keep
    </td>
    
    <td>
      Repeated a tool call without progress
    </td>
    
    <td>
      Loops hide in successful turns
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        turn-off-script
      </code>
    </td>
    
    <td>
      keep
    </td>
    
    <td>
      Did something nobody asked for
    </td>
    
    <td>
      Same
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        audit-review
      </code>
    </td>
    
    <td>
      keep
    </td>
    
    <td>
      This action deserves a reviewer
    </td>
    
    <td>
      Risk is in the combination, not one field
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        job-flaky
      </code>
    </td>
    
    <td>
      enrich
    </td>
    
    <td>
      Succeeded only because it retried
    </td>
    
    <td>
      The retry hides the cause
    </td>
  </tr>
</tbody>
</table>

<collapsible name="the other nine, with code">

### Rank failures and explain slow requests

```typescript
export const severity = defineSignal({
  name: 'severity',
  when: e => (e.status ?? 0) >= 500,
  ask: 'How urgent is this failure for the on-call engineer?',
  score: ['noise', 'watch', 'page'],
})

export const slowCause = defineSignal({
  name: 'slow-cause',
  when: e => (e.durationMs ?? 0) > 2_000,
  ask: 'What dominated the duration of this request?',
  choice: {
    database: 'Query time, lock waits, many round trips',
    upstream: 'A third-party API or another service',
    compute: 'Serialization, rendering, large payloads, CPU work',
    unknown: 'Nothing in the event explains the time',
  },
})
```

The `unknown` option on `slow-cause` is deliberate: without it the model picks the least wrong of three, and you lose the signal that your events do not carry the timings they should.

### Rescue the other 200s that lied

```typescript
export const webhookIgnored = defineSignal({
  name: 'webhook-ignored',
  when: e => e.status === 200 && (e.path ?? '').startsWith('/api/webhooks/'),
  ask: 'The webhook was acknowledged but not acted on',
  criteria: { true: 'Unhandled event type, skipped, no side effect recorded', false: 'The event was processed' },
  keep: v => v.value && v.confidence > 0.85,
})

export const validationBug = defineSignal({
  name: 'validation-bug',
  when: e => e.status === 400,
  ask: 'The rejected input was actually valid and our validation is wrong',
  keep: v => v.value && v.confidence > 0.85,
})
```

A validator that rejects `hugo+test@example.com` returns the same 400 as one that rejects `notanemail`. `validation-bug` reads the rejected value and the reason, and keeps the ones where the reason is wrong.

### Judge agent turns

`evlog/eve` emits one wide event per agent turn with `method: 'EVE'`. A sampled LLM-as-judge reads a few percent; a signal reads every turn. See [eve](https://www.evlog.dev/use-cases/eve) for the event shape.

```typescript
const isTurn = (e: Record<string, unknown>) =>
  e.method === 'EVE' && typeof (e.eve as { turnId?: string } | undefined)?.turnId === 'string'

export const turnResolved = defineSignal({
  name: 'turn-resolved',
  when: isTurn,
  ask: 'The agent accomplished what the user asked for in this turn',
})

export const turnLooping = defineSignal({
  name: 'turn-looping',
  when: isTurn,
  ask: 'The agent repeated the same tool call without making progress',
  keep: v => v.value && v.confidence > 0.85,
})

export const turnOffScript = defineSignal({
  name: 'turn-off-script',
  when: isTurn,
  ask: 'The agent took an action the user did not ask for',
  keep: v => v.value && v.confidence > 0.85,
})
```

### Flag audit actions and flaky jobs

```typescript
export const auditReview = defineSignal({
  name: 'audit-review',
  when: e => typeof e.audit === 'object' && e.audit !== null,
  ask: 'This action deserves a human review',
  criteria: {
    true: 'Privilege change, bulk deletion, export of personal data, action outside business hours by a non-admin',
    false: 'Routine self-service action',
  },
  keep: v => v.value && v.confidence > 0.8,
})

export const jobFlaky = defineSignal({
  name: 'job-flaky',
  when: e => typeof e.operation === 'string' && ((e.retries as number | undefined) ?? 0) > 0,
  ask: 'The job only succeeded because it retried, and the underlying cause is still there',
})
```

[Audit events](https://www.evlog.dev/use-cases/audit/overview) carry the actor, the action and the target; the risk is in the combination, and `audit-review` is one reviewer queue instead of a rule per combination.

</collapsible>

## 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](https://www.evlog.dev/learn/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.

---

- [Overview](https://www.evlog.dev/use-cases/signals/overview)
- [What You Can Ask](https://www.evlog.dev/use-cases/signals/ask)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
