Build on top

Recipes

Concrete copy-paste recipes built on top of the in-process stream and the SSE bridge — devtools, curl one-liners, replay-then-live, and consumer-side analytics.

Real-world patterns that combine the stream API, the SSE bridge, and the filesystem reader (readFsLogs / tailFsLogs).

1. Build a minimal devtool

A live event panel is ~30 lines of EventSource glue + a virtual table. The Nuxt playground page (apps/playground/app/pages/stream.vue) is exactly that — fork it, restyle, ship.

The minimum-viable shape, framework-agnostic:

const events: WideEvent[] = []
const es = new EventSource('/api/_evlog/stream')

es.onmessage = (e) => {
  const env = JSON.parse(e.data)
  if (env.evlog !== '1') return
  if (env.type === 'event' || env.type === 'replay') {
    events.unshift(env.data)
    if (events.length > 500) events.length = 500
    render()
  }
}

es.addEventListener('ping', () => {
  // heartbeat — connection alive
})

Build whatever UI you want around events. React, Vue, Solid, vanilla. Nothing in evlog is opinionated about your renderer.

2. Quick CLI inspection with curl + jq

When you SSH into a self-hosted box, no UI needed:

curl -N http://localhost:3000/api/_evlog/stream \
  | jq -c 'select(.type == "event") | .data'

Filter on the client side as needed:

# Only errors
curl -sN http://localhost:3000/api/_evlog/stream \
  | jq -c 'select(.type == "event" and .data.level == "error") | .data'

# Only one service
curl -sN http://localhost:3000/api/_evlog/stream \
  | jq -c 'select(.type == "event" and .data.service == "checkout") | .data'

# Slow requests
curl -sN http://localhost:3000/api/_evlog/stream \
  | jq -c 'select(.type == "event" and .data.duration > 500) | .data'

-N keeps curl in streaming mode (no buffering). -s is silent.

3. Replay history then go live

History on disk (filesystem drain) + live updates from the SSE bridge = a full picture from any point in time.

import { readFsLogs } from 'evlog/fs'

async function bootstrap(handle: (e: WideEvent) => void) {
  // 1. Replay the last hour from `.evlog/logs/`
  const since = new Date(Date.now() - 60 * 60 * 1000)
  for await (const event of readFsLogs({ since })) {
    handle(event)
  }

  // 2. Switch to the live SSE stream
  const es = new EventSource('/api/_evlog/stream')
  es.onmessage = (e) => {
    const env = JSON.parse(e.data)
    if (env.evlog !== '1') return
    if (env.type === 'event' || env.type === 'replay') {
      handle(env.data)
    }
  }
  return () => es.close()
}

readFsLogs skips files outside the date range, so the replay step is fast even if you keep weeks of history. For a tail-only mode without on-disk replay, point at the SSE endpoint with ?since=<iso> to reuse the in-process ring buffer instead.

4. Filter, transform, aggregate on the consumer

Keep the bridge dumb — every consumer picks what it cares about:

// Just errors
const errors = events.filter(e => e.level === 'error')

// Slow requests
const slowReqs = events.filter(e => typeof e.duration === 'number' && e.duration > 500)

// Group by service
const byService = Object.groupBy(events, e => e.service)

// Rolling error rate (last 100 events)
const last100 = events.slice(0, 100)
const errorRate = last100.filter(e => e.level === 'error').length / last100.length

// Ad-hoc cost analytics — works because evlog/ai writes ai.* fields on every AI call
const totalCost = events
  .filter(e => typeof e.ai?.estimatedCost === 'number')
  .reduce((sum, e) => sum + (e.ai?.estimatedCost as number), 0)

For complex transforms (rolling windows, percentiles, derived series), use a lib (rxjs, observable, anything async-iterator-friendly) on top of the same EventSource source.

5. Self-hosted "tail -f" replacement

Skip the SSE bridge entirely if the consumer runs on the same machine:

import { tailFsLogs } from 'evlog/fs'

const ac = new AbortController()
process.on('SIGINT', () => ac.abort())

for await (const event of tailFsLogs({ signal: ac.signal })) {
  // Process every wide event as it lands on disk
  if (event.level === 'error') notifyOps(event)
}

Works without instrumenting the running app — useful for sidecar / observer processes that watch a directory.

What not to do

  • Don't run the SSE bridge on Vercel Functions / Cloudflare Workers / Lambda. Each invocation is a separate isolate; subscribers in one isolate never see events emitted by other isolates. Use a real broker (Redis Streams, NATS, Pub/Sub) for cross-instance fan-out.
  • Don't put auth-sensitive data in wide events unless your evlog config redacts them. The stream relays exactly what your app emitted — including any unredacted PII.
  • Don't filter at the bridge ("only error events please"). The bridge is purpose-built to be transparent. Filter on the consumer side; that way one filter doesn't starve another consumer.