Build on top

Recipes

Concrete copy-paste recipes — build your own minimal devtool, pipe to curl + jq, replay history then go live, and aggregate on the consumer side.

Real-world patterns that combine the stream API, the SSE bridge, and the filesystem reader.

1. Build a minimal devtool

A live event panel is essentially EventSource + a list. You can fork the playground page (apps/playground/app/pages/stream.vue in this repo) for a richer starting point — but the minimum that ships is much smaller.

Vanilla HTML + JS (drop into any page)

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>evlog mini devtool</title>
  <style>
    body { font: 13px ui-sans-serif, system-ui; margin: 0; padding: 0; }
    table { width: 100%; border-collapse: collapse; }
    td, th { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: left; }
    .lvl-error { color: #ef4444 }
    .lvl-warn  { color: #f59e0b }
    .lvl-info  { color: #3b82f6 }
  </style>
</head>
<body>
  <table id="t">
    <thead><tr><th>time</th><th>level</th><th>service</th><th>action</th></tr></thead>
    <tbody></tbody>
  </table>

  <script>
    const tbody = document.querySelector('#t tbody')
    const es = new EventSource('http://localhost:3000/api/_evlog/stream')

    es.onmessage = (e) => {
      const env = JSON.parse(e.data)
      if (env.evlog !== '1') return
      if (env.type !== 'event' && env.type !== 'replay') return

      const w = env.data
      const tr = document.createElement('tr')
      tr.innerHTML = `
        <td>${new Date(w.timestamp).toLocaleTimeString()}</td>
        <td class="lvl-${w.level}">${w.level}</td>
        <td>${w.service ?? ''}</td>
        <td>${w.action ?? w.message ?? w.path ?? ''}</td>
      `
      tbody.prepend(tr)
      while (tbody.children.length > 200) tbody.lastElementChild.remove()
    }
  </script>
</body>
</html>

Save as devtool.html, open in any browser tab while your evlog-instrumented dev server is running. That's the whole MVP.

Vue 3 component

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import type { WideEvent } from 'evlog'

const events = ref<WideEvent[]>([])
let es: EventSource | null = null

onMounted(() => {
  es = new EventSource('http://localhost:3000/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.value.unshift(env.data as WideEvent)
      if (events.value.length > 500) events.value.length = 500
    }
  }
})

onBeforeUnmount(() => es?.close())
</script>

<template>
  <ul>
    <li v-for="(e, i) in events" :key="`${e.timestamp}-${i}`">
      <code>{{ e.level }}</code>
      <strong>{{ e.service }}</strong>
      <span>{{ e.action ?? e.message ?? e.path }}</span>
    </li>
  </ul>
</template>

React hook

import { useEffect, useState } from 'react'
import type { WideEvent } from 'evlog'

export function useEvlogStream(url = 'http://localhost:3000/api/_evlog/stream') {
  const [events, setEvents] = useState<WideEvent[]>([])

  useEffect(() => {
    const es = new EventSource(url)
    es.onmessage = (e) => {
      const env = JSON.parse(e.data)
      if (env.evlog !== '1') return
      if (env.type === 'event' || env.type === 'replay') {
        setEvents(prev => [env.data, ...prev].slice(0, 500))
      }
    }
    return () => es.close()
  }, [url])

  return events
}

That's the entire integration surface. No SDK, no special types beyond WideEvent exported from evlog.

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. Node / Bun client (fetch + ReadableStream)

Same protocol, no EventSource polyfill needed:

const res = await fetch('http://localhost:3000/api/_evlog/stream')
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''

while (true) {
  const { value, done } = await reader.read()
  if (done) break
  buffer += decoder.decode(value, { stream: true })

  let idx
  while ((idx = buffer.indexOf('\n\n')) !== -1) {
    const frame = buffer.slice(0, idx)
    buffer = buffer.slice(idx + 2)
    const dataLine = frame.split('\n').find(l => l.startsWith('data:'))
    if (!dataLine) continue
    const env = JSON.parse(dataLine.slice(5).trim())
    if (env.type === 'event') console.log(env.data)
  }
}

4. 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'
import type { WideEvent } from 'evlog'

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('http://localhost:3000/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.

5. 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.

6. 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.