Adapters

Custom Adapters

Build your own adapter to send logs to any destination.

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

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
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)
    }
  }
}
Copyright © 2026