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.

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:

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:

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)

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)

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)

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

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:

# 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
Body-store overflow for large LLM payloads ../Body-Store.md
Querying spans back 60-query-api.md
Langfuse-shape ingest instead 52-ingest-langfuse.md
Wire Claude Code 72-integrations-claude-code.md