> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# OpenTelemetry

> Send batches as OTLP/HTTP log records to a collector, Loki, or any OTLP endpoint, with the exact wire shape and a collector config

An `otlp` drain sends the same records as an OTLP/HTTP `ExportLogsServiceRequest` in JSON encoding. Point it at an OpenTelemetry Collector and everything downstream of that collector — Loki, Elastic, Honeycomb, a vendor's OTLP endpoint — gets Lua's logs without a Lua-specific integration.

Use it when you already run a collector, or when you want one integration that outlives whichever backend you are on this year.

**Before you begin**

* An OTLP/HTTP logs endpoint reachable over HTTPS. Private, loopback, link-local, and cloud-metadata addresses are refused; plain `http://` is refused.
* Whatever your endpoint authenticates with — usually a bearer token or an API-key header.

## What to enter

| Field    | Flag              | Value                                                |
| -------- | ----------------- | ---------------------------------------------------- |
| Name     | `--name`          | 1–64 characters, unique in the organization          |
| Type     | `--type otlp`     |                                                      |
| Endpoint | `--endpoint`      | The **full logs URL**, including the `/v1/logs` path |
| Headers  | `--header <name>` | Up to 10. The CLI prompts for each value             |

```bash theme={null}
lua drains create \
  --name "OTel collector" \
  --type otlp \
  --endpoint https://otel.example.com/v1/logs \
  --header Authorization \
  --environments production
```

The endpoint is used verbatim — nothing is appended. If your collector listens on the default HTTP port, the endpoint is `https://<host>:4318/v1/logs`.

`--format` is not used: OTLP drains always send OTLP JSON, gzipped.

<Note>
  OTLP deliveries are **not signed**. There is no `X-Lua-Signature` header and no signing secret; the collector authenticates the sender with the header you configured. If you need a signature, use a [generic HTTPS](/drains/generic-https) drain.
</Note>

Besides your own headers and the transport ones (`Content-Type: application/json`, `Content-Encoding: gzip`), a delivery carries exactly two:

| Header           | Value                                                                                      |
| ---------------- | ------------------------------------------------------------------------------------------ |
| `User-Agent`     | `LuaDrain/1.0 (+https://docs.heylua.ai/drains)`                                            |
| `X-Lua-Batch-Id` | The batch ULID — a request identifier for support and tracing, **not** a deduplication key |

There is no `X-Lua-Signature` and no `X-Lua-Schema` on an OTLP delivery; the schema URL travels in the payload instead, as `resourceLogs[].schemaUrl`.

## How ownership verification works

OTLP uses a **test post**: a single batch containing one `lua.drain.test` record is sent to the endpoint, and any `2xx` from it is accepted as proof that the endpoint exists and accepts your credential.

```bash theme={null}
lua drains verify drn_c8104e77b3a9dd5216f0e442
```

The drain moves from `pending_verification` to `healthy` on a `2xx`, and records buffered since it was created (up to six hours' worth) start flowing. Verification is limited to 5 attempts per drain per hour.

Because the collector is the one judging the request, a `200` here means "the collector accepted it" — not "the backend behind the collector stored it". Check the collector's own exporter metrics after the first batch.

## The wire shape

One batch becomes one `resourceLogs` entry. The full field mapping is on [Event schema](/drains/event-schema#otlp-mapping); this is what it looks like on the wire.

```json theme={null}
{
  "resourceLogs": [
    {
      "schemaUrl": "https://docs.heylua.ai/schemas/logs/1.0",
      "resource": {
        "attributes": [
          { "key": "service.name", "value": { "stringValue": "support-agent" } },
          { "key": "service.namespace", "value": { "stringValue": "org_4f2c9a1b" } },
          { "key": "service.instance.id", "value": { "stringValue": "agent_1789214224176_2vta8rnyn" } },
          { "key": "deployment.environment.name", "value": { "stringValue": "production" } },
          { "key": "gen_ai.agent.id", "value": { "stringValue": "agent_1789214224176_2vta8rnyn" } },
          { "key": "cloud.region", "value": { "stringValue": "eu-west-1" } },
          { "key": "lua.org.id", "value": { "stringValue": "org_4f2c9a1b" } },
          { "key": "lua.agent.id", "value": { "stringValue": "agent_1789214224176_2vta8rnyn" } }
        ]
      },
      "scopeLogs": [
        {
          "scope": { "name": "lua.log-drain", "version": "1.0" },
          "logRecords": [
            {
              "timeUnixNano": "1789217997644000000",
              "observedTimeUnixNano": "1789217999102000000",
              "eventName": "lua.skill.error",
              "severityNumber": 17,
              "severityText": "ERROR",
              "body": { "stringValue": "Ticket lookup failed: upstream timeout" },
              "attributes": [
                { "key": "log.record.uid", "value": { "stringValue": "1789217997644-jtoulxxxq" } },
                { "key": "lua.source", "value": { "stringValue": "skill" } },
                { "key": "lua.primitive.name", "value": { "stringValue": "tickets" } },
                { "key": "gen_ai.tool.name", "value": { "stringValue": "lookup_tickets" } },
                { "key": "gen_ai.operation.name", "value": { "stringValue": "execute_tool" } },
                { "key": "lua.duration_ms", "value": { "intValue": "1841" } },
                { "key": "exception.type", "value": { "stringValue": "Error" } },
                { "key": "exception.message", "value": { "stringValue": "upstream timeout" } }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```

`records[].id` becomes the `log.record.uid` attribute. **Deduplicate on `log.record.uid`, never on `X-Lua-Batch-Id`** — delivery is at-least-once, and a retried record arrives under a new batch id.

### Caps and retry

| Cap                          | Value                                                |
| ---------------------------- | ---------------------------------------------------- |
| Records per batch            | 500                                                  |
| Uncompressed bytes per batch | 1 MiB                                                |
| Compression                  | `gzip`, always                                       |
| Request timeout              | 10 s                                                 |
| Retried status codes         | `429`, `502`, `503`, `504` — and nothing else        |
| Terminal                     | `400`, **`500`**, every other `4xx`, and every `3xx` |

OTLP's own specification is narrower than the generic HTTPS contract on purpose. A `400` from an OTLP endpoint means the payload was rejected, so retrying it cannot help and the batch is dropped and counted instead.

**`500` is terminal here too**, which is where OTLP differs most from the other destinations: a generic HTTPS, Datadog, or Better Stack drain retries a `500`, an OTLP drain does not. Only `429`, `502`, `503`, and `504` are retried. A collector that answers `500` under load therefore loses those batches rather than slowing delivery down — if yours does that, make it answer `503` while it sheds load.

A `200` carrying `partialSuccess.rejectedLogRecords` is treated as delivered and the attempt is marked `errorClass: "partial"`; the refused records are added to the drain's `health.rejectedCount24h`. A `partialSuccess` naming zero refused records is a warning, not a rejection, and leaves a plain `ok` attempt behind. Partial success is never retried — that is what the OTLP specification asks for, and re-sending would duplicate the records the collector *did* accept.

## Test it with curl

Prove the endpoint and the credential before you create the drain. This is the exact shape a delivery has, minus the gzip:

```bash theme={null}
ENDPOINT='https://otel.example.com/v1/logs'
TOKEN='<your collector token>'

curl -sS -i -X POST "$ENDPOINT" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "resourceLogs": [{
      "schemaUrl": "https://docs.heylua.ai/schemas/logs/1.0",
      "resource": { "attributes": [
        { "key": "service.name", "value": { "stringValue": "support-agent" } },
        { "key": "deployment.environment.name", "value": { "stringValue": "production" } },
        { "key": "lua.org.id", "value": { "stringValue": "org_4f2c9a1b" } },
        { "key": "lua.agent.id", "value": { "stringValue": "agent_1789214224176_2vta8rnyn" } }
      ]},
      "scopeLogs": [{
        "scope": { "name": "lua.log-drain", "version": "1.0" },
        "logRecords": [{
          "timeUnixNano": "1789217997644000000",
          "observedTimeUnixNano": "1789217999102000000",
          "eventName": "lua.drain.test",
          "severityNumber": 9,
          "severityText": "INFO",
          "body": { "stringValue": "Lua drain connectivity test" },
          "attributes": [
            { "key": "log.record.uid", "value": { "stringValue": "probe-0001" } },
            { "key": "lua.source", "value": { "stringValue": "drain" } }
          ]
        }]
      }]
    }]
  }'
```

A healthy collector answers `200 {"partialSuccess":{}}`. A `401` means the header name or value is wrong; a `404` usually means the path is missing `/v1/logs`.

Gzip it to test the real thing, since every delivery is compressed:

```bash theme={null}
curl -sS -i -X POST "$ENDPOINT" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Content-Encoding: gzip' \
  --data-binary @<(gzip -c payload.json)
```

## A collector to receive it

A ready-to-run collector configuration — OTLP/HTTP receiver with bearer auth, a batch processor, and an exporter — is on [OpenTelemetry Collector config](/drains/packs/otel-collector), together with the Loki and debug exporters to start from. The [Grafana dashboard](/drains/packs/grafana-dashboard) is built on what that pipeline produces.

## If it isn't working

<AccordionGroup>
  <Accordion title="404 on the test post">
    The endpoint needs the full logs path. `https://otel.example.com` is not enough: use `https://otel.example.com/v1/logs`. Nothing is appended to what you enter.
  </Accordion>

  <Accordion title="Everything is dropped with no retries">
    Your endpoint is answering `400` or `500`. Under the OTLP contract both are terminal — a `400` says the payload was rejected, so re-sending it cannot help, and `500` is outside the four statuses the specification lets a client retry. Check the collector's log for the reason; a strict downstream that refuses unknown attribute keys is the usual cause of a `400`, and the [additive-change policy](/drains/event-schema#versioning) means new attribute keys will keep appearing. For a collector that is merely overloaded, answer `503` instead of `500` and the batch is retried.
  </Accordion>

  <Accordion title="Records arrive but the service name is wrong">
    `service.name` is the agent's name and `service.instance.id` is its id. If a processor in your pipeline overwrites `service.name`, downstream grouping collapses every agent into one service. Nothing in the collector needs to set `service.name` for Lua records — it is already right.
  </Accordion>

  <Accordion title="partialSuccess reports rejected records">
    The batch counted as delivered and is not retried. `lua drains deliveries <id> --json` marks each such attempt `errorClass: "partial"`, and `lua drains status <id> --json` carries the running total under `health.rejectedCount24h`. A steady rejection rate is usually an attribute-limit or a body-size rule in the backend behind your collector.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="OpenTelemetry Collector config" href="/drains/packs/otel-collector">A receiver, processor, and exporter pipeline to paste.</Card>
  <Card title="Grafana dashboard" href="/drains/packs/grafana-dashboard">Panels over the fields this drain produces.</Card>
  <Card title="Event schema" href="/drains/event-schema#otlp-mapping">The full field-by-field OTLP mapping.</Card>
  <Card title="Delivery guarantees" href="/drains/delivery-guarantees">Retry, drops, heartbeat, and health states.</Card>
</Columns>
