fluent-plugin-prometheus 2.2.2 → 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.
@@ -22,9 +22,16 @@ describe Fluent::Plugin::PrometheusInput do
22
22
  describe '#configure' do
23
23
  describe 'bind' do
24
24
  let(:config) { CONFIG + %[
25
- bind 127.0.0.1
25
+ bind ::1
26
26
  ] }
27
27
  it 'should be configurable' do
28
+ expect(driver.instance.bind).to eq('::1')
29
+ end
30
+ end
31
+
32
+ describe 'default bind' do
33
+ let(:config) { CONFIG }
34
+ it 'should be accessible from 127.0.0.1 by default' do
28
35
  expect(driver.instance.bind).to eq('127.0.0.1')
29
36
  end
30
37
  end
@@ -64,6 +71,22 @@ describe Fluent::Plugin::PrometheusInput do
64
71
  expect(driver.instance.content_encoding).to eq(:gzip)
65
72
  end
66
73
  end
74
+
75
+ describe 'default ignore_error_log_interval' do
76
+ let(:config) { CONFIG }
77
+ it 'should be 3600 seconds by default' do
78
+ expect(driver.instance.ignore_error_log_interval).to eq(3600)
79
+ end
80
+ end
81
+
82
+ describe 'error_log_interval' do
83
+ let(:config) { CONFIG + %[
84
+ ignore_error_log_interval 60
85
+ ] }
86
+ it 'should be configurable' do
87
+ expect(driver.instance.ignore_error_log_interval).to eq(60)
88
+ end
89
+ end
67
90
  end
68
91
 
69
92
  describe '#start' do
@@ -331,4 +354,184 @@ describe Fluent::Plugin::PrometheusInput do
331
354
  include_examples 'IPv6 server binding', '[::1]', '::1', 'handles pre-bracketed address correctly'
332
355
  end
333
356
  end
357
+
358
+ describe 'error handling (information disclosure)' do
359
+ let(:config) { LOCAL_CONFIG }
360
+ let(:secret_message) { 'dummy secret detail: password=deadbeef' }
361
+
362
+ shared_examples 'suppressed exception response' do
363
+ it 'returns 500 with text/plain' do
364
+ status, header, _body = subject
365
+ expect(status).to eq(500)
366
+ expect(header['Content-Type']).to eq('text/plain')
367
+ end
368
+
369
+ it 'exposes the exception class only' do
370
+ _status, _header, body = subject
371
+ expect(body).to eq('in_prometheus server error: <RuntimeError>')
372
+ expect(body).not_to include(secret_message)
373
+ end
374
+
375
+ it 'logs the detail on the server side' do
376
+ subject
377
+ expect(driver.logs.any? { |log| log.include?(log_message) }).to be true
378
+ expect(driver.logs.any? { |log| log.include?(secret_message) }).to be true
379
+ end
380
+ end
381
+
382
+ context '#all_metrics' do
383
+ subject { driver.instance.send(:all_metrics) }
384
+
385
+ let(:log_message) { 'in_prometheus: failed to render metrics' }
386
+
387
+ before do
388
+ allow(::Prometheus::Client::Formats::Text).to receive(:marshal).and_raise(RuntimeError, secret_message)
389
+ end
390
+
391
+ include_examples 'suppressed exception response'
392
+ end
393
+
394
+ context '#all_workers_metrics' do
395
+ subject { driver.instance.send(:all_workers_metrics) }
396
+
397
+ let(:log_message) { 'in_prometheus: failed to render workers metrics' }
398
+
399
+ before do
400
+ allow(driver.instance).to receive(:send_request_to_each_worker).and_raise(RuntimeError, secret_message)
401
+ end
402
+
403
+ include_examples 'suppressed exception response'
404
+ end
405
+
406
+ context 'over HTTP' do
407
+ before do
408
+ allow(::Prometheus::Client::Formats::Text).to receive(:marshal).and_raise(RuntimeError, secret_message)
409
+ end
410
+
411
+ it 'does not leak the exception message to the client' do
412
+ driver.run(timeout: 1) do
413
+ Net::HTTP.start('127.0.0.1', port) do |http|
414
+ req = Net::HTTP::Get.new('/metrics')
415
+ res = http.request(req)
416
+ expect(res.code).to eq('500')
417
+ expect(res.body).to eq('in_prometheus server error: <RuntimeError>')
418
+ expect(res.body).not_to include(secret_message)
419
+ end
420
+ end
421
+ end
422
+ end
423
+ end
424
+
425
+ # The LogThrottle spec covers the throttling itself. These examples cover how
426
+ # in_prometheus uses it: the scope it throttles on, the fingerprint it makes
427
+ # from an error, the suppressed_log_count in the log, and what the client
428
+ # gets meanwhile.
429
+ describe 'error log throttling' do
430
+ let(:config) { LOCAL_CONFIG }
431
+ let(:secret_message) { 'dummy secret detail: password=deadbeef' }
432
+ let(:log_message) { 'in_prometheus: failed to render metrics' }
433
+ let(:workers_log_message) { 'in_prometheus: failed to render workers metrics' }
434
+
435
+ # Fluent::Clock.now is monotonic, so a plain Hash is enough to drive it
436
+ let(:clock) { { now: 1000.0 } }
437
+
438
+ def error_logs(message)
439
+ driver.logs.select { |log| log.include?(message) }
440
+ end
441
+
442
+ context 'when rendering metrics keeps failing' do
443
+ before do
444
+ allow(Fluent::Clock).to receive(:now) { clock[:now] }
445
+ allow(::Prometheus::Client::Formats::Text).to receive(:marshal).and_raise(RuntimeError, secret_message)
446
+ end
447
+
448
+ # every iteration raises from the same line, so the exceptions are equal
449
+ # to each other and only ignore_error_log_interval can let a log through
450
+ it 'logs the repeated same failure only once within ignore_error_log_interval' do
451
+ 5.times { driver.instance.send(:all_metrics) }
452
+ expect(error_logs(log_message).size).to eq(1)
453
+ end
454
+
455
+ it 'keeps returning 500 to the client even while the log is suppressed' do
456
+ responses = 5.times.map { driver.instance.send(:all_metrics) }
457
+ expect(error_logs(log_message).size).to eq(1)
458
+ responses.each do |status, _header, body|
459
+ expect(status).to eq(500)
460
+ expect(body).to eq('in_prometheus server error: <RuntimeError>')
461
+ end
462
+ end
463
+
464
+ it 'reports how many logs were suppressed in the meantime' do
465
+ 3.times { driver.instance.send(:all_metrics) }
466
+ clock[:now] += driver.instance.ignore_error_log_interval
467
+ driver.instance.send(:all_metrics)
468
+ logs = error_logs(log_message)
469
+ expect(logs.size).to eq(2)
470
+ expect(logs.first).not_to include('suppressed_log_count')
471
+ expect(logs.last).to include('suppressed_log_count=2')
472
+ end
473
+ end
474
+
475
+ describe 'telling the errors apart' do
476
+ before do
477
+ allow(Fluent::Clock).to receive(:now) { clock[:now] }
478
+ end
479
+
480
+ def log_error(scope, message, error)
481
+ driver.instance.send(:log_error_throttled, scope, message, error: error)
482
+ end
483
+
484
+ # the class and the message both take part in the fingerprint
485
+ it 'logs immediately when the error class differs' do
486
+ log_error(:metrics, log_message, RuntimeError.new(secret_message))
487
+ log_error(:metrics, log_message, ArgumentError.new(secret_message))
488
+ expect(error_logs(log_message).size).to eq(2)
489
+ end
490
+
491
+ it 'logs immediately when the error differs' do
492
+ log_error(:metrics, log_message, RuntimeError.new(secret_message))
493
+ log_error(:metrics, log_message, RuntimeError.new('another failure'))
494
+ expect(error_logs(log_message).size).to eq(2)
495
+ end
496
+
497
+ # the scope, not the log message, picks the slot to throttle on
498
+ it 'suppresses an equal error within a scope even when the log message differs' do
499
+ error = RuntimeError.new(secret_message)
500
+ log_error(:metrics, log_message, error)
501
+ log_error(:metrics, workers_log_message, error)
502
+ expect(error_logs(workers_log_message)).to be_empty
503
+ end
504
+
505
+ # the only example which takes the interval from the configuration
506
+ context 'with ignore_error_log_interval 0' do
507
+ let(:config) { LOCAL_CONFIG + %[
508
+ ignore_error_log_interval 0
509
+ ] }
510
+
511
+ it 'logs every occurrence of the same error' do
512
+ 3.times { log_error(:metrics, log_message, RuntimeError.new(secret_message)) }
513
+ expect(error_logs(log_message).size).to eq(3)
514
+ end
515
+ end
516
+ end
517
+
518
+ # /metrics and /aggregated_metrics are usually scraped in turn, so both of
519
+ # them must be throttled on their own slot
520
+ context 'when both endpoints keep failing alternately' do
521
+ before do
522
+ allow(Fluent::Clock).to receive(:now) { clock[:now] }
523
+ allow(::Prometheus::Client::Formats::Text).to receive(:marshal).and_raise(RuntimeError, secret_message)
524
+ allow(driver.instance).to receive(:send_request_to_each_worker).and_raise(ArgumentError, 'another failure')
525
+ end
526
+
527
+ it 'logs each failure only once within ignore_error_log_interval' do
528
+ 5.times do
529
+ driver.instance.send(:all_metrics)
530
+ driver.instance.send(:all_workers_metrics)
531
+ end
532
+ expect(error_logs(log_message).size).to eq(1)
533
+ expect(error_logs(workers_log_message).size).to eq(1)
534
+ end
535
+ end
536
+ end
334
537
  end
@@ -19,7 +19,35 @@ describe Fluent::Plugin::PrometheusOutput do
19
19
  describe '#testinitlabels' do
20
20
  it_behaves_like 'initalized metrics'
21
21
  end
22
-
22
+
23
+ describe 'limiting label expansion' do
24
+ it_behaves_like 'limits label expansion'
25
+ end
26
+
27
+ # filter_prometheus routes such a record to @ERROR already. The output has to
28
+ # do the same, instead of failing on the router itself.
29
+ describe 'a record which cannot be instrumented' do
30
+ let(:config) {
31
+ BASE_CONFIG + %(
32
+ <metric>
33
+ name failing
34
+ type counter
35
+ desc Something foo.
36
+ key foo
37
+ </metric>
38
+ )
39
+ }
40
+
41
+ it 'emits an error event' do
42
+ driver.run(default_tag: tag) do
43
+ # a non numeric value is refused when the metric is instrumented
44
+ driver.feed(event_time, {'foo' => 'not a number'})
45
+ end
46
+
47
+ expect(driver.error_events.size).to eq(1)
48
+ end
49
+ end
50
+
23
51
  describe '#run' do
24
52
  let(:message) { {"foo" => 100, "bar" => 100, "baz" => 100, "qux" => 10} }
25
53
 
@@ -0,0 +1,127 @@
1
+ require 'spec_helper'
2
+ require 'fluent/plugin/prometheus/log_throttle'
3
+
4
+ describe Fluent::Plugin::Prometheus::LogThrottle do
5
+ # Fluent::Clock.now is monotonic, so a Hash is enough to fake it
6
+ let(:clock) { { now: 1000.0 } }
7
+ let(:interval) { 3600 }
8
+ # in_prometheus builds it out of an error
9
+ let(:fingerprint) { [RuntimeError, 'a'] }
10
+ subject(:throttle) { described_class.new(interval) }
11
+
12
+ before do
13
+ allow(Fluent::Clock).to receive(:now) { clock[:now] }
14
+ end
15
+
16
+ describe '#check' do
17
+ it 'emits on the first occurrence of a key' do
18
+ emit, suppressed = throttle.check(:foo, fingerprint)
19
+ expect(emit).to be true
20
+ expect(suppressed).to eq(0)
21
+ end
22
+
23
+ it 'suppresses the same key within the interval' do
24
+ throttle.check(:foo, fingerprint)
25
+ clock[:now] += interval - 1
26
+ emit, _ = throttle.check(:foo, fingerprint)
27
+ expect(emit).to be false
28
+ end
29
+
30
+ it 'emits again once the interval has elapsed' do
31
+ throttle.check(:foo, fingerprint)
32
+ clock[:now] += interval
33
+ emit, _ = throttle.check(:foo, fingerprint)
34
+ expect(emit).to be true
35
+ end
36
+
37
+ it 'reports how many occurrences were suppressed in the meantime' do
38
+ throttle.check(:foo, fingerprint) # emits, suppressed=0
39
+ 2.times { throttle.check(:foo, fingerprint) } # suppressed 1, then 2
40
+ clock[:now] += interval
41
+ emit, suppressed = throttle.check(:foo, fingerprint)
42
+ expect(emit).to be true
43
+ expect(suppressed).to eq(2)
44
+ end
45
+
46
+ it 'resets the suppressed count after emitting' do
47
+ throttle.check(:foo, fingerprint)
48
+ 2.times { throttle.check(:foo, fingerprint) }
49
+ clock[:now] += interval
50
+ throttle.check(:foo, fingerprint) # emits with suppressed=2
51
+ clock[:now] += interval
52
+ _, suppressed = throttle.check(:foo, fingerprint)
53
+ expect(suppressed).to eq(0)
54
+ end
55
+
56
+ it 'keeps a separate slot per key' do
57
+ expect(throttle.check(:foo, fingerprint).first).to be true
58
+ expect(throttle.check(:bar, fingerprint).first).to be true
59
+ end
60
+
61
+ # filter/out_prometheus throttles on the metric alone, since every drop of
62
+ # a metric reads the same
63
+ context 'without a fingerprint' do
64
+ it 'throttles on the key alone' do
65
+ expect(throttle.check(:foo).first).to be true
66
+ expect(throttle.check(:foo).first).to be false
67
+ expect(throttle.check(:bar).first).to be true
68
+ end
69
+
70
+ it 'reports how many occurrences were suppressed in the meantime' do
71
+ throttle.check(:foo)
72
+ 2.times { throttle.check(:foo) }
73
+ clock[:now] += interval
74
+ emit, suppressed = throttle.check(:foo)
75
+ expect(emit).to be true
76
+ expect(suppressed).to eq(2)
77
+ end
78
+ end
79
+
80
+ it 'emits immediately when the fingerprint changes within the interval' do
81
+ expect(throttle.check(:foo, [RuntimeError, 'a']).first).to be true
82
+ expect(throttle.check(:foo, [RuntimeError, 'b']).first).to be true
83
+ end
84
+
85
+ # the caller makes a new fingerprint for each event, so it has to be
86
+ # compared by value, not by object identity
87
+ it 'suppresses an equal fingerprint given as a different object' do
88
+ expect(throttle.check(:foo, [RuntimeError, 'a']).first).to be true
89
+ expect(throttle.check(:foo, [RuntimeError, 'a']).first).to be false
90
+ end
91
+
92
+ it 'does not carry the suppressed count across a fingerprint change' do
93
+ throttle.check(:foo, [RuntimeError, 'a'])
94
+ 2.times { throttle.check(:foo, [RuntimeError, 'a']) }
95
+ emit, suppressed = throttle.check(:foo, [RuntimeError, 'b'])
96
+ expect(emit).to be true
97
+ expect(suppressed).to eq(0)
98
+ end
99
+
100
+ context 'when interval is zero' do
101
+ let(:interval) { 0 }
102
+
103
+ it 'always emits without consulting the clock' do
104
+ expect(Fluent::Clock).not_to receive(:now)
105
+ 3.times do
106
+ emit, suppressed = throttle.check(:foo, fingerprint)
107
+ expect(emit).to be true
108
+ expect(suppressed).to eq(0)
109
+ end
110
+ end
111
+ end
112
+
113
+ context 'when interval is negative' do
114
+ let(:interval) { -1 }
115
+
116
+ it 'always emits' do
117
+ expect(throttle.check(:foo, fingerprint).first).to be true
118
+ expect(throttle.check(:foo, fingerprint).first).to be true
119
+ end
120
+ end
121
+
122
+ it 'serializes concurrent checks for the same key into a single emission' do
123
+ results = 10.times.map { Thread.new { throttle.check(:foo, fingerprint).first } }.map(&:value)
124
+ expect(results.count(true)).to eq(1)
125
+ end
126
+ end
127
+ end
@@ -50,6 +50,94 @@ describe Fluent::Plugin::Prometheus::ExpandBuilder::PlaceholderExpander do
50
50
  expander.expand('${hostname}')
51
51
  end
52
52
 
53
+ # tag_parts, tag_prefix and tag_suffix must be expanded with the tag,
54
+ # not with "forged"
55
+ context 'with a value named after a tag placeholder' do
56
+ let(:forged_placeholder) do
57
+ {
58
+ 'tag' => '1.2.3',
59
+ 'tag_parts' => %w[forged forged forged],
60
+ 'tag_prefix' => %w[forged forged forged],
61
+ 'tag_suffix' => %w[forged forged forged],
62
+ }
63
+ end
64
+
65
+ it 'expands the placeholders with the tag' do
66
+ expander = builder.build(forged_placeholder)
67
+
68
+ expect(expander.expand('${tag_parts[0]}.${tag_parts[1]}.${tag_parts[2]}')).to eq('1.2.3')
69
+ expect(expander.expand('${tag_parts[-3]}.${tag_parts[-2]}.${tag_parts[-1]}')).to eq('1.2.3')
70
+ expect(expander.expand('${tag_prefix[0]},${tag_prefix[1]},${tag_prefix[2]}')).to eq('1,1.2,1.2.3')
71
+ expect(expander.expand('${tag_suffix[0]},${tag_suffix[1]},${tag_suffix[2]}')).to eq('3,2.3,1.2.3')
72
+ end
73
+
74
+ it 'does not expand an index which the tag does not have' do
75
+ # tag_prefix and tag_suffix have no negative index, so they are kept
76
+ # as they are. This checks that the record does not fill them.
77
+ expander = builder.build(forged_placeholder)
78
+
79
+ expect(expander.expand('${tag_prefix[-1]}')).to eq('${tag_prefix[-1]}')
80
+ expect(expander.expand('${tag_suffix[-1]}')).to eq('${tag_suffix[-1]}')
81
+
82
+ # the tag has 3 parts, so the 4th value of the record is out of it.
83
+ longer = forged_placeholder.merge('tag_parts' => %w[forged forged forged forged])
84
+ expander = builder.build(longer)
85
+
86
+ expect(expander.expand('${tag_parts[3]}')).to eq('${tag_parts[3]}')
87
+ expect(expander.expand('${tag_parts[-4]}')).to eq('${tag_parts[-4]}')
88
+ end
89
+ end
90
+
91
+ # a record may also have a key named "tag_parts[0]", which makes the
92
+ # same placeholder as the tag
93
+ context 'with a value whose key is a tag placeholder' do
94
+ it 'expands the placeholders with the tag' do
95
+ spelled = {
96
+ 'tag' => '1.2.3',
97
+ 'tag_parts[0]' => 'forged',
98
+ 'tag_prefix[0]' => 'forged',
99
+ 'tag_suffix[0]' => 'forged',
100
+ }
101
+ expander = builder.build(spelled)
102
+
103
+ expect(expander.expand('${tag_parts[0]}')).to eq('1')
104
+ expect(expander.expand('${tag_prefix[0]}')).to eq('1')
105
+ expect(expander.expand('${tag_suffix[0]}')).to eq('3')
106
+ end
107
+
108
+ it 'does not expand an index which the tag does not have' do
109
+ spelled = {
110
+ 'tag' => '1',
111
+ 'tag_parts[1]' => 'forged',
112
+ 'tag_parts[-2]' => 'forged',
113
+ 'tag_prefix[-1]' => 'forged',
114
+ 'tag_suffix[-1]' => 'forged',
115
+ }
116
+ expander = builder.build(spelled)
117
+
118
+ expect(expander.expand('${tag_parts[1]}')).to eq('${tag_parts[1]}')
119
+ expect(expander.expand('${tag_parts[-2]}')).to eq('${tag_parts[-2]}')
120
+ expect(expander.expand('${tag_prefix[-1]}')).to eq('${tag_prefix[-1]}')
121
+ expect(expander.expand('${tag_suffix[-1]}')).to eq('${tag_suffix[-1]}')
122
+ end
123
+
124
+ it 'keeps the tag itself' do
125
+ spelled = {
126
+ 'tag' => '1.2.3',
127
+ 'tag_parts' => 'forged',
128
+ 'tag_prefix' => 'forged',
129
+ 'tag_suffix' => 'forged',
130
+ }
131
+ expander = builder.build(spelled)
132
+
133
+ expect(expander.expand('${tag}')).to eq('1.2.3')
134
+ # these are not built from the tag, so they are kept as they are
135
+ expect(expander.expand('${tag_parts}')).to eq('${tag_parts}')
136
+ expect(expander.expand('${tag_prefix}')).to eq('${tag_prefix}')
137
+ expect(expander.expand('${tag_suffix}')).to eq('${tag_suffix}')
138
+ end
139
+ end
140
+
53
141
  context 'when not found placeholder' do
54
142
  it 'prints wanring log and as it is' do
55
143
  expect(log).to receive(:warn).with('unknown placeholder `${tag_prefix[100]}` found').once
@@ -87,6 +175,14 @@ describe Fluent::Plugin::Prometheus::ExpandBuilder::PlaceholderExpander do
87
175
  expect(expander.expand('${tag_suffix[0]}.${tag_suffix[1]}.${tag_suffix[2]}', dynamic_placeholders: dynamic_placeholder)).to eq('3.2.3.1.2.3')
88
176
  end
89
177
 
178
+ it 'expands the placeholders with the dynamic tag' do
179
+ forged = static_placeholder.merge('tag_parts' => %w[forged forged forged])
180
+ expander = builder.build(forged)
181
+
182
+ expect(expander.expand('${tag_parts[0]}.${tag_parts[1]}.${tag_parts[2]}',
183
+ dynamic_placeholders: dynamic_placeholder)).to eq('1.2.3')
184
+ end
185
+
90
186
  it 'does not create expander twice if given the same placeholder' do
91
187
  builder # cached before mock
92
188