Skip to main content

Ingest traces

Endpoint: POST /traces/ingest

Description

An OpenTelemetry (OTLP/HTTP) collector endpoint. Point an OTLP trace exporter at this route and Aegis decodes the payload, validates it, and stores the traces and spans under your account. It responds with the standard OTLP ExportTraceServiceResponse.

You do not send this payload by hand — an OpenTelemetry SDK or the OpenTelemetry Collector produces it for you. Configure your exporter's OTLP/HTTP traces endpoint to https://api.aegisevals.ai/api/v1/traces/ingest and set the Authorization header to your API key.

Ingestion is not billed — storing traces does not consume account credits.

Sharing and ownership

Ownership is taken from the API key, never from the OTLP payload. You cannot set a user, organization, project, or data collection on the request body.

  • Project-scoped API key: traces are stored as org-owned and filed under that key's project in the same request. Later reads with that key only see traces in that project.
  • Private key of an organization member, or a solo-user key: traces stay personal — not org-shared, and not assigned to a project.

Ingestion never attaches a data collection (data_collection_id is always null). Each stored trace records author_email — a snapshot of the API key holder's email at insert time. It is not updated if the account email changes later; it appears on subsequent GET /traces reads, not on the ingest response. See API key scoping.

Idempotency

Ingestion is idempotent on OTLP identity. A retried payload that Aegis already holds returns 200 {} and does not duplicate spans. If a later payload adds new spans to an already-stored OTLP traceId, those spans are stored and the trace summary (timing, span/error counts, tokens, cost, input/output) is recomputed over the union of spans.

Late-arriving spans follow the trace's current filing, not the caller's original tenant at first ingest. When a trace was previously shared or filed into a collection, new spans copy that trace's org_id and data_collection_id. A personal exporter whose trace was shared attaches to their own org-shared row for the same wire id only while they still belong to that organization; after leaving, a new personal export of the same wire id creates a separate personal trace. Org-scoped ingest does not attach to a personal copy from another caller.

Individual spans that cannot be stored (for example malformed or missing ids or clocks, end before start, or token counts out of range) are dropped and reported back via partialSuccess rather than failing the whole request. Those rejected spans are permanent drops, not transient failures — they are rejected the same way on resend, so exporters must not retry them. A payload where every span is unpersistable is still 200 with partialSuccess and nothing stored. A payload that carries no spans at all (an empty Protobuf body, {}, or "resourceSpans": []) is not an error either: it returns 200 {} and stores nothing.

Cost fields

cost_usd is the LLM spend the exporter reported (llm.cost.total). estimated_cost_usd is derived from token counts plus the model catalog, and only when no cost was reported. A span contributes to exactly one of the two; they do not double-count.

Parameters

None (no path or query parameters). Behaviour is driven entirely by request headers.

  • Content-Type — one of:
    • application/x-protobuf — binary OTLP Protobuf (the OTLP SDK default). Also assumed when the header is absent.
    • application/json — OTLP/JSON.
  • Content-Encoding: gzip — optional. When set, the request body is transparently decompressed. Uncompressed bodies are also accepted.
  • Content-Length — if present and larger than 4 MiB, the request is rejected with 413 before the body is read.

Request body

A raw OTLP ExportTraceServiceRequest payload — either binary Protobuf or OTLP/JSON. You normally don't build this yourself; an OpenTelemetry exporter emits it.

OTLP/JSON requirements:

  • Must be a JSON object (a top-level array, scalar, or null is rejected).
  • Field names must use camelCase (for example resourceSpans, scopeSpans, traceId, spanId, parentSpanId, startTimeUnixNano). snake_case is rejected.
  • traceId, spanId, and parentSpanId must be hex strings (not base64).

OTLP/JSON sketch:

{
"resourceSpans": [
{
"resource": {
"attributes": [
{ "key": "service.name", "value": { "stringValue": "my-service" } }
]
},
"scopeSpans": [
{
"scope": { "name": "my-instrumentation", "version": "1.0.0" },
"spans": [
{
"traceId": "<32 hex chars>",
"spanId": "<16 hex chars>",
"parentSpanId": "<16 hex chars or omitted>",
"name": "workflow step",
"kind": "SPAN_KIND_INTERNAL",
"startTimeUnixNano": "1735732800000000000",
"endTimeUnixNano": "1735732800123000000",
"attributes": [
{ "key": "my.attr", "value": { "stringValue": "value" } }
],
"status": { "code": "STATUS_CODE_OK" }
}
]
}
]
}
]
}

Size limits:

  • Compressed body: up to 4 MiB.
  • Decompressed body: up to 16 MiB.

Oversize payloads are a permanent client error — do not retry them.

Error responses

Errors use the standard detail shape. Exact detail strings:

  • 400 — Malformed or invalid payload:
    • "The OTLP trace payload could not be decoded." — malformed Protobuf, JSON, or gzip.
    • "The OTLP trace payload is not a valid trace export." — decoded but not an OTLP trace export (unknown fields, wrong types).
    • "The OTLP trace payload must be a JSON object." — a JSON array, scalar, or null.
    • "OTLP/JSON field names must be camelCase; received '<key>'." — snake_case field name.
    • "A trace with this id has already been ingested." / "One or more spans in this payload have already been ingested." — a concurrent duplicate of the same export. Ordinary retries of the same payload usually return 200 {} because duplicates are skipped.
    • "A value in this payload is out of the storable range."
    • "Database integrity error." — other integrity failures.
  • 403 — "This resource is assigned to a different project than this API key." — the same OTLP trace is already stored under a different project than this project-scoped key.
  • 409 — "Your organization membership changed while processing this request. Please refresh and try again."
  • 413 — Payload too large:
    • "The OTLP trace payload exceeds the maximum accepted size." — declared or actual compressed size over 4 MiB.
    • "The decompressed OTLP trace payload exceeds the maximum accepted size." — decompressed body over 16 MiB (for example a gzip bomb).
  • 415 — "Unsupported OTLP content type: <media_type>." — Content-Type is neither Protobuf nor JSON.
  • 500 — "An error occurred while ingesting traces." / "An error occurred while storing traces." — retryable; exporters should retry on 5xx.

Responses

  • 200 — An OTLP ExportTraceServiceResponse. This is a collector response, not an Aegis resource body. Fields are camelCase, and null / unset fields are omitted.
    • Full success (every persistable span stored, including a retry of an already-stored export) → an empty object {}. The partialSuccess field is omitted (not null).
    • Partial success → the batch was accepted, but some spans could not be stored. The response reports how many were rejected and why. Rejected spans are permanent drops — the exporter must not retry a 200.

Example response (200) — full success

{}

Example response (200) — partial success

{
"partialSuccess": {
"rejectedSpans": 1,
"errorMessage": "Rejected unpersistable spans: missing start or end timestamp."
}
}

curl

Most callers configure an OpenTelemetry exporter rather than calling this route directly. To send a pre-serialized OTLP Protobuf export from a file:

curl -X POST "https://api.aegisevals.ai/api/v1/traces/ingest" \
-H "Authorization: Bearer sk_00000000000000000000000000000000" \
-H "Content-Type: application/x-protobuf" \
--data-binary @trace-export.pb

Sending gzip-compressed OTLP/JSON:

gzip -c trace-export.json | curl -X POST "https://api.aegisevals.ai/api/v1/traces/ingest" \
-H "Authorization: Bearer sk_00000000000000000000000000000000" \
-H "Content-Type: application/json" \
-H "Content-Encoding: gzip" \
--data-binary @-