ObservabilityXLV · Tempo ArchitectureTempoArchitecture
The Distributor
What you'll learn
- Explain the distributor's role in Tempo's write path and its position in front of the ingester ring
- Configure per-tenant ingestion rate limits and ingesters-per-tenant limits in a Tempo deployment
- Diagnose the high-frequency distributor failure modes (rate limiting, ring health, receiver wiring)
- Validate a Tempo distributor is accepting OTLP, Jaeger, and Zipkin traffic and forwarding to ingesters
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A team pushes a new SDK release that over-instruments spans by an
order of magnitude. Within minutes the Tempo distributor starts
returning 429. The on-call engineer opens Grafana, sees a single
tenant called team-checkout consuming every allowed byte per
second, and rate-limits that tenant. The rest of the platform
keeps accepting traces. Without the distributor, that single
runaway service would have either crashed the cluster or filled
the ingester disk.
This lesson describes the Tempo distributor: where it sits, what it validates, how it rate-limits, and how it forwards spans to the ingester ring.
What it is
The Tempo distributor is a stateless service that fronts the ingester pool. Its job is to receive spans over OTLP, Jaeger, or Zipkin, validate them, enforce per-tenant ingestion limits, and forward the spans to the correct ingester using a consistent hash on the trace ID.
The distributor is the only component of Tempo that talks directly to clients. It is also the only component that enforces ingestion policy. An ingester that accepts the same span directly would have no rate-limit protection.
Why a sysadmin cares
The distributor is the buffer between untrusted upstream telemetry and the stateful ingesters. Three operational concerns make it production-critical:
- A bad client can overwhelm the cluster. A single SDK release that double-emits spans, a collector that loops, or a misconfigured sampling policy can push more spans per second than the ingester pool can drain. Without per-tenant limits, one team takes the cluster down for everyone.
- A bad client can poison the data. Traces without a trace ID, batches with timestamps from the year 2030, payloads with ten-megabyte attribute values. The distributor validates these before they cost the ingester CPU.
- A bad client can probe the service. The OTLP, Jaeger, and Zipkin receivers all accept unauthenticated traffic by default. Anything that can reach those ports can send spans. Treating the receiver ports as service-internal is part of the security posture.
How it works
The distributor is a single pipeline of four stages:
Client / Collector
|
v
+------------------+
| Receivers | OTLP gRPC/HTTP, Jaeger thrift/gRPC, Zipkin
+--------+---------+
|
v
+------------------+
| Validation | Trace ID present, span name, timestamp sane,
+--------+---------+ max span size
|
v
+------------------+
| Rate Limiting | Per-tenant ingestion bytes/sec,
+--------+---------+ spans/sec, max traces/sec
|
v
+------------------+
| Fan-out | hash(trace_id) → ingester in the ring
+--------+---------+
|
v
Ingester pool
Each stage maps to a config block. Receivers are under
distributor.receivers.*. Validation rules are under
distributor.receivers.<protocol>.max_recv_msg_size and a
global limit at distributor.global.max_idle_connections. Rate
limiting
is under distributor.limits and per-tenant overrides under
distributor.limits_overrides. The fan-out uses the ingester
ring configured at ingester.lifecycler.ring.
How to configure it
A production distributor config defines the receivers, the global limits, and a per-tenant override for the noisiest tenant:
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: '0.0.0.0:4317'
max_recv_msg_size: 16777216 # 16 MiB; OTLP default is 4 MiB
http:
endpoint: '0.0.0.0:4318'
jaeger:
protocols:
grpc:
endpoint: '0.0.0.0:14250'
thrift_http:
endpoint: '0.0.0.0:14268'
zipkin:
endpoint: '0.0.0.0:9411'
# Global ingestion rate limits. The cluster-wide cap. Per-tenant
# overrides go under overrides below.
global:
ingestion_rate_limit_bytes: 26214400 # 25 MiB/s
ingestion_burst_size_bytes: 36700160 # 35 MiB burst
max_traces_per_user: 50000
max_global_traces_per_user: 0 # 0 = disabled
# Per-tenant overrides. Each tenant can have its own byte and span
# budget. Use it to cap the noisiest tenant without affecting others.
overrides:
tenant_id: team-checkout
ingestion_rate_limit_bytes: 5242880 # 5 MiB/s
ingestion_burst_size_bytes: 8388608 # 8 MiB burst
max_traces_per_user: 10000
Two production details to call out:
max_recv_msg_size: 16777216lets OTLP batches up to 16 MiB through. The OTLP default is 4 MiB and rejects anything larger withResourceExhausted. Bump it when clients legitimately batch large traces.- The per-tenant
overrides:block keys on theX-Scope-OrgIDheader the client sends. The Alloy or collector pipeline must stamp that header for the override to apply. Without it the tenant name isanonymousand gets the global limits.
How to validate it
Five checks confirm the distributor is doing its job:
- Confirm the process is up and accepting the configured receivers:
curl -s http://tempo.internal:3200/distributor/ready
# ready
curl -s http://tempo.internal:3200/status | jq '.distributor'
- Confirm spans are arriving. The most reliable signal is the per-tenant span counter:
curl -s http://tempo.internal:3200/metrics \
| grep tempo_distributor_spans_received_total | head
# tempo_distributor_spans_received_total{tenant="team-checkout"} 4821
-
Confirm the fan-out reaches the ingesters. A successful write shows up in the distributor side as
tempo_distributor_bytes_received_totaland on the ingester side astempo_ingester_bytes_received_total. The two counters should move in lockstep under steady load. -
Confirm a rate limit is actually enforced. Send a flood and watch the
429counter:
# Quickly push many OTLP spans using otel-cli in a loop
for i in $(seq 1 5000); do
otel-cli span export --endpoint tempo.internal:4317 \
--service flood --name probe --attrs i=$i >/dev/null 2>&1
done
curl -s http://tempo.internal:3200/metrics \
| grep tempo_distributor_ingester_append_failures_total
# tempo_distributor_ingester_append_failures_total{ingester="..."} 1
The non-zero append_failures_total is the rate limiter at work.
- Confirm TraceQL can find the traces the distributor accepted:
curl -sG http://tempo.internal:3200/api/search \
--data-urlencode 'q={ resource.service.name = "flood" }' \
--data-urlencode 'limit=5' | jq '.traces | length'
How it can fail
Six shapes appear repeatedly:
- Per-tenant rate limit misconfigured. A tenant that should
have a 10 MiB/s limit is given the global cap. Under load the
tenant’s spans are dropped. Symptom is the
tempo_distributor_ingester_append_failures_totalcounter rising for that tenant. - Wrong header for tenant identification. Alloy stamps
X-Scope-OrgIDdifferently from what the distributor expects. The per-tenant override never matches; every tenant is treated asanonymousand gets the global limits. Symptom istempo_distributor_spans_received_total{tenant="anonymous"}high. - Receiver not bound. The distributor config has
distributor.receivers.otlp.protocols.grpc.endpointbut the bind fails because the port is already in use. Symptom is the process exiting on startup ortempo_distributor_reachablereturningfalsefor the missing protocol. - OTLP batch size too small. A client sends 32 MiB OTLP
batches; the distributor rejects them with
ResourceExhausted. Symptom istempo_distributor_dropped_spans_totalrising alongside themax_recv_msg_sizedefault. - Ring unhealthy. The distributor cannot reach the KV
store. The fan-out cannot find an ingester; writes are
rejected with
503. Symptom istempo_distributor_rings_healthyreturning0. - Auth disabled.
auth_enabled: falsemeans anyone who can reach the receiver ports can submit spans. Symptom is a unexpected spike intempo_distributor_spans_received_totalfrom an unknown source.
How to troubleshoot it
The diagnostic order:
- Is the distributor process up? Check pod status, systemd
unit, or process listing. A crashed distributor shows up as
connection refusedfrom the client. - Is the receiver listening? Use
ss -tlnpornetstatto confirm the OTLP, Jaeger, and Zipkin ports are bound. A config error leaves the port unbound silently if the process treats it as non-fatal. - Is the distributor reaching the KV store? The
tempo_distributor_rings_healthycounter reports1when the ring is reachable. If it is0, the distributor cannot route spans. - Are spans arriving? The
tempo_distributor_spans_received_totalcounter is the ground truth. If it is flat, the problem is on the client side, not the distributor. - Is a tenant being rate-limited? The
tempo_distributor_ingester_append_failures_totalcounter breaks down by tenant. A tenant with rising failures is hitting its limit. - Is the limit configured correctly? Diff the YAML on disk against the running config. Tempo logs the effective config on startup; cross-reference it.
Security implications
The distributor is the largest attack surface in Tempo. Three controls matter:
- Authenticate the receivers. Tempo does not implement authentication itself. Put the OTLP / Jaeger / Zipkin ports behind a reverse proxy that enforces JWT or mTLS, or run them on a private network where only the collectors can reach them.
- Validate tenant identity. The
X-Scope-OrgIDheader is the tenant identifier. An attacker who can spoof the header can bill spans to another tenant or evade per-tenant limits. Strip the header at the reverse proxy and re-add it after authentication. - Limit the maximum message size. A 16 MiB OTLP batch is
large but not abusive. A 1 GiB batch is a denial of service.
Set
max_recv_msg_sizeto the largest legitimate batch and no larger.
Performance implications
The distributor is CPU-bound on protocol decoding and ring lookups:
- CPU. OTLP over HTTP is JSON-decoded per span. A sustained 20 MiB/s of OTLP traffic consumes roughly one CPU core per distributor pod on a modern x86.
- Memory. The per-tenant rate-limiter stores a small token bucket per tenant. A thousand tenants consume a few hundred kilobytes.
- Network. The distributor fans out to
replication_factoringesters. A 25 MiB/s ingest becomes a 75 MiB/s internal stream atreplication_factor: 3. The in-cluster network must absorb this. - Hot tenants. A single noisy tenant can saturate a distributor. Per-tenant rate limits spread the load; they do not eliminate it.
Production guidance
- Run at least two distributor pods behind a load balancer. One pod is a single point of failure; two are not.
- Set
max_recv_msg_sizebased on the largest legitimate OTLP batch, not on a guess. 16 MiB is a reasonable starting point. - Define per-tenant overrides for every team that emits more than 10% of cluster volume.
- Do not expose the OTLP, Jaeger, or Zipkin ports on a public network. Treat them as service-internal.
Verification
You should now be able to answer:
- What is the distributor’s role in the Tempo write path?
- What four stages does every span pass through before it reaches an ingester?
- How is the per-tenant rate limit keyed?
- What happens when a tenant exceeds its rate limit?
- Why must the OTLP and Jaeger receiver ports never be on a public network?
Quiz
Knowledge check · 8 questions
Q1. Which Tempo component enforces per-tenant ingestion rate limits?
Q2. What header does Tempo use to identify the tenant for per-tenant rate-limit overrides?
Q3. When a tenant exceeds its rate limit, Tempo accepts the spans but drops the trace IDs.
Q4. Which receivers can a Tempo distributor accept? (select all that apply)
Q5. The distributor routes each trace to which ingesters?
Q6. Name the metric that confirms a Tempo distributor is accepting OTLP traffic.
Q7. What is the operational effect of setting max_recv_msg_size too small?
Q8. A Tempo distributor outage is visible to queriers as missing traces.
Passing score: 75%. Answers are checked in this browser.