---
title: "Tail Sampling"
description: "Decide after the fact whether to keep an event, knowing its status, duration, and errors. Keep every error, throw away healthy noise."
canonical_url: "https://www.evlog.dev/extend/tail-sampling"
---
# Tail Sampling

> Decide after the fact whether to keep an event, knowing its status, duration, and errors. Keep every error, throw away healthy noise.

<tail-sample-decision>



</tail-sample-decision>

**Tail sampling** is a decision made *after* the request runs, with full knowledge of its outcome (status, duration, errors, custom flags). It's how you keep all errors and slow requests while throwing away the bulk of healthy traffic, the opposite of head sampling, which decides up front before knowing what happens.

The full theory and config reference, covering built-in `keep` rules, custom predicates via `evlog:emit:keep`, combining head + tail sampling, lives at [Sampling](https://www.evlog.dev/learn/sampling). This page covers the **extension surface**: how to plug your own keep logic into the pipeline.

<prompt :actions="["copy","cursor","claude"]" description="Configure tail sampling on evlog" icon="i-lucide-filter">

Set up tail sampling so I keep all errors and slow requests while dropping healthy noise.

- Start with the built-in declarative rules: `evlog.sampling.keep = { status: '>=400', duration: '>1000', path: ['/api/auth/*'] }`
- For multi-field or derived conditions, register an `evlog:emit:keep` hook (Nitro: `nitroApp.hooks.hook('evlog:emit:keep', (ctx) => ...)`); set `ctx.shouldKeep = true` to keep
- Keep the hook fast. It runs on every request after enrichment, so no I/O and no async work
- Combine with head sampling (e.g. 10% of healthy traffic) by setting both `sample` (head) and `keep` (tail)
- Always keep error events (`level: 'error'`) regardless of sampling; double-check rules don't accidentally drop them

Docs: [https://www.evlog.dev/extend/tail-sampling](https://www.evlog.dev/extend/tail-sampling)

</prompt>

## When the built-in rules aren't enough

The built-in declarative `keep` rules cover the typical cases (status code thresholds, duration thresholds, path matching, level matching). Drop to a custom hook when you need:

- **Conditional logic on more than one field** (e.g. "keep if `status >= 500` AND `user.plan === 'enterprise'`")
- **Keep based on a derived value** (e.g. "keep if `event.audit?.context.actor.role === 'admin'`")
- **Stateful decisions** (rare; needs care since sampling runs in the hot path)

## Custom keep hook

The hook signature is the same regardless of framework. The wiring depends on your runtime.

<framework-tabs>

```ts [Nuxt]
nitroApp.hooks.hook('evlog:emit:keep', (ctx) => {
  if (ctx.context.user?.plan === 'enterprise' && ctx.status >= 500) {
    ctx.shouldKeep = true
  }
})
```

```ts [Nitro]
nitroApp.hooks.hook('evlog:emit:keep', (ctx) => {
  if (ctx.context.user?.plan === 'enterprise' && ctx.status >= 500) {
    ctx.shouldKeep = true
  }
})
```

```ts [Plugin (any framework)]
import { definePlugin } from 'evlog/toolkit'

export const keepEnterpriseErrors = definePlugin({
  name: 'keep-enterprise-errors',
  keep(ctx) {
    if (ctx.context.user?.plan === 'enterprise' && ctx.status >= 500) {
      ctx.shouldKeep = true
    }
  },
})

// Then: initLogger({ plugins: [keepEnterpriseErrors] })
//   or: app.use(evlog({ plugins: [keepEnterpriseErrors] }))
```

</framework-tabs>

For non-trivial logic, prefer the plugin shape, since it travels with the rest of your evlog config (drains, enrichers) and is reusable across frameworks.

## Composing several keep predicates

Use `composeKeep` from `evlog/toolkit` to combine multiple predicates into one hook. Each predicate runs independently and the final `shouldKeep` is `true` if any of them set it:

```ts
import { composeKeep } from 'evlog/toolkit'

const keep = composeKeep([
  ({ duration, shouldKeep }) => duration && duration > 2000 ? true : shouldKeep,
  ({ event }) => event.level === 'error',
  ({ context, status }) => context.user?.plan === 'enterprise' && status >= 500,
])
```

Errors in individual predicates are isolated (logged with the `[evlog/keep]` prefix) so a buggy predicate cannot silently drop legitimate events.

## What you receive

The keep hook gets a `TailSamplingContext`:

```ts
interface TailSamplingContext {
  /** The event level (debug | info | warn | error) */
  level: string
  /** HTTP response status, if known */
  status?: number
  /** Request duration in milliseconds, if measured */
  duration?: number
  /** The full accumulated context (everything log.set'd) */
  context: Record<string, unknown>
  /** The fully enriched event ready to drain */
  event: WideEvent
  /** Mutable: set to true to force-keep this event */
  shouldKeep: boolean
}
```

Setting `shouldKeep = true` forces the event through. Setting `shouldKeep = false` is a no-op (other predicates may still keep it; the head sampler decides the default).

## Next steps

- [Sampling](https://www.evlog.dev/learn/sampling): head sampling, tail sampling, the built-in declarative `keep` rules
- [Plugins](https://www.evlog.dev/extend/plugins): when keep belongs in a multi-hook plugin
- [Best Practices](https://www.evlog.dev/reference/best-practices): keep all errors, double-check the rules

---

- [Sampling Reference](https://www.evlog.dev/learn/sampling)
- [Plugins](https://www.evlog.dev/extend/plugins)


## Sitemap

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