OTLP Adapter
The OTLP (OpenTelemetry Protocol) adapter sends logs in the standard OpenTelemetry format. This works with any OTLP-compatible backend including:
- Grafana Cloud (Loki)
- Datadog
- Honeycomb
- Jaeger
- Splunk
- New Relic
- Self-hosted OpenTelemetry Collector
- HyperDX
Add the OTLP drain adapter
Installation
The OTLP adapter comes bundled with evlog:
import { createOTLPDrain } from 'evlog/otlp'
Quick Start
1. Set your OTLP endpoint
OTLP_ENDPOINT=http://localhost:4318
2. Wire the drain to your framework
// server/plugins/evlog-drain.ts
import { createOTLPDrain } from 'evlog/otlp'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createOTLPDrain())
})
// lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createOTLPDrain } from 'evlog/otlp'
export const { withEvlog, useLogger, log, createError } = createEvlog({
service: 'my-app',
drain: createOTLPDrain(),
})
import { createOTLPDrain } from 'evlog/otlp'
app.use(evlog({ drain: createOTLPDrain() }))
import { createOTLPDrain } from 'evlog/otlp'
app.use(evlog({ drain: createOTLPDrain() }))
import { createOTLPDrain } from 'evlog/otlp'
await app.register(evlog, { drain: createOTLPDrain() })
import { createOTLPDrain } from 'evlog/otlp'
app.use(evlog({ drain: createOTLPDrain() }))
import { createOTLPDrain } from 'evlog/otlp'
EvlogModule.forRoot({ drain: createOTLPDrain() })
import { createOTLPDrain } from 'evlog/otlp'
initLogger({ drain: createOTLPDrain() })
Configuration
The adapter reads configuration from multiple sources (highest priority first):
- Overrides passed to
createOTLPDrain() - Runtime config at
runtimeConfig.otlp(Nuxt/Nitro only) - Environment variables
Environment Variables
| Variable | Description |
|---|---|
OTLP_ENDPOINT | OTLP HTTP endpoint (e.g., http://localhost:4318). The standard OTEL_EXPORTER_OTLP_ENDPOINT also works. |
OTLP_HEADERS | Headers as key=value pairs, comma-separated. The standard OTEL_EXPORTER_OTLP_HEADERS also works. |
OTEL_SERVICE_NAME | Service name override |
Runtime Config (Nuxt only)
export default defineNuxtConfig({
runtimeConfig: {
otlp: {
endpoint: '', // Set via OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_ENDPOINT)
},
},
})
Override Options
const drain = createOTLPDrain({
endpoint: 'http://localhost:4318',
serviceName: 'my-api',
headers: {
'Authorization': 'Bearer xxx',
},
resourceAttributes: {
'deployment.environment': 'staging',
},
})
Full Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
endpoint | string | - | OTLP HTTP endpoint (required) |
serviceName | string | From event | Override service.name resource attribute |
headers | object | - | Custom HTTP headers for authentication |
resourceAttributes | object | - | Additional OTLP resource attributes |
timeout | number | 5000 | Request timeout in milliseconds |
Deployment
OTLP is a protocol, not a product — the same adapter talks to a collector you run yourself and to a managed gateway. Only the endpoint and headers change.
Self-hosted
Run an OpenTelemetry Collector and point evlog at it. Nothing else to configure:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
logs:
receivers: [otlp]
exporters: [debug]
docker run --rm -p 4318:4318 \
-v $(pwd)/otel-collector.yaml:/etc/otelcol/config.yaml \
otel/opentelemetry-collector:latest
OTLP_ENDPOINT=http://localhost:4318
From there the collector fans out wherever you want — Loki, ClickHouse, Elasticsearch, a managed backend, or several at once. That indirection is the reason to pick OTLP over a direct adapter.
Managed gateways
Same adapter, a credentialed endpoint:
OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20base64-encoded-credentials
OTLP_ENDPOINT=https://http-intake.logs.datadoghq.com
OTLP_HEADERS=DD-API-KEY=your-api-key
OTLP_ENDPOINT=https://api.honeycomb.io
OTLP_HEADERS=x-honeycomb-team=your-api-key
%20 is a space. The adapter
decodes that format automatically.OTLP Log Format
evlog maps wide events to the OTLP log format:
| evlog Field | OTLP Field |
|---|---|
level | severityNumber / severityText |
timestamp | timeUnixNano |
service | Resource attribute service.name |
environment | Resource attribute deployment.environment |
version | Resource attribute service.version |
region | Resource attribute cloud.region |
traceId | traceId |
spanId | spanId |
method + path + status | body (one-line summary) |
| All other fields | Log attributes (nested objects flattened to dotted keys) |
Log Record Body
The body is a one-line summary of the request — POST /api/checkout (500) — falling back to the service name when the event has no request shape:
{
"severityText": "ERROR",
"body": { "stringValue": "POST /api/checkout (500)" },
"attributes": [
{ "key": "method", "value": { "stringValue": "POST" } },
{ "key": "path", "value": { "stringValue": "/api/checkout" } },
{ "key": "status", "value": { "intValue": "500" } },
{ "key": "user.id", "value": { "stringValue": "usr_123" } },
{ "key": "user.plan", "value": { "stringValue": "premium" } }
]
}
Nothing is dropped: every field of the wide event is sent as an attribute, which is where backends filter, facet, and scrub PII. Keeping the body short also lets them cluster messages into templates — a body containing the whole serialized event is unique on every request, so it clusters into nothing.
Nested Fields
Nested objects are flattened into dotted attribute keys, so each leaf is its own facet:
log.set({ user: { id: 'usr_123', plan: 'premium' } })
// → user.id, user.plan
Arrays stay serialized as a single JSON string. Indexing them (ai.tools.0.name) would turn a list into an unbounded set of distinct attribute keys, which most backends charge for and none can chart. Date values and class instances are serialized whole for the same reason — they have no useful own keys to flatten.
user = {"id":"usr_123",…}). Queries matching on those strings need to move to the flattened keys.Severity Mapping
| evlog Level | OTLP Severity Number | OTLP Severity Text |
|---|---|---|
debug | 5 | DEBUG |
info | 9 | INFO |
warn | 13 | WARN |
error | 17 | ERROR |
Troubleshooting
Missing endpoint error
[evlog/otlp] Missing endpoint. Set OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT
Make sure your endpoint environment variable is set and the server was restarted.
401 Unauthorized
Your authentication headers may be missing or incorrect. Check:
- The
OTEL_EXPORTER_OTLP_HEADERSformat is correct - Credentials are valid and not expired
- The endpoint URL is correct
404 Not Found
The adapter sends to /v1/logs. Make sure your endpoint:
- Supports OTLP HTTP (not gRPC)
- Is the base URL without
/v1/logssuffix
Logs not appearing
- Check the server console for
[evlog/otlp]error messages - Test with a local collector first to verify the format
- Check your backend's ingestion delay (some have 1-2 minute delays)
Direct API Usage
For advanced use cases:
import { sendToOTLP, sendBatchToOTLP, toOTLPLogRecord } from 'evlog/otlp'
// Send a single event
await sendToOTLP(event, {
endpoint: 'http://localhost:4318',
})
// Send multiple events
await sendBatchToOTLP(events, {
endpoint: 'http://localhost:4318',
})
// Convert event to OTLP format (for inspection)
const otlpRecord = toOTLPLogRecord(event)
Next Steps
- Axiom Adapter - Send logs to Axiom
- PostHog Adapter - Send logs to PostHog
- Custom Adapters - Build your own adapter
- Best Practices - Security and production tips
ClickHouse
Insert wide events into ClickHouse over the HTTP interface — typed columns for what you aggregate, full JSON for everything else.
HyperDX
Send wide events to HyperDX via OTLP/HTTP using HyperDX’s documented OpenTelemetry endpoint and authorization header. Zero-config setup with environment variables.