Observability Multi-cloud

Configure Grafana Tempo with TraceQL, Metrics-Generator, and S3 Block Storage

A payments platform team is drowning in trace cost. They run a managed APM whose per-host, per-span pricing has crossed six figures a month, and 95% of the traces it ingests are never looked at — yet the 5% that matter, the ones behind a checkout latency spike at 02:00, are exactly the ones sampled away before an engineer can pull them. The mandate from the platform lead is precise: own the tracing backend, store the raw spans cheaply enough to keep everything for a useful window, and still get the dashboards and SLO alerts the old vendor gave for free. This guide builds that. Grafana Tempo is a distributed tracing backend with one radical design choice: it indexes only by trace ID and stores the trace bodies as columnar Parquet blocks in object storage, so a terabyte of retained traces costs roughly the price of a terabyte of S3 rather than the price of a search cluster. You run it as a set of independently-scalable microservices, point it at an S3 bucket as the only durable store, turn on the metrics-generator so it derives RED metrics (Rate, Errors, Duration) and service-graph metrics from the span stream and remote_writes them to Prometheus, and query it in TraceQL — a language purpose-built for traces where “show me every checkout span over 2s that errored” is one line and “the p95 of the database calls” is an aggregate computed over raw spans.

This is an advanced, hands-on guide, and the deployment is the point. We will not narrate Tempo from a slide; we will stand up a horizontally-scaled cluster on Kubernetes, land traces in S3 in the vParquet4 block format, watch the compactor merge blocks in the background, wire the generator’s RED metrics into Prometheus with exemplars so a latency graph links straight to the trace that caused the spike, and then break things on purpose and read the exact metrics and logs that name the failure. Along the way every component gets its own section — what it does, the config that matters, the limits that bite, and the metric you watch to scale it — because in distributed mode “Tempo is slow” is never a useful sentence; the useful sentence is “the query-frontend’s job queue is backing up because the queriers are S3-read-bound,” and this guide teaches you to say that.

By the end you will have a production-shaped Tempo cluster: traces in S3 under a compaction and retention policy you chose deliberately, RED and service-graph metrics flowing to Prometheus, TraceQL search and TraceQL metrics both working, exemplars closing the loop from a metric spike to a trace, multitenancy and per-tenant limits configured, tail-based sampling at the collector cutting the bill by an order of magnitude, and a clear mental model of when Tempo is the right backend and when Jaeger still wins. You will also have the sizing math to defend the design in a review and the troubleshooting table to keep open at 02:14.

What problem this solves

Distributed tracing has a cost shape that punishes the naive backend. A trace is cheap to produce — the OpenTelemetry SDK adds a span at negligible overhead — but expensive to store and search if you index every attribute, because a full-text or attribute index over high-cardinality span data (URLs, user IDs, request bodies) grows faster than the traces themselves and forces you onto expensive, memory-hungry search infrastructure (this is the Elasticsearch-behind-Jaeger cost curve that drove teams to managed APM in the first place). Managed APM solves the operational pain but replaces it with a pricing model — per host, per span, per GB ingested — that makes keeping traces the enemy. So teams sample aggressively at the head (drop 90% of traces before they are recorded), and then discover during an incident that the trace they need was one of the 90% thrown away.

Tempo attacks the cost curve directly: do not build a big index. Index only the trace ID (and, since recent versions, a small set of block-level metadata Tempo uses to prune which blocks a search must touch), store the trace bodies as compressed columnar Parquet in object storage, and answer “give me trace X” with a cheap object fetch. Search that used to require an index is instead a scan of the columnar blocks — parallelised across queriers and pruned by block metadata — which is slower per query than a hot index but astronomically cheaper to store, and fast enough when you shard the query and let object storage serve the bytes. That single trade — accept scan-based search to eliminate the index — is what lets you keep 100% of sampled traces for 30 days at object-storage prices.

What breaks without this: you either pay APM per-span pricing to keep traces (and sample away the ones you need to control the bill), or you run Jaeger on Elasticsearch and pay for and operate a search cluster whose cost scales with retention. Who hits it: any team past the toy stage of tracing — enough services and enough traffic that trace volume is a real line item, and enough incidents that “we sampled that trace away” has cost them a postmortem. It bites hardest on cost-sensitive platforms with bursty traffic (flash sales, batch jobs) where head sampling is a blunt instrument and the traces that matter cluster exactly in the moments you are tempted to sample harder.

To frame the whole system before the deep dive, here is every Tempo component, what it does, whether it holds state, and the signal you scale it on:

Component Read or write path Stateful? What it does Scale signal
Distributor Write No Receives spans (OTLP/Jaeger/Zipkin), validates, shards by trace ID to ingesters Incoming span rate (CPU-bound)
Ingester Write Yes (WAL + local blocks) Batches spans into blocks in memory, flushes complete blocks to S3 Active trace volume; flush pressure
Metrics-generator Write (tap) Yes (local WAL) Derives RED + service-graph metrics from the span stream, remote_writes to Prometheus Span rate (memory-heavy)
Compactor Background No (leases blocks) Merges small S3 blocks into larger ones; enforces block retention S3 outstanding-blocks backlog
Query-frontend Read No Splits a TraceQL query into sub-jobs, queues and schedules them, assembles results Query queue length; query latency
Querier Read No Pulls sub-jobs, searches ingesters (recent) + S3 blocks (older), returns partials Query concurrency; S3 read throughput

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already operate Kubernetes and Prometheus in anger. Specifically: a cluster (EKS, GKE, AKS, or self-managed) with at least 16 vCPU / 32 GiB of schedulable headroom and kubectl + helm 3.14+ configured against it; an S3 bucket (or S3-compatible store — MinIO, GCS via the S3 API, Cloudflare R2) in the same region as the cluster, plus an IAM role you can attach to pods via IRSA (EKS), Workload Identity (GKE), or a Vault-issued dynamic credential; a running Prometheus (or Grafana Mimir / Grafana Cloud) reachable via remote_write to receive the generator’s output; a Grafana 10.4+ instance where you can add data sources; and an OpenTelemetry Collector (or apps already emitting OTLP) as the trace source. CLI tooling: aws v2, helm, kubectl, and jq. You should be comfortable reading Prometheus metric names and writing basic PromQL, since half the value of Tempo lands in Prometheus.

Where this sits: it is the trace pillar of a self-hosted observability stack. The metrics pillar is Prometheus/Mimir (or VictoriaMetrics for high-cardinality metrics at scale); the logs pillar is Loki in distributed mode on S3 — and Tempo, Loki, and Mimir are deliberately architecturally identical (distributor → ingester → compactor → querier → query-frontend on object storage), so operating one teaches you the others. Traces enter this system from the OpenTelemetry Operator with the target allocator and auto-instrumentation, which is upstream of everything here. The metrics Tempo generates feed the same alerting and on-call flow as the rest of the stack — Grafana OnCall for rotation management and PagerDuty event orchestration off Alertmanager — and the same GitOps controller, Argo CD with SSO and RBAC, reconciles this Helm release from Git.

A quick map of who owns which layer during an incident, so you page the right person:

Layer What lives here Who usually owns it Failure classes it causes
App / SDK OTLP export, span attributes, sampling head App / dev team Missing spans, broken parent-child, wrong service.name
OTel Collector Batching, tail sampling, PII scrubbing, routing Platform / SRE Dropped traces, sampling-away the useful ones, backpressure
Distributor Ingest, validate, shard by trace ID Platform (Tempo) 429 rate-limit, malformed-span rejects
Ingester / generator Block build + flush; metric derivation Platform (Tempo) Lost recent traces, empty RED metrics, OOM
S3 backend Durable block storage Platform + cloud AccessDenied, block-not-found, compaction stall
Query path TraceQL search + assembly Platform (Tempo) Slow/timed-out search, empty TraceQL metrics
Grafana Data source wiring, exemplars, links Platform Broken trace→metric jumps, no service graph

Core concepts

Six mental models make every later decision obvious.

Tempo indexes trace ID, not attributes — that is the whole cost story. A traditional trace store builds a searchable index over span attributes so service = checkout AND status = error is a fast index lookup; that index is what costs money to store and RAM to serve. Tempo builds no such index. It stores trace bodies as columnar Parquet blocks keyed by trace ID and answers attribute searches by scanning the relevant columns of the relevant blocks, pruning which blocks to touch via lightweight block-level metadata. Scan is slower per query than an index hit but is parallelised across queriers and served straight from object storage, so it stays fast enough while the storage bill collapses to object-storage prices. Every trade-off in Tempo descends from this: cheap storage, cheap “get trace by ID,” and a search path that scales by throwing queriers and read bandwidth at a scan.

Distributed mode is a set of microservices bound by hash rings, not one process. The single-binary tempo chart runs everything in one pod — fine for a demo, useless at scale. The tempo-distributed chart runs each component as its own deployment/statefulset. The stateful ones — ingesters and the metrics-generator — coordinate through a hash ring (a consistent-hashing membership structure, backed by memberlist/gossip by default) so the distributor knows which ingesters own a given trace ID and so ownership rebalances when a replica joins or leaves. An ingester that is UNHEALTHY in the ring is not just down — it black-holes the spans hashed to it until it is forgotten or replaced. Reading the ring is therefore the first health check of the whole system.

The write path and the read path are fully decoupled. Spans flow write-side: distributor → ingester → S3 (with the generator tapping the same stream). Queries flow read-side: query-frontend → queriers → (ingesters for recent, S3 for older). These share nothing but the S3 backend and the ingesters’ recent-data window, which is the point — a query storm cannot starve ingestion, and an ingest spike cannot slow search, because they scale on independent signals. When someone says “Tempo is slow,” the first fork is write or read, because they are different subsystems with different bottlenecks.

The metrics-generator turns traces into metrics — it is a second output, not the storage path. The generator receives the same spans the distributor sees and, instead of storing them, computes aggregates: span-metrics (call counts, error counts, latency histograms per service/operation — the RED metrics) and service-graphs (edges between services with request/error/duration per edge). It remote_writes these as Prometheus series. This is how you keep your dashboards after leaving an APM: the dashboards are PromQL over generator output, not queries against traces. Critically, the generator also needs the local-blocks processor if you want TraceQL metricsrate()/quantile_over_time() computed inside Tempo over raw spans — because those aggregate live spans the generator has buffered, not the Prometheus series.

Retention is owned by the compactor’s block_retention, not by S3. Tempo blocks are immutable objects in S3. The compactor does two jobs: it merges many small blocks into fewer large ones (so search touches fewer objects and LIST costs stay low), and it deletes blocks older than block_retention. If you instead expire objects with an S3 lifecycle rule, you will delete blocks Tempo still believes exist and get “block not found” errors on search. Set block_retention to your real query window and set the S3 lifecycle rule (as a safety net only) a few days longer, so the compactor always owns deletion.

Sampling is upstream and it is where the real cost lever lives. Tempo stores what it receives; it does not sample. Head sampling (decide at the root span, in the SDK) is cheap but blind — it drops traces before it knows if they errored. Tail sampling (buffer the whole trace at the collector, then decide) sees the finished trace and can keep 100% of errors and slow traces while dropping boring fast-200s. Tail sampling at the collector is the single biggest cost lever in this entire build: it cuts ingest and storage by ~10× while keeping every trace an engineer would actually pull. Tempo’s job is to store the survivors cheaply; the collector’s job is to choose the survivors well.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary at the end repeats these for lookup; this table is the mental model side by side:

Concept One-line definition Where it lives Why it matters
Distributed mode Each component is its own scalable deployment tempo-distributed Helm chart Independent scaling of ingest vs query
Hash ring Consistent-hash membership of stateful components Ingesters, metrics-generator Ownership + rebalance; UNHEALTHY = data loss
Block (vParquet) Immutable columnar Parquet file of traces S3 The storage + search unit; scan target
WAL Write-ahead log before a block is flushed Ingester / generator local disk Crash recovery for un-flushed spans
TraceQL Query language for traces (+ metrics) Query path One-line filters and span aggregates
Metrics-generator Derives RED + service-graph metrics from spans Its own deployment Keeps your dashboards after leaving APM
Span-metrics Per-service RED series (traces_spanmetrics_*) Prometheus (via remote_write) Rate/errors/duration dashboards
Service-graph Per-edge request/error/duration series Prometheus The auto-drawn service map
local-blocks Generator processor buffering raw spans Generator Required for TraceQL metrics queries
Exemplar A trace ID stamped on a metric sample Prometheus + Grafana Jump from a metric spike to a trace
Compaction Merging small blocks into large ones Compactor Fewer objects, cheaper LIST, faster search
block_retention How long blocks stay before deletion Compactor config Owns deletion — not an S3 lifecycle rule
Multitenancy X-Scope-OrgID isolates data + limits per tenant All components One cluster, many teams, per-tenant quotas
Tail sampling Decide per-trace after it completes OTel Collector The ~10× cost lever; keeps errors/slow

The Tempo microservices, component by component

Distributed mode exists so each tier scales on its own signal and fails in isolation. Enumerate them.

Distributor — the ingest front door

The distributor is stateless and CPU-bound. It terminates the receiver protocols (OTLP/gRPC on 4317, OTLP/HTTP on 4318, plus legacy Jaeger and Zipkin if enabled), validates each span against the tenant’s limits, and shards spans to ingesters by trace ID so that every span of a trace lands on the same replica set (default replication factor 3). It holds nothing durably: if a distributor dies mid-flight, in-flight spans on that pod are lost, which is why you run several and let the collector retry. Its defining failure is the 429 — when a tenant exceeds ingestion_rate_limit_bytes, the distributor rejects spans with a rate-limit error, and the fix is to raise the limit or fix a client sending too much.

Distributor knobs that matter and how to reason about them:

Setting What it does Default When to change Gotcha
distributor.receivers.otlp Enable OTLP gRPC/HTTP receivers on (chart) Almost always keep on Disabling both = no ingest
ingestion_rate_limit_bytes (override) Per-tenant bytes/sec accepted ~15 MB/s Raise before real load bites Enforced per distributor × replicas
ingestion_burst_size_bytes (override) Burst allowance above the rate ~20 MB/s Bursty traffic (batch flushes) Too low → 429 on spikes
max_bytes_per_trace (override) Cap a single trace’s size ~5 MB Raise for wide fan-out traces A runaway trace is dropped, not truncated silently
log_received_spans Debug-log every received span off Never in prod Floods logs; huge cost
replicas Distributor pod count 3 (this guide) Scale on span-rate CPU Stateless — scale freely

The metric to watch is tempo_distributor_spans_received_total (throughput) against distributor CPU; when spans-received outpaces CPU headroom, add replicas. tempo_discarded_spans_total with its reason label tells you why spans are being dropped (rate-limited, too-large, out-of-order) — a non-zero rate here is your first ingest alarm.

Ingester — batch spans into blocks, flush to S3

The ingester is stateful and the most delicate component. It accumulates spans in memory (backed by a WAL on local disk for crash recovery), assembles them into blocks, and when a block is complete (by age or size) it flushes the block to S3 and cuts a new one. A trace is queryable from the ingester the moment its spans arrive (the “recent data” window) and remains so until the block is flushed and the ingester’s local copy is dropped, after which it is served from S3. Because ingesters own trace-ID ranges via the ring, scaling them is deliberate: adding a replica triggers a ring rebalance and the new member must warm up; removing one must flush its blocks first (a graceful shutdown) or you lose un-flushed recent data.

Ingester lifecycle knobs:

Setting What it does Default Trade-off
max_block_duration Max age before a block is cut and flushed ~30 min Shorter = fresher in S3, more small blocks; longer = fewer, larger blocks, more RAM held
max_block_bytes Max block size before cutting ~500 MB Larger blocks = better compression + fewer objects; more RAM per block
complete_block_timeout How long a flushed block stays queryable locally ~15 min Covers the window before S3 is authoritative
flush_check_period How often the ingester checks for flushable blocks ~10 s Rarely changed
persistence.size (PVC) WAL + local block disk 20 Gi (this guide) Undersize → WAL fills, ingest stalls
replicas / RF Ingester count and replication factor 3 / 3 RF 3 needs ≥3 ingesters; fewer = data-loss risk

The trap: ingesters hold recent data in RAM, so an ingestion spike (or a max_block_duration set too long) drives memory up and can OOM the pod, which drops its un-flushed spans and forces a ring rebalance mid-incident. Give ingesters memory headroom, watch tempo_ingester_bytes_metric_total/live-trace counts, and never scale them down without a graceful drain.

Metrics-generator — RED metrics and service graphs from spans

The metrics-generator is the component that makes Tempo more than a trace lookup. It subscribes to the same span stream and runs processors that each emit a family of Prometheus series:

It remote_writes to Prometheus and, when told, stamps exemplars (a trace ID on a metric sample) so a graph links to a trace. It is the priciest component to run because it processes every span, and it holds a local WAL, so it is stateful and the most likely to OOM under a surge — give it the most memory headroom of any component.

Generator processors and what each costs you:

Processor Emits Enables Cost profile Turn on when
span-metrics traces_spanmetrics_* (RED) Rate/error/duration dashboards Moderate; series cardinality grows with span-name × service Always — this replaces the APM dashboards
service-graphs traces_service_graph_* (edges) The service map / node graph Higher; must pair client+server spans (memory) You want the auto-drawn topology
local-blocks (buffers raw spans) TraceQL rate/quantile_over_time Memory to hold recent spans You want in-Tempo span aggregation

Cardinality is the generator’s danger. span-metrics labels series by span name and service; if apps put high-cardinality values in span names (a raw URL with an ID in it, say) the series count explodes and Prometheus pays the price. Use the collector to normalise span names before they reach the generator, and cap metrics_generator.processor.span_metrics.dimensions to the labels you actually chart.

Compactor — merge blocks, enforce retention

The compactor is a background worker that fights entropy in S3. Ingesters produce many small blocks (one per ingester per max_block_duration); left alone, search would have to open thousands of objects and LIST costs would climb. The compactor leases blocks (so multiple compactors do not fight over the same ones — but you must shard leasing or run a single compactor per tenant shard) and merges small blocks into larger ones, improving compression and cutting object count. It also enforces block_retention: blocks older than the window are deleted here, which is why retention is a compactor setting and not an S3 lifecycle rule. Run one un-sharded compactor per tenant shard; multiple un-sharded compactors race over the same blocks and waste work.

Compactor knobs:

Setting What it does Default Guidance
compaction.block_retention Delete blocks older than this ~336h (14d) Set to your real query window; owns deletion
compaction.compacted_block_retention Keep the source block briefly after compaction ~1h Safety window; rarely changed
compaction.compaction_window Time bucket compacted together ~1h Larger = bigger merged blocks
compaction.max_compaction_objects Max objects merged per pass large Bound memory of a compaction pass
compaction.max_block_bytes Cap the size of a compacted block ~100 GB Prevents unwieldy mega-blocks
replicas Compactor count 1 (per shard) More only with sharding configured

Watch tempo_compactor_blocks_total (should rise as work is done) and the outstanding-blocks / compaction-backlog metrics; a growing backlog with a flat blocks_total means the compactor is under-provisioned or wedged, and search will slow as small blocks pile up.

Query-frontend and querier — the read path

The query-frontend is the read-side scheduler. A TraceQL search over a wide time range is not one operation — the frontend shards it into sub-jobs (by block, by time slice), puts them on an in-memory queue, and hands them to queriers that pull work. It also caches results and enforces per-query limits. The querier does the actual work: for each sub-job it searches the ingesters (recent, un-flushed data) and/or S3 blocks (older data), scans the relevant Parquet columns, and returns partial matches the frontend assembles. Both are stateless, so they scale freely — but the querier’s real ceiling is S3 read throughput and concurrency, not CPU, because a big search is bytes-from-object-storage-bound.

Read-path knobs:

Setting Component What it does Default When to change
max_outstanding_per_tenant query-frontend Queue depth per tenant ~2000 Raise for heavy concurrent search
query_frontend.max_retries query-frontend Retry failed sub-jobs ~2 Flaky S3 / transient errors
querier.max_concurrent_queries querier Parallel sub-jobs per querier ~20 Raise to use more S3 bandwidth
querier.frontend_worker.parallelism querier Worker connections to the frontend tuned Match to querier count
search.max_duration (override) frontend Longest searchable time span ~0 (unbounded) Cap to protect the cluster
search.max_bytes_per_trace (override) frontend Cap trace size on read matches ingest Guard against giant traces
replicas both Pod counts 2 frontend / 3 querier Scale querier on search latency

The signal for the frontend is queue length (tempo_query_frontend_queue_length); a persistently deep queue means the queriers cannot keep up — add queriers. The signal for queriers is search latency and S3 read metrics; if latency is high but querier CPU is idle, you are S3-read-bound and the fix is more queriers (more parallel readers) and/or block/results caching, not bigger querier pods.

S3 as the block backend, and the block format

Tempo’s durability is entirely in S3; the ingesters and generator hold only transient recent data. Getting the backend right is therefore load-bearing.

What the S3 identity actually needs

Tempo needs read, write, list, and delete on the bucket — delete is not optional, because the compactor removes superseded and expired blocks; a policy missing s3:DeleteObject produces a compactor that can never clean up, and S3 object count and cost climb until search degrades. Create the bucket with versioning off (Tempo manages its own immutable block lifecycle; versioning just doubles storage and confuses deletion) and put a lifecycle rule in only as a safety net set longer than block_retention.

export AWS_REGION=ap-south-1
export TEMPO_BUCKET=kloudvin-tempo-traces-prod

aws s3api create-bucket \
  --bucket "$TEMPO_BUCKET" \
  --region "$AWS_REGION" \
  --create-bucket-configuration LocationConstraint="$AWS_REGION"

# Block all public access — traces contain request internals.
aws s3api put-public-access-block \
  --bucket "$TEMPO_BUCKET" \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# Default SSE-KMS encryption at rest, with a bucket key to cut KMS request cost.
aws s3api put-bucket-encryption \
  --bucket "$TEMPO_BUCKET" \
  --server-side-encryption-configuration '{
    "Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"},"BucketKeyEnabled":true}]
  }'

The minimal IAM policy Tempo needs on that bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TempoBucketList",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::kloudvin-tempo-traces-prod"
    },
    {
      "Sid": "TempoObjectRW",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::kloudvin-tempo-traces-prod/*"
    }
  ]
}

Create an IRSA role so Tempo pods assume this without any static keys — static AWS keys in a values file are exactly the leak we refuse to repeat:

eksctl create iamserviceaccount \
  --cluster kloudvin-prod \
  --namespace tracing \
  --name tempo \
  --attach-policy-arn arn:aws:iam::123456789012:policy/TempoS3Access \
  --role-name kloudvin-tempo-irsa \
  --approve

For non-AWS clusters or where you centralize secrets, HashiCorp Vault is the alternative: enable Vault’s AWS secrets engine and have the Vault Agent sidecar inject a short-lived, dynamically-leased S3 credential into the pod, so Tempo never holds a long-lived key. The pattern is the same as the External Secrets Operator with Vault and AWS Secrets flow you likely already run. We use IRSA below because it removes the credential entirely.

The S3-compatibility matrix — Tempo speaks the S3 API, so many backends work, but the knobs differ:

Backend endpoint example forcePathStyle Delete support Notes
AWS S3 s3.ap-south-1.amazonaws.com false yes IRSA for credential-free auth
MinIO (self-host) minio.storage.svc:9000 true yes Set insecure if HTTP in-cluster
GCS (S3 API) storage.googleapis.com false yes HMAC keys, or prefer native GCS backend
Cloudflare R2 <acct>.r2.cloudflarestorage.com true yes No egress fees; check request-rate limits
Ceph RGW / on-prem rgw.internal:7480 true yes Validate multipart + delete behaviour

The block format: vParquet and why columnar matters

Tempo has evolved its on-disk block format; the modern format is vParquet (currently vParquet4), where each block is an Apache Parquet file — a columnar layout in which all values of one field (all durations, all service.names) are stored contiguously. Columnar is what makes scan-based search viable: a TraceQL filter on duration > 2s reads only the duration column of the candidate blocks, not the whole trace body, so the scan touches a fraction of the bytes and compresses far better (like values sit together). Older formats (v2, earlier vParquet versions) exist mainly for migration; new clusters should use the latest vParquet the version supports, and Tempo can read older blocks while writing new ones so upgrades are non-disruptive.

Block-format choices at a glance:

Format Layout TraceQL search Compression Use it for
v2 Row-oriented (legacy) Limited / slow Lower Legacy blocks only; do not choose new
vParquet2/3 Columnar Parquet (older) Yes Good Existing clusters mid-migration
vParquet4 Columnar Parquet (current) Yes, richest (dedicated columns, better pruning) Best New clusters — the default choice

The practical rule: pick vParquet4 for a new build, let the compactor rewrite older blocks forward over time, and never expire blocks with an S3 lifecycle rule that could delete a format Tempo still references. The columnar layout is also why caching helps so much on the read path — a block’s column chunks are cacheable, so a hot time range that many queries scan can be served from a memcached/Redis block cache instead of re-reading S3 every time.

TraceQL — the query language, in depth

TraceQL is what makes Tempo usable by humans and dashboards rather than only by “give me trace ID X.” It has two halves: structural trace search (find traces/spans matching conditions) and metrics (aggregate over the matched spans). Learn both.

Structural search: selecting spans and scoping conditions

A TraceQL query is a set of span-set filters in { }. Inside the braces you match intrinsics (properties every span has: name, duration, status, kind) and attributes namespaced by scope: resource. (from the resource, e.g. resource.service.name) and span. (span attributes, e.g. span.http.status_code). Conditions combine with && (both true on the same span) and ||. The subtlety is scope: && inside one { } means one span satisfies both conditions; to relate different spans in the same trace you chain span-sets.

The queries that motivate the whole project:

# Every checkout span slower than 2s that errored — one span, three conditions.
{ resource.service.name = "checkout" && duration > 2s && status = error }
# Traces where the frontend called checkout AND checkout hit a slow DB span —
# two different spans in the same trace, related with the descendant operator.
{ resource.service.name = "frontend" } >> { resource.service.name = "checkout" && name = "db.query" && duration > 500ms }
# Filter by a custom span attribute (e.g. a tenant or a feature flag).
{ span.http.route = "/api/checkout" && span.app.tenant = "acme" && status = error }

The core TraceQL selectors you use daily:

Selector Matches Example
name (intrinsic) Span/operation name { name = "HTTP POST /checkout" }
duration (intrinsic) Span duration { duration > 2s }
status (intrinsic) Span status { status = error } (also ok, unset)
kind (intrinsic) Span kind { kind = server }
resource.<key> Resource attribute { resource.service.name = "checkout" }
span.<key> Span attribute { span.http.status_code = 500 }
&& / || Both / either on the same span { status = error && duration > 1s }
{A} >> {B} B is a descendant of A (any depth) ancestor→descendant relation
{A} > {B} B is a direct child of A parent→child relation
| select(...) Return specific attributes on results | select(span.http.url)

TraceQL metrics: aggregating over raw spans

The second half of TraceQL computes metrics from the matched spans inside Tempo — this is what needs the local-blocks processor. Pipe a span-set into an aggregate:

# p95 latency of the checkout service's DB calls, from raw spans.
{ resource.service.name = "checkout" && name = "db.query" } | quantile_over_time(duration, .95)
# Request rate of errored checkout spans, by HTTP route.
{ resource.service.name = "checkout" && status = error } | rate() by (span.http.route)
# Count of slow spans over a window, grouped by service.
{ duration > 1s } | count_over_time() by (resource.service.name)

TraceQL metrics functions and what they compute:

Function Computes Typical use
rate() Per-second rate of matching spans Ad-hoc error/throughput rate without pre-building a metric
count_over_time() Count of matching spans per step “How many slow spans in the last hour?”
quantile_over_time(attr, q) Quantile of an attribute (usually duration) p50/p95/p99 latency straight from spans
histogram_over_time(attr) Histogram of an attribute over time Latency distribution shape
... by (labels) Group the aggregate by attributes Per-route, per-service breakdowns

The distinction that trips people up: generator span-metrics live in Prometheus and are always-on, pre-aggregated, and cheap to query for dashboards; TraceQL metrics are computed on demand inside Tempo over recent buffered spans and are for exploration, bounded by how much the local-blocks processor retains. Use generator metrics for standing dashboards and alerts; use TraceQL metrics to slice a question no pre-built metric answers — “p99 of only the spans with span.app.tenant = acme,” which no fixed Prometheus series carries.

Where each answer lives — the routing table:

Question Answer from Why
“Show me the trace behind this spike” TraceQL search ({ ... }) + exemplar Structural search returns the trace
“Rate/errors/p95 per service for a dashboard” Prometheus (traces_spanmetrics_*) Pre-aggregated, cheap, always-on
“The service map” Prometheus (traces_service_graph_*) Edge metrics from service-graphs
“p99 of spans filtered by a rare attribute” TraceQL metrics (| quantile_over_time) No fixed Prometheus series carries it
“Every error span on service X right now” TraceQL search Ad-hoc structural filter

Correlation: trace-to-metrics, trace-to-logs, and exemplars

The reason to co-locate Tempo with Prometheus and Loki is correlation — the ability to pivot from a metric to a trace to a log without changing tools. Three wires make it work, and all three are configured on Grafana’s Tempo data source plus the generator.

Exemplars are the metric→trace pivot. When the generator writes a span-metric sample, it can stamp a trace ID on that sample as an exemplar (a representative data point). In Grafana, an exemplar renders as a diamond on the latency graph, and clicking it opens that trace. This requires two things: send_exemplars: true on the generator’s remote_write, and a Prometheus started with --enable-feature=exemplar-storage (exemplars are off by default). Miss either and the diamonds never appear.

Trace-to-metrics (tracesToMetrics on the data source) is the trace→metric pivot: from a span in a trace, jump to the RED metrics for its service. Trace-to-logs (tracesToLogsV2) is the trace→log pivot: from a span, jump to Loki filtered by the same trace ID or service, which is why your logs must carry the trace ID (the OTel Collector or your logging library injects it). Service map (serviceMap) points Grafana at the Prometheus holding the service-graphs series so it can draw the topology.

The correlation wires and what each buys:

Wire Config location Direction Requires Buys you
Exemplars Generator remote_write.send_exemplars + Prometheus feature flag Metric → trace exemplar-storage enabled Click a latency spike → the trace
Trace-to-metrics tracesToMetrics on data source Trace → metric Prometheus with span-metrics From a span → its service’s RED
Trace-to-logs tracesToLogsV2 on data source Trace → logs Trace ID in log lines + Loki From a span → its logs
Service map serviceMap on data source (renders) service-graphs in Prometheus The auto-drawn service topology
Node graph nodeGraph.enabled (renders) service-graph metrics Interactive graph view

The data source provisioning that wires all of it:

# grafana-datasource-tempo.yaml (provisioning)
apiVersion: 1
datasources:
  - name: Tempo
    type: tempo
    access: proxy
    uid: tempo
    url: http://tempo-query-frontend.tracing.svc:3200
    jsonData:
      tracesToMetrics:
        datasourceUid: prometheus        # trace → metric
        spanStartTimeShift: '-2m'
        spanEndTimeShift: '2m'
      tracesToLogsV2:
        datasourceUid: loki              # trace → logs (by trace ID)
        spanStartTimeShift: '-2m'
        spanEndTimeShift: '2m'
        filterByTraceID: true
      serviceMap:
        datasourceUid: prometheus        # renders the service graph
      nodeGraph:
        enabled: true
      search:
        hide: false                      # enables the TraceQL search tab

Retention, compaction, and multitenancy

Three operational disciplines keep a Tempo cluster healthy and cheap over months, not just on day one.

Retention: block_retention owns deletion

Set retention where it belongs — on the compactor — and let S3 lifecycle be a backstop only:

compactor:
  config:
    compaction:
      block_retention: 720h      # 30 days queryable; the compactor deletes older blocks

Then set the S3 lifecycle rule a few days longer (say 33 days) so the compactor, not S3, is always the thing that deletes a live block. Retention is a cost-and-value dial: 30 days is generous; many teams keep 7–14 for cost, and some keep a short window for most traces and a long window for a separate low-volume “keep everything” tenant.

Retention design choices:

Choice Setting Effect When
Uniform 30-day block_retention: 720h All traces 30d Simple, generous, single tenant
Cost-lean 7-day block_retention: 168h All traces 7d Cost-sensitive; incidents caught fast
Per-tenant split override block_retention per tenant Long for a “keep-all” tenant, short for the noisy one Multitenant with mixed value
S3 lifecycle backstop bucket rule > block_retention Deletes only if compactor fails to Always, as a safety net (longer than block_retention)

Compaction discipline

The compactor is a background component but not a fire-and-forget one. Run one un-sharded compactor per tenant shard; if you need more compaction throughput, enable compactor sharding (which partitions block ownership) rather than adding un-sharded replicas that race. Watch the compaction backlog: a steadily growing count of outstanding uncompacted blocks means search will slow (more objects to open) and LIST/request costs will climb, and the fix is more compaction capacity (sharded) or a larger compaction window. The block-format matters here too — the compactor rewrites merged blocks in the current vParquet version, so compaction is also how a cluster migrates its historical blocks to the newest format.

Multitenancy: one cluster, many teams

Tempo is natively multitenant via the X-Scope-OrgID header. When multitenancy is enabled, every write and read must carry the header naming the tenant; Tempo namespaces the tenant’s blocks in S3 under a per-tenant prefix and applies per-tenant overrides (ingestion limits, retention, generator processors). This is how one Tempo cluster serves every team with isolation and independent quotas, rather than running a cluster per team.

Multitenancy and per-tenant override knobs:

Aspect Mechanism Notes
Tenant identity X-Scope-OrgID: <tenant> header on read+write Injected by the collector/gateway per team
Enabling it multitenancy_enabled: true (auth config) Once on, the header is mandatory
Per-tenant limits overrides per tenant (rate, burst, max-trace) Different quotas per team
Per-tenant retention block_retention override Long-keep tenant vs noisy tenant
Per-tenant processors metrics_generator.processors override Only generate RED for teams that want it
Storage isolation Per-tenant S3 prefix Namespaced; not separate buckets
Single-tenant default tenant anonymous when auth off Fine for one team; enable auth for many

The gotcha: turn multitenancy on and every client must send X-Scope-OrgID or writes and reads fail — so you enable it at the same time you configure the collector (write side) and Grafana/the gateway (read side) to inject the header. For a single team, leave it off and Tempo uses the anonymous tenant.

Sampling interplay: head vs tail, and where it lives

Tempo stores what arrives; the volume that arrives is decided upstream, and getting that decision right is the single biggest cost and fidelity lever in the system. Two strategies, at two places:

Head sampling happens in the application SDK at the root span: a probabilistic decision (keep 10%) made before the trace exists, propagated to children so the whole trace is consistently kept or dropped. It is cheap (no buffering) and consistent, but blind — it cannot keep errors or slow traces preferentially because it decides before it knows the outcome. Good for cutting a firehose to a manageable base rate.

Tail sampling happens at the OTel Collector after the whole trace has arrived and been buffered for a decision window: now you can inspect the finished trace and keep 100% of traces that errored or exceeded a latency threshold while probabilistically sampling the boring rest. It costs memory (buffer every trace for the window) and requires all spans of a trace to reach the same collector instance (so you shard by trace ID with a load-balancing exporter), but it is how you beat an APM’s cost without losing the traces that matter.

The sampling matrix:

Strategy Where Sees outcome? Cost Keeps errors/slow? Use for
Head (probabilistic) SDK root span No Negligible No (blind) Cutting a firehose to a base rate
Head (parent-based) SDK No Negligible No Consistent keep/drop across a trace
Tail (status/latency) Collector Yes Memory (buffer window) Yes The cost lever — keep what matters
Rate-limiting Collector/distributor N/A Low N/A Hard cap to protect the backend

The collector tail-sampling config that keeps every error and slow trace, samples the rest:

# otel-collector-config.yaml (Collector running in the cluster)
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }

processors:
  batch: {}
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 2000 }
      - name: sample-rest
        type: probabilistic
        probabilistic: { sampling_percentage: 10 }

exporters:
  otlp/tempo:
    endpoint: tempo-distributor.tracing.svc:4317
    tls: { insecure: true }   # in-cluster; mTLS via service mesh in prod

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [tail_sampling, batch]
      exporters: [otlp/tempo]

The interplay to remember: generator metrics should be computed on the full stream, before tail sampling drops traces, or your RED metrics undercount. Best practice is to run the metrics-generator on the un-sampled stream (or compute span-metrics at the collector) so dashboards reflect all traffic, while only the stored traces are sampled. If the generator sits behind the sampler, your rates and error counts reflect the 10% survivors, not reality — a subtle, costly mistake.

Architecture at a glance

Tempo in distributed mode is a set of independently-scalable microservices, not one process, and the diagram traces both the write path and the read path so you can localise any problem to a hop. Read it left to right on the write side: spans leave the app SDKs, pass through the OpenTelemetry Collector (which batches, tail-samples, and scrubs PII), and enter Tempo at the distributor, which validates and shards them by trace ID to the ingesters. The ingesters batch spans into vParquet blocks and flush them to S3, while the metrics-generator taps the same span stream and — instead of storing traces — derives RED metrics and service-graph metrics that it remote_writes to Prometheus with exemplars. In the background the compactor merges small S3 blocks into larger ones and deletes blocks past block_retention, keeping search fast and object count sane.

Now read the read side, which shares nothing with the write side but S3 and the ingesters’ recent window: a TraceQL query enters the query-frontend, which shards it into sub-jobs and queues them; queriers pull the jobs and fan out across the ingesters (for recent data) and S3 blocks (for older data), scanning the relevant Parquet columns and returning partials the frontend assembles. Grafana sits in front of both pillars — it runs TraceQL against Tempo for traces and PromQL against Prometheus for the derived metrics, and stitches them with exemplars so a spike on a latency panel jumps straight to the trace behind it. The whole shape mirrors Loki and Mimir deliberately; learn Tempo’s rings, blocks, and compaction and you have learned all three.

Grafana Tempo distributed-mode architecture: application SDKs export spans to an OpenTelemetry Collector that batches, tail-samples and scrubs PII, then sends OTLP to the Tempo distributor, which shards spans by trace ID to ingesters (replication factor 3); ingesters batch spans into vParquet blocks and flush them to an S3 bucket while a metrics-generator taps the same span stream to derive RED span-metrics and service-graph metrics that it remote_writes to Prometheus with exemplars; a compactor merges small S3 blocks into larger ones and enforces block_retention; on the read side a query-frontend shards each TraceQL query into sub-jobs that queriers pull and run against ingesters for recent data and S3 blocks for older data, scanning Parquet columns; Grafana queries Tempo over TraceQL and Prometheus over PromQL and links a metric spike to its trace via exemplars

Real-world scenario

Northwind Pay runs a card-processing platform on EKS in ap-south-1: about 140 microservices, ~85,000 spans/second at peak, fronted by a managed APM whose bill had reached $41,000/month and was climbing with traffic. The platform team is six engineers. The APM sampled at the head to control cost — keeping ~8% of traces — and the recurring failure mode was brutal: a checkout latency incident would spike at 02:00, the on-call would open the APM to pull the offending trace, and it would not be there, because head sampling had thrown it away before it knew it was slow. Three postmortems in a quarter ended with “trace not retained.” The mandate was to own the backend, keep everything that mattered for 30 days, and not lose the dashboards.

They built Tempo tempo-distributed on the existing cluster: distributor ×4, ingester ×4 (RF 3, 20 Gi WAL PVCs), querier ×4, query-frontend ×2, compactor ×1, metrics-generator ×3, all pointed at a new SSE-KMS S3 bucket via IRSA, with Prometheus (Mimir, actually) receiving the generator’s RED and service-graph metrics. The key decision was tail sampling at the collector: keep 100% of errors and 100% of traces over 2s, probabilistically sample the fast-200s at 10%. That cut stored trace volume by ~11× — from 85k spans/s to an effective ~7.5k spans/s reaching Tempo — while retaining every trace an engineer would ever pull. Crucially, they ran span-metric generation on the un-sampled stream so the RED dashboards still reflected all 85k spans/s of real traffic.

The first week surfaced two classic mistakes. TraceQL metrics queries (quantile_over_time) returned nothing, and the fix was adding the local-blocks processor they had omitted (span-metrics alone gives the Prometheus series but not in-Tempo aggregation). And exemplars did not link — the Prometheus/Mimir side had not been started with exemplar storage enabled, so the diamonds never rendered; enabling it closed the loop from a p95 panel to the trace. Two weeks in, the generator OOM-ed during a flash sale because span names carried raw URLs with order IDs, exploding series cardinality; they normalised span names at the collector (/checkout/{orderId} instead of the raw path) and gave the generator more memory headroom, and it held.

The outcome: 30 days of all sampled traces in S3 at roughly $900/month of object storage plus compaction compute, the RED and service-graph dashboards rebuilt in PromQL (indistinguishable from the APM’s), exemplars linking every latency spike to its trace, and TraceQL search that answered “every checkout error over 2s in the last 24h” in seconds. The APM was retired. Total observability spend fell from $41,000 to about $6,500/month all-in (Tempo compute + S3 + the Mimir/Grafana they already ran), and — the number that mattered to the platform lead — the next 02:00 checkout incident ended with the offending trace pulled in under a minute, because it had never been sampled away.

The migration as a timeline, because the order of moves is the lesson:

Phase Move Effect Watch-out
Week 0 Stand up tempo-distributed on S3, dual-send from collector Traces land in both APM and Tempo Ring health; ingester WAL sizing
Week 1 Add tail sampling (errors + >2s + 10%) Stored volume ↓ ~11× Keep generator on un-sampled stream
Week 1 Add local-blocks processor TraceQL metrics start working Silent empty results without it
Week 1 Enable exemplar storage on Prometheus Metric→trace links render Off by default
Week 2 Normalise span names at collector Generator cardinality controlled Raw URLs explode series
Week 3 Cut APM, Tempo is primary $41k → ~$6.5k/mo Validate 30-day retention first
Ongoing Watch compaction backlog, generator memory Stable search + metrics Backlog growth = add compaction

Advantages and disadvantages

The index-only-by-trace-ID design both enables the cost win and constrains how you search. Weigh it honestly:

Advantages (why Tempo wins) Disadvantages (where it bites)
Storage cost collapses to object-storage prices — keep everything for a long window cheaply Attribute search is a scan, not an index hit — big searches are read-bandwidth-bound and slower than an indexed store
No search cluster to run (no Elasticsearch for Jaeger) — S3 is the only durable store You must operate the microservices + rings; it is not a single binary at scale
Metrics-generator gives RED + service-graph for free from spans — keep your dashboards after leaving APM Generator processes every span — priciest component, cardinality-sensitive, OOM-prone under surge
TraceQL is a real query language (structural + metrics), not a box of filters TraceQL metrics need local-blocks and are bounded by what the generator buffers
Exemplars close the loop metric→trace; native trace→logs/metrics correlation with Loki/Prometheus Correlation needs deliberate wiring (feature flags, trace IDs in logs) — silent when misconfigured
Architecturally identical to Loki/Mimir — operate one, operate all Same operational surface as three microservice systems if you run the full stack
Native multitenancy + per-tenant limits — one cluster, many teams Multitenancy makes X-Scope-OrgID mandatory everywhere the moment it is on

Tempo is the right backend when trace volume is a real cost line, you want long retention, and you already run (or will run) Prometheus and object storage — which is most platform teams past the toy stage. It is the wrong first choice when you need rich, indexed, ad-hoc attribute search as the primary access pattern with sub-second latency over huge time ranges (an indexed store or a managed APM serves that better), or when you have neither object storage nor the appetite to operate microservices and a single-binary Jaeger with an in-memory or small backend is enough. The disadvantages are all manageable — but only if you know they exist, which is the point of this guide.

Tempo vs Jaeger — choosing the tracing backend

The most common question in a design review is “why not Jaeger?” — so answer it concretely. Both are open-source distributed tracing backends that ingest OTLP; they differ in how they store and search.

Dimension Grafana Tempo Jaeger
Primary index Trace ID only (scan for attributes) Full attribute index (Elasticsearch/Cassandra/etc.)
Durable backend Object storage (S3/GCS) — cheap Elasticsearch / Cassandra / OpenSearch — a cluster to run
Attribute search TraceQL scan (read-bandwidth-bound) Fast indexed search
Storage cost at long retention Very low (object-storage prices) High (scales with index + retention)
Metrics from traces Built-in metrics-generator (RED + service graph) SPM via a metrics store; less integrated
Query language TraceQL (structural + metrics) Tag/duration filters; no aggregation language
Operational model Microservices on object storage (Loki/Mimir-shaped) Collector + query + a storage cluster
Grafana integration First-class (same vendor) Good, via the Jaeger data source
Best fit Cheap long retention, keep-everything, Grafana stack Rich indexed ad-hoc search as the main pattern

The decision rule, as a table:

If you need… It points to… Because
Long retention at minimal storage cost Tempo Object storage vs an index cluster
RED metrics + service map without extra tooling Tempo The metrics-generator is built in
Fast, rich, indexed ad-hoc attribute search Jaeger (or APM) Index beats scan for that access pattern
A Grafana-native stack (Loki + Mimir + traces) Tempo Same architecture, same vendor, correlation
Minimal ops on a small footprint Jaeger single-binary No microservices/rings to run at small scale
Aggregate queries over spans (quantile_over_time) Tempo TraceQL metrics; Jaeger has no such language

The honest summary: choose Tempo when the constraint is storage cost at retention and you live in the Grafana/Prometheus/object-storage world (the common case that motivated this guide); choose Jaeger when the constraint is indexed search latency and richness as your primary access pattern and you are willing to run and pay for the storage cluster that provides it. They are not mutually exclusive — some teams run Tempo for cheap long-term retention and a small Jaeger or an APM sample for hot indexed search.

Hands-on lab

Stand up Tempo in distributed mode on Kubernetes with an S3 (or MinIO) backend, land traces, prove RED metrics and TraceQL both work, then break one thing on purpose and read the exact signal. This is the centerpiece — do every step. If you do not have AWS S3 handy, the lab notes where MinIO substitutes.

Step 1 — Namespace, repo, and the S3 identity.

export AWS_REGION=ap-south-1
export TEMPO_BUCKET=kloudvin-tempo-lab-$RANDOM
kubectl create namespace tracing
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

# Create the bucket (skip if using MinIO in-cluster).
aws s3api create-bucket --bucket "$TEMPO_BUCKET" --region "$AWS_REGION" \
  --create-bucket-configuration LocationConstraint="$AWS_REGION" -o json
aws s3api put-public-access-block --bucket "$TEMPO_BUCKET" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Expected: the bucket is created and public access is blocked. Create the IRSA service account (eksctl create iamserviceaccount ... from the S3 section) or, for the lab, an in-cluster MinIO and a static credential secret — but never a static AWS key in a values file for anything beyond a throwaway lab.

Step 2 — Write the Helm values (S3 backend + generator + per-component replicas).

tempo-values.yaml:

# --- Storage: traces live in S3 (or MinIO), nowhere else durable ---
storage:
  trace:
    backend: s3
    s3:
      bucket: kloudvin-tempo-lab   # match $TEMPO_BUCKET
      endpoint: s3.ap-south-1.amazonaws.com
      region: ap-south-1
      # No access_key/secret_key — IRSA supplies credentials.
    block:
      version: vParquet4           # current columnar format

serviceAccount:
  create: false
  name: tempo                      # the IRSA-annotated SA from step 1

# --- Metrics-generator: derive RED + service-graph from spans ---
metricsGenerator:
  enabled: true
  config:
    storage:
      remote_write:
        - url: http://prometheus.monitoring.svc:9090/api/v1/write
          send_exemplars: true     # required for metric→trace links

# Turn processors on globally and route all tenants to the generator.
global_overrides:
  defaults:
    metrics_generator:
      processors:
        - service-graphs           # node/edge latency between services
        - span-metrics             # the RED metrics (rate, errors, duration)
        - local-blocks             # REQUIRED for TraceQL metrics over spans

# --- Component replicas (scale independently later) ---
distributor:   { replicas: 3 }
ingester:
  replicas: 3
  persistence: { enabled: true, size: 20Gi }
querier:       { replicas: 3 }
queryFrontend: { replicas: 2 }
metricsGenerator: { replicas: 2 }

# --- Compactor: 30-day retention, single replica per shard ---
compactor:
  replicas: 1
  config:
    compaction:
      block_retention: 720h        # 30 days; the compactor owns deletion

Three choices teams get wrong and this file gets right: local-blocks must be in the generator processors or TraceQL metrics queries silently return nothing; send_exemplars: true is what lets a Prometheus latency graph link back to a real trace; and block_retention on the compactor — not an S3 lifecycle rule — governs how long traces are queryable.

Step 3 — Deploy and confirm the cluster is healthy.

helm upgrade --install tempo grafana/tempo-distributed \
  --namespace tracing --values tempo-values.yaml --version 1.18.0

kubectl -n tracing rollout status deploy/tempo-distributor
kubectl -n tracing get pods -l app.kubernetes.io/name=tempo

Expected: distributor, ingester, querier, query-frontend, compactor, and metrics-generator pods all Running. Now confirm each stateful component registered ACTIVE in its hash ring — an UNHEALTHY ingester black-holes the spans hashed to it:

kubectl -n tracing port-forward svc/tempo-distributor 3200:3200 &
curl -s http://localhost:3200/ingester/ring | jq '.shards[] | {id, state}'
curl -s http://localhost:3200/metrics-generator/ring | jq '.shards[] | {id, state}'

Expected: every shard reads ACTIVE. If any is UNHEALTHY, check that pod’s logs before sending traffic.

Step 4 — Point a trace source at Tempo and generate traffic.

Apply the OTel Collector config from the sampling section (or point existing OTLP apps at tempo-distributor.tracing.svc:4317). The fastest smoke test is tracegen, which ships with Tempo and emits synthetic traces:

kubectl -n tracing run tracegen --rm -it --restart=Never \
  --image=ghcr.io/grafana/tempo/tracegen:latest -- \
  -otlp-endpoint tempo-distributor.tracing.svc:4317 \
  -otlp-insecure -duration 60s -workers 4

Expected: tracegen reports spans generated. Confirm the distributor accepted them:

curl -s http://localhost:3200/metrics | grep tempo_distributor_spans_received_total

Expected: a non-zero, rising counter.

Step 5 — Prove blocks are landing in S3 and the compactor is working.

# Blocks should appear under the tenant prefix (may take a flush cycle).
aws s3 ls "s3://$TEMPO_BUCKET/" --recursive | tail -n 10

# Compactor is doing work (count rises over time).
curl -s http://localhost:3200/metrics | grep tempo_compactor_blocks_total

Expected: objects listed in S3 (block files under a per-tenant prefix), and a non-zero tempo_compactor_blocks_total after a few minutes. If S3 is empty after 30+ minutes, jump to the troubleshooting table (almost always IAM/s3:DeleteObject or the IRSA annotation).

Step 6 — Wire Tempo into Grafana and run TraceQL.

Apply the grafana-datasource-tempo.yaml from the correlation section (or add the data source in the UI pointing at http://tempo-query-frontend.tracing.svc:3200). In Grafana Explore, select the Tempo data source and the TraceQL tab, and run a structural search:

{ duration > 100ms }

Expected: tracegen’s synthetic traces appear; click one to see the span tree. Then run a TraceQL metrics query to prove local-blocks is working:

{ } | rate() by (name)

Expected: a rate series per span name. Empty result = you forgot local-blocks (the most common lab failure).

Step 7 — Prove RED metrics reached Prometheus, and rebuild a dashboard.

kubectl -n monitoring port-forward svc/prometheus 9090:9090 &
curl -s 'http://localhost:9090/api/v1/query?query=traces_spanmetrics_calls_total' \
  | jq '.data.result | length'

Expected: a non-zero count — RED metrics are landing. Rebuild the APM’s core panels in PromQL. Request rate per service:

sum by (service) (rate(traces_spanmetrics_calls_total[5m]))

Error rate (fraction of calls with a non-OK span status):

sum by (service) (rate(traces_spanmetrics_calls_total{status_code="STATUS_CODE_ERROR"}[5m]))
/
sum by (service) (rate(traces_spanmetrics_calls_total[5m]))

p95 duration from the generator’s latency histogram:

histogram_quantile(0.95,
  sum by (service, le) (rate(traces_spanmetrics_latency_bucket[5m])))

Because the generator wrote exemplars, a spike on the p95 panel in Grafana shows a diamond; clicking it drops you into the slow trace behind it — the loop the old sampled vendor could never close (this needs Prometheus started with --enable-feature=exemplar-storage).

Step 8 — Break one thing on purpose and read the signal. Trigger a 429 by dropping the ingestion rate limit far below the offered load, then read the exact discard reason:

# Apply a tiny per-tenant rate limit, then run tracegen harder than it allows.
kubectl -n tracing patch cm tempo-overrides --type merge -p \
  '{"data":{"overrides.yaml":"overrides:\n  defaults:\n    ingestion_rate_limit_bytes: 100000\n"}}' 2>/dev/null || true
kubectl -n tracing run tracegen2 --rm -it --restart=Never \
  --image=ghcr.io/grafana/tempo/tracegen:latest -- \
  -otlp-endpoint tempo-distributor.tracing.svc:4317 -otlp-insecure -duration 30s -workers 16

curl -s http://localhost:3200/metrics | grep 'tempo_discarded_spans_total'

Expected: tempo_discarded_spans_total{reason="rate_limited"} climbs — the exact signal a 429 produces. Reverting the limit clears it. This is the muscle memory that matters: a symptom (spans not landing) has a named metric that tells you the cause.

Validation checklist. You deployed distributed Tempo on S3, confirmed rings ACTIVE, landed traces (blocks in S3, compactor working), ran TraceQL structural and metrics queries, saw RED metrics in Prometheus with working exemplars, and reproduced a rate-limit discard and read its metric. Each step proved one hop of the topology:

Step What you did What it proves Real-world analogue
3 Rings ACTIVE Stateful components joined; no black-holing First health check every incident
5 Blocks in S3 + compactor working Durable path + entropy control work The storage half of the system
6 TraceQL search + | rate() Query path + local-blocks work Answering “show me the trace”
7 RED metrics + exemplars Generator + correlation work Rebuilding the APM dashboards
8 Reproduce a 429, read the metric Failures have named signals The 90-second diagnosis

Teardown. One Helm release plus a bucket; S3 data survives an uninstall by design (re-attach a new cluster to existing traces), so delete data only when you mean it:

helm uninstall tempo -n tracing
kubectl delete namespace tracing
# Only when you genuinely want the traces gone:
aws s3 rm "s3://$TEMPO_BUCKET/" --recursive
aws s3api delete-bucket --bucket "$TEMPO_BUCKET" --region "$AWS_REGION"

Cost note. On a lab cluster this is a few pods for an hour plus a near-empty S3 bucket — well under a dollar. Delete the namespace and empty the bucket and everything stops.

Common mistakes & troubleshooting

This is the playbook you bookmark. First as a scannable table you can read at 02:14, then the reasoning for the ones that bite hardest.

# Symptom Root cause Confirm (exact cmd / path) Fix
1 TraceQL metrics (rate/quantile_over_time) return empty local-blocks processor not enabled Generator config processors list; run {} | rate() (empty) Add local-blocks to metrics_generator.processors
2 Spans accepted but nothing appears in S3 IAM missing s3:DeleteObject, or IRSA annotation didn’t attach kubectl -n tracing logs deploy/tempo-ingester for AccessDenied; aws s3 ls empty Add s3:DeleteObject; verify IRSA SA annotation
3 429 / spans dropped under load Per-tenant ingestion_rate_limit_bytes too low tempo_discarded_spans_total{reason="rate_limited"} rising Raise rate + burst limits; enable collector batch
4 Service graph empty in Grafana service-graphs off, or serviceMap points at wrong Prometheus Generator processors; data source serviceMap.datasourceUid Enable processor; fix data source UID
5 S3 object count explodes / search slows Multiple un-sharded compactors racing, or compactor under-provisioned Compaction backlog metric rising; tempo_compactor_blocks_total flat Run one compactor per shard, or enable sharded compaction
6 Exemplars don’t link metric → trace send_exemplars: false, or Prometheus lacks exemplar storage Data source; Prometheus flags Set send_exemplars: true; --enable-feature=exemplar-storage
7 Ingester OOM, recent traces lost max_block_duration too long / RAM too low / ingest spike Ingester memory near limit before restart; ring rebalance More RAM; lower max_block_duration; scale ingesters (drained)
8 Generator OOM under load Span-name/attribute cardinality explosion Prometheus series count for traces_spanmetrics_* huge Normalise span names at collector; cap dimensions; more memory
9 “block not found” on search S3 lifecycle rule deleted a live block Bucket lifecycle vs block_retention; querier logs Set lifecycle > block_retention; let compactor own deletion
10 Ingester UNHEALTHY in the ring, spans lost Pod crashed / not draining / clock skew curl .../ingester/ring shows UNHEALTHY Restart/replace pod; graceful drain on scale-down
11 RED metrics undercount vs real traffic Generator sits behind tail sampling Generator on sampled stream; rates too low Run generator on un-sampled stream (or metrics at collector)
12 Writes/reads fail after enabling multitenancy X-Scope-OrgID header missing on client Distributor/querier 401/no-tenant errors Inject X-Scope-OrgID at collector + Grafana/gateway
13 Search slow, querier CPU idle S3 read-bandwidth-bound, too few queriers Query latency high; querier CPU low; frontend queue deep Add queriers; enable block/results cache
14 Query-frontend queue deep, timeouts Queriers can’t keep up / max_outstanding too low tempo_query_frontend_queue_length high Add queriers; raise max_outstanding_per_tenant

The expanded form for the entries that bite hardest:

1. TraceQL metrics queries return empty. Root cause: the local-blocks processor is not enabled, so the generator emits Prometheus series but does not buffer raw spans for in-Tempo aggregation. Confirm: run { } | rate() in Explore — it returns nothing while { } search returns traces. Fix: add local-blocks to metrics_generator.processors (alongside span-metrics); restart the generator. span-metrics alone gives you the dashboards but never the TraceQL rate/quantile_over_time.

2. Spans accepted but nothing lands in S3. Root cause: almost always IAM — the role is missing s3:DeleteObject (so the compactor fails and, depending on version, the write path stalls) or the IRSA annotation never attached the role to the pods. Confirm: kubectl -n tracing logs deploy/tempo-ingester | grep -i accessdenied and aws s3 ls "s3://$TEMPO_BUCKET/" empty. Fix: add s3:DeleteObject to the policy and verify the service account has the eks.amazonaws.com/role-arn annotation and that serviceAccount.name in the values matches it.

5. S3 object count explodes and search slows. Root cause: more than one un-sharded compactor, so replicas fight over the same blocks and waste work, or a single compactor that cannot keep up with block production. Confirm: the compaction-backlog metric climbs while tempo_compactor_blocks_total stays flat. Fix: run exactly one compactor per tenant shard, or enable compactor sharding to partition ownership before adding replicas.

8. Generator OOM under load. Root cause: span names or dimensions carry high-cardinality values (a raw URL with an ID), so span-metrics produces an explosion of Prometheus series and the generator’s memory blows up buffering them. Confirm: the series count for traces_spanmetrics_* is enormous; generator memory pins before the OOM. Fix: normalise span names at the collector (/checkout/{id}), cap processor.span_metrics.dimensions to the labels you actually chart, and give the generator memory headroom. This is the single most common generator failure at scale.

11. RED metrics undercount real traffic. Root cause: the metrics-generator is downstream of the tail sampler, so it only sees the ~10% of traces that were kept, and your rates/error counts reflect survivors, not reality. Confirm: generator input equals sampled output; dashboard rates are suspiciously low versus known traffic. Fix: generate span-metrics on the un-sampled stream — either run the generator on the pre-sampling path or compute span-metrics at the collector — so dashboards reflect all traffic while only stored traces are sampled.

Best practices

The alerts worth wiring before the next incident — leading indicators, not lagging “search is down”:

Alert on Signal Threshold (starting point) Why it’s leading
Discarded spans tempo_discarded_spans_total rate > 0 sustained 5 min First sign of 429 / too-large / out-of-order
Ingester memory container memory % > 85% for 10 min Predicts OOM + recent-trace loss
Generator memory container memory % > 85% for 10 min Predicts cardinality-driven OOM
Compaction backlog outstanding uncompacted blocks rising trend Search will slow; LIST cost climbs
Query queue depth tempo_query_frontend_queue_length high + rising Queriers behind; search latency next
Ring health UNHEALTHY component count ≥ 1 for 5 min Black-holed spans / failed reads
Exemplar linkage manual/synthetic check broken Correlation quietly stopped working

Security notes

Traces carry request internals — URLs, headers, sometimes IDs — so treat the pipeline as sensitive data.

The security controls that also prevent incidents:

Control Mechanism Secures against Also prevents
Collector PII scrubbing redaction/transform processors Sensitive data in S3 blocks Expensive selective deletion later
SSE-KMS + block-public-access Bucket encryption + BPA Trace exfiltration Accidental public exposure regressions
IRSA / Vault dynamic creds Assumed role / leased secret Long-lived key leak Credentials in a values file (the leak)
Least-privilege S3 policy Scoped read/write/list/delete Cross-bucket access Over-broad delete on other data
IdP in front of Grafana OIDC/SAML + group→role Anonymous trace access Wrong-audience data exposure
Trusted X-Scope-OrgID Header set by gateway only Cross-tenant reads Tenant data leakage

Cost & sizing

The entire reason for this build is unit economics, so size it deliberately and instrument the savings.

S3 storage is the cheap part — sampling is the lever. Storing traces in S3 turns the dominant cost from per-span vendor pricing into roughly the price of object storage plus compaction compute; a terabyte of retained traces is single-digit dollars a month at rest. The biggest lever by far is tail sampling at the collector (keep errors + slow, sample the rest): it cuts stored volume by ~10× while keeping every trace that matters, so it dominates both the S3 line and the compute needed to ingest and search.

The metrics-generator is the priciest component to run because it processes every span and its cost scales with span-metric cardinality, not just volume. Right-size its replicas and memory to span rate, control span-name cardinality at the collector, and remember its output lands in Prometheus — where high cardinality costs you on the metrics side. Ingesters cost RAM (they hold recent data); queriers cost S3 read bandwidth on heavy search; the distributor and compactor are comparatively cheap.

Rough component sizing from real signals:

Component Scales with Starting point (mid-size, ~50k spans/s post-sampling) Right-size by
Distributor Span-rate CPU 3 replicas, ~1 vCPU each Watch spans-received vs CPU
Ingester Active-trace RAM 3 replicas, 4–8 GiB, 20 Gi WAL Memory headroom; block flush rate
Metrics-generator Span-rate + cardinality 2–3 replicas, most memory of any component Series count; OOM risk
Compactor S3 block backlog 1 per shard (or sharded) Compaction backlog trend
Query-frontend Query concurrency 2 replicas Queue depth
Querier Search bytes / S3 read 3+ replicas Search latency; add for read bandwidth
S3 Retained bytes Pennies/GB/month Retention window; block size/compression

A rough monthly picture, mid-size platform after tail sampling: Tempo compute (the pods above) on existing cluster headroom, plus S3 storage for 30 days of sampled traces (~hundreds of GB to low TB) at object-storage prices, plus the Prometheus/Mimir cost of the generated series. The dominant savings is retiring per-span APM pricing; Northwind Pay went from ~$41,000 to ~$6,500/month all-in. Watch two lines: S3 request/LIST cost (kept low by compaction merging small blocks) and Prometheus series cost from generator cardinality (kept low by normalising span names). Tune block_retention to your real query window — 30 days is generous; 7–14 is common — and let the compactor, not S3, delete.

The cost drivers and what each one buys:

Cost driver What you pay for Rough scale Lever to control it
Stored trace volume S3 GB at rest Pennies/GB/month Tail sampling (~10×); retention window
Metrics-generator compute CPU/RAM to process every span Priciest Tempo component Right-size replicas; control cardinality
Prometheus series (generator output) Metric ingestion/storage Grows with span-name cardinality Normalise span names; cap dimensions
Querier / S3 read Bytes scanned on heavy search Per-GB read + querier CPU Block/results cache; cap search.max_duration
S3 request cost PUT/GET/LIST operations Rises with small-object count Compaction merges blocks → fewer LISTs
Ingester RAM Recent-data buffering Per-instance memory Sized to active-trace volume

Interview & exam questions

1. Why is Grafana Tempo so much cheaper to store traces than Jaeger on Elasticsearch? Tempo indexes only by trace ID and stores trace bodies as columnar Parquet blocks in object storage, so it never builds the big attribute index whose storage and RAM cost dominates an Elasticsearch-backed store. Attribute search becomes a parallelised scan of Parquet columns pruned by block metadata — slower per query than an index hit but astronomically cheaper to retain, since cost tracks object-storage prices, not index size.

2. What is the metrics-generator and what does it produce? A Tempo component that taps the span stream and derives Prometheus series instead of storing traces: span-metrics (traces_spanmetrics_* — the RED call/error counters and latency histogram) via the span-metrics processor, service-graph edge metrics via service-graphs, and buffered raw spans for TraceQL metrics via local-blocks. It remote_writes these to Prometheus, which is how you keep your dashboards after leaving an APM.

3. Why do TraceQL quantile_over_time/rate queries return nothing even though span-metrics are in Prometheus? Because those in-Tempo aggregate queries require the local-blocks processor, which buffers raw spans for computation; span-metrics alone only produces the Prometheus series. Add local-blocks to metrics_generator.processors. This is the single most common Tempo misconfiguration.

4. Head sampling vs tail sampling — where does each live and what can each do? Head sampling is in the SDK at the root span: cheap, consistent, but blind (it decides before it knows if the trace errored or was slow). Tail sampling is at the OTel Collector after the whole trace is buffered: it can keep 100% of errors and slow traces while sampling the rest, at the cost of memory and requiring all spans of a trace to reach the same collector. Tail sampling is the ~10× cost lever that keeps the traces you actually need.

5. Retention: what deletes old traces, and what is the classic mistake? The compactor’s block_retention deletes blocks older than the window — retention is a compactor setting, not an S3 lifecycle rule. The classic mistake is expiring objects with an S3 lifecycle rule, which deletes a block Tempo still references and produces “block not found” on search. Set the S3 lifecycle backstop longer than block_retention so the compactor always owns deletion.

6. Which Tempo components are stateful, and why does it matter? The ingesters and the metrics-generator — they hold recent data (WAL + in-memory blocks / buffered spans) and coordinate via a hash ring. It matters because an UNHEALTHY ring member black-holes the traces hashed to it, an OOM or ungraceful scale-down loses un-flushed recent data, and adding a replica triggers a rebalance that must warm up. You drain them before scale-down and give them memory headroom.

7. How do exemplars close the loop between metrics and traces, and what two things must be enabled? An exemplar stamps a representative trace ID onto a metric sample, so a spike on a latency panel in Grafana renders a clickable diamond that opens that exact trace. Two prerequisites: send_exemplars: true on the generator’s remote_write, and Prometheus started with --enable-feature=exemplar-storage (exemplars are off by default). Miss either and no diamonds appear.

8. Why can the metrics-generator undercount your real traffic, and how do you prevent it? If the generator sits downstream of the tail sampler it only sees the sampled survivors (say 10%), so its RED rates and error counts reflect a fraction of reality. Prevent it by generating span-metrics on the un-sampled stream — run the generator on the pre-sampling path or compute span-metrics at the collector — so dashboards reflect all traffic while only stored traces are sampled.

9. What is TraceQL and how is its metrics half different from its search half? TraceQL is Tempo’s query language: structural search finds traces/spans matching conditions in { } (intrinsics like duration/status, resource./span. attributes, &&/||, and ancestor/descendant operators), while its metrics half aggregates over matched spans (rate, count_over_time, quantile_over_time ... by (labels)). Search returns traces; metrics compute values inside Tempo over spans the local-blocks processor buffers.

10. When would you choose Jaeger over Tempo? When your primary access pattern is fast, rich, indexed ad-hoc attribute search over large time ranges and you are willing to run and pay for the storage cluster (Elasticsearch/Cassandra) that provides it, or when you want a minimal single-binary footprint at small scale. Tempo wins when the constraint is storage cost at long retention and you live in the Grafana/Prometheus/object-storage world with built-in RED metrics from traces.

11. How does compaction affect both cost and search, and how many compactors do you run? Compaction merges the many small blocks ingesters produce into fewer large ones, which cuts LIST/request cost, improves compression, and speeds search (fewer objects to open); it also enforces retention. Run one un-sharded compactor per tenant shard — multiple un-sharded compactors race over the same blocks; scale compaction by enabling sharding, not by adding racing replicas.

12. Why do the read path and write path scale independently, and what signal drives each? They share only the S3 backend and the ingesters’ recent window, so a query storm cannot starve ingestion and vice versa. Write-side: distributor on span-rate CPU, ingester on active-trace RAM. Read-side: query-frontend on queue depth, querier on search latency / S3 read bandwidth. “Tempo is slow” first forks into write or read because they are different subsystems.

These map to vendor-neutral observability and SRE competencies rather than a single cert, but the concepts appear across the Grafana Certified Observability material, CNCF/CKA-adjacent platform-ops knowledge, and any OpenTelemetry-focused assessment. A compact mapping:

Question theme Competency area
Index-by-trace-ID, block format, cost model Observability architecture / storage design
Metrics-generator, RED, service graphs Telemetry-to-metrics; SRE golden signals
TraceQL search + metrics Query languages / trace analysis
Head vs tail sampling Telemetry pipeline design (OpenTelemetry)
Rings, stateful scaling, compaction, retention Distributed-systems operations
Exemplars, trace↔metric↔log correlation Full-stack observability integration

Quick check

  1. TraceQL search ({ status = error }) returns traces, but { } | rate() returns nothing. What is missing and where do you add it?
  2. Spans are accepted (distributor counter rising) but S3 stays empty after 30 minutes. Name the single most likely cause and the log line to look for.
  3. True or false: to keep your RED dashboards accurate, the metrics-generator should sit after the tail sampler so it only counts the traces you store.
  4. What actually deletes traces older than your retention window, and what is the mistake that causes “block not found” errors on search?
  5. A latency graph in Grafana shows the p95 climbing but there are no clickable exemplar diamonds. Name the two things that must be enabled for exemplars to link to traces.

Answers

  1. The local-blocks processor is missing. Add it to metrics_generator.processors (alongside span-metrics) and restart the generator — span-metrics alone gives the Prometheus series but not in-Tempo rate/quantile_over_time.
  2. IAM — almost always the role is missing s3:DeleteObject or the IRSA annotation didn’t attach the role to the pods. Look for AccessDenied in kubectl -n tracing logs deploy/tempo-ingester, and verify the service account annotation and that serviceAccount.name matches.
  3. False. If the generator sits after the sampler it only sees the ~10% survivors and undercounts real traffic. Run it on the un-sampled stream (or compute span-metrics at the collector) so dashboards reflect all traffic while only stored traces are sampled.
  4. The compactor’s block_retention deletes old blocks. The mistake is expiring objects with an S3 lifecycle rule (which deletes blocks Tempo still references → “block not found”); set the lifecycle backstop longer than block_retention so the compactor owns deletion.
  5. send_exemplars: true on the generator’s remote_write, and Prometheus started with --enable-feature=exemplar-storage. Exemplars are off by default; miss either and the diamonds never render.

Glossary

Next steps

You can now stand up, wire, and defend a distributed Tempo cluster on S3. Build outward across the stack:

Grafana TempoTraceQLOpenTelemetryDistributed TracingS3KubernetesMetrics-GeneratorObservability
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading