ObservabilityXLVIII · Trace TroubleshootingTraceTroubleshooting
High-Cardinality Attributes
What you'll learn
- Recognise a high-cardinality attribute explosion from the symptom of slow TraceQL queries and growing index size
- Explain the index cost of an unbounded attribute in Tempo and the search cost in TraceQL
- Configure a cardinality budget per attribute and enforce it at the SDK or collector
- Diagnose the specific attribute that is blowing the budget using the Tempo metrics endpoint
- Identify the most common production cause: a request_id or user_id stamped as a span attribute
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
The on-call engineer opens a TraceQL query that worked last
week. The query is { resource.service.name = "checkout" }. The
Grafana panel spins for forty-five seconds and returns a
“query timeout”. They open the Tempo metrics endpoint. The
metric tempo_search_external_lookups_total is climbing. The
metric tempo_index_search_result_count is enormous. The Tempo
ingester’s blocklist has more entries than last week. Something
about the indexed attributes has changed.
A colleague diffs the application’s instrumentation. Last week,
the checkout service stamped user.email on every span as a
debugging aid during the previous incident. The PR was merged.
The attribute is in production. The user.email value space is
unbounded — every new user is a new value. Tempo has indexed
every distinct value. The index has blown.
This is the lesson. Attribute cardinality is the cost driver of distributed tracing. The cost is invisible until the index blows.
What it is
A high-cardinality attribute is a span attribute whose value
space is large or unbounded. The cardinality of an attribute is
the number of distinct values the attribute takes across all
spans. An attribute with a value space of three (success,
failure, pending) has cardinality 3. An attribute with a
value space of one value per user has cardinality equal to the
user count.
Tempo indexes span attributes for search. The index is a per-attribute inverted map from value to spans. The size of the map is proportional to the number of distinct values times the number of spans that contain each value. A high-cardinality attribute makes the index large, the search slow, and the storage cost high.
The cost shape is non-linear. A service with 10 000 distinct
users and 100 spans per user has 1 million spans and a
cardinality-100 000 attribute. The search { user.id = "..." }
is O(1) per match, but the index itself is many gigabytes.
Why a sysadmin cares
Tempo’s design budget assumes bounded cardinality. The OTel
semantic conventions explicitly call out attributes that are
acceptable to index (http.method, http.status_code,
service.name) versus attributes that are not (user.id,
request.id, raw URLs, raw error messages).
Three operational payoffs ride on the cardinality budget:
- Query latency. A TraceQL query that selects on a high-cardinality attribute has to scan the inverted index. The scan is O(matches), not O(spans), but the index is large. The query is slow. The Grafana panel times out.
- Index size. The inverted index is in memory at the querier. A cardinality explosion is a memory exhaustion. Tempo starts swapping; queries slow by an order of magnitude.
- Storage cost. Every distinct value in every block is stored on disk. The block size grows with the cardinality. The retention math changes — what looked like a 30-day retention at 1 TB is now a 7-day retention at 5 TB.
The cost is paid in the next query, the next retention window, and the next capacity review.
How it works — the mental model
Tempo stores traces as blocks. A block contains every span received in a window (typically 5 to 30 minutes). Within the block, Tempo builds an inverted index over the span and resource attributes.
Incoming spans (from the collector)
+-- block for window [T, T+15m)
+-- inverted index:
span.http.status_code = 200 -> [span_a, span_b, ...]
span.http.status_code = 500 -> [span_x, span_y]
resource.service.name = "checkout" -> [every span]
user.id = "u12345" -> [span_a, span_c]
user.id = "u12346" -> [span_b]
...
(the size of the index grows with the cardinality
of every attribute)
+-- the block is flushed to storage
+-- the inverted index lives in memory at the querier
A TraceQL query { span.http.status_code = 500 } is a lookup
against the inverted index. The lookup is O(matches). A query
{ user.id = "u12345" } is also a lookup, but the inverted
index for user.id has as many entries as there are users.
How to configure it
The fix is in two places: the application (don’t stamp high-cardinality attributes) and the collector (strip or transform them).
The OTel Collector processor for stripping attributes:
# /etc/otelcol-contrib/config.yaml
processors:
# Drop high-cardinality attributes before they reach Tempo.
attributes/limit:
include:
# Only allow these attributes; drop everything else.
match_type: strict
attributes:
- service.name
- service.namespace
- service.instance.id
- http.method
- http.status_code
- http.target # the path, not the URL
- http.route
- db.system
- db.statement # consider the cost
- error.type
actions:
- key: db.statement
# Truncate long SQL statements to bound the cardinality.
action: truncate
max_value_len: 200
# Hash high-cardinality identifiers that are useful for the
# application but should not be indexed by Tempo.
transform:
trace_statements:
- context: span
statements:
- replace_pattern(attributes["user.id"], "user.id", "$$user_id_hash")
- hash(attributes["user.id"], "user.id_hash")
- delete(attributes, "user.id")
Three patterns to apply:
- Allow-list. Only stamp the attributes the team has agreed
to index. The
attributes/limitprocessor drops everything else. The application discipline catches what the collector misses. - Truncate. Long strings (SQL statements, error messages, URLs) have unbounded cardinality by virtue of their length. Truncate to a fixed maximum length; the first 200 characters of a SQL statement are usually enough to identify the query.
- Hash. High-cardinality identifiers (
user.id,order.id) are useful for application-level debugging but useless for cross-cutting search. Hash the value to a fixed-size string; the cardinality is bounded by the hash space.
The Grafana Alloy equivalent:
otelcol.processor.attributes "limit" {
include {
match_type = "strict"
attributes = [
"service.name",
"service.namespace",
"service.instance.id",
"http.method",
"http.status_code",
"http.target",
"http.route",
"db.system",
"error.type",
]
}
output { traces = [otelcol.processor.batch.default.input] }
}
How to validate it
The diagnostic ladder:
# 1. What is the cardinality of the indexed attributes?
# Use Tempo's metric endpoint to inspect the highest-cardinality
# columns. (The exact metric name depends on the Tempo version;
# in 2.x it is tempo_index_search_result_count and the cardinality
# must be derived from the blocklist inspection.)
curl -sf http://tempo:3200/api/metrics | grep -E "tempo_index|cardinality"
# tempo_search_external_lookups_total{...} 41283
# tempo_search_external_lookup_result_count{...} 18432
# (the lookup result count tells you how many spans a search
# found; a query that returns a huge result count for a
# "specific" filter is a high-cardinality signal)
# 2. Which attribute is the culprit?
# TraceQL with stats by is the operational diagnostic.
tctl trace search --query='{ resource.service.name = "checkout" } | stats by (span.http.status_code, http.method) (count)' --since=1h
# (small table: cardinality is bounded)
#
# Repeat with a suspected high-cardinality attribute:
tctl trace search --query='{ resource.service.name = "checkout" } | stats by (span.user.id) (count)' --since=1h
# (huge table: user.id has exploded)
# 3. What is the Tempo block size in storage?
du -sh /var/tempo/blocks/
# (compare to last week's baseline; a sudden growth is a
# cardinality-explosion signal)
# 4. Is the OTel Collector stripping attributes?
kubectl logs deploy/alloy -c alloy | grep -i "attributes"
# (the collector logs the matched and dropped attributes at
# startup)
# 5. TraceQL search latency.
# Run a representative query and time it.
time tctl trace search --query='{ span.http.status_code = 500 }' --since=1h --limit=10
# real 0m4.213s
# (a healthy query is under 1 second; sustained multi-second
# queries on "specific" filters are a cardinality signal)
# 6. Is the allow-list enforced?
grep -A 20 "attributes/limit:" /etc/otelcol-contrib/config.yaml
# (the include list should match the team's agreed attribute set;
# a missing list means everything is indexed)
The TraceQL stats by query is the structural answer. A
stats by on a high-cardinality attribute returns an enormous
table; a stats by on a bounded attribute returns a small one.
How it can fail
Six recurring failure modes.
user.idstamped on every span. The application developer wanted to debug a per-user issue. They addedattributes["user.id"] = user.IDto the span. The value space equals the user count. Symptom: Tempo’s index size grows with the user base; queries onuser.idare slow even when the result set is small.- Raw URL stamped as
http.target. The application stamps the full URL — query string included — on every span. The value space equals the number of distinct URLs, which grows with each campaign, each A/B test, each search query. Symptom:http.targetcardinality climbs; the index bloats. - Full SQL statement stamped as
db.statement. The application stamps the parameterised SQL on every span. Long statements, dynamic table names, and dynamic column lists push the cardinality up. Symptom:db.statementcardinality climbs; the index bloats. - Error messages stamped as
error.message. The application stamps the exception message on every span. The message includes per-request details (the input that failed, the user that hit the error). Symptom:error.messagecardinality grows with the failure surface. - The OTel Collector allow-list is missing. The collector forwards every attribute the SDK stamps. Without an allow- list, the team has no enforcement layer; the discipline is entirely in the application. Symptom: the index grows with whatever the application happens to stamp.
- The application was migrated from logs. The team
traditionally logged
request_id,session_id,correlation_id. They “moved it to tracing” by stamping the same identifiers as span attributes. Symptom: the trace index now carries the same cardinality as the log label space — which was already a budget problem.
How to troubleshoot it
The diagnostic order:
- What is the cardinality of each indexed attribute? Run
stats byon the suspect attributes. The output table size is the cardinality. - What is the change? Diff this week’s metrics from last week’s. A sudden growth is a recent code change; a slow growth is a long-running drift.
- Where is the attribute stamped? The application source. A PR that adds an attribute is the structural fix.
- Is the allow-list in place? The collector config. If the allow-list is missing, add it. If the allow-list is in place but the attribute is in the list, the application discipline is the fix.
- Can the attribute be hashed? A
user.idis useful for per-user debugging but useless for cross-cutting search. Hash it to a fixed-size string; the cardinality is bounded. - Can the attribute be truncated? A
db.statementis useful for query identification; the first 200 characters usually suffice. Truncate it.
Security implications
High-cardinality attributes are a PII exposure vector. A
user.email attribute stamps the email on every span. Tempo
stores it for the retention window. The querier can retrieve
it. The on-call engineer can search for it. A breach of the
Tempo credentials is a breach of every user’s email.
The remediation is hashing. The application computes a deterministic hash of the identifier (HMAC-SHA256 with a service-scoped secret) and stamps the hash. The hash is unique per user but not reversible. A search for the hash returns the same spans as a search for the original value, but the value itself is never stored.
A second risk is around the data egress. Tempo’s search API
returns the span content, including the attribute values. A
service that queries Tempo for a user.id and returns the
value to a client has inadvertently built a user-enumeration
endpoint. The remediation is to expose only the hash to the
search API and to keep the original identifier out of Tempo
entirely.
Performance implications
The cost of high cardinality is paid in three places.
- Index memory. Tempo’s inverted index lives in memory at the querier. A cardinality-1 000 000 attribute adds roughly 50 MB to the index per block. A search over 24 hours at 5-minute blocks is 288 blocks. The memory cost is cumulative.
- Query latency. A TraceQL query on a high-cardinality attribute is a scan of the index column. The scan is O(distinct values), not O(matches). A search for a value that exists in 10 spans still scans the entire index column.
- Storage. Every distinct value of every indexed attribute is stored in the block. The block size is proportional to the cardinality sum across all indexed attributes.
The performance budget is roughly 10 000 distinct values per attribute across the search window. Attributes above that threshold should be hashed, truncated, or excluded from the index.
Production guidance
- Allow-list the indexed attributes. The collector should drop any attribute not on the list. The application discipline is the second line of defence.
- Hash the high-cardinality identifiers.
user.id,order.id,session.idare useful for application-level debugging but useless for cross-cutting search. Hash them. - Truncate the unbounded strings. SQL statements, error messages, URLs — every unbounded string should have a maximum length.
- Run
stats byweekly. The cardinality table is a budget document. A growing cardinality on any attribute is a sign that the budget is being eroded. - Alert on Tempo index size. A sudden growth in the block list size is a cardinality-explosion signal.
Verification
You should now be able to answer:
- What is the difference between a high-cardinality attribute and a high-volume attribute?
- Why does Tempo’s index size grow with attribute cardinality?
- What is the most common production cause of a cardinality explosion?
Quiz
Knowledge check · 8 questions
Q1. A TraceQL query that worked last week now times out. The Tempo index size has doubled. The most likely cause is:
Q2. Why does a high-cardinality attribute make TraceQL queries slow?
Q3. A user.id attribute is safe to stamp on every span because the value space is bounded by the user count.
Q4. Which of these are real causes of attribute cardinality explosions in production?
Q5. A team needs to keep user.id available for per-user debugging but not in the Tempo index. What is the right pattern?
Q6. Name the OTel Collector processor pattern that drops any attribute not on an explicit allow-list.
Q7. Hashing a high-cardinality identifier before stamping it as a span attribute preserves the per-user debugging capability without making the plaintext value retrievable.
Q8. A TraceQL stats by query on http.target returns 500 000 distinct values in a one-hour window. What does this indicate?
Passing score: 75%. Answers are checked in this browser.