Skip to content

Commit

Permalink
add gauge with expire
Browse files Browse the repository at this point in the history
  • Loading branch information
senid231 committed Dec 6, 2023
1 parent 7d9dd3c commit b4bca0d
Show file tree
Hide file tree
Showing 12 changed files with 466 additions and 132 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- add gauge_with_expire

## [0.2.0] - 2023-12-06
- remove gauge_with_time, add expired_stats_collector, add tests
Expand Down
87 changes: 75 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,41 @@ module Prometheus
include ::PrometheusExporter::Ext::Server::StatsCollector
self.type = 'my'

# The :gauge_with_time will allow you to write timestamp to gauge metric when value were observer.
# It will replace data with same labels and recalculate timestamp.
register_metric :last_duration_seconds, :gauge_with_time, 'duration of last operation execution'
register_metric :task_duration_seconds_sum, :counter, 'sum of operation execution durations'
register_metric :task_duration_seconds_count, :counter, 'sum of operation execution runs'
# The `register_gauge_with_expire` will remove or zero expired metric.
# when no :strategy option passed, default is `:removing`, available options are `:removing, :zeroing`.
# when no :ttl option passed, default is 60, any numeric greater than 0 can be used.
register_gauge_with_expire :last_duration_seconds, 'duration of last operation execution', ttl: 300

register_counter :task_duration_seconds_sum, 'sum of operation execution durations'
register_counter :task_duration_seconds_count, 'sum of operation execution runs'
end
end
```

as alternative you can use `ExpiredStatsCollector` if you want all metric data to be removed after expiration
```ruby
require 'prometheus_exporter/ext'
require 'prometheus_exporter/ext/server/stats_collector'

module Prometheus
class MyCollector < ::PrometheusExporter::Server::TypeCollector
include ::PrometheusExporter::Ext::Server::ExpiredStatsCollector
self.type = 'my'
self.ttl = 300 # default 60

# Optionally you can expire old_metric when specific new metric is collected.
# If this block returns true then old_metric will be removed.
unique_metric_by do |new_metric, old_metric|
new_metric['labels'] == old_metric['labels']
end

register_gauge :last_duration_seconds, 'duration of last operation execution'
register_counter :task_duration_seconds_sum, 'sum of operation execution durations'
register_counter :task_duration_seconds_count, 'sum of operation execution runs'
end
end
````

### When metrics should be send periodically with given frequency
create instrumentation
```ruby
Expand Down Expand Up @@ -126,15 +152,32 @@ module Prometheus
class MyCollector < ::PrometheusExporter::Server::TypeCollector
include ::PrometheusExporter::Ext::Server::StatsCollector
self.type = 'my'

# The :gauge_with_time will allow you to write timestamp to gauge metric when value were observer.
# It will replace data with same labels and recalculate timestamp.
register_metric :last_processed_duration, :gauge_with_time, 'duration of last processed record'
# Default ttl 60, default strategy `:removing`.
register_gauge_with_expire :last_processed_duration, 'duration of last processed record'
register_metric :processed_count, :gauge_with_time, 'count of processed records'
end
end
```

as alternative you can use `ExpiredStatsCollector` if you want all metric data to be removed after expiration
```ruby
require 'prometheus_exporter/ext'
require 'prometheus_exporter/ext/server/stats_collector'
module Prometheus
class MyCollector < ::PrometheusExporter::Server::TypeCollector
include ::PrometheusExporter::Ext::Server::ExpiredStatsCollector
self.type = 'my'
# By default ttl is 60
# By default deletes old metrics only when it's expired
register_gauge :last_processed_duration, 'duration of last processed record'
register_metric :processed_count, :gauge_with_time, 'count of processed records'
end
end
````
### You also can easily test your instrumentations and collectors using new matchers
instrumentation test
Expand Down Expand Up @@ -168,7 +211,7 @@ collector test
RSpec.describe Prometheus::MyCollector do
describe '#collect' do
subject do
collector.collect(metric.deep_stringify_keys)
collector.metrics
end

let(:collector) { described_class.new }
Expand All @@ -178,19 +221,39 @@ RSpec.describe Prometheus::MyCollector do
metric_labels: {},
labels: { operation_name: 'test' },
last_duration_seconds: 1.2,
duration_seconds_sum: 3,4,
duration_seconds_sum: 3.4,
duration_seconds_count: 1
}
end

let(:collect_data) do
collector.collect(metric.deep_stringify_keys)
end

it 'observes prometheus metrics' do
subject
expect(collector.metrics).to contain_exactly(
a_gauge_with_time_metric('my_last_duration_seconds').with([1.2, ms_since_epoch], metric[:labels]),
a_gauge_with_expire_metric('my_last_duration_seconds').with(1.2, metric[:labels]),
a_counter_metric('my_duration_seconds_sum').with(3.4, metric[:labels]),
a_counter_metric('my_duration_seconds_count').with(1, metric[:labels])
)
end

context 'when collected data is expired' do
let(:collect_data) do
super()
sleep 60.1 # when gauge_with_expire ttl is 60
end

it 'observes empty prometheus metrics' do
subject
expect(collector.metrics).to contain_exactly(
a_gauge_with_expire_metric('my_last_duration_seconds').empty,
a_counter_metric('my_duration_seconds_sum').with(3.4, metric[:labels]),
a_counter_metric('my_duration_seconds_count').with(1, metric[:labels])
)
end
end
end
end
```
Expand Down
116 changes: 116 additions & 0 deletions lib/prometheus_exporter/ext/metric/gauge_with_expire.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# frozen_string_literal: true

require 'prometheus_exporter/metric'

module PrometheusExporter::Ext::Metric
class GaugeWithExpire < PrometheusExporter::Metric::Gauge
NULLIFY_STRATEGY_UPDATE = ->(labels) { expiration_times[labels] = now_time + ttl }.freeze
NULLIFY_STRATEGY_EXPIRE = ->(labels) { data.delete(labels) }.freeze
ZEROING_STRATEGY_UPDATE = ->(labels) do
if data[labels].zero?
expiration_times.delete(labels)
else
expiration_times[labels] = now_time + ttl
end
end.freeze
ZEROING_STRATEGY_EXPIRE = ->(labels) { data[labels] = 0 }.freeze

class << self
# @return [Hash]
# key - strategy name
# value [Hash] with keys: :on_update, :on_expire
# :on_update [Proc] yieldparam labels [Hash] - updates expiration_times after data was updated (instance exec)
# :on_expire [Proc] yieldparam labels [Hash] - updates data after expiration_times was expired (instance exec)
def strategies
{
removing: { on_update: NULLIFY_STRATEGY_UPDATE, on_expire: NULLIFY_STRATEGY_EXPIRE },
zeroing: { on_update: ZEROING_STRATEGY_UPDATE, on_expire: ZEROING_STRATEGY_EXPIRE }
}
end

def default_ttl
60
end
end

attr_reader :ttl, :expiration_times

def initialize(name, help, opts = {})
super(name, help)
@ttl = opts[:ttl] || self.class.default_ttl
raise ArgumentError, ':ttl must be numeric' unless ttl.is_a?(Numeric)
raise ArgumentError, ":ttl must be greater than zero: #{ttl.inspect}" unless ttl.positive?

@strategy = self.class.strategies.fetch(opts[:strategy] || :removing) do
raise ArgumentError, "Unknown strategy: #{opts[:strategy].inspect}"
end
end

def reset!
@expiration_times = {}
super
end

def metric_text
expire
super
end

def remove(labels)
result = super
remove_expired_at(labels)
result
end

def observe(value, labels = {})
result = super
value.nil? ? remove_expired_at(labels) : update_expired_at(labels)
result
end

def increment(labels = {}, value = 1)
result = super
update_expired_at(labels)
result
end

def decrement(labels = {}, value = 1)
result = super
update_expired_at(labels)
result
end

def expire
now = now_time
expiration_times.each do |labels, expired_at|
if expired_at < now
remove_data_when_expired(labels)
remove_expired_at(labels)
end
end
end

def to_h
expire
super
end

private

def remove_data_when_expired(labels)
instance_exec(labels, &@strategy[:on_expire])
end

def update_expired_at(labels)
instance_exec(labels, &@strategy[:on_update])
end

def remove_expired_at(labels)
expiration_times.delete(labels)
end

def now_time
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
end
end
end
4 changes: 4 additions & 0 deletions lib/prometheus_exporter/ext/rspec/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def a_gauge_metric(name)
a_prometheus_metric(PrometheusExporter::Metric::Gauge, name)
end

def a_gauge_with_expire_metric(name)
a_prometheus_metric(PrometheusExporter::Ext::Metric::GaugeWithExpire, name)
end

def a_counter_metric(name)
a_prometheus_metric(PrometheusExporter::Metric::Counter, name)
end
Expand Down
5 changes: 5 additions & 0 deletions lib/prometheus_exporter/ext/rspec/metric_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ def with(value, labels)
self
end

def empty
@metric_payload = {}
self
end

def description_of(object)
RSpec::Support::ObjectFormatter.new(nil).format(object)
end
Expand Down
4 changes: 2 additions & 2 deletions lib/prometheus_exporter/ext/rspec/send_metrics_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def matches?(actual_proc)
if expected
expected_value = @ordered ? expected : match_array(expected)
values_match?(expected_value, actual)
elsif @times
values_match?(@times, actual.size)
elsif @qty
values_match?(@qty, actual.size)
else
actual.size >= 1
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'prometheus_exporter/metric'
require_relative '../metric/gauge_with_expire'

module PrometheusExporter::Ext::Server
module BaseCollectorMethods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ module ClassMethods
# Defines a rule how old metric will be replaced with new one.
# @yield compare new metric with existing one.
# @yieldparam new_metric [Hash] new metric data.
# @yieldparam metric [Hash] existing metric data.
# @yieldparam old_metric [Hash] existing metric data.
# @yieldreturn [Boolean] if true existing metric will be replaced with new one.
def unique_metric_by(&block)
@filter = block
Expand Down
11 changes: 11 additions & 0 deletions lib/prometheus_exporter/ext/server/stats_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ class << self
def included(klass)
super
klass.include BaseCollectorMethods
klass.extend ClassMethods
end
end

module ClassMethods
# Registers PrometheusExporter::Metric::GaugeWithExpire observer.
# @param name [Symbol] metric name.
# @param help [String] metric description.
# @param opts [Hash] additional options, supports `ttl` and `strategy` keys.
def register_gauge_with_expire(name, help, opts = {})
register_metric(name, help, PrometheusExporter::Ext::Metric::GaugeWithExpire, opts)
end
end

Expand Down
Loading

0 comments on commit b4bca0d

Please sign in to comment.