Docs menu

From the open engine’s docs (traza 0.28.0). View source Engine routes reach a Traza Cloud project through the gateway.

Ingest

Traza accepts spans on two routes. They write to the same store, honour the same (trace_id, span_id) primary key, and return under the same durability contract. The only difference is the wire format.

Route Format Use it when
POST /v1/spans Traza’s native JSON You control the producer and want the smallest possible client
POST /v1/traces OTLP/HTTP, protobuf or JSON You are pointing an OpenTelemetry SDK, Collector, or any OTLP exporter at Traza

Both are gated by the same authentication and both need the rw scope when authentication is configured.

Which one should I use?

Use POST /v1/traces if you already have OpenTelemetry instrumentation. That is the whole point of it: two environment variables and your existing SDK exports to Traza with nothing else to write. You keep OTel’s batching, retry, and sampling.

Use POST /v1/spans if you are emitting spans yourself. The body is a plain JSON array, so a producer is a few lines in any language and needs no OTel dependency. It is also the route the seed tool, the benchmarks, and most of the test suite use.

There is no functional reason to prefer one over the other once a span is stored: an OTLP span and a native span with the same fields are the same record.

POST /v1/spans — native JSON

The body is either a bare array or an object with a spans key. The first non-whitespace byte decides which, so there is no ambiguity and no fallback parse.

curl -X POST http://localhost:8080/v1/spans \
  -H 'Content-Type: application/json' \
  -d '[{"trace_id":"trace-1","span_id":"span-1","name":"charge","service":"checkout",
        "start_time_unix_nano":1700000000000000000,
        "end_time_unix_nano":1700000000002500000,
        "status":"ok","attributes":{"region":"us-east"}}]'
{"accepted":1,"durability":"wal"}

The equivalent envelope form:

curl -X POST http://localhost:8080/v1/spans \
  -H 'Content-Type: application/json' \
  -d '{"spans":[{"trace_id":"trace-1","span_id":"span-1","name":"charge","service":"checkout",
                 "start_time_ns":1700000000000000000,"end_time_ns":1700000000002500000}]}'

accepted counts the spans in the batch. durability echoes what the acknowledgement guarantees — buffered, wal, or flushed. See durability.

Validation

A batch is atomic with respect to validation: if any span is invalid, nothing from the batch is stored.

Condition Response
Body is neither an array nor {"spans": …} 400 {"error":"body must be an array or {spans: [...]}"}
A required field is missing 400 {"error":"missing field \name` at line 1 column 31”}`
trace_id is empty 400 {"error":"span 0: trace_id is empty"}
span_id is empty 400 {"error":"span 0: span_id is empty"}
An admission cap is exceeded (too many attributes or events, an oversized key or span, too many spans) 400 naming the cap and the span, e.g. span 0 (trace t1, span s1): 1500 attributes exceeds the max-span-attributes cap of 1024
The tenant’s monthly quota or ingest pause refuses the batch 429 with Retry-After; see quota refusals
The store could not accept the write 503 — retry with backoff

The index in the error message is the span’s position in the batch.

Batching

Batching is the single largest lever on ingest throughput, because the per-batch costs — the writer lock, the log frame, the fsync — are paid once per request rather than once per span. The benchmarks use 1,000 spans per request. Bodies are capped at 64 MiB.

Connections are persistent (HTTP/1.1 keep-alive) by default, so a client sending many batches should reuse one connection rather than reconnecting per request.

POST /v1/traces — OTLP/HTTP

Point any OpenTelemetry SDK at Traza:

export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:8080
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf   # or http/json
export OTEL_EXPORTER_OTLP_COMPRESSION=gzip         # or none

Content-Encoding: gzip is decoded on both ingest routes, with the decompressed body held to the same 64 MiB cap as an uncompressed one; see compressed bodies.

The route accepts an ExportTraceServiceRequest. Content-Type: application/x-protobuf selects the binary decoder; anything else is parsed as OTLP/HTTP JSON. The protobuf decoder lowers its input to exactly the JSON shape the JSON decoder expects and then shares the same mapping — one mapping, two encodings, so every conformance behaviour applies to both.

gRPC is not served. Use the http/protobuf exporter setting, which every OTel SDK supports.

A JSON request:

curl -X POST http://localhost:8080/v1/traces \
  -H 'Content-Type: application/json' \
  -d '{"resourceSpans":[{
        "resource":{"attributes":[{"key":"service.name","value":{"stringValue":"otlp-demo"}}]},
        "scopeSpans":[{"spans":[{
          "traceId":"5b8efff798038103d269b633813fc60c",
          "spanId":"eee19b7ec3c1b174",
          "name":"GET /orders",
          "startTimeUnixNano":"1700000002000000000",
          "endTimeUnixNano":"1700000002050000000",
          "attributes":[{"key":"http.status_code","value":{"intValue":"200"}}]
        }]}]}]}'
{"partialSuccess":{}}

Reading it back shows the mapping:

curl http://localhost:8080/v1/traces/5b8efff798038103d269b633813fc60c
{"annotations":[],"spans":[{"attributes":{"http.status_code":200},"end_time_ns":1700000002050000000,"events":[],"name":"GET /orders","parent_span_id":null,"service":"otlp-demo","span_id":"eee19b7ec3c1b174","start_time_ns":1700000002000000000,"status":"","trace_id":"5b8efff798038103d269b633813fc60c"}],"trace_id":"5b8efff798038103d269b633813fc60c"}

A successful protobuf request answers 200 with Content-Type: application/x-protobuf and a zero-length body, which is the encoding of an empty ExportTraceServiceResponse. A successful JSON request answers 200 with {"partialSuccess":{}}.

OTLP mapping

OTLP Traza span
traceId / spanId / parentSpanId Lowercased hex string. Non-hex is a 400
startTimeUnixNano / endTimeUnixNano start_time_ns / end_time_ns. Accepted as a JSON string (OTLP JSON’s u64 encoding) or a number
Resource attribute service.name service
Resource attribute traza.tenant The span’s tenant, validated like any client-supplied one
Resource attributes service.version, deployment.environment.name, deployment.environment, telemetry.sdk.name, telemetry.sdk.version Copied onto every span of the resource as span attributes, beneath scope and span attributes (see below)
Every other resource attribute Dropped — see below
Scope attributes Merged beneath span attributes, which take precedence on a collision
attributes (AnyValue) Flattened to plain JSON: intValue becomes a number, doubleValue a double, stringValue a string
status.code STATUS_CODE_OK / 1 status: "ok"
status.code STATUS_CODE_ERROR / 2 status: "error"
Any other or absent status status: ""
events Span events, attributes flattened the same way
links Span links, ids lowercased

A link whose id pair is empty is dropped rather than treated as fatal — a link to nothing is meaningless, and rejecting a whole batch for it would be worse.

Which resource attributes reach the span

service.name becomes the span’s service, and traza.tenant becomes its tenant.

Five more say which build produced the span: service.version, deployment.environment.name, deployment.environment, telemetry.sdk.name and telemetry.sdk.version. These are copied onto every span of their resource as ordinary span attributes, so they are stored, searchable and read by the change radar. A copied attribute sits beneath the scope’s and the span’s own attributes: a span that already carries one of these keys keeps its value. Only scalar values (string, number or boolean) are copied, and a string only up to 256 bytes. Each adds a few bytes to every span. The admission caps do not count these five attributes, so a span admitted at --max-span-attributes or --max-span-bytes before the copy is still admitted after it. That holds for native ingest too: a span may carry these five, in that shape, beyond its caps.

The rest are discarded. service.instance.id, host.name, k8s.*, telemetry.sdk.language and every other resource attribute are not stored and cannot be filtered on. Scope attributes are different: those are merged beneath the span’s own.

If you need a resource attribute to be queryable, copy it onto spans — an SDK span processor, or an OpenTelemetry Collector transform processor moving resource.attributes[...] to attributes[...]. Span attributes round-trip faithfully and are indexed like any other.

Before v0.28 this page said other resource attributes were “merged beneath span attributes”. They never were. The five build attributes above are the first to reach the span, and only those five do.

span.kind has no dedicated Traza field. If you need it queryable, carry it as an attribute.

Error handling

A body that does not decode — malformed protobuf, malformed JSON, a non-hex id, a timestamp that is not a u64, a damaged gzip body — is a 400 with the reason, and so is an exceeded admission cap. A quota or ingest-pause refusal is a 429 with Retry-After, which OTLP exporters honour by retrying later. A store failure is a 503. There is no partial acceptance: partialSuccess is always empty because a batch either lands entirely or is rejected entirely.

LLM and agent telemetry

Both routes preserve gen_ai.*, llm.*, and traceloop.* attributes verbatim, and Traza’s derived views read them directly. An app instrumented with OpenLLMetry or the OpenTelemetry GenAI conventions populates sessions, provider and model rollups, and token/cost analytics with no attribute renaming on your side.

The recognized keys, their precedence, and query recipes are in LLM semantics.

Making writes durable

POST /v1/flush seals everything currently buffered into a segment:

curl -X POST http://localhost:8080/v1/flush
{"flushed":true}

You rarely need this. Flushing happens automatically at --flush-spans (10,000 by default), and in the default wal mode an acknowledged write is already fsynced to the log before the response is sent — it survives a crash whether or not it has been sealed. POST /v1/flush is useful in tests, before a planned shutdown, or to force segment statistics to appear.

Using the engine directly

The engine is a library; a process that generates its own spans can skip HTTP entirely and get the same durability:

use traza::{Config, Span, SpanFilter, Store};

let store = Store::open("./data", Config::default())?;

store.ingest(Span {
    trace_id: "trace-1".into(),
    span_id: "span-1".into(),
    parent_span_id: None,
    name: "charge".into(),
    service: "checkout".into(),
    start_time_ns: 1_700_000_000_000_000_000,
    end_time_ns: 1_700_000_000_002_500_000,
    status: "ok".into(),
    attributes: Default::default(),
    events: Vec::new(),
    links: Vec::new(),
    extra: Default::default(),
})?;

store.flush()?;   // or let it flush automatically at the threshold

let slow = store.query(&SpanFilter {
    service: Some("checkout".into()),
    min_duration_ns: Some(2_000_000),
    ..SpanFilter::default()
})?;

Store::ingest_batch takes a Vec<Span> and is the batched form. A data directory has exactly one writer, so an embedding process must not also run a server against the same directory.