Sizing Trace Storage and Retention Costs
Storage cost is spans/second × bytes per span × index multiplier × retention seconds, and of those four terms only two are worth optimising — bytes per span (attribute discipline) and retention (tiering) — because the other two are set by your traffic and your sampling policy.
Context and when it matters
Trace storage bills surprise people because three of the four terms grow independently. Traffic grows with the business. Spans per request grow every time someone instruments a new library — adding database and cache instrumentation routinely triples span volume overnight. Attribute size grows as teams add context. Nobody changed the sampling rate, and the bill doubled.
Modelling this before you commit to a backend, a retention window, or a vendor contract takes an afternoon and changes the decision surprisingly often. It also tells you which lever to pull when the bill does move: dropping retention from 30 days to 14 halves the cost with almost no impact on debugging, while halving the sample rate halves your chance of having the trace you need.
What a span actually costs at rest
Index overhead is where the two common backends diverge sharply. Tempo indexes only trace IDs plus a small set of block-level summaries, so overhead is roughly 5–15% and the object store holds compressed blocks. Jaeger with Elasticsearch indexes every tag by default, and the inverted index frequently exceeds the size of the documents themselves — a 2–3× multiplier is normal on attribute-rich spans. That difference dominates the comparison more than raw storage pricing does.
Do the arithmetic
# storage_model.py — everything in one place, so assumptions are visible
SPANS_PER_SEC = 9_900 # post-sampling, measured at the collector
BYTES_PER_SPAN = 420 # compressed, at rest, measured
INDEX_MULTIPLIER = 1.10 # Tempo ~1.05-1.15, ES-backed Jaeger ~2-3
RETENTION_DAYS = 14
daily_bytes = SPANS_PER_SEC * BYTES_PER_SPAN * INDEX_MULTIPLIER * 86_400
steady_state = daily_bytes * RETENTION_DAYS
print(f"per day: {daily_bytes / 1024**4:.2f} TB") # 0.35 TB
print(f"steady state: {steady_state / 1024**4:.2f} TB") # 4.9 TB
# Object storage at ~$0.023/GB-month:
gb = steady_state / 1024**3
print(f"storage: ${gb * 0.023:,.0f}/month") # ~$115/month
# The line people forget: request costs. Tempo issues GET requests per query,
# and a busy dashboard can rival storage cost on its own.
Two figures from that model are worth internalising. First, raw object storage for a mid-sized fleet is cheap — hundreds of dollars, not thousands. Second, the expensive parts are elsewhere: the compute that ingests and queries, the index if you chose a backend that builds one, and the egress if your queries cross a region boundary. A cost model that stops at gigabytes-times-price understates the real number by a large factor.
Retention tiers beat a single window
Debugging value decays fast. Most trace lookups happen within an hour of the event, nearly all within a day, and what remains after that is compliance and capacity planning rather than debugging.
# tempo.yaml — a hot local tier with a longer object-store tail
storage:
trace:
backend: s3
s3:
bucket: traces-prod
blocklist_poll: 5m
# Local cache serves the first few days from disk; older blocks come
# from the object store on demand.
cache: memcached
block:
version: vParquet4
compactor:
compaction:
# This is the retention lever. Halving it halves steady-state storage.
block_retention: 336h # 14 days
compacted_block_retention: 1h
The costs that are not storage
Object storage is usually the smallest line in a tracing bill, which is why models that only count gigabytes mislead. Four other costs matter, and their relative sizes differ enough between deployments that it is worth checking which one dominates yours before optimising anything.
Ingest compute. Collectors and ingesters must handle peak throughput plus incident-time amplification, and they are sized for that peak rather than for the average. A pipeline handling 10,000 spans/second at steady state typically runs several collector replicas and a similar number of ingesters, and that compute frequently costs more per month than the storage it feeds.
Query compute. Tempo’s queriers decompress blocks on demand, so an expensive aggregate over a wide window is real CPU. Dashboards that run TraceQL aggregates on a thirty-second refresh can dominate query cost while nobody is watching them.
Object-store request charges. Fetching a trace is several GET requests; a busy environment issuing millions of them per month pays a request bill that can rival the storage bill. Caching the hot tier locally is as much a cost control as a latency one.
Egress. Traces crossing a region or cloud boundary — a collector in one region exporting to a backend in another, or a vendor endpoint outside your network — are billed per gigabyte at rates that dwarf storage pricing. This is the single most common cause of a tracing bill that is an order of magnitude above the model.
The practical consequence is that the cheapest architecture is usually the one that keeps the pipeline close to the workload: collect in-region, sample before the expensive hop, and only move what you intend to keep.
Verification
# Actual bytes written per second, against the model
rate(tempo_ingester_bytes_written_total[1h])
# Bytes per span, measured rather than assumed
rate(tempo_ingester_bytes_written_total[1h])
/ rate(tempo_ingester_spans_received_total[1h])
# For Jaeger on Elasticsearch, the index multiplier that actually applies
elasticsearch_indices_store_size_bytes / elasticsearch_indices_docs_primary
Compare the measured bytes-per-span against your model quarterly and after any instrumentation change. A jump usually traces back to one team adding an attribute — a payload body, a full URL, a stack trace on a frequent error — rather than to traffic growth.
Decision criteria
- Cost dominated by retention? Tier it. Three days hot plus eleven cold typically costs 40% of a flat fourteen and changes nothing about day-to-day debugging.
- Cost dominated by index? That is a backend decision, not a tuning one. An attribute-rich fleet on Elasticsearch-backed Jaeger pays a 2–3× multiplier that Tempo simply does not charge — see Jaeger vs Tempo vs Zipkin.
- Cost dominated by span volume? Look at spans per request before touching the sample rate. Aggregating forty cache spans into one with a count attribute is free coverage; halving the sample rate is not.
- Compliance requires long retention? Retain a filtered subset — error traces and audited endpoints only — rather than everything. See configuring Jaeger retention policies for compliance.
The three levers, ranked by what they cost you in debugging ability per unit of saving:
Common pitfalls
- Modelling with uncompressed bytes. OTLP compresses roughly 4:1 and stored blocks compress further. Using wire bytes overstates storage several-fold and leads to an unnecessarily aggressive sample rate.
- Forgetting request and egress charges. Object-store GET pricing and cross-region egress can exceed storage on a query-heavy deployment.
- Assuming the multiplier is constant. Index overhead scales with attribute cardinality, not span count, so adding one high-cardinality attribute can move it materially.
- Modelling steady state on day one. Storage grows for a full retention period before it plateaus, so the first two weeks look reassuringly cheap and the invoice at the end of the month is double what anyone expected. Always quote the steady-state figure, and label the ramp explicitly when reporting early numbers.
- Sizing for average rather than peak. Ingest capacity must handle peak plus incident-time amplification; storage can be sized for the average, but the two are different calculations and conflating them under-provisions the ingest path.
Related
- Calculating sampling rates for a trace budget — the volume half of the same equation
- Jaeger vs Tempo vs Zipkin decision guide — how the index multiplier differs by backend
- Controlling span attribute cardinality — the discipline that keeps bytes-per-span down