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 |