ObservabilityXXXIV · Loki Labels and CardinalityLokiLabels
The Loki Label Rule
What you'll learn
- State the Loki label rule and explain why it differs from the Prometheus labelling model
- Distinguish index labels, structured metadata, and parsed fields by cost and use
- Describe the cost model: streams in memory, chunk flushes, and query fan-out
- Identify the operational signals that show the rule is being honoured or broken
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 page is at 04:00. An ingester in the Loki cluster is using 9.4 GiB of resident memory and rising. The on-call engineer opens the stream count dashboard, sees 11.3 million active streams on one tenant, and the picture resolves in one glance: a label that should not be a label has become one. This is the lesson Loki teaches the hard way, and it is the lesson this module exists to prevent.
What it is
The Loki label rule is one sentence: stream labels are a low cardinality index, not a query surface for arbitrary log content. A label is justified as a stream label only if its cardinality (number of distinct values the system will see) is small, bounded, and operationally meaningful for every query the team will write.
The rule exists because Loki’s index is built around streams. A stream is the unique combination of labels attached to a sequence of log lines from one source. The ingester keeps the head of every active stream in memory, the indexer indexes the label set of every stream, and the query path walks the index by label first and only then opens the chunks. Each distinct stream label set is one entry in every index lookup that touches it.
Why a sysadmin cares
In Prometheus, adding a label has a known, mostly bounded cost: it multiplies the time series count, and the storage model expects that. In Loki, the same addition multiplies streams, and the ingester holds each stream in memory until the chunk is flushed. A single label with unbounded cardinality, such as a request ID, a session ID, or a user ID, does not make the system slower. It makes the system unrunnable. The failure shape is an ingester out of memory event, query timeouts on every tenant because the index fan-out has exploded, and an outage that the application owner did not cause and cannot fix from the application side.
The rule therefore is not a stylistic preference. It is a load boundary.
How it works
Loki receives log lines through a distributor. Each line arrives with a static set of labels (typically extracted by the agent, such as Alloy or Promtail) and an entry, which is the raw line plus a timestamp. The distributor hashes the label set, computes the stream ID, and forwards the line to the ingester that owns that stream on the hash ring.
Log line from agent
|
v
+------------------+ hash(labels) +-----------------+
| distributor | -------------------> | ingester pool |
| (labels + entry)| | (per-stream |
+------------------+ | head in RAM) |
+-----------------+
|
chunk flush on
size or time
|
v
+-----------------+
| object storage |
| (S3 / GCS / ..) |
+-----------------+
The ingester keeps two things for each stream: the in-memory
head (a compressed log of recent lines) and the on-disk tail
(chunks). The indexer indexes the label set, not the lines. A
query such as {job="nginx", level="error"} resolves to a list
of streams whose label set matches, then opens the relevant chunks
and scans them.
The cost model
Three costs move when a label changes. They are not the same magnitude and they do not arrive at the same time.
- In-memory stream count. Each distinct label set is one
active stream per ingester that owns it. The Prometheus metric
loki_ingester_streamsis the canonical signal. Sustained values above a few hundred thousand streams per ingester are a warning. One million per ingester is the operating ceiling for most clusters. - Index size. The TSDB or boltdb index stores one entry per stream per fingerprint per time window. Index size scales with stream count, not with log volume. A 10x jump in stream count is a 10x jump in index size.
- Query fan-out. Each
{label=value}selector walks the fingerprint index. A query with five selectors multiplies the lookup work for every fingerprint that matches. When streams are bounded, this is fast. When streams are unbounded, the same query touches millions of index entries before it returns.
How to configure it
The label rule is enforced at three layers: the agent pipeline,
the distributor’s schema validation, and the limits_config on
the Loki server. The agent is where most labels are born, so it
is where most violations are prevented.
# /etc/alloy/config.alloy (Grafana Alloy 0.110.x compatible)
# Pipeline: keep the good labels, drop the bad ones, then parse.
loki.source.file "app" {
targets = local.file_match("/var/log/app/*.log")
forward_to = loki.relabel.drop_bad.receiver
}
loki.relabel "drop_bad" {
forward_to = loki.process.parse.receiver
# 1. Remove anything that smells like a per-request value.
rule {
action = "labeldrop"
regex = "request_id|session_id|user_id|trace_id|span_id"
}
# 2. Bound the value length on labels we do keep.
rule {
action = "labelmap"
regex = "kubernetes_(.+)"
}
}
loki.process "parse" {
forward_to = loki.write.local.receiver
# Promote structured metadata without it touching the index.
stage.json {
expressions = { "level" = "", "status" = "" }
source = "entry"
}
stage.labels {
values = { "level" = "" }
}
}
loki.write "local" {
endpoint {
url = "http://loki-distributor:3100/loki/api/v1/push"
}
}
The matching limits_config on Loki enforces a ceiling at the
server. The agent is the first line; the server is the last line.
# /etc/loki/config.yaml (Loki 3.x)
limits_config:
# Hard ceiling on the number of distinct label values for a label
# name, per stream fingerprint window. Defaults differ by version;
# set them explicitly.
max_label_name_length: 63
max_label_value_length: 1024 # lower than the 2048 default
# Reject any stream that exceeds these bounds. The ingest path
# will return 4xx and log to the rejected_samples metric.
reject_old_samples: true
reject_old_samples_max_age: 168h # 7 days
# Per-tenant ingestion rate (bytes / sec). Prevents a single
# tenant from filling the distributor.
ingestion_rate_mb: 32
ingestion_burst_size_mb: 48
# Cardinality guard: reject streams whose label set has more
# distinct values than this per time window.
max_label_values_per_label: 200
How to validate it
Three checks confirm the rule is being honoured. Each has a distinct signal.
# 1. Stream count per tenant. The cardinal number.
# Severity: READ-ONLY
logcli series --analyzer-ingester --since=1h \
'{job=~".+"}' | awk '{print $1}' | sort | uniq -c | sort -rn | head
Expected output: a list of tenant IDs, each with a stream count in the thousands to tens of thousands. If any tenant shows hundreds of thousands, the rule has been broken and you have an incident in progress.
# 2. Top labels by cardinality. Shows you *which* label set is
# exploding.
# Severity: READ-ONLY
logcli series --analyzer-ingester --since=1h \
'{job=~".+"}' | awk -F'{' '{print $2}' | awk -F'}' '{print $1}' \
| tr ',' '\n' | awk -F'=' '{print $1}' | sort | uniq -c | sort -rn | head -20
A healthy cluster shows a flat distribution: job, instance,
namespace, app, level, env. A broken cluster shows one
label whose line count is rising every hour.
# 3. Server-side rejection rate. Confirms the limits_config is
# actually firing.
# Severity: READ-ONLY
curl -s 'http://loki-distributor:3100/metrics' \
| grep '^loki_distributor_samples_rejected_total'
A non-zero counter is healthy if the rejection rate is stable. A non-zero counter that is doubling every hour means a deployment introduced a new bad label and the agent is rejecting it downstream.
How it can fail
Six failure shapes recur in production. Each has a recognisable symptom that points to the rule.
-
Per-request ID in labels. A team adds
request_idas a stream label so they can filter by it in Grafana. Symptom:loki_ingester_streamsrises linearly with traffic; ingest latency spikes; query latency for unrelated tenants rises because the index fan-out touches every fingerprint. -
Free-form string in a label. A log line contains a JSON blob; the pipeline maps one field into a label. Symptom:
max_label_values_per_labelrejections appear; distributor logs showstream too bigerrors. -
Timestamp in labels. A misconfigured parser stamps
received_ator the line timestamp into the label set. Every log line opens a new stream. Symptom: ingesters OOM within minutes of the misconfiguration reaching production. -
PII in labels. An application owner adds
customer_emailoruser_idto make a dashboard filter easy. Symptom: the label rule violation appears first as a security incident, not a capacity incident. The breach is reported before the dashboard is. -
Two labels that multiply. Individually each is bounded, together they are not.
pod(tens) timesmethod(handfuls) is fine;podtimesrequest_idis not. Symptom: stream count is the product of the two, not the sum. -
Cardinality creep. A label that was bounded when added grows unbounded over months. A
versionlabel starts at three values and ends at fifty thousand because the deployment system stamps a unique commit SHA. Symptom: the cardinality budget is consumed gradually and only noticed when a new tenant cannot ingest.
How to troubleshoot it
When the rule has been broken, the diagnostic order is the same every time.
1. Confirm the rule is broken (stream count is rising abnormally)
|
v
2. Identify which label set is the offender (top-N labels)
|
v
3. Find the agent / source producing it (grep pipeline config)
|
v
4. Decide: fix at agent (preferred) or block at server (faster)
|
v
5. Roll out the fix; verify stream count stops rising
|
v
6. Document the lesson in the label audit
The single most useful command during a cardinality incident is
the top-N labels query above. The second most useful is
grep -R request_id /etc/alloy to find the offending pipeline
stages. The third is logcli series for the specific label set
to confirm the stream count is dropping after the fix.
Security implications
Labels are queryable by every user with read access to the
tenant. PII, customer identifiers, and tokens must never reach
the label set. They are also written to the index, which means
they are replicated, backed up, and retained under whichever
retention policy applies. The label rule is the first line of
defence against a PII leak that outlives the log line itself.
A second line is limits_config.max_label_value_length to bound
the size of any value that does land in the index.
Performance implications
The performance implications of the label rule are not subtle. Stream count drives ingester memory; index size drives query latency at the index store; query fan-out drives frontend latency. A cluster that has been run within the rule stays within the rule for years. A cluster that has been run without the rule degrades on a curve that ends in an unscheduled restart of the ingesters.
Verification
You should now be able to answer:
- In one sentence, what is the Loki label rule?
- Why is a high-cardinality label more expensive in Loki than in Prometheus?
- Where in the pipeline is the rule enforced: agent, server, or both?
- What metric shows the rule is being broken in real time?
- What is the difference between a label, a structured metadata field, and a parsed log field?
Quiz
Knowledge check · 8 questions
Q1. What is the Loki label rule in one sentence?
Q2. Which metric is the canonical signal for a label rule violation?
Q3. Adding a single high cardinality label such as request_id to a stream label set has roughly the same cost in Loki as in Prometheus.
Q4. Which fields belong in the stream label set, not in structured metadata or log content?
Q5. Name two Loki metrics that confirm the label rule is being honoured.
Q6. Where in the pipeline should the label rule be enforced first?
Q7. What is the difference between a structured metadata field and a stream label?
Q8. Which logcli query returns the top labels by cardinality over the last hour?
Passing score: 75%. Answers are checked in this browser.