RedMirror Reflection is now the default way to use RedMirror — one offline binary that gives your own coding agent the power to find real bugs and prove them, right on your machine. Get started →
Memory exhaustion Fixed · merged Go · observability

A high-cardinality log field could grow mtail's memory without bound

Targetmtail (jaqx0r)
ClassCardinality-explosion DoS
Triggerhigh-cardinality labels from log input
Reportedgoogle/mtail #1006
FixedPR #476 (merged)

mtail, the Go tool that turns application logs into Prometheus-style metrics, enforced its per-metric cardinality cap only at garbage-collection time. A burst of high-cardinality labels from log input could therefore push a metric past its limit and grow memory without bound between GC cycles. The fix, merged as jaqx0r/mtail PR #476, applies the cap where each series is created.

mtail extracts Prometheus-style metrics from application logs: you write a small program that runs over each log line and increments counters. Those counters are almost always labelled with fields pulled straight from the log, a request path, a status code, a User-Agent. Every distinct combination of label values is a new time series held in memory.

We found that mtail's per-metric cardinality cap, the limit that is supposed to stop that set of series from growing without bound, was not enforced where the series are actually created. A burst of distinct label values could push a metric far past its configured limit, and memory grew with it.

Feed a metric that is labelled by a user-controlled field, say a URL path or a User-Agent, a stream of distinct values, and the series count climbs unbounded until the next garbage-collection pass, which may be far too late.

Why was the cardinality cap checked on the wrong path?

The limit existed, but only Store.Gc enforced it, on a periodic sweep. The hot path that runs for every matching log line, Metric.GetDatum, created a new label series for every unseen combination without checking the cap at all:

// Metric.GetDatum, before the fix
if series not present {
    create new series      // no cardinality check here
}
// the limit was applied only later, in Store.Gc

Between two garbage-collection cycles, nothing bounded the number of series. A flood of unique label values, which is exactly what log fields like request paths, error messages, or spoofable headers look like under load or attack, creates unbounded data in that window. Because logs routinely carry values an outside user influences, adversarial log volume turns directly into adversarial memory use.

How was it fixed?

PR #476 (merged) moves the enforcement to where series are born. GetDatum now applies the cap at creation time with an evict-oldest policy: when a metric is already at its limit and a new label combination arrives, the oldest series is removed before the new one is added.

The approach was green-lit in google/mtail #1006. The result is that the invariant “series per metric stays within the limit” now holds continuously, not just after the next sweep.

Why is this the kind of bug RedMirror finds?

A metric store is a state machine whose state is the number of series per metric, and the invariant is simple: that count stays at or below the configured limit at all times. The defect was that the limit was true eventually (at the next GC) but not continuously. RedMirror looks for a reachable sequence of inputs that drives a quantity past its bound before anything prunes it, and reports the run of inputs that gets there. A cap enforced on the wrong path is precisely the sort of “holds eventually, not always” gap that shows up when you model when each check actually runs.

Frequently asked questions

What is the mtail cardinality bug?

mtail's per-metric cardinality cap, the limit meant to stop a metric's label series from growing without bound, was enforced only during garbage collection in Store.Gc, not on the hot path that creates series. Between GC cycles a burst of distinct label values could push a metric far past its limit and grow memory with it.

How could a high-cardinality log field exhaust mtail's memory?

mtail labels its counters with fields pulled straight from the log, such as a request path, a status code, or a User-Agent, and every distinct label combination is a new time series held in memory. Because the cap was checked only at GC time, feeding a metric a stream of distinct user-controlled values let the series count climb unbounded until the next garbage-collection pass.

Has the mtail cardinality bug been fixed?

Yes. PR #476 in jaqx0r/mtail is merged. It moves enforcement to where series are created, so GetDatum now applies the cap at creation time with an evict-oldest policy: when a metric is already at its limit and a new label combination arrives, the oldest series is removed before the new one is added.

What language and project does this affect?

mtail is a Go tool that extracts Prometheus-style metrics from application logs. The approach was green-lit in google/mtail #1006 and merged as jaqx0r/mtail PR #476.

How did RedMirror find this bug?

RedMirror models a metric store as a state machine whose state is the number of series per metric, with the invariant that the count stays at or below the configured limit at all times. It looks for a reachable sequence of inputs that drives that quantity past its bound before anything prunes it, and reports the run of inputs that gets there. The cap held eventually, at the next GC, but not continuously.

Find the “holds eventually, not always” gaps in your code

Limits, quotas, and caches all have an invariant about a bounded quantity. RedMirror looks for the reachable input sequence that breaks the bound between the moments your code checks it, and reports it as a concrete path.

Get RedMirror Read the docs