Cloud or Self-Hosted
Send logs via OpenTelemetry Protocol (OTLP) to Grafana, Datadog, Honeycomb, and any compatible backend. Supports gRPC and HTTP transports.

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:

src/index.ts
import { createOTLPDrain } from 'evlog/otlp'

Quick Start

1. Set your OTLP endpoint

.env
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())
})

Configuration

The adapter reads configuration from multiple sources (highest priority first):

  1. Overrides passed to createOTLPDrain()
  2. Runtime config at runtimeConfig.otlp (Nuxt/Nitro only)
  3. Environment variables

Environment Variables

VariableDescription
OTLP_ENDPOINTOTLP HTTP endpoint (e.g., http://localhost:4318). The standard OTEL_EXPORTER_OTLP_ENDPOINT also works.
OTLP_HEADERSHeaders as key=value pairs, comma-separated. The standard OTEL_EXPORTER_OTLP_HEADERS also works.
OTEL_SERVICE_NAMEService name override

Runtime Config (Nuxt only)

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    otlp: {
      endpoint: '', // Set via OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_ENDPOINT)
    },
  },
})

Override Options

server/plugins/evlog-drain.ts
const drain = createOTLPDrain({
  endpoint: 'http://localhost:4318',
  serviceName: 'my-api',
  headers: {
    'Authorization': 'Bearer xxx',
  },
  resourceAttributes: {
    'deployment.environment': 'staging',
  },
})

Full Configuration Reference

OptionTypeDefaultDescription
endpointstring-OTLP HTTP endpoint (required)
serviceNamestringFrom eventOverride service.name resource attribute
headersobject-Custom HTTP headers for authentication
resourceAttributesobject-Additional OTLP resource attributes
timeoutnumber5000Request 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:

otel-collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    logs:
      receivers: [otlp]
      exporters: [debug]
Terminal
docker run --rm -p 4318:4318 \
  -v $(pwd)/otel-collector.yaml:/etc/otelcol/config.yaml \
  otel/opentelemetry-collector:latest
.env
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
Grafana Cloud uses URL-encoded headers — the %20 is a space. The adapter decodes that format automatically.

OTLP Log Format

evlog maps wide events to the OTLP log format:

evlog FieldOTLP Field
levelseverityNumber / severityText
timestamptimeUnixNano
serviceResource attribute service.name
environmentResource attribute deployment.environment
versionResource attribute service.version
regionResource attribute cloud.region
traceIdtraceId
spanIdspanId
method + path + statusbody (one-line summary)
All other fieldsLog 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:

OTLP log record
{
  "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 still sent — as an OTLP record field (timestamp, traceId, spanId), a resource attribute (service, environment, version, region, commitHash), or a log attribute for everything else. Attributes are where backends filter, facet, and scrub PII; keeping the body short also lets them cluster messages into templates, which a body containing the whole serialized event never does.

Nested Fields

Nested objects are flattened into dotted attribute keys, so each leaf is its own facet:

server/api/checkout.post.ts
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.

Changed in evlog 2.26: nested fields used to be sent as one JSON-string attribute per top-level key (user = {"id":"usr_123",…}). Queries matching on those strings need to move to the flattened keys.
Changed in evlog 2.26: the body used to carry the whole event as JSON, duplicating what the attributes already contained. If you built queries against the JSON body, filter on the attributes instead.

Severity Mapping

evlog LevelOTLP Severity NumberOTLP Severity Text
debug5DEBUG
info9INFO
warn13WARN
error17ERROR

Troubleshooting

Missing endpoint error

Console
[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:

  1. The OTEL_EXPORTER_OTLP_HEADERS format is correct
  2. Credentials are valid and not expired
  3. 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/logs suffix

Logs not appearing

  1. Check the server console for [evlog/otlp] error messages
  2. Test with a local collector first to verify the format
  3. Check your backend's ingestion delay (some have 1-2 minute delays)

Direct API Usage

For advanced use cases:

server/utils/otlp.ts
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