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.
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
- Watch
- Try it
- Predict
- Capture
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.
Highlighted lines are the ones running in the diagram right now.
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 entryif len(active_streams[tenant]) >= max_streams_per_user:reject('per-stream limit exceeded')chunk = open_chunk(stream_id)active_streams[tenant][stream_id] = chunkstreams_created_total.inc()return chunk
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 indexedif len(mapping[index].fields) >= 1000:# index.mapping.total_fields.limitraise MappingExplosion(index)mapping[index].fields[key] = infer_type(value)write_to_inverted_index(index, key, value)
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.bodybody['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