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_EXPORTER_OTLP_LOGS_ENDPOINTFull logs URL, used as-is with no /v1/logs appended. Takes precedence over OTEL_EXPORTER_OTLP_ENDPOINT.
OTEL_EXPORTER_OTLP_LOGS_HEADERSHeaders for the logs signal. Merged over OTEL_EXPORTER_OTLP_HEADERS, winning on conflicts.
OTEL_EXPORTER_OTLP_COMPRESSIONgzip or none. OTEL_EXPORTER_OTLP_LOGS_COMPRESSION takes precedence.
OTEL_EXPORTER_OTLP_PROTOCOLhttp/json or http/protobuf. OTEL_EXPORTER_OTLP_LOGS_PROTOCOL takes precedence. grpc is not supported.
OTEL_SERVICE_NAMEService name override
OTEL_RESOURCE_ATTRIBUTESResource attributes as key=value pairs, comma-separated. resourceAttributes and the event's service, environment, version, region and commitHash take precedence.

These follow the OpenTelemetry exporter specification, so a deployment that already configures an OTel SDK through the environment needs no evlog-specific variables.

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
recordShape'json' | 'compact''json'How the record carries the event (details)
compression'gzip' | 'none''none'Gzip the request body and send Content-Encoding: gzip
protocol'http/json' | 'http/protobuf''http/json'Request body encoding (details)
semanticConventionsbooleanfalseAdd OpenTelemetry attribute names next to the evlog ones (details)
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, so 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, plus deployment.environment.name with semanticConventions
versionResource attribute service.version
regionResource attribute cloud.region
traceIdtraceId
spanIdspanId
All other fieldsLog attributes

spanId is the span of the request that produced the event. The TraceContext enricher records the caller's span from an incoming traceparent as parentSpanId, which is sent as a log attribute, so set spanId from your tracer's active span to link the record to the server span.

Attribute values keep their type: integers are sent as intValue, other finite numbers as doubleValue, booleans as boolValue, and arrays whose elements share one primitive type as arrayValue. In the json shape, nested plain objects are sent as kvlistValue. Anything else is serialized to a stringValue. A traceId or spanId that is not valid W3C hex, or is all zeros, stays an ordinary attribute.

Every field of the wide event is sent as an OTLP record field, a resource attribute, or a log attribute. null and undefined are omitted rather than transmitted. Both shapes drop them at every level, so a nested { user: { id: null } } has no user.id attribute in compact and no id entry in the json key-value list.

Record Shape

recordShape controls how the record carries the event. The default is json.

{
  "body": { "stringValue": "{\"timestamp\":\"…\",\"method\":\"POST\",\"user\":{\"id\":\"usr_123\"}}" },
  "attributes": [
    { "key": "method", "value": { "stringValue": "POST" } },
    { "key": "user", "value": { "kvlistValue": { "values": [
      { "key": "id", "value": { "stringValue": "usr_123" } },
      { "key": "plan", "value": { "stringValue": "premium" } }
    ] } } }
  ]
}

compact is worth switching to when your backend charges by ingested volume or facets on attributes:

  • The body is a one-line summary: POST /api/checkout (500), falling back to the service name, instead of the whole event repeated next to the attributes. Backends that cluster messages into templates can only do so with a stable body.
  • Nested fields become dotted attributes, so each leaf is its own facet:
    server/api/checkout.post.ts
    const drain = createOTLPDrain({ recordShape: 'compact' })
    
    log.set({ user: { id: 'usr_123', plan: 'premium' } })
    // → user.id, user.plan
    

Only plain objects are walked. Arrays are sent as a single attribute, an arrayValue when their elements share one primitive type and a JSON string otherwise. Indexing them, as 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. The same goes for anything else that is not a plain object, such as a Date. An empty object stays a single {} attribute rather than disappearing.

compact becomes the default in the next major. Switch early if you are setting a project up now. Moving later means rewriting the queries built on the json shape.

Semantic Conventions

With semanticConventions: true, each record also carries the OpenTelemetry semantic convention names for the fields evlog knows, so backends with built-in HTTP, error, and GenAI views pick them up. The evlog names stay, so existing queries keep working.

evlog fieldAttribute
methodhttp.request.method
pathurl.path
statushttp.response.status_code
userAgent.rawuser_agent.original
error.name / error.message / error.stackexception.type / exception.message / exception.stacktrace
ai.model / ai.provider / ai.responseIdgen_ai.request.model / gen_ai.provider.name / gen_ai.response.id
ai.inputTokens / ai.outputTokensgen_ai.usage.input_tokens / gen_ai.usage.output_tokens
ai.cacheReadTokens / ai.cacheWriteTokensgen_ai.usage.cache_read.input_tokens / gen_ai.usage.cache_creation.input_tokens
ai.finishReasongen_ai.response.finish_reasons

A field is mapped only when it has the type the convention requires, and an attribute the event already sets under that name is not overwritten. The resource also gets deployment.environment.name. The option becomes the default in the next major.

Protobuf

With protocol: 'http/protobuf', the adapter sends the same request as binary protobuf with Content-Type: application/x-protobuf. Use it for collectors and gateways that only accept protobuf, or to shrink the payload. The encoder ships with evlog, has no dependencies, and is only loaded when this protocol is selected, so evlog/otlp stays the same size for JSON users.

server/plugins/evlog-drain.ts
const drain = createOTLPDrain({
  endpoint: 'http://localhost:4318',
  protocol: 'http/protobuf',
  compression: 'gzip',
})

To encode a request yourself, evlog/otlp/protobuf exports encodeOTLPLogsRequest().

Severity Mapping

evlog LevelOTLP Severity NumberOTLP Severity Text
trace1TRACE
debug5DEBUG
info9INFO
warn13WARN
error17ERROR
fatal21FATAL

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). For a protobuf-only receiver, set protocol: 'http/protobuf'
  • 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