Skip to content

Introduce config parameter limit label expansion - #261

Open
kenhys wants to merge 2 commits into
fluent:masterfrom
kenhys:limit-cardinality-oom
Open

Introduce config parameter limit label expansion#261
kenhys wants to merge 2 commits into
fluent:masterfrom
kenhys:limit-cardinality-oom

Conversation

@kenhys

@kenhys kenhys commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

In the previous versions, there is no mechanism to limit label expansion. That causes a possibility of cardinality OOM DoS.

To mitigate such situation, introduced the following parameters:

  • max_label_value_length: The maximum length of a label value.
  • max_series_per_metric: The maximum number of label sets a metric can hold.

The above parameter is configurable for filter_prometheus and out_prometheus.

For example, if about 8 million records are loaded without cardinality
limitation, RSS increased from 64MB to 582MB. It might cause OOM DoS.
In contrast to that case with cardinality limitation, RSS increased
from 64MB to 84 MB in similar case.

Additionally the following counter is introduced too.

  • fluentd_prometheus_dropped_label_sets_total

@kenhys
kenhys force-pushed the limit-cardinality-oom branch 2 times, most recently from c8accbc to 243d3cc Compare August 6, 2026 05:18
@kenhys
kenhys marked this pull request as ready for review August 6, 2026 05:19
@kenhys
kenhys requested a review from Watson1978 August 6, 2026 06:44

@Watson1978 Watson1978 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems that once a record has raised an exception, valid records sent afterwards no longer show up in the metric.

Please try attached file to reproduce.
repro-261.tar.gz

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces configurable safeguards to bound Prometheus label cardinality and label value growth in filter_prometheus and out_prometheus, mitigating cardinality-driven memory exhaustion risks.

Changes:

  • Add max_label_value_length (truncate label values) and max_series_per_metric (drop new label sets beyond a cap) with per-<metric> overrides.
  • Add shared LogThrottle and use it to throttle repeated “dropped label set” warnings (and refactor in_prometheus error throttling to use it).
  • Add/extend specs and documentation for the new limiting behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
spec/fluent/plugin/prometheus/log_throttle_spec.rb Adds unit tests for the new shared log throttling utility.
spec/fluent/plugin/filter_prometheus_spec.rb Adds coverage for max_series_per_metric behavior and throttled warning logging in the filter plugin.
README.md Documents new label expansion limiting parameters and behavior details.
lib/fluent/plugin/prometheus.rb Implements label truncation, series limiting, label-set limit warnings, and shared LogThrottle.
lib/fluent/plugin/out_prometheus.rb Passes plugin-level metric limit options into metric construction.
lib/fluent/plugin/in_prometheus.rb Replaces bespoke throttling with shared LogThrottle.
lib/fluent/plugin/filter_prometheus.rb Passes plugin-level metric limit options into metric construction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/fluent/plugin/prometheus.rb
Comment thread lib/fluent/plugin/prometheus.rb Outdated
@Watson1978

Copy link
Copy Markdown
Contributor

Both limits are on by default, so upgrading changes the exported metrics of every existing user — and the change is silent

max_label_value_length defaults to 256 and max_series_per_metric defaults to 10000, so a user who upgrades the gem without touching their configuration gets both. Two different kinds of damage follow, and neither of them is visible.

Truncation merges label sets that used to be distinct

Verified with the default configuration (no limit set anywhere):

<filter test.**>
  @type prometheus
  <metric>
    name test_truncated
    type counter
    desc test
    key val
    <labels>
      path $.path
    </labels>
  </metric>
</filter>

Feeding two records whose path differs only after the 256th character produces one series instead of two, and their values are summed:

series = 1
path length = 256
value = 2.0
log lines = 0

Label values longer than 256 characters are not exotic — URLs with query strings, Kubernetes annotations, SQL statements and exception messages all reach that length routinely. For those users, upgrading makes existing series disappear and a new merged series appear in their place. Prometheus sees the old series go stale, so recording rules, dashboards and alerts built on them break, and the counter values are wrong rather than merely missing.

The cap drops records once a metric is saturated

A deployment that legitimately runs above 10000 label sets today starts losing everything past the 10001st after the upgrade, with no configuration change on their side.

Suggestion

Please consider defaulting both to 0 (unlimited) and letting operators opt in. The feature is valuable, but it changes the meaning of data that already exists, and that is the kind of change that should be a deliberate act rather than a side effect of bundle update. If the defaults stay on, this needs to be called out in the ChangeLog explicitly as a breaking change, and ideally the release should be a minor/major bump rather than a patch.


A dropped label set leaves almost no trace

This is what makes the previous point serious: when a record is dropped, there is essentially no way for an operator to find out.

  • rescue LabelSetLimitError in instrument_single and instrument does not call router.emit_error_event, so @ERROR never sees the record. That is a reasonable choice on its own — dropping is intended here, not an error — but it means the record is gone with no route to inspect it.
  • warn_label_set_limit is throttled by ignore_error_log_interval, which defaults to 3600. One log.warn per metric per hour, no matter how many records are discarded in between.
  • suppressed_log_count is only reported when the next warning fires. If fluentd is restarted or the configuration is reloaded before the interval elapses, the accumulated count is never emitted at all, so the operator sees a single warning and has no way to learn how much was discarded.
  • Truncation is not logged at all, at any rate.
  • Nothing changes on the Prometheus side. The scrape target stays up == 1, the endpoint returns 200, and the input plugin keeps accepting records normally. The metric simply has fewer series than it should, which no alert can express unless absent() was already written in advance for that exact series.

In an earlier reproduction of a related problem, 161 dropped records produced exactly one log line while every POST returned HTTP 200. Data loss at that ratio of signal is very hard to attribute after the fact.

Suggestion

Self-instrument the drops so they are visible in Prometheus itself rather than only in logs — for example a counter such as fluentd_prometheus_dropped_label_sets_total{name="<metric name>"}, and a companion counter for truncated label values. That gives operators something they can alert on and graph, which is exactly what this plugin exists to provide for everything else.

If both limits are going to stay on by default, this feels like a prerequisite rather than a nice-to-have: the defaults are what make the loss possible, and this is what would make it noticeable.


This comment was written by Claude (Claude Code). The behaviour described above was verified by running the plugin at 39a9ae3.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (3)

lib/fluent/plugin/prometheus.rb:485

  • release_series deletes the entry unconditionally, which can under-count series when another thread successfully instrumented the same label set while this (reserved) call failed. That can allow subsequent new label sets to pass the @series.size check and exceed max_series_per_metric.

If you adopt a reserved/confirmed state (e.g. :reserved vs :confirmed), only delete when the label set is still reserved, and update the comment above this method (it currently describes the unsafe behavior as intended).

        def release_series(label)
          @series_mutex.synchronize do
            @series.delete(label)
          end
        end

lib/fluent/plugin/prometheus.rb:408

  • A failing instrumentation can free a series slot that another concurrent instrumentation of the same new label set has effectively “confirmed” by succeeding. This happens because the label set is reserved before yielding, but nothing marks it as confirmed on success, so release_series cannot distinguish an in-flight reservation from an established series.

Confirm the label set after a successful yield, so release_series can safely decide whether deletion is still allowed.

          reserved = reserve_series!(label)
          begin
            yield label
          rescue
            release_series(label) if reserved

lib/fluent/plugin/prometheus.rb:473

  • reserve_series! records a newly taken slot as true. To make release_series safe under concurrent instrumentation of the same label set (one success + one failure), store a distinct reserved state (e.g. :reserved) and have successful instrumentations mark it confirmed.
            @series[label] = true
            next true
          end

kenhys and others added 2 commits August 17, 2026 16:39
In the previous versions, there is no mechanism to limit label
expansion. That causes a possibility of cardinality OOM DoS.

To mitigate such situation, introduced the following parameters for
filter_prometheus and out_prometheus:

* max_label_value_length: The maximum length of a label value.
* max_series_per_metric: The maximum number of label sets a metric can
hold.

For example, if about 8 million records are loaded without cardinality
limitation, RSS increased from 64MB to 582MB. It might cause OOM DoS.
In contrast to that case with cardinality limitation, RSS increased
from 64MB to 84 MB in similar case.

Additionally the following counter is introduced too.

* fluentd_prometheus_dropped_label_sets_total

About internal design:

Both limits are unlimited by default and have to be opted in. Enabling
them changes what an existing metric exposes without telling anyone: a
truncated label value merges label sets which were distinct so far and
sums their values, and a dropped label set loses the record. Label
values above a few hundred characters are not exotic, and a deployment
which legitimately runs above 10000 label sets would start losing
records right after an upgrade. That is a decision for the operator who
knows the records, so each limit can be overridden per
<metric> as well.

A metric is instrumented through with_label_set, which takes the slot
before instrumenting instead of counting the label set afterwards: two
threads which build a new label set at the same time would otherwise
both pass the check and expand the metric beyond max_series_per_metric.
The slot is taken as :reserved and marked :confirmed once the client
actually holds the label set, so that a record which fails to be
instrumented, for example when the value of `key` is not a number,
gives its own reservation back without dropping a series a concurrent
record established in the meantime. Records which fail that way must
not consume the limit, otherwise they could exhaust it and make the
following valid label sets dropped.

A drop is hard to notice, since it is not routed to @error and the
warning is throttled by ignore_error_log_interval, which both plugins
now take as well. Count the drops in a new
fluentd_prometheus_dropped_label_sets_total counter, labelled with the
metric name and registered on the first drop, so that an operator can
alert on a metric which is losing records. The throttling itself is
extracted into LogThrottle, which in_prometheus shares.

Then warning message is logged like this:

  2026-08-06 14:06:04 +0900 [warn]: prometheus: dropped a label set
  because the metric reached
  max_series_per_metric. name="access_requests_total"
  max_series_per_metric=10000

Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com>
Co-Authored-By: Claude <noreply@anthropic.com>
max_series_per_metric refuses a label set once a metric is full,
max_label_value_length truncates a label value, and the warning about a
dropped label set is throttled by LogThrottle, which in_prometheus
shares.

in_prometheus_spec used to cover the throttling logic itself, from
before LogThrottle was extracted. Some test cases were removed from
there.  Keep there only what a unit spec cannot reach: which slot a
failure is throttled on, how a fingerprint is built out of an
exception, the suppressed_log_count the log carries, and the 500 the
client keeps receiving meanwhile.

Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@kenhys
kenhys force-pushed the limit-cardinality-oom branch from b764674 to 7207697 Compare August 17, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants