# OpenTelemetry (OTLP) ingest

ActivityLog accepts OpenTelemetry Protocol over HTTP. If you already have OTel instrumented — or you run an OpenTelemetry Collector — you can point the OTLP exporter at our endpoints and get the same `Message` rows you'd get from native ingest.

Two signal types are supported:

| Endpoint | What it accepts |
|---|---|
| `POST /api/v1/otlp/v1/logs` | OTLP log records |
| `POST /api/v1/otlp/v1/traces` | OTLP span batches |

OTLP metrics are **not** supported — see "What's not supported" at the bottom.

## Why OTLP over native

| Pick OTLP if... | Pick native if... |
|---|---|
| You already have OpenTelemetry instrumented | You're starting fresh and just want to push events |
| You want trace hierarchies (spans + child spans) | Single events are all you need |
| Your LLM SDK emits `gen_ai.*` attributes | You don't care about LLM cost analytics |
| You run an OpenTelemetry Collector | You don't want another moving part |

Both feed the same database. Mixing them in one tenant is fine and common (collector for app traces, native for one-off webhook receivers).

## Authentication

Bearer auth — same `al_…` token you'd use for native ingest.

```
Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN
```

OTel SDKs and the Collector both accept this on the `otlphttp` / `otlp` exporter via the `headers` config below. Mint the token in the portal as described in [`20-setup-system-tokens.md`](./20-setup-system-tokens.md).

## Content types

| Header | Format |
|---|---|
| `Content-Type: application/x-protobuf` | Binary protobuf — what most SDKs send by default. **Recommended.** |
| `Content-Type: application/json` | OTLP/JSON encoding — handy for debugging with `curl`. |

`Content-Encoding: gzip` is supported on both. The receiver decompresses transparently.

## OpenTelemetry Collector config

If you're already running a collector, add this exporter:

```yaml
exporters:
  otlphttp/activitylog:
    endpoint: https://api.activitylog.com/api/v1/otlp
    headers:
      Authorization: "Bearer al_REPLACE_WITH_YOUR_TOKEN"
    compression: gzip
```

Note the endpoint stops at `/otlp` — the collector appends `/v1/logs` and `/v1/traces` per signal.

Then route signals to it:

```yaml
service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/activitylog]
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/activitylog]
```

Run-of-the-mill collector config; nothing ActivityLog-specific.

## Direct SDK config (no collector)

### .NET (`OpenTelemetry.Exporter.OpenTelemetryProtocol`)

```csharp
builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("my-backend"))
    .WithLogging(opts => opts.AddOtlpExporter(o =>
    {
        o.Endpoint = new Uri("https://api.activitylog.com/api/v1/otlp/v1/logs");
        o.Protocol = OtlpExportProtocol.HttpProtobuf;
        o.Headers  = "Authorization=Bearer al_REPLACE_WITH_YOUR_TOKEN";
    }))
    .WithTracing(opts => opts.AddOtlpExporter(o =>
    {
        o.Endpoint = new Uri("https://api.activitylog.com/api/v1/otlp/v1/traces");
        o.Protocol = OtlpExportProtocol.HttpProtobuf;
        o.Headers  = "Authorization=Bearer al_REPLACE_WITH_YOUR_TOKEN";
    }));
```

### Python (`opentelemetry-exporter-otlp-proto-http`)

```python
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

log_exporter = OTLPLogExporter(
    endpoint="https://api.activitylog.com/api/v1/otlp/v1/logs",
    headers={"Authorization": "Bearer al_REPLACE_WITH_YOUR_TOKEN"},
)

trace_exporter = OTLPSpanExporter(
    endpoint="https://api.activitylog.com/api/v1/otlp/v1/traces",
    headers={"Authorization": "Bearer al_REPLACE_WITH_YOUR_TOKEN"},
)
```

### Node.js (`@opentelemetry/exporter-trace-otlp-http`)

```javascript
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');

const traceExporter = new OTLPTraceExporter({
  url: 'https://api.activitylog.com/api/v1/otlp/v1/traces',
  headers: { Authorization: 'Bearer al_REPLACE_WITH_YOUR_TOKEN' },
});
```

## How OTLP maps to `Message` rows

Each `LogRecord` or `Span` becomes one `Message` row. Resource attributes inherit down to every record in the batch.

| OTLP field | `Message` column |
|---|---|
| `LogRecord.body` / `Span.name` | `body` / `type` |
| `LogRecord.severity_number` | mapped to `level` (`debug`/`info`/`warn`/`error`) |
| `LogRecord.attributes[]` | `metadata` |
| `LogRecord.trace_id` / `Span.trace_id` | `traceId` (first-party column) |
| `Span.span_id` | `spanId` (first-party column) |
| `Span.parent_span_id` | resolved to `parentMessageId` |
| `Span.start_time_unix_nano` | `timestamp` |
| `Span.end_time_unix_nano` | `endTimestamp` (sets `durationMs`) |
| `Resource.service.name` | `serviceName` (first-party column, indexed) |

Full mapping in [`../Telemetry-Ingest.md`](../Telemetry-Ingest.md) § 2.2 (logs) and § 3.2 (spans).

### Routing multiple services to multiple systems

By default, all records ingested under a single token land in the system the token belongs to.

To split, set `resource.attributes."service.name"` per record. If `service.name` matches the name of another system **already provisioned under the same tenant**, the record is routed there. Lets one collector multiplex many services into many systems with one token.

## LLM telemetry — the `gen_ai.*` promotion rule

OpenTelemetry has a `gen_ai.*` semantic convention for LLM activity. When ActivityLog sees these attributes on a span, it promotes them to **first-party columns** for fast aggregation:

| OTLP attribute | First-party column |
|---|---|
| `gen_ai.system` | `llmSystem` (e.g. `"anthropic"`, `"openai"`) |
| `gen_ai.request.model` | `model` |
| `gen_ai.usage.input_tokens` | `inputTokens` |
| `gen_ai.usage.output_tokens` | `outputTokens` |
| `gen_ai.usage.cache_read_input_tokens` | `cacheReadTokens` |
| `gen_ai.usage.cache_creation_input_tokens` | `cacheCreateTokens` |
| `gen_ai.usage.cost` | `costMicrocents` (value × 10⁶) |

This lets you query "total cost by model last week" without a JSON path scan. The `gen_ai.prompt` / `gen_ai.completion` attributes stay in the message body — Enterprise customers can keep these in full content mode for compliance/replay; Pro and Free tiers either omit them or store them up to the body cap.

The full mapping (with the legacy `prompt_tokens`/`completion_tokens` aliases for older SDK versions) is documented in [`../Telemetry-Ingest.md`](../Telemetry-Ingest.md) § 3.3.

## Body size and overflow

OTLP bodies — especially `gen_ai.prompt` and `gen_ai.completion` — frequently exceed the tier inline cap. Behavior:

| Tier | Inline cap | Overflow |
|---|---|---|
| Free | 16 KB | Truncated; warning in `PartialSuccess.error_message` |
| Pro | 256 KB | Truncated; warning in `PartialSuccess.error_message` |
| Enterprise | 256 KB inline + 16 MB body-store overflow | Bodies > 256 KB go to Azure Blob, transparently rehydrated on read |

Body-store overflow is documented in [`../Body-Store.md`](../Body-Store.md).

## Response codes

OTLP responses are protobuf or JSON `ExportXxxServiceResponse` envelopes. The HTTP status tells you the big picture:

| HTTP | Meaning |
|---|---|
| 200 | All records accepted, or partial success (rejected count in `partial_success.rejected_log_records`) |
| 400 | Malformed protobuf / JSON / unsupported `Content-Type` |
| 401 | Missing or invalid auth |
| 413 | Batch too large (>16 MB decompressed, or > 10 000 records) |
| 415 | Unsupported `Content-Encoding` (use gzip or none) |
| 429 | Tenant rate-limit hit — `Retry-After` header tells you when |
| 500 | Server fault — quote the `X-Correlation-Id` to support |

## Limits

| Limit | Default |
|---|---|
| Request body, post-decompression | 16 MB |
| Records per batch | 10,000 |
| Attribute key length | 256 bytes |
| Attribute value size | 64 KB (truncated above) |
| Resource attributes per record | 256 |
| Record attributes | 128 |
| Events per span | 128 |
| Links per span | 32 |

## What's not supported

| Feature | Status |
|---|---|
| OTLP-metrics | **Not supported.** Aggregation infra is a separate problem; deferred until customer demand. |
| gRPC transport | **Deferred.** HTTP/Protobuf covers the SDK defaults; gRPC variant tracked as a future ticket. |
| Streaming/long-poll generations | Maps to duration-message lifecycle (open on `generation-create`, close on usage finalize). |
| Self-hosting / on-prem collector | OTLP receivers run inside the SaaS API. We don't ship a downloadable collector. |

## Verify your wiring

After configuring the exporter, send one trace and check it lands:

```bash
# This snippet sends a single OTLP-JSON span.
curl -X POST https://api.activitylog.com/api/v1/otlp/v1/traces \
  -H "Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceSpans": [{
      "resource": { "attributes": [{ "key": "service.name", "value": { "stringValue": "smoke-test" } }] },
      "scopeSpans": [{
        "spans": [{
          "traceId":           "5b8aa5a2d2c872e8321cf37308d69df2",
          "spanId":            "051581bf3cb55c13",
          "name":              "smoke.test",
          "kind":              1,
          "startTimeUnixNano": "1730812800000000000",
          "endTimeUnixNano":   "1730812800100000000"
        }]
      }]
    }]
  }'
```

Expected: HTTP 200 with an empty `ExportTraceServiceResponse`. Then check the portal Messages view — your span should appear with `type: "smoke.test"` and `serviceName: "smoke-test"`.

## What's next

| Goal | Doc |
|---|---|
| The exact OTLP→`Message` mapping | [`../Telemetry-Ingest.md`](../Telemetry-Ingest.md) |
| Body-store overflow for large LLM payloads | [`../Body-Store.md`](../Body-Store.md) |
| Querying spans back | [`60-query-api.md`](./60-query-api.md) |
| Langfuse-shape ingest instead | [`52-ingest-langfuse.md`](./52-ingest-langfuse.md) |
| Wire Claude Code | [`72-integrations-claude-code.md`](./72-integrations-claude-code.md) |
