Adapters

Browser Drain

Framework-agnostic browser log transport — send client-side logs to your server via fetch or sendBeacon.

Most observability tools focus on server-side logs. The browser drain gives you a framework-agnostic way to send structured logs from the browser to any HTTP endpoint — no vendor SDK, no framework coupling.

Quick Start

app.ts
import { initLogger, log } from 'evlog'
import { createBrowserLogDrain } from 'evlog/browser'

const drain = createBrowserLogDrain({
  drain: { endpoint: 'https://logs.example.com/v1/ingest' },
})
initLogger({ drain })

log.info({ action: 'page_view', path: location.pathname })

How It Works

  1. log.info() / log.warn() / log.error() push events into a memory buffer
  2. Events are batched by size (default 25) or time interval (default 2 s)
  3. Batches are sent via fetch with keepalive: true so requests survive page navigation
  4. When the page becomes hidden (tab switch, navigation), buffered events are flushed via navigator.sendBeacon as a fallback
  5. Your server endpoint receives a DrainContext[] JSON array and processes it however you like

Two-Tier API

createBrowserLogDrain(options)

High-level, pre-composed: creates a pipeline with batching, retry, and auto-flush on visibilitychange. Returns a PipelineDrainFn<DrainContext> directly usable with initLogger({ drain }).

import { initLogger, log } from 'evlog'
import { createBrowserLogDrain } from 'evlog/browser'

const drain = createBrowserLogDrain({
  drain: { endpoint: 'https://logs.example.com/v1/ingest' },
  pipeline: { batch: { size: 50, intervalMs: 5000 } },
})

initLogger({ drain })
log.info({ action: 'click', target: 'buy-button' })

createBrowserDrain(config)

Low-level transport function. Use this when you want full control over the pipeline configuration:

import { createBrowserDrain } from 'evlog/browser'
import { createDrainPipeline } from 'evlog/pipeline'
import type { DrainContext } from 'evlog'

const transport = createBrowserDrain({
  endpoint: 'https://logs.example.com/v1/ingest',
})
const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 100, intervalMs: 10000 },
  retry: { maxAttempts: 5 },
})

const drain = pipeline(transport)

Configuration Reference

BrowserDrainConfig

OptionDefaultDescription
endpoint(required) Full URL of the server ingest endpoint
headersCustom headers sent with each fetch request (e.g. Authorization, X-API-Key)
timeout5000Request timeout in milliseconds
useBeacontrueUse sendBeacon when the page is hidden

BrowserLogDrainOptions

OptionDefaultDescription
drain(required) BrowserDrainConfig object
pipeline{ batch: { size: 25, intervalMs: 2000 }, retry: { maxAttempts: 2 } }Pipeline configuration overrides
autoFlushtrueAuto-register visibilitychange flush listener

sendBeacon Fallback

When useBeacon is enabled (the default) and the page becomes hidden, the drain automatically switches from fetch to navigator.sendBeacon. This ensures logs are delivered even when the user closes the tab or navigates away — no data loss on page exit.

sendBeacon has a browser-imposed payload limit (~64 KB). If the payload exceeds this, the drain throws an error. Keep batch sizes reasonable (the default of 25 is well within limits).

Authentication

Pass custom headers to protect your ingest endpoint:

const drain = createBrowserLogDrain({
  drain: {
    endpoint: 'https://logs.example.com/v1/ingest',
    headers: {
      'Authorization': 'Bearer ' + token,
    },
  },
})
headers are applied to fetch requests only. The sendBeacon API does not support custom headers — when the page is hidden and sendBeacon is used, headers are not sent. If your endpoint requires authentication, consider validating via a session cookie (credentials: 'same-origin' is set by default) or disable sendBeacon with useBeacon: false.

Server Endpoint

Your server needs a POST endpoint that accepts a DrainContext[] JSON body. Here are examples for common frameworks:

Express

server.ts
app.post('/v1/ingest', express.json(), (req, res) => {
  for (const entry of req.body) {
    console.log('[BROWSER]', JSON.stringify(entry))
  }
  res.sendStatus(204)
})

Hono

server.ts
app.post('/v1/ingest', async (c) => {
  const body = await c.req.json()
  for (const entry of body) {
    console.log('[BROWSER]', JSON.stringify(entry))
  }
  return c.body(null, 204)
})

Full Control

Combine createBrowserDrain with createDrainPipeline for maximum flexibility:

app.ts
import { initLogger, log } from 'evlog'
import type { DrainContext } from 'evlog'
import { createBrowserDrain } from 'evlog/browser'
import { createDrainPipeline } from 'evlog/pipeline'

const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 100, intervalMs: 10000 },
  retry: { maxAttempts: 5, backoff: 'exponential' },
  maxBufferSize: 500,
  onDropped: (events) => {
    console.warn(`Dropped ${events.length} browser events`)
  },
})

const drain = pipeline(createBrowserDrain({
  endpoint: 'https://logs.example.com/v1/ingest',
  timeout: 3000,
}))

initLogger({ drain })

log.info({ action: 'app_init' })

// Flush on page unload
window.addEventListener('beforeunload', () => drain.flush())
See the full browser example for a working Hono server + browser page that demonstrates the complete flow end to end.

Next Steps

Copyright © 2026