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.

@kenhys
kenhys force-pushed the limit-cardinality-oom branch from ed6fde4 to c8accbc Compare August 6, 2026 05:14
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.

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>
@kenhys
kenhys force-pushed the limit-cardinality-oom branch 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

Metric#labels registered a label set into @Series before the metric was
actually instrumented. When the instrumentation failed afterwards, for
example when the value of `key` is not a number and Counter#increment
raises, the label set consumed max_series_per_metric even though nothing
was recorded.

Records which fail that way could therefore exhaust the limit and make
the following valid label sets dropped.

Split the responsibility: check_series_limit! only refuses an unknown
label set once the limit is reached, and the new remember_series counts
it after the instrumentation succeeded. Each metric type now goes
through with_label_set, which calls them in that order.

Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com>
Co-Authored-By: Claude <noreply@anthropic.com>

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 on lines +369 to +373
def with_label_set(record, expander)
label = labels(record, expander)
yield label
remember_series(label)
end
Comment on lines +45 to +49
desc 'The maximum length of a label value. Longer values are truncated. 0 means unlimited.'
config_param :max_label_value_length, :integer, default: DEFAULT_MAX_LABEL_VALUE_LENGTH
desc 'The maximum number of label sets a metric can hold. Exceeding label sets are dropped. 0 means unlimited.'
config_param :max_series_per_metric, :integer, default: DEFAULT_MAX_SERIES_PER_METRIC
desc 'The interval to suppress the repeated same error log.'
@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.

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