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

server/plugins/evlog-drain.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', async (ctx) => {
    // ctx.event contains the full wide event
    // ctx.request contains request metadata
    // ctx.headers contains safe HTTP headers

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

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)
    }
  }
}
server/plugins/evlog-drain.ts
import { createMyAdapter } from '~/lib/my-adapter'

export default defineNitroPlugin((nitroApp) => {
  const drain = createMyAdapter({
    apiKey: process.env.MY_SERVICE_API_KEY!,
  })

  nitroApp.hooks.hook('evlog:drain', drain)
})

Reading from Runtime Config

Follow the evlog adapter pattern for zero-config setup:

lib/my-adapter.ts
function getRuntimeConfig() {
  try {
    const { useRuntimeConfig } = require('nitropack/runtime')
    return useRuntimeConfig()
  } catch {
    return undefined
  }
}

export function createMyAdapter(overrides?: Partial<MyAdapterConfig>) {
  return async (ctx: DrainContext) => {
    const runtimeConfig = getRuntimeConfig()

    // Support runtimeConfig.evlog.myService and runtimeConfig.myService
    const evlogConfig = runtimeConfig?.evlog?.myService
    const rootConfig = runtimeConfig?.myService

    const config = {
      apiKey: overrides?.apiKey ?? evlogConfig?.apiKey ?? rootConfig?.apiKey ?? process.env.MY_SERVICE_API_KEY,
      endpoint: overrides?.endpoint ?? evlogConfig?.endpoint ?? rootConfig?.endpoint,
    }

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

    // Send the event...
  }
}

Filtering Events

Filter which events to send:

server/plugins/evlog-drain.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', async (ctx) => {
    // Only send errors
    if (ctx.event.level !== 'error') return

    // Skip health checks
    if (ctx.request?.path === '/health') return

    // Skip sampled-out events
    if (ctx.event._sampled === false) return

    await sendToMyService(ctx.event)
  })
})

Transforming Events

Transform events before sending:

server/plugins/evlog-drain.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', async (ctx) => {
    // Transform to your service's format
    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, batch events before sending:

server/plugins/evlog-drain.ts
import type { WideEvent } from 'evlog'

const batch: WideEvent[] = []
const BATCH_SIZE = 100
const FLUSH_INTERVAL = 5000 // 5 seconds

async function flush() {
  if (batch.length === 0) return

  const events = batch.splice(0, batch.length)
  await fetch('https://api.example.com/logs/batch', {
    method: 'POST',
    body: JSON.stringify(events),
  })
}

// Flush periodically
setInterval(flush, FLUSH_INTERVAL)

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', async (ctx) => {
    batch.push(ctx.event)

    if (batch.length >= BATCH_SIZE) {
      await flush()
    }
  })
})
Note: Batching in serverless environments (Vercel, Cloudflare Workers) requires careful handling since the runtime may terminate before the batch flushes. Consider using the platform's native batching or a queue service.

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

Copyright © 2026