Cardinality is the killer

For each unique combination of indexed dimensions, the system has to keep a separate something — a stream in Loki, a posting-list entry in ELK; the number of unique combinations is the cardinality of the indexed shape, and putting a high-cardinality dimension (request_id, user_id, trace_id) into labels or mapped fields blows up the index in minutes on either system.

Previously

ELK answered the query in milliseconds because the index already knew which documents contained ERROR. The temptation is to index more things — but 'more things' has unbounded values, and unbounded values destroy the index on both systems.

Scene 06

Cardinality is the killer

  1. Watch
  2. Try it
  3. Predict
  4. Capture
DIMENSION CONTROLADD LABEL: pod_name (50 values)LOKI · LABEL CARDINALITYstreams = service(3) × env(2) × level(3) × pod_name(50)base = 18900 streamsSTREAMS900/ 100k ceilingINDEX SIZECHUNKS ON S3900growingACTIVEapproaching tenant ceiling · 900/100k streamsELK · MAPPING EXPLOSIONcluster mapping budgetMAPPED FIELDS · 47/1000EMITTED JSON · KEYS ARE DYNAMIC{ "ts": "2025-05-08T12:01:33Z", "service": "api", "level": "INFO", "msg": "request handled" }idleRULE · CARDINALITY ROUTINGlow-cardinality → labels (Loki) / mapped fields (ELK)high-cardinality → body (Loki) / structured metadata or non-indexed string (ELK)Loki + pod_name (50 values from rolling deploys): 18 → 900 streams. Amber — approaching the 100k tenant ceili…
What to watch for

Both backends start at green baselines. LEFT pane (Loki) has 18 streams from service × env × level. RIGHT pane (ELK) has 47 of 1,000 mapped fields used. Read the rule-card at the bottom but don't act on it yet — Manipulate is where you'll feel why it matters.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Loki.ensure_stream
every unique label-set is its own stream + chunk
def ensure_stream(tenant, label_set):
stream_id = hash(sorted(label_set.items()))
if stream_id in active_streams[tenant]:
return active_streams[tenant][stream_id]
# new label-set -> new stream, new chunk, new index entry
if len(active_streams[tenant]) >= max_streams_per_user:
reject('per-stream limit exceeded')
chunk = open_chunk(stream_id)
active_streams[tenant][stream_id] = chunk
streams_created_total.inc()
return chunk
ELK.dynamic_mapping
each new JSON key becomes a mapped field
def index_document(index, doc):
for key, value in flatten(doc).items():
if key not in mapping[index].fields:
if mapping[index].dynamic is False:
continue # ignored, not indexed
if len(mapping[index].fields) >= 1000:
# index.mapping.total_fields.limit
raise MappingExplosion(index)
mapping[index].fields[key] = infer_type(value)
write_to_inverted_index(index, key, value)
Pipeline.relabel
the rule-card fix: high-card out of the indexed shape
def relabel(entry):
# Low-cardinality dimensions stay as labels / mapped fields.
indexed = pick(entry, ['service', 'env', 'level', 'region'])
# High-cardinality dimensions move to the body /
# structured metadata, searchable by line filter only.
body = entry.body
body['request_id'] = entry.pop('request_id', None)
body['user_id'] = entry.pop('user_id', None)
body['trace_id'] = entry.pop('trace_id', None)
return { 'labels': indexed, 'body': body }

Where this sits in Build a distributed logging stack (ELK / Loki)

Scene 06 of 12. Every unique label-set is a Loki stream; every dynamic key is an ELK mapping field. Putting request_id in either kills the index in minutes — low-card → labels, high-card → body.

Up next. The cardinality rule says high-cardinality dimensions belong in the body, not in labels or mapped fields — which means the body is BIG, and most of it is OLD. We can't keep all of it on NVMe.

All 12 scenes in Build a distributed logging stack (ELK / Loki) · Every curriculum

Built with Arqly
Every scene in Build a distributed logging stack (ELK / Loki) builds on the one before it.All 12 Build a distributed logging stack (ELK / Loki) scenes