Skip to content

didww/prometheus_exporter-ext

Repository files navigation

PrometheusExporter::Ext

Tests

Extension for Ruby Prometheus Exporter. Adds DSL for building your custom Prometheus instrumentations and collectors. Allow to remove/zero expired gauge metrics in a collector.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add prometheus_exporter-ext

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install prometheus_exporter-ext

Usage

When metrics should be send on particular event

create instrumentation

# lib/prometheus/my_instrumentation.rb
require 'prometheus_exporter/ext'
require 'prometheus_exporter/ext/instrumentation/base_stats'

module Prometheus
  class MyInstrumentation < ::PrometheusExporter::Ext::Instrumentation::BaseStats
    self.type = 'my'

    def collect(duration, operation)
      collect_data(
        labels: { operation_name: operation },
        last_duration_seconds: duration,
        duration_seconds_sum: duration,
        duration_seconds_count: 1
      )
    rescue StandardError => e
      Rails.logger.error("Failed to send metrics Prometheus #{self.class.name} #{e}")
      Rails.error.report(e, handled: true, severity: :error, context: { prometheus: self.class.name })
    end
  end
end

then send metrics from your code

  time_start = Time.current.to_i
begin
  MyOperation.run
ensure
  duration = Time.current.to_i - time_start
  Prometheus::MyInstrumentation.new.collect(duration, 'my_operation')
  ## you can add additional labels or override client
  Prometheus::MyInstrumentation.new(
    client: PrometheusExporter::Client.new(...),
    metric_labels: { foo: 'bar' }
  ).collect(duration)
end

so metrics will be collected by

require 'prometheus_exporter/ext'
require 'prometheus_exporter/ext/server/stats_collector'

module Prometheus
  class MyCollector < ::PrometheusExporter::Server::TypeCollector
    include ::PrometheusExporter::Ext::Server::StatsCollector
    self.type = 'my'

    # 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

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

# lib/prometheus/my_instrumentation.rb
require 'prometheus_exporter/ext'
require 'prometheus_exporter/ext/instrumentation/periodic_stats'

module Prometheus
  class MyPeriodicInstrumentation < ::PrometheusExporter::Ext::Instrumentation::PeriodicStats
    self.type = 'my'

    def collect
      count = MyItem.processed.count
      last_duration = MyItem.processed.last&.duration
      collect_data(
        labels: { some_label: 'some_value' },
        last_processed_duration: last_duration || 0,
        processed_count: count
      )
    rescue StandardError => e
      Rails.logger.error("Failed to send metrics Prometheus #{self.class.name} #{e}")
      Rails.error.report(e, handled: true, severity: :error, context: { prometheus: self.class.name })
    end
  end
end

then send metrics from your code

Prometheus::MyInstrumentation.start
## you can override frequency in seconds
Prometheus::MyInstrumentation.start(frequency: 60)
## also you can add additional labels or override client
Prometheus::MyInstrumentation.start(
  client: PrometheusExporter::Client.new(...),
  metric_labels: { foo: 'bar' }
)
# to stop instrumentation call `Prometheus::MyInstrumentation.stop`

so metrics will be collected by

require 'prometheus_exporter/ext'
require 'prometheus_exporter/ext/server/stats_collector'

module Prometheus
  class MyCollector < ::PrometheusExporter::Server::TypeCollector
    include ::PrometheusExporter::Ext::Server::StatsCollector
    self.type = 'my'
    
    # 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

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

require 'prometheus_exporter/ext/rspec'

RSpec.describe Prometheus::MyInstrumentation do
  describe '#collect' do
    subject { described_class.new.collect(duration, operation) }
    let(:duration) { 1.23 }
    let(:operation) { 'test' }

    it 'sends prometheus metrics' do
      expect { subject }.to send_metrics(
        [
          type: 'my',
          metric_labels: {},
          labels: { operation_name: operation },
          last_duration_seconds: duration,
          duration_seconds_sum: duration,
          duration_seconds_count: 1
        ]
      )
    end
  end
end

collector test

RSpec.describe Prometheus::MyCollector do
  describe '#collect' do
    subject do
      collector.metrics
    end

    let(:collector) { described_class.new }
    let(:metric) do
      {
        type: 'my',
        metric_labels: {},
        labels: { operation_name: 'test' },
        last_duration_seconds: 1.2,
        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_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

Development

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.

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.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/didww/prometheus_exporter-ext.

License

The gem is available as open source under the terms of the MIT License.