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
- 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
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)
}
}
}