fluent-plugin-prometheus 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ require 'fluent/clock'
2
+
3
+ module Fluent
4
+ module Plugin
5
+ module Prometheus
6
+ # Suppresses the repeated log for the same key within the interval.
7
+ # in_prometheus and filter/out_prometheus use it, each with its own
8
+ # instance. The key decides what is throttled: an error scope or a
9
+ # metric. When a fingerprint is given, a log whose fingerprint differs
10
+ # from the last one is not suppressed.
11
+ class LogThrottle
12
+ Entry = Struct.new(:time, :fingerprint, :suppressed)
13
+
14
+ def initialize(interval)
15
+ @interval = interval
16
+ @mutex = Mutex.new
17
+ # one entry per key, so this does not grow without a limit
18
+ @entries = {}
19
+ end
20
+
21
+ # Returns [emit, suppressed_count]. emit is true for the first log of a
22
+ # key, for a new fingerprint, and after the interval has passed.
23
+ # suppressed_count is how many logs were suppressed since the last one
24
+ # was emitted. Without a fingerprint, a key is throttled by the
25
+ # interval alone.
26
+ def check(key, fingerprint = nil)
27
+ return [true, 0] if @interval <= 0
28
+
29
+ @mutex.synchronize do
30
+ now = Fluent::Clock.now
31
+ last = @entries[key]
32
+ if last.nil? || last.fingerprint != fingerprint || (now - last.time) >= @interval
33
+ suppressed = (last && last.fingerprint == fingerprint) ? last.suppressed : 0
34
+ @entries[key] = Entry.new(now, fingerprint, 0)
35
+ [true, suppressed]
36
+ else
37
+ last.suppressed += 1
38
+ [false, 0]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,6 +2,10 @@ module Fluent
2
2
  module Plugin
3
3
  module Prometheus
4
4
  class ExpandBuilder
5
+ # ${tag}, ${tag_parts[...]}, ${tag_prefix[...]} and ${tag_suffix[...]}
6
+ # must be built from the tag only.
7
+ TAG_DERIVED_PLACEHOLDER = /\A\$\{tag(_parts|_prefix|_suffix)?(\[[^\]]*\])?\}\z/.freeze
8
+
5
9
  def self.build(placeholder, log:)
6
10
  new(log: log).build(placeholder)
7
11
  end
@@ -12,6 +16,7 @@ module Fluent
12
16
 
13
17
  def build(placeholder_values)
14
18
  placeholders = {}
19
+ tag_placeholders = {}
15
20
  placeholder_values.each do |key, value|
16
21
  case value
17
22
  when Array
@@ -26,13 +31,22 @@ module Fluent
26
31
  end
27
32
  else
28
33
  if key == 'tag'
29
- placeholders.merge!(build_tag(value))
34
+ tag_placeholders = build_tag(value)
30
35
  else
31
36
  placeholders["${#{key}}"] = value
32
37
  end
33
38
  end
34
39
  end
35
40
 
41
+ # A record may have an array named "tag_parts", or a key named
42
+ # "tag_parts[0]". Both make the same placeholder as the tag does.
43
+ # merge! is not enough here, because a record can also use an index
44
+ # which build_tag does not make, such as ${tag_parts[3]} for a tag
45
+ # of 3 parts, or ${tag_prefix[-1]}. So remove them first, then such
46
+ # a placeholder stays unknown.
47
+ placeholders.delete_if { |k, _| TAG_DERIVED_PLACEHOLDER.match?(k) }
48
+ placeholders.merge!(tag_placeholders)
49
+
36
50
  Fluent::Plugin::Prometheus::ExpandBuilder::PlaceholderExpander.new(@log, placeholders)
37
51
  end
38
52
 
@@ -1,5 +1,6 @@
1
1
  require 'prometheus/client'
2
2
  require 'prometheus/client/formats/text'
3
+ require 'fluent/plugin/prometheus/log_throttle'
3
4
  require 'fluent/plugin/prometheus/placeholder_expander'
4
5
 
5
6
  module Fluent
@@ -31,6 +32,101 @@ module Fluent
31
32
 
32
33
  module Prometheus
33
34
  class AlreadyRegisteredError < StandardError; end
35
+ class LabelSetLimitError < StandardError; end
36
+
37
+ # 0 or less means unlimited. The limit is unlimited by default, because
38
+ # enabling it changes the existing metrics silently: dropping a label set
39
+ # loses the record without any way to recover it. An operator who needs
40
+ # to bound the cardinality has to opt in explicitly.
41
+ DEFAULT_MAX_SERIES_PER_METRIC = 0
42
+ DEFAULT_IGNORE_ERROR_LOG_INTERVAL = 3600
43
+
44
+ # the drops are visible in Prometheus, not only in the Fluentd log
45
+ DROPPED_LABEL_SETS_METRIC_NAME = :fluentd_prometheus_dropped_label_sets_total
46
+ DROPPED_LABEL_SETS_METRIC_DESC = 'The total number of records dropped because the metric reached max_series_per_metric.'
47
+
48
+ def self.included(klass)
49
+ klass.class_eval do
50
+ desc 'The maximum number of label sets a metric can hold. Exceeding label sets are dropped. 0 (default) means unlimited.'
51
+ config_param :max_series_per_metric, :integer, default: DEFAULT_MAX_SERIES_PER_METRIC
52
+ desc 'The interval to suppress the repeated warning about the drops.'
53
+ config_param :ignore_error_log_interval, :time, default: DEFAULT_IGNORE_ERROR_LOG_INTERVAL
54
+ end
55
+ end
56
+
57
+ # The label sets a client metric holds. The client registry keys its
58
+ # metrics by name alone, so every <metric> section with the same name
59
+ # instruments the same client metric and shares this set, instead of
60
+ # holding max_series_per_metric label sets of its own.
61
+ class SeriesSet
62
+ # The set is kept on the client metric, so that it is found again by
63
+ # every section. The registry does not drop its metrics, so the set is
64
+ # still there after a reload.
65
+ IVAR = :@fluent_plugin_prometheus_series_set
66
+
67
+ # Sections are built at configuration time, which is single threaded,
68
+ # so this needs no lock.
69
+ def self.of(client_metric)
70
+ client_metric.instance_variable_get(IVAR) ||
71
+ client_metric.instance_variable_set(IVAR, new)
72
+ end
73
+
74
+ def initialize
75
+ @series = {}
76
+ @mutex = Mutex.new
77
+ end
78
+
79
+ # Only <initlabels> come from the configuration. Counting a label set
80
+ # a record brought would refuse a good configuration after a reload.
81
+ def initial_size
82
+ @mutex.synchronize { @series.count { |_, state| state == :initial } }
83
+ end
84
+
85
+ # Checking the limit and taking the slot happen under the same lock, so
86
+ # that concurrent calls cannot both take the last one. The slot stays
87
+ # :reserved until the instrumentation confirms it, so that a failing
88
+ # call can tell an in-flight reservation from a series the client holds.
89
+ def reserve(label, limit, name)
90
+ @mutex.synchronize do
91
+ next false if @series.key?(label)
92
+
93
+ if @series.size >= limit
94
+ raise LabelSetLimitError, "#{name} reached max_series_per_metric (#{limit})"
95
+ end
96
+
97
+ @series[label] = :reserved
98
+ next true
99
+ end
100
+ end
101
+
102
+ # Marks a label set as established, once the client actually holds it.
103
+ # The limit is not checked on purpose: the series exists on the client
104
+ # side already, so it has to be accounted for even when a concurrent
105
+ # failure gave the reservation back in the meantime.
106
+ def confirm(label)
107
+ @mutex.synchronize do
108
+ # #initial_size has to keep counting it, so a record on it does not
109
+ # change where it came from
110
+ next if @series[label] == :initial
111
+
112
+ @series[label] = :confirmed
113
+ end
114
+ end
115
+
116
+ # The limit is not checked here either: a section which cannot hold
117
+ # these label sets is refused when the configuration is read.
118
+ def confirm_initial(label)
119
+ @mutex.synchronize { @series[label] = :initial }
120
+ end
121
+
122
+ # Gives a reserved slot back when the instrumentation failed, so that a
123
+ # label set the client does not hold does not consume the limit. One
124
+ # which a concurrent call confirmed in the meantime is kept: the client
125
+ # holds that series.
126
+ def release(label)
127
+ @mutex.synchronize { @series.delete(label) if @series[label] == :reserved }
128
+ end
129
+ end
34
130
 
35
131
  def self.parse_labels_elements(conf)
36
132
  labels = conf.elements.select { |e| e.name == 'labels' }
@@ -119,7 +215,7 @@ module Fluent
119
215
  base_initlabels
120
216
  end
121
217
 
122
- def self.parse_metrics_elements(conf, registry, labels = {})
218
+ def self.parse_metrics_elements(conf, registry, labels = {}, opts = {})
123
219
  metrics = []
124
220
  conf.elements.select { |element|
125
221
  element.name == 'metric'
@@ -130,17 +226,25 @@ module Fluent
130
226
  end
131
227
  case element['type']
132
228
  when 'summary'
133
- metrics << Fluent::Plugin::Prometheus::Summary.new(element, registry, labels)
229
+ metrics << Fluent::Plugin::Prometheus::Summary.new(element, registry, labels, opts)
134
230
  when 'gauge'
135
- metrics << Fluent::Plugin::Prometheus::Gauge.new(element, registry, labels)
231
+ metrics << Fluent::Plugin::Prometheus::Gauge.new(element, registry, labels, opts)
136
232
  when 'counter'
137
- metrics << Fluent::Plugin::Prometheus::Counter.new(element, registry, labels)
233
+ metrics << Fluent::Plugin::Prometheus::Counter.new(element, registry, labels, opts)
138
234
  when 'histogram'
139
- metrics << Fluent::Plugin::Prometheus::Histogram.new(element, registry, labels)
235
+ metrics << Fluent::Plugin::Prometheus::Histogram.new(element, registry, labels, opts)
140
236
  else
141
237
  raise ConfigError, "type option must be 'counter', 'gauge', 'summary' or 'histogram'"
142
238
  end
143
239
  }
240
+
241
+ # <metric> sections with the same name share one client
242
+ # metric. All of their <initlabels> label sets are known only
243
+ # after every section is built, so the check runs here and not
244
+ # in the constructor not to depend on the order of the
245
+ # sections.
246
+ metrics.each(&:check_series_limit!)
247
+
144
248
  metrics
145
249
  end
146
250
 
@@ -165,6 +269,49 @@ module Fluent
165
269
  @placeholder_values = {}
166
270
  @placeholder_expander_builder = Fluent::Plugin::Prometheus.placeholder_expander(log)
167
271
  @hostname = Socket.gethostname
272
+ @label_set_limit_log_throttle = Fluent::Plugin::Prometheus::LogThrottle.new(@ignore_error_log_interval)
273
+ @dropped_label_sets_counter = nil
274
+ end
275
+
276
+ def metric_options
277
+ {
278
+ max_series_per_metric: @max_series_per_metric,
279
+ }
280
+ end
281
+
282
+ # Registered on the first occurrence only, so that a plugin which never
283
+ # drops anything does not expose a counter which stays 0 forever. Its
284
+ # labels come from the configuration, so they cannot blow up on their own.
285
+ def limit_counter(name, docstring, labels)
286
+ @registry.counter(name, docstring: docstring, labels: labels)
287
+ rescue ::Prometheus::Client::Registry::AlreadyRegisteredError
288
+ # another plugin instance shares the registry and registered it first
289
+ Fluent::Plugin::Prometheus::Metric.get(@registry, name, :counter, docstring)
290
+ end
291
+
292
+ def dropped_label_sets_counter
293
+ @dropped_label_sets_counter ||=
294
+ limit_counter(DROPPED_LABEL_SETS_METRIC_NAME, DROPPED_LABEL_SETS_METRIC_DESC, [:name])
295
+ end
296
+
297
+ def warn_label_set_limit(metric)
298
+ # the drop is always counted, while the log below is throttled
299
+ dropped_label_sets_counter.increment(labels: { name: metric.name.to_s })
300
+
301
+ warn_throttled(@label_set_limit_log_throttle, metric.name,
302
+ "prometheus: dropped a label set because the metric reached max_series_per_metric.",
303
+ name: metric.name, max_series_per_metric: metric.max_series_per_metric)
304
+ end
305
+
306
+ # The counter above is never throttled, only the log which comes with it:
307
+ # one line per record would flood the Fluentd log, and the count is in
308
+ # Prometheus already.
309
+ def warn_throttled(throttle, key, message, **details)
310
+ emit, suppressed = throttle.check(key)
311
+ return unless emit
312
+
313
+ details = details.merge(suppressed_log_count: suppressed) if suppressed > 0
314
+ log.warn(message, details)
168
315
  end
169
316
 
170
317
  def instrument_single(tag, time, record, metrics)
@@ -180,6 +327,9 @@ module Fluent
180
327
  metrics.each do |metric|
181
328
  begin
182
329
  metric.instrument(record, expander)
330
+ rescue Fluent::Plugin::Prometheus::LabelSetLimitError
331
+ # dropping the label set is intended, so it is not an error event
332
+ warn_label_set_limit(metric)
183
333
  rescue => e
184
334
  log.warn "prometheus: failed to instrument a metric.", error_class: e.class, error: e, tag: tag, name: metric.name
185
335
  router.emit_error_event(tag, time, record, e)
@@ -201,6 +351,9 @@ module Fluent
201
351
  metrics.each do |metric|
202
352
  begin
203
353
  metric.instrument(record, expander)
354
+ rescue Fluent::Plugin::Prometheus::LabelSetLimitError
355
+ # dropping the label set is intended, so it is not an error event
356
+ warn_label_set_limit(metric)
204
357
  rescue => e
205
358
  log.warn "prometheus: failed to instrument a metric.", error_class: e.class, error: e, tag: tag, name: metric.name
206
359
  router.emit_error_event(tag, time, record, e)
@@ -214,8 +367,9 @@ module Fluent
214
367
  attr_reader :name
215
368
  attr_reader :key
216
369
  attr_reader :desc
370
+ attr_reader :max_series_per_metric
217
371
 
218
- def initialize(element, registry, labels)
372
+ def initialize(element, registry, labels, opts = {})
219
373
  ['name', 'desc'].each do |key|
220
374
  if element[key].nil?
221
375
  raise ConfigError, "metric requires '#{key}' option"
@@ -230,6 +384,10 @@ module Fluent
230
384
  @base_labels = Fluent::Plugin::Prometheus.parse_labels_elements(element)
231
385
  @base_labels = labels.merge(@base_labels)
232
386
 
387
+ # <metric> overrides the limit given by the plugin
388
+ @max_series_per_metric = metric_limit(element, 'max_series_per_metric',
389
+ opts.fetch(:max_series_per_metric, DEFAULT_MAX_SERIES_PER_METRIC))
390
+
233
391
  if @initialized
234
392
  @base_initlabels = Fluent::Plugin::Prometheus.parse_initlabels_elements(element, @base_labels)
235
393
  end
@@ -254,12 +412,31 @@ module Fluent
254
412
  if v.is_a?(String)
255
413
  label[k] = expander.expand(v)
256
414
  else
257
- label[k] = v.call(record)
415
+ label[k] = normalize_label_value(v.call(record))
258
416
  end
259
417
  end
260
418
  label
261
419
  end
262
420
 
421
+ # Instruments a record through the given block and keeps its label set
422
+ # as a series once the client holds it. The slot is taken before
423
+ # instrumenting and given back when the client refused the record, so
424
+ # that a label set the client does not hold does not exhaust
425
+ # max_series_per_metric.
426
+ def with_label_set(record, expander)
427
+ label = labels(record, expander)
428
+ reserved = reserve_series!(label)
429
+ begin
430
+ result = yield label
431
+ rescue
432
+ # a call which joined another's reservation has nothing to release
433
+ release_series(label) if reserved
434
+ raise
435
+ end
436
+ confirm_series(label)
437
+ result
438
+ end
439
+
263
440
  def self.get(registry, name, type, docstring)
264
441
  metric = registry.get(name)
265
442
 
@@ -273,10 +450,101 @@ module Fluent
273
450
 
274
451
  metric
275
452
  end
453
+
454
+ # <initlabels> label sets go to the client as soon as the
455
+ # configuration is read. Every <metric> section with the same name
456
+ # shares them, so reject a section which has no room for the label
457
+ # sets.
458
+ def check_series_limit!
459
+ return if @max_series_per_metric <= 0
460
+ # two <initlabels> blocks with the same values make one label set
461
+ held = @series_set.initial_size
462
+ return if held <= @max_series_per_metric
463
+
464
+ raise ConfigError, "metric #{@name} already holds #{held} label sets from <initlabels>, " \
465
+ "shared by every <metric> section with this name, " \
466
+ "but max_series_per_metric is #{@max_series_per_metric} in this section: " \
467
+ "the limit is already exceeded before any record arrives"
468
+ end
469
+
470
+ private
471
+
472
+ # The client refuses a value which is not a number, but it is only
473
+ # called once the label set has been reserved, and Summary refuses it
474
+ # only once it has already incremented its count: releasing the slot
475
+ # does not take that half instrumented series back from the client.
476
+ # Refuse the value before it reaches either.
477
+ def validate_value!(value)
478
+ return if value.is_a?(Numeric)
479
+
480
+ raise ArgumentError, 'value must be a number'
481
+ end
482
+
483
+ def metric_limit(element, name, default)
484
+ return default unless element.has_key?(name)
485
+
486
+ begin
487
+ # base 10 explicitly, so that a value like 08 is not an octal
488
+ Integer(element[name], 10)
489
+ rescue ArgumentError, TypeError
490
+ raise ConfigError, "#{name} in <metric> must be an integer: #{element[name]}"
491
+ end
492
+ end
493
+
494
+ # Called by a subclass, once it has its client metric.
495
+ def bind_series_set(client_metric)
496
+ @series_set = SeriesSet.of(client_metric)
497
+
498
+ return unless @initialized
499
+
500
+ # The client gets them even when this section has no limit, so they
501
+ # take their slots in both cases. A section with the same name shares
502
+ # this set and has to see them. Their number is fixed by the
503
+ # configuration. Counting them cannot leak like the label sets that
504
+ # records bring.
505
+ @base_initlabels.each do |initlabels|
506
+ @series_set.confirm_initial(normalize_label_set(initlabels))
507
+ end
508
+ end
509
+
510
+ # The SeriesSet keys a label set by its values, so the same value has to
511
+ # look the same whether a RecordAccessor or <initlabels> produced it.
512
+ def normalize_label_value(value)
513
+ value.is_a?(String) ? value : value.to_s
514
+ end
515
+
516
+ def normalize_label_set(label)
517
+ label.each_with_object({}) do |(k, v), normalized|
518
+ normalized[k] = normalize_label_value(v)
519
+ end
520
+ end
521
+
522
+ # Returns true when this call took the slot, which tells #with_label_set
523
+ # whether it has something to give back on failure.
524
+ def reserve_series!(label)
525
+ # If the limit is off, a label set from a record is not counted.
526
+ # If it is counted, the set grows with every new label set and
527
+ # uses too much memory.
528
+ return false if @max_series_per_metric <= 0
529
+
530
+ @series_set.reserve(label, @max_series_per_metric, @name)
531
+ end
532
+
533
+ def confirm_series(label)
534
+ return if @max_series_per_metric <= 0
535
+
536
+ @series_set.confirm(label)
537
+ end
538
+
539
+ def release_series(label)
540
+ return if @max_series_per_metric <= 0
541
+
542
+ @series_set.release(label)
543
+ end
276
544
  end
277
545
 
278
546
  class Gauge < Metric
279
- def initialize(element, registry, labels)
547
+ def initialize(element, registry, labels, opts = {})
280
548
  super
281
549
  if @key.nil?
282
550
  raise ConfigError, "gauge metric requires 'key' option"
@@ -287,6 +555,7 @@ module Fluent
287
555
  rescue ::Prometheus::Client::Registry::AlreadyRegisteredError
288
556
  @gauge = Fluent::Plugin::Prometheus::Metric.get(registry, element['name'].to_sym, :gauge, element['desc'])
289
557
  end
558
+ bind_series_set(@gauge)
290
559
 
291
560
  if @initialized
292
561
  Fluent::Plugin::Prometheus::Metric.init_label_set(@gauge, @base_initlabels, @base_labels)
@@ -300,19 +569,23 @@ module Fluent
300
569
  value = @key.call(record)
301
570
  end
302
571
  if value
303
- @gauge.set(value, labels: labels(record, expander))
572
+ validate_value!(value)
573
+ with_label_set(record, expander) do |label|
574
+ @gauge.set(value, labels: label)
575
+ end
304
576
  end
305
577
  end
306
578
  end
307
579
 
308
580
  class Counter < Metric
309
- def initialize(element, registry, labels)
581
+ def initialize(element, registry, labels, opts = {})
310
582
  super
311
583
  begin
312
584
  @counter = registry.counter(element['name'].to_sym, docstring: element['desc'], labels: @base_labels.keys)
313
585
  rescue ::Prometheus::Client::Registry::AlreadyRegisteredError
314
586
  @counter = Fluent::Plugin::Prometheus::Metric.get(registry, element['name'].to_sym, :counter, element['desc'])
315
587
  end
588
+ bind_series_set(@counter)
316
589
 
317
590
  if @initialized
318
591
  Fluent::Plugin::Prometheus::Metric.init_label_set(@counter, @base_initlabels, @base_labels)
@@ -332,12 +605,15 @@ module Fluent
332
605
  # ignore if record value is nil
333
606
  return if value.nil?
334
607
 
335
- @counter.increment(by: value, labels: labels(record, expander))
608
+ validate_value!(value)
609
+ with_label_set(record, expander) do |label|
610
+ @counter.increment(by: value, labels: label)
611
+ end
336
612
  end
337
613
  end
338
614
 
339
615
  class Summary < Metric
340
- def initialize(element, registry, labels)
616
+ def initialize(element, registry, labels, opts = {})
341
617
  super
342
618
  if @key.nil?
343
619
  raise ConfigError, "summary metric requires 'key' option"
@@ -348,6 +624,7 @@ module Fluent
348
624
  rescue ::Prometheus::Client::Registry::AlreadyRegisteredError
349
625
  @summary = Fluent::Plugin::Prometheus::Metric.get(registry, element['name'].to_sym, :summary, element['desc'])
350
626
  end
627
+ bind_series_set(@summary)
351
628
 
352
629
  if @initialized
353
630
  Fluent::Plugin::Prometheus::Metric.init_label_set(@summary, @base_initlabels, @base_labels)
@@ -361,13 +638,16 @@ module Fluent
361
638
  value = @key.call(record)
362
639
  end
363
640
  if value
364
- @summary.observe(value, labels: labels(record, expander))
641
+ validate_value!(value)
642
+ with_label_set(record, expander) do |label|
643
+ @summary.observe(value, labels: label)
644
+ end
365
645
  end
366
646
  end
367
647
  end
368
648
 
369
649
  class Histogram < Metric
370
- def initialize(element, registry, labels)
650
+ def initialize(element, registry, labels, opts = {})
371
651
  super
372
652
  if @key.nil?
373
653
  raise ConfigError, "histogram metric requires 'key' option"
@@ -385,6 +665,7 @@ module Fluent
385
665
  rescue ::Prometheus::Client::Registry::AlreadyRegisteredError
386
666
  @histogram = Fluent::Plugin::Prometheus::Metric.get(registry, element['name'].to_sym, :histogram, element['desc'])
387
667
  end
668
+ bind_series_set(@histogram)
388
669
 
389
670
  if @initialized
390
671
  Fluent::Plugin::Prometheus::Metric.init_label_set(@histogram, @base_initlabels, @base_labels)
@@ -398,7 +679,10 @@ module Fluent
398
679
  value = @key.call(record)
399
680
  end
400
681
  if value
401
- @histogram.observe(value, labels: labels(record, expander))
682
+ validate_value!(value)
683
+ with_label_set(record, expander) do |label|
684
+ @histogram.observe(value, labels: label)
685
+ end
402
686
  end
403
687
  end
404
688
  end