Skip to main content
RunBook Academy

ObservabilityXL · Log RetentionLogRetention

Retention Policies Per Stream

Advanced⏱ ~22 minbash

What you'll learn

  • Map a Loki retention policy onto a per-stream, per-tenant, or global setting and choose which fits
  • Apply the conflict-resolution order across global, per-tenant, and per-stream rules
  • Configure stream selectors that pin specific sources to a longer window
  • Verify that a stream-level override beats the tenant default using a synthetic query

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

Not yet marked complete on this device.

The audit is three weeks away. An engineer opens the retention map and finds that the payments stream is at 30 days. The PCI control requires 365. The mistake was not made today — it was made when the Kubernetes manifest was hand-edited six months ago and the security review did not cross-check against the per-tenant override. The right fix is more than a YAML edit, because the wrong fix would re-introduce the same drift in another six months.

This lesson is the model that makes the per-stream policy hold under audit.

What a retention policy is in Loki 3.x

A retention policy in Loki is one of three things:

  1. The global default — limits_config.retention_period. Applied to every tenant that does not override it.
  2. The per-tenant override — limits_config.overrides.<tenant>.retention_period. Applied to every stream within that tenant that does not match a more specific rule.
  3. The per-stream selector — limits_config.retention_stream (a list of selector rules). Applied to streams that match {...} log stream selectors. The most specific rule wins.

When all three are unset, the compactor has a per-tenant window but the value is zero/disabled unless the master compactor.retention_enabled: true is set.

The platform’s policy is therefore one document with three columns: which selector, what window, which tenant it lives under, and the citation for why.

Why a sysadmin cares

Because the most common audit failure shape is not “logs are gone” but “we kept the logs we shouldn’t have, and didn’t keep the logs we should have.” The two shapes pull in opposite directions:

  • Over-retentive streams — a tenant at 365 days when its real compliance regime is GDPR. The audit finds PII older than necessary; the fine is the retention itself.
  • Under-retentive streams — a PCI stream at 30 days when the regime is 365. The audit finds evidence missing; the fine is the absence.

The same platform can fail in both directions at the same time, because the failure is in the map between stream and window, not in the engine.

How the three layers interact

                 limits_config
                       |
       +---------------+---------------+
       |                               |
   global default                overrides:<tenant>
   retention_period                     |
       |                               |
       v                               v
   every tenant                  every stream in tenant
                                   not matched below
       +-------------------------------+
                       |
                       v
            retention_stream:
              - selector: '{job="..."}'
                priority: 1
                retention_period: ...
              - selector: '{namespace="..."}'
                priority: 2
                retention_period: ...
                       |
                       v
              highest-priority match wins
                  for that single stream

The resolution order is:

  1. The compactor master switch (must be true).
  2. The per-tenant override (if any).
  3. The per-stream selector with the highest matching priority.
  4. If no selector matches, the per-tenant default.
  5. If no override exists, the global default.

The selector mechanism is what makes a policy expressible in one place while still letting a single tenant carry five different windows.

How to configure it

The full structure, expressed once:

# loki-config.yaml (excerpt)
limits_config:
  retention_period: 720h        # 30d, global default

  per_tenant_overrides:
    config: |
      overrides:
        payments:
          retention_period: 8760h   # PCI: 365d

  retention_stream:
    - selector: '{namespace="kube-system"} | logfmt'
      priority: 100
      retention_period: 2160h       # K8s control plane, 90d

    - selector: '{job="authn-audit"}'
      priority: 100
      retention_period: 17520h      # SOC2: 730d

    - selector: '{app="checkout"}'
      priority: 50
      retention_period: 720h        # ops default

    - selector: '{app="payments"}'
      priority: 100
      retention_period: 8760h       # overrides the tenant default

compactor:
  retention_enabled: true

The most common shape in production:

  • A 30-day global default.
  • 5–15 per-tenant overrides for streams that fall outside the default (PCI, audit, control plane).
  • A short list of per-stream selectors, used only when a single tenant contains streams with materially different windows.

The selector-list is the dial most operators turn wrongly. A list with 200 entries is a code smell; the per-tenant override is the right place for 90 percent of policies.

How to validate it

Three checks, run from the sysadmin shell:

# 1. Confirm the live config carries the override map
#    as you expect, with all priorities readable.
curl -s http://loki-config:3100/config | \
  jq '.limits_config.retention_stream
      | map({selector: .selector, priority, retention: .retention_period})'
# expected:
#  [{"selector":"{namespace=\"kube-system\"} | logfmt","priority":100,
#    "retention":"2160h"},
#   {"selector":"{job=\"authn-audit\"}","priority":100,
#    "retention":"17520h"},
#   ...]
# 2. Push a synthetic line into a stream at the boundary
#    of the policy in question, wait one compaction cycle,
#    query across the boundary, and read the verdict.
logcli --addr=http://loki-gateway --org=payments \
  query '{app="payments"} |= "retention-canary"' \
  --since=10d --limit=20
# at Day 10 of a 365d window:    line returned.
# at Day 30 of a 30d window:      400 too_old.
# 3. Produce the per-tenant retention report from the
#    compactor's metrics. (Lesson 6 covers the script; here,
#    verify the metric is non-zero.)
curl -s http://loki-compactor:3100/metrics | \
  grep '^loki_compactor_retention_marked_chunks_total' | head
# expected:
#   loki_compactor_retention_marked_chunks_total{...} 1283

When all three checks line up with the policy document, the map matches the engine.

How it can fail

Failure modeObservable symptom
Two selectors match the same stream with no priority setThe lower-indexed entry wins (insertion order), which the reviewer cannot read from the YAML. The audit deliverable says one thing; the engine does another.
Stream selector has a typo in the label keyNo match. Stream falls through to the tenant default. Look like the override was missing; was never set.
Per-tenant override set, but per_tenant_overrides.config stringification is wrongThe override does not load at start. Loki logs unable to parse overrides.
Selector regex-style match (.=~) used where exact match (=) is wantedStream over-matches; the longer retention applies to streams the operator never intended.
Window extended at the per-stream level to debug an incident and not revertedCost rises slowly month over month. The selector remains, the runbook to revert it is not.
Tenant override applied at a higher level than the stream selector priorityThe stream selector never fires because the override gates it. The per-stream policy is silently inert.

Security implications

  • Selector expressivity. A selector can read labels the operator expects to be tenant-private. Validate that selectors use only labels that are themselves tenant-side. A selector {tenant="payments"} is a control boundary leak: the compactor will read cross-tenant labels to decide which window applies.
  • Audit trail. Every change to the per-stream policy needs a change ticket and a security review. Because the selector is a list, a malicious addition can hide in a long YAML diff.
  • Cross-tenant reads. When the compactor scans, it indexes by tenant id first; the selector is matched within the tenant. Confirm this is the case in the Loki version you run — earlier versions had divergent semantics.

Performance implications

  • Selector scan cost. Each compactor cycle runs every selector against every stream in its tenant scope. A long selector list multiplied by a high-stream-count tenant multiplies the compactor work. Keep the list to the streams that genuinely need per-stream rules.
  • Regex selectors. =~ is more expensive than =. Use = unless the pattern is necessary.
  • Selector churn. Adding or removing a selector forces a recut of the compactor’s per-tenant working state. Avoid thrash.

Production guidance

  • One artefact: the policy document. The YAML is generated from the document; not the other way around.
  • Default with the global value; override the few tenants that need a different window; use per-stream selectors sparingly.
  • Set priorities explicitly. Use numbers that reflect regime severity, not insertion order.
  • Every per-stream entry must have a citation in the policy document, a requester, and a date.
  • Re-run the three validation checks after every change. Drift between the document and the engine is the failure shape to avoid.

Verification

You should now be able to answer:

  • What is the resolution order between the global default, a per-tenant override, and a per-stream selector?
  • Why does Loki 3.x use a numeric priority on stream selectors, instead of YAML order?
  • What is the most common production shape of a retention policy in Loki?
  • How do you prove a per-stream selector is actually winning over the tenant default?

Quiz

Knowledge check · 8 questions

  1. Q1. Which layer of the retention policy wins for a stream that matches a retention_stream selector with priority 100?

  2. Q2. Which two values together determine the resolution of a retention_stream rule that matches multiple entries?

  3. Q3. A retention_stream selector with no priority field is treated as priority 0.

  4. Q4. Which rule fires first for a stream in the payments tenant that matches both {app="payments"} priority 100 and the tenant override of 365d?

  5. Q5. You have 15 tenants that each need a single different window. What is the right dial?

  6. Q6. A selector `=~` is more expensive at compactor scan time than an exact `=`.

  7. Q7. Which of these belong in the policy document alongside the YAML override?

  8. Q8. Two selectors match the same stream and have the same priority. Which wins?

Passing score: 75%. Answers are checked in this browser.