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
- Never throw errors - The drain should not crash your app
- Log failures silently - Use
console.errorfor debugging - Use timeouts - Prevent hanging requests
- 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
- Axiom Adapter - See a production-ready adapter implementation
- OTLP Adapter - OpenTelemetry Protocol adapter
- Best Practices - Security and production tips