prometheus_exporter-ext 0.2.4 → 0.3.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e47f266c5bc957436f07c63c88f870e2eafe2744d358f762d291ed8b58b5aad5
4
- data.tar.gz: 1a62ac477d59603197c5076f2d0b42e0f0bccaaa8082d509eec46bd7df547cc8
3
+ metadata.gz: 9f48189e693644e88572d2c0e53d88b5e3b24ff0d5fbf5a81ab83917a9a33611
4
+ data.tar.gz: 54899812b42e64fd0de8b68bde3ac516be1312d0d83bec943aa1aac6b61c93ae
5
5
  SHA512:
6
- metadata.gz: 2475868468870c9ec5ce1fd819fc125c3ba22718df62c11ef5da6c27295d87ccbbeaf4aef7676bb7e52c43cdb786a25410f20cbabcad1e379048bc4767806a37
7
- data.tar.gz: 8c83ba275e8f4233fccc3a55b7c4a069ded287880dc431680b736159d124bf8bb6a5dd0642547484b050f97e4ac47c5e8a1360850cd23f3e1185c89253c76832
6
+ metadata.gz: 293fff8a0de37c36dcfbec873546b8416443ac6717eaf2c1c112104a8bd117a115ccf9af9a793e3bb92d243cbe88e8709763b109deec2628b88f1f44651c4da9
7
+ data.tar.gz: 85f241c1fd687dd12648b37bba25d6608353ac0e8f493427ed870500ced02e3eb6931271b1b5023d86d4d3c1467bdefe8e58fbb339aef6a15801154e863707fe
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.3.2] - 2026-06-11
4
+ - fix gemspec missing `spec.files` so the published gem actually ships its source files (0.3.1 and earlier shipped empty)
5
+
6
+ ## [0.3.1] - 2024-05-04
7
+ - ProcCpu add hostname label
8
+
9
+ ## [0.3.0] - 2024-05-04
10
+ - replace ProcStat with ProcCpu
11
+
12
+ ## [0.2.4] - 2024-05-03
13
+ - add PrometheusExporter::Ext::Server::ProcStatCollector
14
+
15
+ ## [0.2.3] - 2024-05-03
16
+ - add PrometheusExporter::Ext::Instrumentation::ProcStat
17
+
18
+ ## [0.2.2] - 2023-12-08
19
+ - fix send_metrics RSpec matcher
20
+
21
+ ## [0.2.1] - 2023-12-06
22
+ - add gauge_with_expire, add tests, update readme
23
+
24
+ ## [0.2.0] - 2023-12-06
25
+ - remove gauge_with_time, add expired_stats_collector, add tests
26
+
27
+ ## [0.1.0] - 2023-11-26
28
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Denis Talakevich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # PrometheusExporter::Ext
2
+
3
+ ![Tests](https://github.com/didww/prometheus_exporter-ext/workflows/Tests/badge.svg)
4
+
5
+ Extension for [Ruby Prometheus Exporter](https://github.com/discourse/prometheus_exporter).
6
+ Adds DSL for building your custom Prometheus instrumentations and collectors.
7
+ Allow to remove/zero expired gauge metrics in a collector.
8
+
9
+ ## Installation
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ $ bundle add prometheus_exporter-ext
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ $ gem install prometheus_exporter-ext
18
+
19
+ ## Usage
20
+
21
+ ### When metrics should be send on particular event
22
+ create instrumentation
23
+ ```ruby
24
+ # lib/prometheus/my_instrumentation.rb
25
+ require 'prometheus_exporter/ext'
26
+ require 'prometheus_exporter/ext/instrumentation/base_stats'
27
+
28
+ module Prometheus
29
+ class MyInstrumentation < ::PrometheusExporter::Ext::Instrumentation::BaseStats
30
+ self.type = 'my'
31
+
32
+ def collect(duration, operation)
33
+ collect_data(
34
+ labels: { operation_name: operation },
35
+ last_duration_seconds: duration,
36
+ duration_seconds_sum: duration,
37
+ duration_seconds_count: 1
38
+ )
39
+ rescue StandardError => e
40
+ Rails.logger.error("Failed to send metrics Prometheus #{self.class.name} #{e}")
41
+ Rails.error.report(e, handled: true, severity: :error, context: { prometheus: self.class.name })
42
+ end
43
+ end
44
+ end
45
+ ```
46
+
47
+ then send metrics from your code
48
+ ```ruby
49
+ time_start = Time.current.to_i
50
+ begin
51
+ MyOperation.run
52
+ ensure
53
+ duration = Time.current.to_i - time_start
54
+ Prometheus::MyInstrumentation.new.collect(duration, 'my_operation')
55
+ ## you can add additional labels or override client
56
+ Prometheus::MyInstrumentation.new(
57
+ client: PrometheusExporter::Client.new(...),
58
+ labels: { foo: 'bar' }
59
+ ).collect(duration)
60
+ end
61
+ ```
62
+
63
+ so metrics will be collected by
64
+ ```ruby
65
+ require 'prometheus_exporter/ext'
66
+ require 'prometheus_exporter/ext/server/stats_collector'
67
+
68
+ module Prometheus
69
+ class MyCollector < ::PrometheusExporter::Server::TypeCollector
70
+ include ::PrometheusExporter::Ext::Server::StatsCollector
71
+ self.type = 'my'
72
+
73
+ # The `register_gauge_with_expire` will remove or zero expired metric.
74
+ # when no :strategy option passed, default is `:removing`, available options are `:removing, :zeroing`.
75
+ # when no :ttl option passed, default is 60, any numeric greater than 0 can be used.
76
+ register_gauge_with_expire :last_duration_seconds, 'duration of last operation execution', ttl: 300
77
+
78
+ register_counter :task_duration_seconds_sum, 'sum of operation execution durations'
79
+ register_counter :task_duration_seconds_count, 'sum of operation execution runs'
80
+ end
81
+ end
82
+ ```
83
+
84
+ as alternative you can use `ExpiredStatsCollector` if you want all metric data to be removed after expiration
85
+ ```ruby
86
+ require 'prometheus_exporter/ext'
87
+ require 'prometheus_exporter/ext/server/stats_collector'
88
+
89
+ module Prometheus
90
+ class MyCollector < ::PrometheusExporter::Server::TypeCollector
91
+ include ::PrometheusExporter::Ext::Server::ExpiredStatsCollector
92
+ self.type = 'my'
93
+ self.ttl = 300 # default 60
94
+
95
+ # Optionally you can expire old_metric when specific new metric is collected.
96
+ # If this block returns true then old_metric will be removed.
97
+ unique_metric_by do |new_metric, old_metric|
98
+ new_metric['labels'] == old_metric['labels']
99
+ end
100
+
101
+ register_gauge :last_duration_seconds, 'duration of last operation execution'
102
+ register_counter :task_duration_seconds_sum, 'sum of operation execution durations'
103
+ register_counter :task_duration_seconds_count, 'sum of operation execution runs'
104
+ end
105
+ end
106
+ ````
107
+
108
+ ### When metrics should be send periodically with given frequency
109
+ create instrumentation
110
+ ```ruby
111
+ # lib/prometheus/my_instrumentation.rb
112
+ require 'prometheus_exporter/ext'
113
+ require 'prometheus_exporter/ext/instrumentation/periodic_stats'
114
+
115
+ module Prometheus
116
+ class MyPeriodicInstrumentation < ::PrometheusExporter::Ext::Instrumentation::PeriodicStats
117
+ self.type = 'my'
118
+
119
+ def collect
120
+ count = MyItem.processed.count
121
+ last_duration = MyItem.processed.last&.duration
122
+ collect_data(
123
+ labels: { some_label: 'some_value' },
124
+ last_processed_duration: last_duration || 0,
125
+ processed_count: count
126
+ )
127
+ rescue StandardError => e
128
+ Rails.logger.error("Failed to send metrics Prometheus #{self.class.name} #{e}")
129
+ Rails.error.report(e, handled: true, severity: :error, context: { prometheus: self.class.name })
130
+ end
131
+ end
132
+ end
133
+ ```
134
+
135
+ then send metrics from your code
136
+ ```ruby
137
+ Prometheus::MyInstrumentation.start
138
+ ## you can override frequency in seconds
139
+ Prometheus::MyInstrumentation.start(frequency: 60)
140
+ ## also you can add additional labels or override client
141
+ Prometheus::MyInstrumentation.start(
142
+ client: PrometheusExporter::Client.new(...),
143
+ labels: { foo: 'bar' }
144
+ )
145
+ # to stop instrumentation call `Prometheus::MyInstrumentation.stop`
146
+ ```
147
+
148
+ so metrics will be collected by
149
+ ```ruby
150
+ require 'prometheus_exporter/ext'
151
+ require 'prometheus_exporter/ext/server/stats_collector'
152
+
153
+ module Prometheus
154
+ class MyCollector < ::PrometheusExporter::Server::TypeCollector
155
+ include ::PrometheusExporter::Ext::Server::StatsCollector
156
+ self.type = 'my'
157
+
158
+ # Default ttl 60, default strategy `:removing`.
159
+ register_gauge_with_expire :last_processed_duration, 'duration of last processed record'
160
+ register_metric :processed_count, :gauge_with_time, 'count of processed records'
161
+ end
162
+ end
163
+ ```
164
+
165
+ as alternative you can use `ExpiredStatsCollector` if you want all metric data to be removed after expiration
166
+ ```ruby
167
+ require 'prometheus_exporter/ext'
168
+ require 'prometheus_exporter/ext/server/stats_collector'
169
+
170
+ module Prometheus
171
+ class MyCollector < ::PrometheusExporter::Server::TypeCollector
172
+ include ::PrometheusExporter::Ext::Server::ExpiredStatsCollector
173
+ self.type = 'my'
174
+ # By default ttl is 60
175
+ # By default deletes old metrics only when it's expired
176
+
177
+ register_gauge :last_processed_duration, 'duration of last processed record'
178
+ register_metric :processed_count, :gauge_with_time, 'count of processed records'
179
+ end
180
+ end
181
+ ````
182
+
183
+ ### You also can easily test your instrumentations and collectors using new matchers
184
+
185
+ instrumentation test
186
+ ```ruby
187
+ require 'prometheus_exporter/ext/rspec'
188
+
189
+ RSpec.describe Prometheus::MyInstrumentation do
190
+ describe '#collect' do
191
+ subject { described_class.new.collect(duration, operation) }
192
+ let(:duration) { 1.23 }
193
+ let(:operation) { 'test' }
194
+
195
+ it 'sends prometheus metrics' do
196
+ expect { subject }.to send_metrics(
197
+ [
198
+ type: 'my',
199
+ labels: { operation_name: operation },
200
+ last_duration_seconds: duration,
201
+ duration_seconds_sum: duration,
202
+ duration_seconds_count: 1
203
+ ]
204
+ )
205
+ end
206
+ end
207
+ end
208
+ ```
209
+
210
+ collector test
211
+ ```ruby
212
+ RSpec.describe Prometheus::MyCollector do
213
+ describe '#collect' do
214
+ subject do
215
+ collector.metrics
216
+ end
217
+
218
+ let(:collector) { described_class.new }
219
+ let(:metric) do
220
+ {
221
+ type: 'my',
222
+ labels: { operation_name: 'test' },
223
+ last_duration_seconds: 1.2,
224
+ duration_seconds_sum: 3.4,
225
+ duration_seconds_count: 1
226
+ }
227
+ end
228
+
229
+ let(:collect_data) do
230
+ collector.collect(metric.deep_stringify_keys)
231
+ end
232
+
233
+ it 'observes prometheus metrics' do
234
+ subject
235
+ expect(collector.metrics).to contain_exactly(
236
+ a_gauge_with_expire_metric('my_last_duration_seconds').with(1.2, metric[:labels]),
237
+ a_counter_metric('my_duration_seconds_sum').with(3.4, metric[:labels]),
238
+ a_counter_metric('my_duration_seconds_count').with(1, metric[:labels])
239
+ )
240
+ end
241
+
242
+ context 'when collected data is expired' do
243
+ let(:collect_data) do
244
+ super()
245
+ sleep 60.1 # when gauge_with_expire ttl is 60
246
+ end
247
+
248
+ it 'observes empty prometheus metrics' do
249
+ subject
250
+ expect(collector.metrics).to contain_exactly(
251
+ a_gauge_with_expire_metric('my_last_duration_seconds').empty,
252
+ a_counter_metric('my_duration_seconds_sum').with(3.4, metric[:labels]),
253
+ a_counter_metric('my_duration_seconds_count').with(1, metric[:labels])
254
+ )
255
+ end
256
+ end
257
+ end
258
+ end
259
+ ```
260
+
261
+ ## Development
262
+
263
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
264
+
265
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
266
+
267
+ ## Contributing
268
+
269
+ Bug reports and pull requests are welcome on GitHub at https://github.com/didww/prometheus_exporter-ext.
270
+
271
+ ## License
272
+
273
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: [:spec, :rubocop]
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prometheus_exporter/client'
4
+ require_relative '../../ext'
5
+
6
+ module PrometheusExporter::Ext::Instrumentation
7
+ class BaseStats
8
+ class << self
9
+ attr_accessor :type
10
+ end
11
+
12
+ def initialize(client: PrometheusExporter::Client.default, labels: {})
13
+ @labels = labels.transform_keys(&:to_sym)
14
+ @client = client
15
+ end
16
+
17
+ def type
18
+ self.class.type
19
+ end
20
+
21
+ def collect
22
+ raise NotImplementedError
23
+ end
24
+
25
+ private
26
+
27
+ # @param data [Array,Hash]
28
+ def collect_data(data)
29
+ data_list = data.is_a?(Array) ? data : [data]
30
+ metrics = data_list.map { |data_item| build_metric(data_item) }
31
+ metrics.map { |metric| @client.send_json(metric) }
32
+ end
33
+
34
+ # @param data [Hash]
35
+ # @return [Hash]
36
+ def build_metric(data)
37
+ metric = data.transform_keys(&:to_sym)
38
+ metric[:type] = type
39
+ metric[:labels] ||= {}
40
+ metric[:labels].transform_keys!(&:to_sym)
41
+ metric[:labels].merge!(@labels)
42
+ metric
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_stats'
4
+
5
+ module PrometheusExporter::Ext::Instrumentation
6
+ class PeriodicStats < BaseStats
7
+ class << self
8
+ def start(frequency: 30, client: PrometheusExporter::Client.default, **)
9
+ raise ArgumentError, 'Expected frequency to be a number' unless frequency.is_a?(Numeric)
10
+ raise ArgumentError, 'Expected frequency to be a positive number' if frequency.negative?
11
+
12
+ klass = self
13
+ stop
14
+ instrumentation = new(client:, **)
15
+ @stop_thread = false
16
+
17
+ @thread = Thread.new do
18
+ until @stop_thread
19
+ begin
20
+ instrumentation.collect
21
+ rescue StandardError => e
22
+ client.logger.error("#{klass} Prometheus Exporter Failed To Collect Stats")
23
+ client.logger.error("#{e.class} #{e.backtrace&.join("\n")}")
24
+ ensure
25
+ sleep frequency
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ def started?
32
+ !!@thread&.alive?
33
+ end
34
+
35
+ def stop
36
+ # to avoid a warning
37
+ @thread = nil unless defined?(@thread)
38
+
39
+ if @thread&.alive?
40
+ @stop_thread = true
41
+ @thread.wakeup
42
+ @thread.join
43
+ end
44
+ @thread = nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'periodic_stats'
4
+ require_relative '../proc_self_stat'
5
+
6
+ module PrometheusExporter::Ext::Instrumentation
7
+ class ProcCpu < PeriodicStats
8
+ self.type = 'proc_cpu'
9
+
10
+ class << self
11
+ def start(type:, labels: {}, **)
12
+ labels = labels.merge(type:)
13
+
14
+ super(labels:, **)
15
+ end
16
+ end
17
+
18
+ def initialize(...)
19
+ @last_cpu_time = 0.0
20
+ super
21
+ end
22
+
23
+ def collect
24
+ stat = PrometheusExporter::Ext::ProcSelfStat.get
25
+ collect_data(
26
+ labels: {
27
+ pid: stat.pid,
28
+ hostname: ::PrometheusExporter.hostname
29
+ },
30
+ usage_seconds_total: stat.cpu_time - @last_cpu_time
31
+ )
32
+ @last_cpu_time = stat.cpu_time
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prometheus_exporter/metric'
4
+ require_relative '../../ext'
5
+
6
+ module PrometheusExporter::Ext::Metric
7
+ class GaugeWithExpire < PrometheusExporter::Metric::Gauge
8
+ NULLIFY_STRATEGY_UPDATE = ->(labels) { expiration_times[labels] = now_time + ttl }.freeze
9
+ NULLIFY_STRATEGY_EXPIRE = ->(labels) { data.delete(labels) }.freeze
10
+ ZEROING_STRATEGY_UPDATE = ->(labels) do
11
+ if data[labels].zero?
12
+ expiration_times.delete(labels)
13
+ else
14
+ expiration_times[labels] = now_time + ttl
15
+ end
16
+ end.freeze
17
+ ZEROING_STRATEGY_EXPIRE = ->(labels) { data[labels] = 0 }.freeze
18
+
19
+ class << self
20
+ # @return [Hash]
21
+ # key - strategy name
22
+ # value [Hash] with keys: :on_update, :on_expire
23
+ # :on_update [Proc] yieldparam labels [Hash] - updates expiration_times after data was updated (instance exec)
24
+ # :on_expire [Proc] yieldparam labels [Hash] - updates data after expiration_times was expired (instance exec)
25
+ def strategies
26
+ {
27
+ removing: { on_update: NULLIFY_STRATEGY_UPDATE, on_expire: NULLIFY_STRATEGY_EXPIRE },
28
+ zeroing: { on_update: ZEROING_STRATEGY_UPDATE, on_expire: ZEROING_STRATEGY_EXPIRE }
29
+ }
30
+ end
31
+
32
+ def default_ttl
33
+ 60
34
+ end
35
+ end
36
+
37
+ attr_reader :ttl, :expiration_times
38
+
39
+ def initialize(name, help, opts = {})
40
+ super(name, help)
41
+ @ttl = opts[:ttl] || self.class.default_ttl
42
+ raise ArgumentError, ':ttl must be numeric' unless ttl.is_a?(Numeric)
43
+ raise ArgumentError, ":ttl must be greater than zero: #{ttl.inspect}" unless ttl.positive?
44
+
45
+ @strategy = self.class.strategies.fetch(opts[:strategy] || :removing) do
46
+ raise ArgumentError, "Unknown strategy: #{opts[:strategy].inspect}"
47
+ end
48
+ end
49
+
50
+ def reset!
51
+ @expiration_times = {}
52
+ super
53
+ end
54
+
55
+ def metric_text
56
+ expire
57
+ super
58
+ end
59
+
60
+ def remove(labels)
61
+ result = super
62
+ remove_expired_at(labels)
63
+ result
64
+ end
65
+
66
+ def observe(value, labels = {})
67
+ result = super
68
+ value.nil? ? remove_expired_at(labels) : update_expired_at(labels)
69
+ result
70
+ end
71
+
72
+ def increment(labels = {}, value = 1)
73
+ result = super
74
+ update_expired_at(labels)
75
+ result
76
+ end
77
+
78
+ def decrement(labels = {}, value = 1)
79
+ result = super
80
+ update_expired_at(labels)
81
+ result
82
+ end
83
+
84
+ def expire
85
+ now = now_time
86
+ expiration_times.each do |labels, expired_at|
87
+ if expired_at < now
88
+ remove_data_when_expired(labels)
89
+ remove_expired_at(labels)
90
+ end
91
+ end
92
+ end
93
+
94
+ def to_h
95
+ expire
96
+ super
97
+ end
98
+
99
+ private
100
+
101
+ def remove_data_when_expired(labels)
102
+ instance_exec(labels, &@strategy[:on_expire])
103
+ end
104
+
105
+ def update_expired_at(labels)
106
+ instance_exec(labels, &@strategy[:on_update])
107
+ end
108
+
109
+ def remove_expired_at(labels)
110
+ expiration_times.delete(labels)
111
+ end
112
+
113
+ def now_time
114
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrometheusExporter::Ext
4
+ # https://man7.org/linux/man-pages/man5/proc_pid_stat.5.html
5
+ class ProcSelfStat
6
+ KERNEL_PAGE_SIZE = `getconf PAGESIZE`.chomp.to_i rescue 4096 # rubocop:disable Style/RescueModifier
7
+ TICKS_PER_SEC = Etc.sysconf(Etc::SC_CLK_TCK)
8
+
9
+ class << self
10
+ def get
11
+ stat = File.read('/proc/self/stat')
12
+ parts = stat.match(/\A(\d+)\s\((.*)\)\s([A-Z])\s(.*)/)[1..]
13
+ rest = parts.pop.split
14
+ new(*parts, *rest)
15
+ end
16
+ end
17
+
18
+ attr_reader :pid,
19
+ :comm,
20
+ :state,
21
+ :utime,
22
+ :stime,
23
+ :starttime,
24
+ :vsize,
25
+ :rss
26
+
27
+ def initialize(*fields)
28
+ @pid = fields[0]
29
+ @comm = fields[1]
30
+ @state = fields[2]
31
+ @utime = Integer(fields[13])
32
+ @stime = Integer(fields[14])
33
+ @starttime = Integer(fields[21])
34
+ @vsize = Integer(fields[22])
35
+ @rss = Integer(fields[23])
36
+ @fields = fields
37
+ end
38
+
39
+ # @return [Float]
40
+ def cpu_time
41
+ (utime + stime).to_f / TICKS_PER_SEC
42
+ end
43
+
44
+ # @return [Integer]
45
+ def rss_bytes
46
+ rss * KERNEL_PAGE_SIZE
47
+ end
48
+
49
+ def to_a
50
+ @fields.dup
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ pid:,
56
+ comm:,
57
+ state:,
58
+ utime:,
59
+ stime:,
60
+ starttime:,
61
+ vsize:,
62
+ rss:
63
+ }
64
+ end
65
+
66
+ def to_s
67
+ "#<#{self.class.name} #{to_h.map { |k, v| "#{k}=#{v.inspect}" }.join(' ')}>"
68
+ end
69
+
70
+ def inspect
71
+ to_s
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative 'metric_matcher'
5
+ require_relative 'send_metrics_matcher'
6
+
7
+ module PrometheusExporter::Ext::RSpec
8
+ module Matchers
9
+ def send_metrics(*expected)
10
+ expected = nil if expected.empty?
11
+ PrometheusExporter::Ext::RSpec::SendMetricsMatcher.new(expected)
12
+ end
13
+
14
+ def a_prometheus_metric(klass, name)
15
+ MetricMatcher.new(klass, name)
16
+ end
17
+
18
+ def a_gauge_metric(name)
19
+ a_prometheus_metric(PrometheusExporter::Metric::Gauge, name)
20
+ end
21
+
22
+ def a_gauge_with_expire_metric(name)
23
+ a_prometheus_metric(PrometheusExporter::Ext::Metric::GaugeWithExpire, name)
24
+ end
25
+
26
+ def a_counter_metric(name)
27
+ a_prometheus_metric(PrometheusExporter::Metric::Counter, name)
28
+ end
29
+
30
+ def a_histogram_metric(name)
31
+ a_prometheus_metric(PrometheusExporter::Metric::Histogram, name)
32
+ end
33
+
34
+ def a_summary_metric(name)
35
+ a_prometheus_metric(PrometheusExporter::Metric::Summary, name)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrometheusExporter::Ext::RSpec
4
+ class MetricMatcher
5
+ include RSpec::Matchers::DSL::DefaultImplementations
6
+ include RSpec::Matchers
7
+ include RSpec::Matchers::Composable
8
+
9
+ attr_reader :metric_class, :metric_name, :metric_payload, :actual
10
+
11
+ def initialize(metric_class, metric_name)
12
+ @metric_class = metric_class
13
+ @metric_name = metric_name.to_s
14
+ @metric_payload = nil
15
+ end
16
+
17
+ def name
18
+ 'be a prometheus metric'
19
+ end
20
+
21
+ def expected
22
+ "#{metric_class}(name=#{metric_name}, to_h=#{description_of(metric_payload)})"
23
+ end
24
+
25
+ def matches?(actual)
26
+ @actual = actual
27
+
28
+ return false unless values_match?(metric_class, actual.class)
29
+ return false unless values_match?(metric_name, actual.name.to_s)
30
+
31
+ actual_payload = actual.to_h.transform_keys { |labels| labels.transform_keys(&:to_s) }
32
+ return false if !metric_payload.nil? && !values_match?(metric_payload, actual_payload)
33
+
34
+ true
35
+ end
36
+
37
+ def with(value, labels)
38
+ @metric_payload ||= {}
39
+ metric_payload[labels.transform_keys(&:to_s)] = value
40
+ self
41
+ end
42
+
43
+ def empty
44
+ @metric_payload = {}
45
+ self
46
+ end
47
+
48
+ def description_of(object)
49
+ RSpec::Support::ObjectFormatter.new(nil).format(object)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrometheusExporter::Ext::RSpec
4
+ class SendMetricsMatcher
5
+ include RSpec::Matchers::DSL::DefaultImplementations
6
+ include RSpec::Matchers
7
+ include RSpec::Matchers::Composable
8
+
9
+ attr_reader :expected, :actual
10
+
11
+ # @param expected [Array<Hash(Symbol)>,nil]
12
+ def initialize(expected)
13
+ @expected = expected
14
+ @ordered = false
15
+ @times = nil
16
+ end
17
+
18
+ def name
19
+ 'sends metrics to prometheus'
20
+ end
21
+
22
+ def supports_block_expectations?
23
+ true
24
+ end
25
+
26
+ def matches?(actual_proc)
27
+ raise ArgumentError, "#{name} matcher supports only block expectations" unless actual_proc.is_a?(Proc)
28
+
29
+ metrics_before = PrometheusExporter::Ext::RSpec::TestClient.instance.metrics
30
+ actual_proc.call
31
+ metrics_after = PrometheusExporter::Ext::RSpec::TestClient.instance.metrics - metrics_before
32
+ @actual = metrics_after.map { |metric| deep_symbolize_keys(metric) }
33
+
34
+ if expected
35
+ expected_value = @ordered ? expected : match_array(expected)
36
+ values_match?(expected_value, actual)
37
+ elsif @times
38
+ values_match?(@times, actual.size)
39
+ else
40
+ actual.size >= 1
41
+ end
42
+ end
43
+
44
+ def failure_message
45
+ if expected
46
+ expected_value = @ordered ? expected : match_array(expected)
47
+ +"expected #{name} to receive #{description_of(expected_value)}, but got\n #{description_of(actual)}"
48
+ elsif @times
49
+ values_match?(@times, actual.size)
50
+ +"expected #{name} to receive #{@times} metrics, but got #{actual.size}\n #{description_of(actual)}"
51
+ else
52
+ actual.size
53
+ +"expected #{name} to receive more than 1 metric, but got #{actual.size}\n #{description_of(actual)}"
54
+ end
55
+ end
56
+
57
+ def ordered
58
+ raise ArgumentError, 'ordered cannot be when expected not provided' if expected.nil?
59
+ raise ArgumentError, 'ordered cannot be used with times' if @times
60
+
61
+ @ordered = true
62
+ self
63
+ end
64
+
65
+ def times(qty)
66
+ raise ArgumentError, 'times argument must be an integer' unless qty.is_a?(Integer)
67
+ raise ArgumentError, 'times argument must be >= 1' unless qty >= 1
68
+ raise ArgumentError, 'ordered cannot be when expected is provided' unless expected.nil?
69
+ raise ArgumentError, 'ordered cannot be used with times' if @ordered
70
+
71
+ @times = qty
72
+ self
73
+ end
74
+
75
+ def description_of(object)
76
+ RSpec::Support::ObjectFormatter.new(nil).format(object)
77
+ end
78
+
79
+ private
80
+
81
+ def deep_symbolize_keys(hash)
82
+ new_hash = {}
83
+ hash.each do |k, v|
84
+ new_hash[k.to_sym] = v.is_a?(Hash) ? deep_symbolize_keys(v) : v
85
+ end
86
+ new_hash
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module PrometheusExporter::Ext::RSpec
6
+ class TestClient
7
+ include Singleton
8
+
9
+ def initialize
10
+ super
11
+ reset
12
+ end
13
+
14
+ def metrics
15
+ @metrics.dup
16
+ end
17
+
18
+ def send_json(data)
19
+ @metrics << data
20
+ end
21
+
22
+ def reset
23
+ @metrics = []
24
+ end
25
+
26
+ def stop
27
+ nil
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prometheus_exporter/client'
4
+ require_relative 'rspec/matchers'
5
+ require_relative 'rspec/test_client'
6
+
7
+ # Setups default client before it used anywhere.
8
+ # use `include_examples :observes_prometheus_metrics` in specs
9
+ PrometheusExporter::Client.default = PrometheusExporter::Ext::RSpec::TestClient.instance
10
+
11
+ RSpec.configure do |config|
12
+ config.include PrometheusExporter::Ext::RSpec::Matchers
13
+
14
+ config.before do
15
+ PrometheusExporter::Ext::RSpec::TestClient.instance.reset
16
+ end
17
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prometheus_exporter/metric'
4
+ require_relative '../metric/gauge_with_expire'
5
+
6
+ module PrometheusExporter::Ext::Server
7
+ module BaseCollectorMethods
8
+ class << self
9
+ private
10
+
11
+ def included(klass)
12
+ super
13
+ klass.singleton_class.attr_accessor :type, :registered_metrics
14
+ klass.registered_metrics = {}
15
+ klass.extend ClassMethods
16
+ end
17
+ end
18
+
19
+ module ClassMethods
20
+ # Registers metric observer.
21
+ # @param name [Symbol] metric name.
22
+ # @param help [String] metric description.
23
+ # @param metric_class [Class<PrometheusExporter::Metric::Base>] observer class.
24
+ # @param args [Array] additional arguments for observer class.
25
+ # rubocop:disable Metrics/ParameterLists
26
+ def register_metric(name, help, metric_class, *args)
27
+ # rubocop:enable Metrics/ParameterLists
28
+ name = name.to_s
29
+ raise ArgumentError, "metric #{name} is already registered" if registered_metrics.key?(name)
30
+
31
+ registered_metrics[name] = { help:, metric_class:, args: }
32
+ end
33
+
34
+ # Registers PrometheusExporter::Metric::Counter observer.
35
+ # @param name [Symbol] metric name.
36
+ # @param help [String] metric description.
37
+ def register_counter(name, help)
38
+ register_metric(name, help, PrometheusExporter::Metric::Counter)
39
+ end
40
+
41
+ # Registers PrometheusExporter::Metric::Gauge observer.
42
+ # @param name [Symbol] metric name.
43
+ # @param help [String] metric description.
44
+ def register_gauge(name, help)
45
+ register_metric(name, help, PrometheusExporter::Metric::Gauge)
46
+ end
47
+
48
+ # Registers PrometheusExporter::Metric::Summary observer.
49
+ # @param name [Symbol] metric name.
50
+ # @param help [String] metric description.
51
+ # @param opts [Hash] additional options, supports `quantiles` key.
52
+ def register_summary(name, help, opts = {})
53
+ register_metric(name, help, PrometheusExporter::Metric::Summary, opts)
54
+ end
55
+
56
+ # Registers PrometheusExporter::Metric::Histogram observer.
57
+ # @param name [Symbol] metric name.
58
+ # @param help [String] metric description.
59
+ # @param opts [Hash] additional options, supports `buckets` key.
60
+ def register_histogram(name, help, opts = {})
61
+ register_metric(name, help, PrometheusExporter::Metric::Histogram, opts)
62
+ end
63
+ end
64
+
65
+ # @return [String]
66
+ def type
67
+ self.class.type
68
+ end
69
+
70
+ private
71
+
72
+ # Adds metrics to observers with matched name.
73
+ # @param observers [Hash] returned by #build_observers.
74
+ # @param obj [Hash] metric data.
75
+ def fill_observers(observers, obj)
76
+ observers.each do |name, observer|
77
+ value = obj[name]
78
+ observer.observe(value, obj['labels']) if value
79
+ end
80
+ end
81
+
82
+ # Generally metrics sent via PrometheusExporter::Ext::Instrumentation::BaseStats populate labels to `labels` key.
83
+ # But PrometheusExporter::Client populate it's own labels to `custom_labels` key.
84
+ # Here we merge them into single `labels` key.
85
+ # @param obj [Hash]
86
+ # @return [Hash]
87
+ def normalize_labels(obj)
88
+ obj['labels'] ||= {}
89
+ custom_labels = obj.delete('custom_labels')
90
+ obj['labels'].merge!(custom_labels) if custom_labels
91
+ obj
92
+ end
93
+
94
+ # @return [Hash] key is metric name, value is observer.
95
+ def build_observers
96
+ observers = {}
97
+ self.class.registered_metrics.each do |name, metric|
98
+ observers[name] = metric[:metric_class].new("#{type}_#{name}", metric[:help], *metric[:args])
99
+ end
100
+ observers
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prometheus_exporter/server/metrics_container'
4
+ require_relative 'base_collector_methods'
5
+
6
+ module PrometheusExporter::Ext::Server
7
+ module ExpiredStatsCollector
8
+ class << self
9
+ private
10
+
11
+ def included(klass)
12
+ super
13
+ klass.include BaseCollectorMethods
14
+ klass.singleton_class.attr_accessor :filter, :ttl
15
+ klass.ttl = 60
16
+ klass.extend ClassMethods
17
+ end
18
+ end
19
+
20
+ module ClassMethods
21
+ # Defines a rule how old metric will be replaced with new one.
22
+ # @yield compare new metric with existing one.
23
+ # @yieldparam new_metric [Hash] new metric data.
24
+ # @yieldparam old_metric [Hash] existing metric data.
25
+ # @yieldreturn [Boolean] if true existing metric will be replaced with new one.
26
+ def unique_metric_by(&block)
27
+ @filter = block
28
+ end
29
+ end
30
+
31
+ def initialize
32
+ super
33
+ @data = PrometheusExporter::Server::MetricsContainer.new(
34
+ ttl: self.class.ttl,
35
+ filter: self.class.filter
36
+ )
37
+ end
38
+
39
+ # Returns all metrics collected by this collector.
40
+ # @return [Array<PrometheusExporter::Metric::Base>]
41
+ def metrics
42
+ observers = build_observers
43
+ @data.each do |obj|
44
+ fill_observers(observers, obj)
45
+ end
46
+
47
+ observers.values
48
+ end
49
+
50
+ # Collects metric data received from client.
51
+ # @param obj [Hash] metric data.
52
+ def collect(obj)
53
+ normalize_labels(obj)
54
+ @data << obj
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'stats_collector'
4
+
5
+ module PrometheusExporter::Ext::Server
6
+ class ProcCpuCollector < PrometheusExporter::Server::TypeCollector
7
+ include ::PrometheusExporter::Ext::Server::StatsCollector
8
+ self.type = 'proc_cpu'
9
+
10
+ register_counter :usage_seconds_total, 'Cumulative CPU time consumed by the process in core-seconds'
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prometheus_exporter/server/web_server'
4
+ require_relative '../../ext'
5
+
6
+ module PrometheusExporter::Ext::Server
7
+ class SingletonWebServer < PrometheusExporter::Server::WebServer
8
+ class << self
9
+ attr_accessor :server
10
+
11
+ def start(opts)
12
+ self.server = new(opts)
13
+ server.start
14
+ end
15
+
16
+ def stop
17
+ server.stop
18
+ self.server = nil
19
+ end
20
+
21
+ def build_htpasswd(htpasswd_path, username:, password:)
22
+ htpasswd = WEBrick::HTTPAuth::Htpasswd.new(htpasswd_path)
23
+ htpasswd.set_passwd PrometheusExporter::DEFAULT_REALM, username, password
24
+ htpasswd.flush
25
+ end
26
+
27
+ # @yieldparam collector [PrometheusExporter::Server::Collector]
28
+ def configure_collector
29
+ yield server.collector
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_collector_methods'
4
+
5
+ module PrometheusExporter::Ext::Server
6
+ module StatsCollector
7
+ class << self
8
+ private
9
+
10
+ def included(klass)
11
+ super
12
+ klass.include BaseCollectorMethods
13
+ klass.extend ClassMethods
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ # Registers PrometheusExporter::Metric::GaugeWithExpire observer.
19
+ # @param name [Symbol] metric name.
20
+ # @param help [String] metric description.
21
+ # @param opts [Hash] additional options, supports `ttl` and `strategy` keys.
22
+ def register_gauge_with_expire(name, help, opts = {})
23
+ register_metric(name, help, PrometheusExporter::Ext::Metric::GaugeWithExpire, opts)
24
+ end
25
+ end
26
+
27
+ def initialize
28
+ super
29
+ @observers = build_observers
30
+ end
31
+
32
+ # Returns all metrics collected by this collector.
33
+ # @return [Array<PrometheusExporter::Metric::Base>]
34
+ def metrics
35
+ @observers.values
36
+ end
37
+
38
+ # Collects metric data received from client.
39
+ # @param obj [Hash] metric data.
40
+ def collect(obj)
41
+ obj = normalize_labels(obj)
42
+ fill_observers(@observers, obj)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrometheusExporter
4
+ module Ext
5
+ VERSION = '0.3.2'
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ext/version'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prometheus_exporter-ext
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Talakevich
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-03 00:00:00.000000000 Z
11
+ date: 2026-06-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prometheus_exporter
@@ -30,7 +30,28 @@ email:
30
30
  executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
33
- files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - lib/prometheus_exporter/ext.rb
39
+ - lib/prometheus_exporter/ext/instrumentation/base_stats.rb
40
+ - lib/prometheus_exporter/ext/instrumentation/periodic_stats.rb
41
+ - lib/prometheus_exporter/ext/instrumentation/proc_cpu.rb
42
+ - lib/prometheus_exporter/ext/metric/gauge_with_expire.rb
43
+ - lib/prometheus_exporter/ext/proc_self_stat.rb
44
+ - lib/prometheus_exporter/ext/rspec.rb
45
+ - lib/prometheus_exporter/ext/rspec/matchers.rb
46
+ - lib/prometheus_exporter/ext/rspec/metric_matcher.rb
47
+ - lib/prometheus_exporter/ext/rspec/send_metrics_matcher.rb
48
+ - lib/prometheus_exporter/ext/rspec/test_client.rb
49
+ - lib/prometheus_exporter/ext/server/base_collector_methods.rb
50
+ - lib/prometheus_exporter/ext/server/expired_stats_collector.rb
51
+ - lib/prometheus_exporter/ext/server/proc_cpu_collector.rb
52
+ - lib/prometheus_exporter/ext/server/singleton_web_server.rb
53
+ - lib/prometheus_exporter/ext/server/stats_collector.rb
54
+ - lib/prometheus_exporter/ext/version.rb
34
55
  homepage: https://github.com/senid231/prometheus_exporter-ext
35
56
  licenses:
36
57
  - MIT
@@ -54,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
54
75
  - !ruby/object:Gem::Version
55
76
  version: '0'
56
77
  requirements: []
57
- rubygems_version: 3.5.6
78
+ rubygems_version: 3.5.16
58
79
  signing_key:
59
80
  specification_version: 4
60
81
  summary: Extended Prometheus Exporter for Ruby