Custom Adapters
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
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:
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)
}
}
}
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:
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:
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:
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, use the Drain Pipeline to batch events, retry on failure, and handle buffer overflow automatically:
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
export default defineNitroPlugin((nitroApp) => {
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)),
})
})
nitroApp.hooks.hook('evlog:drain', drain)
nitroApp.hooks.hook('close', () => drain.flush())
})
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)
}
}
}
Next Steps
- Axiom Adapter - See a production-ready adapter implementation
- OTLP Adapter - OpenTelemetry Protocol adapter
- PostHog Adapter - PostHog product analytics adapter
- Best Practices - Security and production tips
Better Stack
Send wide events to Better Stack (formerly Logtail) for log management, alerting, and dashboards. Zero-config setup with environment variables.
Pipeline
Batch events, retry on failure, and protect against buffer overflow with the shared drain pipeline. Supports fan-out to multiple adapters.