Introduce config parameter limit label expansion - #261
Conversation
c8accbc to
243d3cc
Compare
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) andmax_series_per_metric(drop new label sets beyond a cap) with per-<metric>overrides. - Add shared
LogThrottleand use it to throttle repeated “dropped label set” warnings (and refactorin_prometheuserror 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.
Both limits are on by default, so upgrading changes the exported metrics of every existing user — and the change is silent
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 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 A dropped label set leaves almost no traceThis is what makes the previous point serious: when a record is dropped, there is essentially no way for an operator to find out.
In an earlier reproduction of a related problem, 161 dropped records produced exactly one log line while every Suggestion Self-instrument the drops so they are visible in Prometheus itself rather than only in logs — for example a counter such as 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. |
39a9ae3 to
b764674
Compare
There was a problem hiding this comment.
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_seriesdeletes 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.sizecheck and exceedmax_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_seriescannot 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 astrue. To makerelease_seriessafe 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
b764674 to
7207697
Compare
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>
That metric is visible when label was truncated and merged into series. Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com> Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com> Co-Authored-By: Claude <noreply@anthropic.com>
7207697 to
3dff32d
Compare
| Note that the number of label sets is counted per metric of each plugin | ||
| instance. When two plugin instances instrument the same metric name, each of | ||
| them has its own limit. |
There was a problem hiding this comment.
This explanation may not be sufficient.
Setting max_series_per_metric 2 doesn't actually cap the metric at 2 series
when two <metric> blocks (or two plugin instances) share the same name:
<filter test.**>
@type prometheus
max_series_per_metric 2
<metric>
name dup_total
type counter
key val
<labels>
path $.path
</labels>
</metric>
<metric>
name dup_total
type counter
key val
<labels>
path $.path
</labels>
</metric>
</filter>
Feed 4 records with distinct path values (2 through each <metric> block) and
dup_total ends up with 4 series, not 2 — each <metric> block gets its own
Metric Ruby object with its own @series Hash, but both wrap the same
underlying ::Prometheus::Client::Counter (the registry dedupes by name
alone, prometheus.rb:462-474), so neither @series knows about the other.
require 'spec_helper'
describe 'max_series_per_metric with a metric name shared by two Metric instances' do
let(:registry) { ::Prometheus::Client::Registry.new }
let(:max_series_per_metric) { 2 }
def build_metric
element = Fluent::Config::Element.new(
'metric', '',
{
'name' => 'dup_total',
'type' => 'counter',
'desc' => 'Something foo.',
'key' => 'val',
'max_series_per_metric' => max_series_per_metric.to_s,
},
[Fluent::Config::Element.new('labels', '', { 'path' => '$.path' }, [])]
)
Fluent::Plugin::Prometheus::Counter.new(element, registry, {}, {})
end
let(:expander) { double('expander') }
def instrument(metric, path, value = 1)
metric.instrument({ 'val' => value, 'path' => path }, expander)
end
it 'lets the shared client-side counter exceed max_series_per_metric' do
metric_a = build_metric
metric_b = build_metric
expect(metric_a.instance_variable_get(:@counter))
.to equal(metric_b.instance_variable_get(:@counter))
# each wrapper independently accepts up to its own max_series_per_metric
instrument(metric_a, '/a1')
instrument(metric_a, '/a2')
expect { instrument(metric_a, '/a3') }
.to raise_error(Fluent::Plugin::Prometheus::LabelSetLimitError)
instrument(metric_b, '/b1')
instrument(metric_b, '/b2')
expect { instrument(metric_b, '/b3') }
.to raise_error(Fluent::Plugin::Prometheus::LabelSetLimitError)
# ... yet the metric actually exposed to Prometheus holds FOUR series,
# twice the configured max_series_per_metric of 2
client_counter = registry.get(:dup_total)
##########################################################################
# Configured `max_series_per_metric: 2`,
# however 4 metrics existed.
p client_counter.values.keys.size
# => 4
p client_counter.values.keys
# => [{path: "/a1"}, {path: "/a2"}, {path: "/b1"}, {path: "/b2"}]
end
endIt should block inconsistent setting in early stage. Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com>
Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com>
Sections which instrument the same metric share one client metric, so they have to truncate the same way. With a shorter and a longer limit, one label value ends up in two label sets, once cut at each limit: the metric holds "/abc" and "/abcdefg" for the same "/abcdefghij", the merge the truncation is meant to do happens or not depending on which section took the record, and fluentd_prometheus_truncated_label_values_total sums both under one series, so which limit did the cut cannot be told. The length every metric was configured with is kept in the VariableStore, which fluentd empties for the duration of a graceful reload and puts back when the reload fails. Keeping it next to the metric would work until a reload: the client registry is global to the process, so the metric outlives the reload and a length stored on it would refuse the new one. max_series_per_metric stays per section, since it only decides whether a label set is accepted and not what it looks like. Signed-off-by: Kentaro Hayashi <hayashi@clear-code.com> Co-Authored-By: Claude <noreply@anthropic.com>
3712a26 to
200f2dc
Compare
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:
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.