Sending events with OTLP
The OpenTelemetry logs endpoint: exact path, JSON-only transport, which attributes map to which fields, and the one defect that should decide your choice.
If you already run an OpenTelemetry Collector, you already have a log pipeline, and pointing one more exporter at Qorionix is less work than deploying anything new. That is the entire case for this endpoint, and it is a good case. This article covers the exact contract, what maps to what, and the one honest reason you might choose the other endpoint instead.
The endpoint
POST /api/v1/siem/otlp/v1/logsNote the shape carefully. OpenTelemetry exporters conventionally append /v1/logs to whatever base you configure, so the value you put in your exporter configuration is the path without that suffix:
Exporter endpoint: https://<your-api-host>/api/v1/siem/otlp
Resulting request: POST https://<your-api-host>/api/v1/siem/otlp/v1/logsIf your exporter sends to the literal endpoint you configure without appending anything, configure the full path including /v1/logs. Getting this wrong produces a 404 from the router rather than an error from the handler, which is a useful way to tell the two mistakes apart.
Protocol: HTTP with JSON, and nothing else
This is the constraint most likely to catch you out, because it is not the default in most tooling.
- OTLP over HTTP only. gRPC is not supported. The reasoning is recorded in the source: gRPC transport was out of scope for the first version, so that the standard library HTTP server already in use could be kept and the OpenTelemetry protobuf dependency avoided. That is a legitimate trade, and it is also a real limitation if your fleet is standardised on gRPC.
- JSON encoding only. Protobuf over HTTP is not accepted either — the body is parsed as JSON. Most collectors default to protobuf when exporting over HTTP, so this usually needs an explicit setting.
exporters:
otlphttp/qorionix:
endpoint: https://<your-api-host>/api/v1/siem/otlp
encoding: json
headers:
Authorization: Bearer zqi_YOUR_TOKEN_HERE
service:
pipelines:
logs:
exporters: [otlphttp/qorionix]The option names vary between collector versions and between the collector and the language SDKs — check your own version's reference for the exact key that selects JSON encoding. The two facts that do not vary are the path and that the body must be JSON.
Authentication
The same ingest token and the same header as the HTTP Event Collector. Both prefixes are accepted, matched case-insensitively:
Authorization: Bearer zqi_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Authorization: Splunk zqi_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXOne difference matters operationally: OTLP traffic is not counted against the token's usage figures. The collector endpoint records events, bytes, last-used time and last-used address on every accepted request; this endpoint does not. A token used only for OTLP will show events_ingested of zero forever, which is not evidence that nothing arrived. Combined with the swallowed storage errors above, this means the token counters cannot be used to verify an OTLP pipeline at all. Search is the only check that works.
The rate limiter applies to this endpoint exactly as it does to the collector — 1,000 events per second steady and 2,000 burst per token per process — and it misreports a throttle as 401 invalid token here too. See Ingest tokens.
What maps to what
The handler reads a deliberately small part of the OTLP envelope: resourceLogs, each entry's resource.attributes, then scopeLogs and the logRecords within. Everything else in the envelope is ignored rather than rejected, so a payload with instrumentation scope detail or trace correlation identifiers is accepted — those fields simply do not survive into the stored event.
| OTLP location | Becomes |
|---|---|
logRecords[].timeUnixNano | The event timestamp. |
logRecords[].observedTimeUnixNano | The timestamp, when timeUnixNano is absent or zero. If both are missing, the time of arrival is used. |
logRecords[].severityNumber | Severity, mapped as in the next table. |
logRecords[].body | The raw event text. |
resource.attributes["host.name"] | The host. |
logRecords[].attributes["category"] | The event category. |
logRecords[].attributes["action"] | The action. |
logRecords[].attributes["outcome"] | The outcome — success or failure. |
logRecords[].attributes["user.name"] | The user. |
logRecords[].attributes["source.ip"] | The source address. |
logRecords[].attributes["destination.ip"] | The destination address. |
Every record attribute is also retained as a general field, so an attribute not in that list is not lost — it is simply not one of the named fields that searches and detection selections read directly. host.name is read from the resource attributes rather than the record attributes, which is where OpenTelemetry conventionally puts it; setting it per record will not populate the host.
The source name on every event from this endpoint is the fixed string otlp. It is not derived from the token's parser and it cannot be set per record. This matters because normalisation chooses its mapping from the source name, so an OTLP event is mapped generically regardless of what the payload actually describes. If you are shipping a specific, recognisable format — Microsoft audit records, CloudTrail, a Cisco firewall — you will get materially better field extraction by posting it to the collector endpoint with the matching parser instead.
| OTLP severityNumber | Stored severity |
|---|---|
| 21 and above (FATAL) | 10 |
| 17 to 20 (ERROR) | 8 |
| 13 to 16 (WARN) | 6 |
| 9 to 12 (INFO) | 4 |
| 5 to 8 (DEBUG) | 2 |
| 4 and below, or unset | 1 |
Responses
Success is 200, not the 202 the collector endpoint returns. The body is the OTLP partial-success envelope with nothing in it, which is the protocol's way of saying every record was taken.
HTTP/1.1 200 OK
Content-Type: application/json
{"partialSuccess":{}}405 Method Not Allowed method not allowed
401 Unauthorized missing bearer token
401 Unauthorized invalid token
400 Bad Request read failed
400 Bad Request empty OTLP payload
400 Bad Request <the JSON decoder's own message>There is no 503 in that list, and its absence is the defect described at the top of this article rather than evidence that storage cannot fail. Note also that a payload which is valid JSON but contains no resourceLogs is accepted with 200 and produces zero events — structurally indistinguishable, from the client's side, from a payload that produced thousands.
When to choose OTLP over the collector
The honest summary: choose OTLP when you already have an OpenTelemetry pipeline and the operational saving of not adding a second shipper outweighs the loss of delivery confirmation. Choose the collector endpoint in every other case.
| Property | HTTP Event Collector | OTLP |
|---|---|---|
| Tells you when storage failed | Yes — 503 | No — still 200 |
| Counts against token usage figures | Yes | No |
| Format-specific parsing | Yes, selected by the token | No — always generic, source is always otlp |
| Accepts plain text lines | Yes | No — JSON envelope only |
| Works with an existing OpenTelemetry Collector | Needs an extra exporter or shipper | Yes, one exporter block |
A reasonable middle path, if you are committed to OpenTelemetry: keep the OTLP exporter for volume and convenience, and additionally post your highest-value source — identity events, firewall denials — to the collector endpoint with the correct parser, so the events you will actually search on are properly mapped and confirmed stored.
What to read next
- Sending events with the HTTP Event Collector — the endpoint with delivery confirmation.
- Ingest tokens — the credential both endpoints share.
- Searching your logs — the only reliable way to verify an OTLP pipeline.