Adapters

Custom Adapters

Build your own adapter to send logs to any destination. Factory patterns, batching, filtering, and error handling best practices.

You can create custom adapters to send logs to any service or destination. An adapter is simply a function that receives a DrainContext and sends the data somewhere.

Basic Structure

A drain is a function that receives a DrainContext and sends data somewhere:

lib/my-drain.ts
import type { DrainContext } from 'evlog'

const drain = async (ctx: DrainContext) => {
  await fetch('https://your-service.com/logs', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(ctx.event),
  })
}

Then wire it to your framework:

// server/plugins/evlog-drain.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', drain)
})

DrainContext Reference

types.ts
interface DrainContext {
  /** The complete wide event with all accumulated context */
  event: WideEvent

  /** Request metadata */
  request?: {
    method: string
    path: string
    requestId: string
  }

  /** Safe HTTP headers (sensitive headers filtered) */
  headers?: Record<string, string>
}

interface WideEvent {
  timestamp: string
  level: 'debug' | 'info' | 'warn' | 'error'
  service: string
  environment?: string
  version?: string
  region?: string
  commitHash?: string
  requestId?: string
  // ... plus all fields added via log.set()
  [key: string]: unknown
}

Factory Pattern

For reusable adapters, use the factory pattern:

lib/my-adapter.ts
import type { DrainContext } from 'evlog'

export interface MyAdapterConfig {
  apiKey: string
  endpoint?: string
  timeout?: number
}

export function createMyAdapter(config: MyAdapterConfig) {
  const endpoint = config.endpoint ?? 'https://api.myservice.com/ingest'
  const timeout = config.timeout ?? 5000

  return async (ctx: DrainContext) => {
    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), timeout)

    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': config.apiKey,
        },
        body: JSON.stringify(ctx.event),
        signal: controller.signal,
      })

      if (!response.ok) {
        console.error(`[my-adapter] Failed: ${response.status}`)
      }
    } catch (error) {
      if (error instanceof Error && error.name === 'AbortError') {
        console.error('[my-adapter] Request timed out')
      } else {
        console.error('[my-adapter] Error:', error)
      }
    } finally {
      clearTimeout(timeoutId)
    }
  }
}

Then pass the adapter to your framework like any other drain:

const drain = createMyAdapter({
  apiKey: process.env.MY_SERVICE_API_KEY!,
})

Reading Configuration

The recommended pattern is: overrides > environment variables. If you also need Nuxt/Nitro runtimeConfig support, add it as a fallback:

lib/my-adapter.ts
export function createMyAdapter(overrides?: Partial<MyAdapterConfig>) {
  return async (ctx: DrainContext) => {
    const config = {
      apiKey: overrides?.apiKey ?? process.env.MY_SERVICE_API_KEY,
      endpoint: overrides?.endpoint ?? process.env.MY_SERVICE_ENDPOINT,
    }

    if (!config.apiKey) {
      console.error('[my-adapter] Missing API key')
      return
    }

    // Send the event...
  }
}

Filtering Events

Filter which events to send inside the drain function:

lib/my-drain.ts
const drain = async (ctx: DrainContext) => {
  if (ctx.event.level !== 'error') return
  if (ctx.request?.path === '/health') return
  if (ctx.event._sampled === false) return

  await sendToMyService(ctx.event)
}

Transforming Events

Transform events before sending:

lib/my-drain.ts
const drain = async (ctx: DrainContext) => {
  const payload = {
    ts: new Date(ctx.event.timestamp).getTime(),
    severity: ctx.event.level.toUpperCase(),
    message: JSON.stringify(ctx.event),
    labels: {
      service: ctx.event.service,
      env: ctx.event.environment,
    },
    attributes: {
      method: ctx.event.method,
      path: ctx.event.path,
      status: ctx.event.status,
      duration: ctx.event.duration,
    },
  }

  await fetch('https://logs.example.com/v1/push', {
    method: 'POST',
    body: JSON.stringify(payload),
  })
}

Batching

For high-throughput scenarios, use the Drain Pipeline to batch events, retry on failure, and handle buffer overflow automatically:

lib/my-drain.ts
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'

const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 100, intervalMs: 5000 },
})

const drain = pipeline(async (batch) => {
  await fetch('https://api.example.com/logs/batch', {
    method: 'POST',
    body: JSON.stringify(batch.map(ctx => ctx.event)),
  })
})
See the Pipeline documentation for the full options reference, retry strategies, and buffer overflow handling.

Error Handling Best Practices

  1. Never throw errors - The drain should not crash your app
  2. Log failures silently - Use console.error for debugging
  3. Use timeouts - Prevent hanging requests
  4. Graceful degradation - Skip sending if config is missing
lib/robust-adapter.ts
export function createRobustAdapter(config: Config) {
  return async (ctx: DrainContext) => {
    // Validate config
    if (!config.apiKey) {
      console.error('[adapter] Missing API key, skipping')
      return
    }

    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), 5000)

    try {
      await fetch(config.endpoint, {
        method: 'POST',
        body: JSON.stringify(ctx.event),
        signal: controller.signal,
      })
    } catch (error) {
      // Log but don't throw
      console.error('[adapter] Failed to send:', error)
    } finally {
      clearTimeout(timeoutId)
    }
  }
}

Next Steps