---
title: "OTLP Adapter"
description: "Send logs via OpenTelemetry Protocol (OTLP) to Grafana, Datadog, Honeycomb, and any compatible backend. Supports gRPC and HTTP transports."
canonical_url: "https://www.evlog.dev/integrate/adapters/hybrid/otlp"
---
# OTLP Adapter

> 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**
- **Sazabi**

<prompt :actions="["copy","cursor","claude"]" description="Add the OTLP drain adapter" icon="i-simple-icons-opentelemetry">

Add the OTLP drain adapter to send evlog wide events via OpenTelemetry Protocol.

1. Identify which framework I'm using and follow its evlog integration pattern
2. Install evlog if not already installed
3. Import createOTLPDrain from 'evlog/otlp'
4. Wire createOTLPDrain() into my framework's drain configuration
5. Set OTLP_ENDPOINT environment variable (collector URL)
6. Optionally set OTLP_HEADERS for authentication
7. Test by triggering a request and checking your OTLP backend (Grafana, Datadog, Honeycomb, etc.)

Adapter docs: [https://www.evlog.dev/integrate/adapters/hybrid/otlp](https://www.evlog.dev/integrate/adapters/hybrid/otlp)
Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview)

</prompt>

## Installation

The OTLP adapter comes bundled with evlog:

```typescript [src/index.ts]
import { createOTLPDrain } from 'evlog/otlp'
```

## Quick Start

### 1. Set your OTLP endpoint

```bash [.env]
OTLP_ENDPOINT=http://localhost:4318
```

### 2. Wire the drain to your framework

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createOTLPDrain } from 'evlog/otlp'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain())
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createOTLPDrain } from 'evlog/otlp'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain())
})
```

```typescript [Next.js]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(),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createOTLPDrain } from 'evlog/otlp'

export const { handle, handleError } = createEvlogHooks({ drain: createOTLPDrain() })
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createOTLPDrain } from 'evlog/otlp'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain())
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createOTLPDrain } from 'evlog/otlp'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({ drain: createOTLPDrain() }),
]
```

```typescript [Hono]src/index.ts
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({ drain: createOTLPDrain() }))
```

```typescript [Express]src/index.ts
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({ drain: createOTLPDrain() }))
```

```typescript [Fastify]src/index.ts
import { createOTLPDrain } from 'evlog/otlp'

await app.register(evlog, { drain: createOTLPDrain() })
```

```typescript [Elysia]src/index.ts
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({ drain: createOTLPDrain() }))
```

```typescript [NestJS]src/app.module.ts
import { createOTLPDrain } from 'evlog/otlp'

EvlogModule.forRoot({ drain: createOTLPDrain() })
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createOTLPDrain } from 'evlog/otlp'

const handler = withEvlog(new RPCHandler(router), { drain: createOTLPDrain() })
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createOTLPDrain } from 'evlog/otlp'

export default withEvlog(handler, { drain: createOTLPDrain() })
```

```typescript [Standalone]
import { createOTLPDrain } from 'evlog/otlp'

initLogger({ drain: createOTLPDrain() })
```

</framework-tabs>

## 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

<table>
<thead>
  <tr>
    <th>
      Variable
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        OTLP_ENDPOINT
      </code>
    </td>
    
    <td>
      OTLP HTTP endpoint (e.g., <code>
        http://localhost:4318
      </code>
      
      ). The standard <code>
        OTEL_EXPORTER_OTLP_ENDPOINT
      </code>
      
       also works.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        OTLP_HEADERS
      </code>
    </td>
    
    <td>
      Headers as <code>
        key=value
      </code>
      
       pairs, comma-separated. The standard <code>
        OTEL_EXPORTER_OTLP_HEADERS
      </code>
      
       also works.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        OTEL_EXPORTER_OTLP_LOGS_ENDPOINT
      </code>
    </td>
    
    <td>
      Full logs URL, used as-is with no <code>
        /v1/logs
      </code>
      
       appended. Takes precedence over <code>
        OTEL_EXPORTER_OTLP_ENDPOINT
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        OTEL_EXPORTER_OTLP_LOGS_HEADERS
      </code>
    </td>
    
    <td>
      Headers for the logs signal. Merged over <code>
        OTEL_EXPORTER_OTLP_HEADERS
      </code>
      
      , winning on conflicts.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        OTEL_EXPORTER_OTLP_COMPRESSION
      </code>
    </td>
    
    <td>
      <code>
        gzip
      </code>
      
       or <code>
        none
      </code>
      
      . <code>
        OTEL_EXPORTER_OTLP_LOGS_COMPRESSION
      </code>
      
       takes precedence.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        OTEL_EXPORTER_OTLP_PROTOCOL
      </code>
    </td>
    
    <td>
      <code>
        http/json
      </code>
      
       or <code>
        http/protobuf
      </code>
      
      . <code>
        OTEL_EXPORTER_OTLP_LOGS_PROTOCOL
      </code>
      
       takes precedence. <code>
        grpc
      </code>
      
       is not supported.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        OTEL_SERVICE_NAME
      </code>
    </td>
    
    <td>
      Service name override
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        OTEL_RESOURCE_ATTRIBUTES
      </code>
    </td>
    
    <td>
      Resource attributes as <code>
        key=value
      </code>
      
       pairs, comma-separated. <code>
        resourceAttributes
      </code>
      
       and the event's <code>
        service
      </code>
      
      , <code>
        environment
      </code>
      
      , <code>
        version
      </code>
      
      , <code>
        region
      </code>
      
       and <code>
        commitHash
      </code>
      
       take precedence.
    </td>
  </tr>
</tbody>
</table>

These follow the [OpenTelemetry exporter specification](https://opentelemetry.io/docs/specs/otel/protocol/exporter/), so a deployment that already configures an OTel SDK through the environment needs no evlog-specific variables.

### Runtime Config (Nuxt only)

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

### Override Options

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createOTLPDrain } from 'evlog/otlp'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }))
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createOTLPDrain } from 'evlog/otlp'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }))
})
```

```typescript [Next.js]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({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createOTLPDrain } from 'evlog/otlp'

export const { handle, handleError } = createEvlogHooks({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
})
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createOTLPDrain } from 'evlog/otlp'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }))
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createOTLPDrain } from 'evlog/otlp'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({
      drain: createOTLPDrain({
        endpoint: 'http://localhost:4318',
        serviceName: 'my-api',
        headers: {
          'Authorization': 'Bearer xxx',
        },
        resourceAttributes: {
          'deployment.environment': 'staging',
        },
      }),
    }),
]
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
}))
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
}))
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import { createOTLPDrain } from 'evlog/otlp'

await app.register(evlog, {
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
})
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
}))
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import { createOTLPDrain } from 'evlog/otlp'

EvlogModule.forRoot({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
})
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createOTLPDrain } from 'evlog/otlp'

const handler = withEvlog(new RPCHandler(router), {
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
})
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createOTLPDrain } from 'evlog/otlp'

export default withEvlog(handler, {
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
})
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'
import { createOTLPDrain } from 'evlog/otlp'

initLogger({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    serviceName: 'my-api',
    headers: {
      'Authorization': 'Bearer xxx',
    },
    resourceAttributes: {
      'deployment.environment': 'staging',
    },
  }),
})
```

</framework-tabs>

### Full Configuration Reference

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        endpoint
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      OTLP HTTP endpoint (required)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        serviceName
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      From event
    </td>
    
    <td>
      Override <code>
        service.name
      </code>
      
       resource attribute
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        headers
      </code>
    </td>
    
    <td>
      <code>
        object
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Custom HTTP headers for authentication
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        resourceAttributes
      </code>
    </td>
    
    <td>
      <code>
        object
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Additional OTLP resource attributes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        recordShape
      </code>
    </td>
    
    <td>
      <code>
        'json' | 'compact'
      </code>
    </td>
    
    <td>
      <code>
        'json'
      </code>
    </td>
    
    <td>
      How the record carries the event (<a href="#record-shape">
        details
      </a>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        compression
      </code>
    </td>
    
    <td>
      <code>
        'gzip' | 'none'
      </code>
    </td>
    
    <td>
      <code>
        'none'
      </code>
    </td>
    
    <td>
      Gzip the request body and send <code>
        Content-Encoding: gzip
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        protocol
      </code>
    </td>
    
    <td>
      <code>
        'http/json' | 'http/protobuf'
      </code>
    </td>
    
    <td>
      <code>
        'http/json'
      </code>
    </td>
    
    <td>
      Request body encoding (<a href="#protobuf">
        details
      </a>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        semanticConventions
      </code>
    </td>
    
    <td>
      <code>
        boolean
      </code>
    </td>
    
    <td>
      <code>
        false
      </code>
    </td>
    
    <td>
      Add OpenTelemetry attribute names next to the evlog ones (<a href="#semantic-conventions">
        details
      </a>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        timeout
      </code>
    </td>
    
    <td>
      <code>
        number
      </code>
    </td>
    
    <td>
      <code>
        5000
      </code>
    </td>
    
    <td>
      Request timeout in milliseconds
    </td>
  </tr>
</tbody>
</table>

## 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:

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

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    logs:
      receivers: [otlp]
      exporters: [debug]
```

```bash [Terminal]
docker run --rm -p 4318:4318 \
  -v $(pwd)/otel-collector.yaml:/etc/otelcol/config.yaml \
  otel/opentelemetry-collector:latest
```

```bash [.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:

<code-group>

```bash [Grafana Cloud]
OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20base64-encoded-credentials
```

```bash [Datadog]
OTLP_ENDPOINT=https://http-intake.logs.datadoghq.com
OTLP_HEADERS=DD-API-KEY=your-api-key
```

```bash [Honeycomb]
OTLP_ENDPOINT=https://api.honeycomb.io
OTLP_HEADERS=x-honeycomb-team=your-api-key
```

</code-group>

<callout color="info" icon="i-lucide-info">

Grafana Cloud uses URL-encoded headers, so the `%20` is a space. The adapter
decodes that format automatically.

</callout>

## OTLP Log Format

evlog maps wide events to the OTLP log format:

<table>
<thead>
  <tr>
    <th>
      evlog Field
    </th>
    
    <th>
      OTLP Field
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        level
      </code>
    </td>
    
    <td>
      <code>
        severityNumber
      </code>
      
       / <code>
        severityText
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        timestamp
      </code>
    </td>
    
    <td>
      <code>
        timeUnixNano
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        service
      </code>
    </td>
    
    <td>
      Resource attribute <code>
        service.name
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        environment
      </code>
    </td>
    
    <td>
      Resource attribute <code>
        deployment.environment
      </code>
      
      , plus <code>
        deployment.environment.name
      </code>
      
       with <code>
        semanticConventions
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        version
      </code>
    </td>
    
    <td>
      Resource attribute <code>
        service.version
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        region
      </code>
    </td>
    
    <td>
      Resource attribute <code>
        cloud.region
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        traceId
      </code>
    </td>
    
    <td>
      <code>
        traceId
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        spanId
      </code>
    </td>
    
    <td>
      <code>
        spanId
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      All other fields
    </td>
    
    <td>
      Log attributes
    </td>
  </tr>
</tbody>
</table>

`spanId` is the span of the request that produced the event. The [TraceContext enricher](https://www.evlog.dev/use-cases/enrichers#tracecontextenricher) 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`.

<code-group>

```json [json (default)]
{
  "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" } }
    ] } } }
  ]
}
```

```json [compact]
{
  "body": { "stringValue": "POST /api/checkout (500)" },
  "attributes": [
    { "key": "method", "value": { "stringValue": "POST" } },
    { "key": "user.id", "value": { "stringValue": "usr_123" } },
    { "key": "user.plan", "value": { "stringValue": "premium" } }
  ]
}
```

</code-group>

`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:```typescript [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.

<callout color="info" icon="i-lucide-info">

`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.

</callout>

### Semantic Conventions

With `semanticConventions: true`, each record also carries the [OpenTelemetry semantic convention](https://opentelemetry.io/docs/specs/semconv/) 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.

<table>
<thead>
  <tr>
    <th>
      evlog field
    </th>
    
    <th>
      Attribute
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        method
      </code>
    </td>
    
    <td>
      <code>
        http.request.method
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        path
      </code>
    </td>
    
    <td>
      <code>
        url.path
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        status
      </code>
    </td>
    
    <td>
      <code>
        http.response.status_code
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        userAgent.raw
      </code>
    </td>
    
    <td>
      <code>
        user_agent.original
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        error.name
      </code>
      
       / <code>
        error.message
      </code>
      
       / <code>
        error.stack
      </code>
    </td>
    
    <td>
      <code>
        exception.type
      </code>
      
       / <code>
        exception.message
      </code>
      
       / <code>
        exception.stacktrace
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.model
      </code>
      
       / <code>
        ai.provider
      </code>
      
       / <code>
        ai.responseId
      </code>
    </td>
    
    <td>
      <code>
        gen_ai.request.model
      </code>
      
       / <code>
        gen_ai.provider.name
      </code>
      
       / <code>
        gen_ai.response.id
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.inputTokens
      </code>
      
       / <code>
        ai.outputTokens
      </code>
    </td>
    
    <td>
      <code>
        gen_ai.usage.input_tokens
      </code>
      
       / <code>
        gen_ai.usage.output_tokens
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.cacheReadTokens
      </code>
      
       / <code>
        ai.cacheWriteTokens
      </code>
    </td>
    
    <td>
      <code>
        gen_ai.usage.cache_read.input_tokens
      </code>
      
       / <code>
        gen_ai.usage.cache_creation.input_tokens
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.finishReason
      </code>
    </td>
    
    <td>
      <code>
        gen_ai.response.finish_reasons
      </code>
    </td>
  </tr>
</tbody>
</table>

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.

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createOTLPDrain } from 'evlog/otlp'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }))
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createOTLPDrain } from 'evlog/otlp'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }))
})
```

```typescript [Next.js]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({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createOTLPDrain } from 'evlog/otlp'

export const { handle, handleError } = createEvlogHooks({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
})
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createOTLPDrain } from 'evlog/otlp'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }))
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createOTLPDrain } from 'evlog/otlp'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({
      drain: createOTLPDrain({
        endpoint: 'http://localhost:4318',
        protocol: 'http/protobuf',
        compression: 'gzip',
      }),
    }),
]
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
}))
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
}))
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import { createOTLPDrain } from 'evlog/otlp'

await app.register(evlog, {
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
})
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import { createOTLPDrain } from 'evlog/otlp'

app.use(evlog({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
}))
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import { createOTLPDrain } from 'evlog/otlp'

EvlogModule.forRoot({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
})
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createOTLPDrain } from 'evlog/otlp'

const handler = withEvlog(new RPCHandler(router), {
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
})
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createOTLPDrain } from 'evlog/otlp'

export default withEvlog(handler, {
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
})
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'
import { createOTLPDrain } from 'evlog/otlp'

initLogger({
  drain: createOTLPDrain({
    endpoint: 'http://localhost:4318',
    protocol: 'http/protobuf',
    compression: 'gzip',
  }),
})
```

</framework-tabs>

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

### Severity Mapping

<table>
<thead>
  <tr>
    <th>
      evlog Level
    </th>
    
    <th>
      OTLP Severity Number
    </th>
    
    <th>
      OTLP Severity Text
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        trace
      </code>
    </td>
    
    <td>
      1
    </td>
    
    <td>
      TRACE
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        debug
      </code>
    </td>
    
    <td>
      5
    </td>
    
    <td>
      DEBUG
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        info
      </code>
    </td>
    
    <td>
      9
    </td>
    
    <td>
      INFO
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        warn
      </code>
    </td>
    
    <td>
      13
    </td>
    
    <td>
      WARN
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      17
    </td>
    
    <td>
      ERROR
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        fatal
      </code>
    </td>
    
    <td>
      21
    </td>
    
    <td>
      FATAL
    </td>
  </tr>
</tbody>
</table>

## Troubleshooting

### Missing endpoint error

```text [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:

```typescript [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

- [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) - Send logs to Axiom
- [PostHog Adapter](https://www.evlog.dev/integrate/adapters/cloud/posthog) - Send logs to PostHog
- [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter
- [Best Practices](https://www.evlog.dev/reference/best-practices) - Security and production tips

---

- [OpenTelemetry Docs](https://opentelemetry.io/docs/)
- [Custom Adapters](https://www.evlog.dev/extend/custom-drains)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
