From 4f0019fc14fa4a9dca55e64628b7da3f78d9812e Mon Sep 17 00:00:00 2001 From: Viktor Sheiko Date: Fri, 17 Nov 2017 00:18:18 +0300 Subject: [PATCH 001/189] Added synchronize to net/http calls Net::HTTP is not threadsafe. Unfortanutely, sometimes it causes IOError's during working on multhreading app. Mutex should solve that problem. --- lib/prometheus/client/push.rb | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index b9990efe..a89074dc 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -1,5 +1,6 @@ # encoding: UTF-8 +require 'thread' require 'net/http' require 'uri' @@ -21,6 +22,7 @@ class Push attr_reader :job, :instance, :gateway, :path def initialize(job, instance = nil, gateway = nil) + @mutex = Mutex.new @job = job @instance = instance @gateway = gateway || DEFAULT_GATEWAY @@ -31,15 +33,21 @@ def initialize(job, instance = nil, gateway = nil) end def add(registry) - request('POST', registry) + synchronize do + request('POST', registry) + end end def replace(registry) - request('PUT', registry) + synchronize do + request('PUT', registry) + end end def delete - @http.send_request('DELETE', path) + synchronize do + @http.send_request('DELETE', path) + end end private @@ -69,6 +77,10 @@ def request(method, registry) @http.send_request(method, path, data, HEADER) end + + def synchronize + @mutex.synchronize { yield } + end end end end From e23ef4f82008034e87584ee0cfcdd0e01ea0c378 Mon Sep 17 00:00:00 2001 From: Yuki Ito Date: Fri, 19 Jan 2018 18:28:58 +0900 Subject: [PATCH 002/189] Implement Registry#unregister --- lib/prometheus/client/registry.rb | 6 ++++++ spec/prometheus/client/registry_spec.rb | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/prometheus/client/registry.rb b/lib/prometheus/client/registry.rb index d508d156..97e8847b 100644 --- a/lib/prometheus/client/registry.rb +++ b/lib/prometheus/client/registry.rb @@ -31,6 +31,12 @@ def register(metric) metric end + def unregister(name) + @mutex.synchronize do + @metrics.delete(name.to_sym) + end + end + def counter(name, docstring, base_labels = {}) register(Counter.new(name, docstring, base_labels)) end diff --git a/spec/prometheus/client/registry_spec.rb b/spec/prometheus/client/registry_spec.rb index 9b589c38..3ca9db47 100644 --- a/spec/prometheus/client/registry_spec.rb +++ b/spec/prometheus/client/registry_spec.rb @@ -52,6 +52,20 @@ def registry.exist?(*args) end end + describe '#unregister' do + it 'unregister a registered metric' do + registry.register(double(name: :test)) + registry.unregister(:test) + expect(registry.exist?(:test)).to eql(false) + end + + it "doesn't raise when unregistering a not registered metrics" do + expect do + registry.unregister(:test) + end.not_to raise_error + end + end + describe '#counter' do it 'registers a new counter metric container and returns the counter' do metric = registry.counter(:test, 'test docstring') From 85db51ea675cdcb4296c4fe45b6f52b9f5bd3bd7 Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Thu, 15 Feb 2018 14:23:03 +0100 Subject: [PATCH 003/189] Remove RuboCop Github warns about a low security vulernability in the rubocop gem (which is only used during development). I tried briefly to upgrade the gem, but I was greeted with hundreds of new style checks. The vast majority for whom RuboCop demanded the opposite in previous versions. I'm just tired of spending my time to follow the guidelines of whoever is in charge of RuboCop this year. As much as I'd like to enforce a consistent code style, the rubocop project doesn't seem to have a clear idea on such style either. --- .rubocop.yml | 15 --------------- Gemfile | 1 - Rakefile | 6 +----- lib/prometheus/middleware/collector.rb | 2 -- 4 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 .rubocop.yml diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 19201ee3..00000000 --- a/.rubocop.yml +++ /dev/null @@ -1,15 +0,0 @@ -AllCops: - Exclude: - - lib/prometheus/client/version.rb - -AlignHash: - EnforcedHashRocketStyle: table - -Style/TrailingCommaInArguments: - EnforcedStyleForMultiline: comma - -Style/TrailingCommaInLiteral: - EnforcedStyleForMultiline: comma - -Metrics/AbcSize: - Max: 18 diff --git a/Gemfile b/Gemfile index fdddf886..7b1bb8d5 100644 --- a/Gemfile +++ b/Gemfile @@ -13,7 +13,6 @@ group :test do gem 'rack-test' gem 'rake' gem 'rspec' - gem 'rubocop', '< 0.42' gem 'term-ansicolor', '< 1.4' if ruby_version?('< 2.0') gem 'tins', '< 1.7' if ruby_version?('< 2.0') end diff --git a/Rakefile b/Rakefile index 4262dd70..cf7648d2 100644 --- a/Rakefile +++ b/Rakefile @@ -2,10 +2,9 @@ require 'bundler' require 'rspec/core/rake_task' -require 'rubocop/rake_task' desc 'Default: run specs' -task default: [:spec, :rubocop] +task default: [:spec] # test alias task test: :spec @@ -15,7 +14,4 @@ RSpec::Core::RakeTask.new do |t| t.rspec_opts = '--require ./spec/spec_helper.rb' end -desc 'Lint code' -RuboCop::RakeTask.new - Bundler::GemHelper.install_tasks diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 659a3244..a6822ec0 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -39,13 +39,11 @@ def call(env) # :nodoc: protected - # rubocop:disable Metrics/LineLength aggregation = lambda do |str| str .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)}, '/:uuid\\1') .gsub(%r{/\d+(/|$)}, '/:id\\1') end - # rubocop:enable Metrics/LineLength COUNTER_LB = proc do |env, code| { From 47a6366c45727b2709ac059c6742c31f5369c595 Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Fri, 4 May 2018 15:06:16 +0200 Subject: [PATCH 004/189] client/push: Add basic auth support --- README.md | 17 ++-- lib/prometheus/client/push.rb | 21 +++-- spec/prometheus/client/push_spec.rb | 129 ++++++++++++++++++---------- 3 files changed, 104 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index b16f44dc..9242e756 100644 --- a/README.md +++ b/README.md @@ -66,29 +66,30 @@ integrated [example application](examples/rack/README.md). The Ruby client can also be used to push its collected metrics to a [Pushgateway][8]. This comes in handy with batch jobs or in other scenarios where it's not possible or feasible to let a Prometheus server scrape a Ruby -process. +process. TLS and basic access authentication are supported. + +**Attention**: The implementation still uses the legacy API of the pushgateway. ```ruby require 'prometheus/client' require 'prometheus/client/push' -prometheus = Prometheus::Client.registry +registry = Prometheus::Client.registry # ... register some metrics, set/increment/observe/etc. their values # push the registry state to the default gateway -Prometheus::Client::Push.new('my-batch-job').add(prometheus) +Prometheus::Client::Push.new('my-batch-job').add(registry) -# optional: specify the instance name (instead of IP) and gateway -Prometheus::Client::Push.new( - 'my-job', 'instance-name', 'http://example.domain:1234').add(prometheus) +# optional: specify the instance name (instead of IP) and gateway. +Prometheus::Client::Push.new('my-batch-job', 'foobar', 'https://example.domain:1234').add(registry) # If you want to replace any previously pushed metrics for a given instance, # use the #replace method. -Prometheus::Client::Push.new('my-batch-job', 'instance').replace(prometheus) +Prometheus::Client::Push.new('my-batch-job').replace(registry) # If you want to delete all previously pushed metrics for a given instance, # use the #delete method. -Prometheus::Client::Push.new('my-batch-job', 'instance').delete +Prometheus::Client::Push.new('my-batch-job').delete ``` ## Metrics diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index a89074dc..dd48fef2 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -16,7 +16,6 @@ class Push DEFAULT_GATEWAY = 'http://localhost:9091'.freeze PATH = '/metrics/jobs/%s'.freeze INSTANCE_PATH = '/metrics/jobs/%s/instances/%s'.freeze - HEADER = { 'Content-Type' => Formats::Text::CONTENT_TYPE }.freeze SUPPORTED_SCHEMES = %w(http https).freeze attr_reader :job, :instance, :gateway, :path @@ -26,27 +25,28 @@ def initialize(job, instance = nil, gateway = nil) @job = job @instance = instance @gateway = gateway || DEFAULT_GATEWAY - @uri = parse(@gateway) @path = build_path(job, instance) + @uri = parse("#{@gateway}#{@path}") + @http = Net::HTTP.new(@uri.host, @uri.port) - @http.use_ssl = @uri.scheme == 'https' + @http.use_ssl = (@uri.scheme == 'https') end def add(registry) synchronize do - request('POST', registry) + request(Net::HTTP::Post, registry) end end def replace(registry) synchronize do - request('PUT', registry) + request(Net::HTTP::Put, registry) end end def delete synchronize do - @http.send_request('DELETE', path) + request(Net::HTTP::Delete) end end @@ -72,10 +72,13 @@ def build_path(job, instance) end end - def request(method, registry) - data = Formats::Text.marshal(registry) + def request(req_class, registry = nil) + req = req_class.new(@uri) + req.content_type = Formats::Text::CONTENT_TYPE + req.basic_auth(@uri.user, @uri.password) if @uri.user + req.body = Formats::Text.marshal(registry) if registry - @http.send_request(method, path, data, HEADER) + @http.request(req) end def synchronize diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index cffe6a6b..3e908eec 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -3,8 +3,9 @@ require 'prometheus/client/push' describe Prometheus::Client::Push do + let(:gateway) { 'http://localhost:9091' } let(:registry) { Prometheus::Client.registry } - let(:push) { Prometheus::Client::Push.new('test-job') } + let(:push) { Prometheus::Client::Push.new('test-job', nil, gateway) } describe '.new' do it 'returns a new push instance' do @@ -12,6 +13,8 @@ end it 'uses localhost as default Pushgateway' do + push = Prometheus::Client::Push.new('test-job') + expect(push.gateway).to eql('http://localhost:9091') end @@ -30,6 +33,30 @@ end end + describe '#add' do + it 'sends a given registry to via HTTP POST' do + expect(push).to receive(:request).with(Net::HTTP::Post, registry) + + push.add(registry) + end + end + + describe '#replace' do + it 'sends a given registry to via HTTP PUT' do + expect(push).to receive(:request).with(Net::HTTP::Put, registry) + + push.replace(registry) + end + end + + describe '#delete' do + it 'deletes existing metrics with HTTP DELETE' do + expect(push).to receive(:request).with(Net::HTTP::Delete) + + push.delete + end + end + describe '#path' do it 'uses the default metrics path if no instance value given' do push = Prometheus::Client::Push.new('test-job') @@ -51,63 +78,73 @@ end end - describe '#add' do - it 'pushes a given registry to the configured Pushgateway via HTTP' do + describe '#request' do + let(:content_type) { Prometheus::Client::Formats::Text::CONTENT_TYPE } + let(:data) { Prometheus::Client::Formats::Text.marshal(registry) } + let(:uri) { URI.parse("#{gateway}/metrics/jobs/test-job") } + + it 'sends marshalled registry to the specified gateway' do + request = double(:request) + expect(request).to receive(:content_type=).with(content_type) + expect(request).to receive(:body=).with(data) + expect(Net::HTTP::Post).to receive(:new).with(uri).and_return(request) + http = double(:http) - expect(http).to receive(:send_request).with( - 'POST', - '/metrics/jobs/foo/instances/bar', - Prometheus::Client::Formats::Text.marshal(registry), - 'Content-Type' => Prometheus::Client::Formats::Text::CONTENT_TYPE, - ) expect(http).to receive(:use_ssl=).with(false) - expect(Net::HTTP).to receive(:new).with('pu.sh', 9091).and_return(http) + expect(http).to receive(:request).with(request) + expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) - described_class.new('foo', 'bar', 'http://pu.sh:9091').add(registry) + push.send(:request, Net::HTTP::Post, registry) end - it 'pushes a given registry to the configured Pushgateway via HTTPS' do - http = double(:http) - expect(http).to receive(:send_request).with( - 'POST', - '/metrics/jobs/foo/instances/bar', - Prometheus::Client::Formats::Text.marshal(registry), - 'Content-Type' => Prometheus::Client::Formats::Text::CONTENT_TYPE, - ) - expect(http).to receive(:use_ssl=).with(true) - expect(Net::HTTP).to receive(:new).with('pu.sh', 9091).and_return(http) - - described_class.new('foo', 'bar', 'https://pu.sh:9091').add(registry) - end - end + it 'deletes data from the registry' do + request = double(:request) + expect(request).to receive(:content_type=).with(content_type) + expect(Net::HTTP::Delete).to receive(:new).with(uri).and_return(request) - describe '#replace' do - it 'replaces any existing metrics with registry' do http = double(:http) - expect(http).to receive(:send_request).with( - 'PUT', - '/metrics/jobs/foo/instances/bar', - Prometheus::Client::Formats::Text.marshal(registry), - 'Content-Type' => Prometheus::Client::Formats::Text::CONTENT_TYPE, - ) expect(http).to receive(:use_ssl=).with(false) - expect(Net::HTTP).to receive(:new).with('pu.sh', 9091).and_return(http) + expect(http).to receive(:request).with(request) + expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) - described_class.new('foo', 'bar', 'http://pu.sh:9091').replace(registry) + push.send(:request, Net::HTTP::Delete) end - end - describe '#delete' do - it 'deletes existing metrics from the configured Pushgateway' do - http = double(:http) - expect(http).to receive(:send_request).with( - 'DELETE', - '/metrics/jobs/foo/instances/bar', - ) - expect(http).to receive(:use_ssl=).with(false) - expect(Net::HTTP).to receive(:new).with('pu.sh', 9091).and_return(http) + context 'HTTPS support' do + let(:gateway) { 'https://localhost:9091' } - described_class.new('foo', 'bar', 'http://pu.sh:9091').delete + it 'uses HTTPS when requested' do + request = double(:request) + expect(request).to receive(:content_type=).with(content_type) + expect(request).to receive(:body=).with(data) + expect(Net::HTTP::Post).to receive(:new).with(uri).and_return(request) + + http = double(:http) + expect(http).to receive(:use_ssl=).with(true) + expect(http).to receive(:request).with(request) + expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) + + push.send(:request, Net::HTTP::Post, registry) + end + end + + context 'Basic Auth support' do + let(:gateway) { 'https://super:secret@localhost:9091' } + + it 'sets Basic Auth header when requested' do + request = double(:request) + expect(request).to receive(:content_type=).with(content_type) + expect(request).to receive(:basic_auth).with('super', 'secret') + expect(request).to receive(:body=).with(data) + expect(Net::HTTP::Put).to receive(:new).with(uri).and_return(request) + + http = double(:http) + expect(http).to receive(:use_ssl=).with(true) + expect(http).to receive(:request).with(request) + expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) + + push.send(:request, Net::HTTP::Put, registry) + end end end end From 5dd6a8dd2944dc31b68ec10a7a9cf65f1547ed2a Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Fri, 4 May 2018 15:47:30 +0200 Subject: [PATCH 005/189] Use fixed quantile dependency Signed-off-by: Tobias Schmidt --- prometheus-client.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index cbe86a72..c9277dc2 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -15,5 +15,5 @@ Gem::Specification.new do |s| s.files = %w(README.md) + Dir.glob('{lib/**/*}') s.require_paths = ['lib'] - s.add_dependency 'quantile', '~> 0.2.0' + s.add_dependency 'quantile', '~> 0.2.1' end From 011aaf985a09b855c8dcab6bd6327341b07e09ca Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Fri, 4 May 2018 15:39:22 +0200 Subject: [PATCH 006/189] Release v0.8.0 Signed-off-by: Tobias Schmidt --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index e8546c71..06161002 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '0.7.1' + VERSION = '0.8.0' end end From f593eb88f9cf6787411adccdcfa3320869b95174 Mon Sep 17 00:00:00 2001 From: Holger Arndt Date: Fri, 15 Jun 2018 10:34:07 +0200 Subject: [PATCH 007/189] extend the invalid signature message show the exepected keys and the given keys to help debugging --- lib/prometheus/client/label_set_validator.rb | 4 +++- spec/prometheus/client/label_set_validator_spec.rb | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index b5fb64c4..9536f4fa 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -35,7 +35,9 @@ def validate(labels) valid?(labels) unless @validated.empty? || match?(labels, @validated.first.last) - raise InvalidLabelSetError, 'labels must have the same signature' + raise InvalidLabelSetError, "labels must have the same signature " \ + "(keys given: #{labels.keys.sort} vs." \ + " keys expected: #{@validated.first.last.keys.sort}" end @validated[labels.hash] = labels diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index 6098a10d..f6071aaf 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -63,7 +63,7 @@ expect do validator.validate(method: 'get', exception: 'NoMethodError') - end.to raise_exception(invalid) + end.to raise_exception(invalid, /keys given: \[:exception, :method\] vs. keys expected: \[:code, :method\]/) end end end From 300de858a10fb54c011c9451e92e536e5edb661c Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Fri, 24 Aug 2018 09:11:28 +0200 Subject: [PATCH 008/189] Remove codeclimate and gemnasium badges Codeclimate integration was removed due to github services being deprecated. Gemnasium ceased to exist. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 9242e756..bd6e963a 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ through a HTTP interface. Intended to be used together with a [![Gem Version][4]](http://badge.fury.io/rb/prometheus-client) [![Build Status][3]](http://travis-ci.org/prometheus/client_ruby) -[![Dependency Status][5]](https://gemnasium.com/prometheus/client_ruby) -[![Code Climate][6]](https://codeclimate.com/github/prometheus/client_ruby) [![Coverage Status][7]](https://coveralls.io/r/prometheus/client_ruby) ## Usage @@ -184,8 +182,6 @@ rake [2]: http://rack.github.io/ [3]: https://secure.travis-ci.org/prometheus/client_ruby.svg?branch=master [4]: https://badge.fury.io/rb/prometheus-client.svg -[5]: https://gemnasium.com/prometheus/client_ruby.svg -[6]: https://codeclimate.com/github/prometheus/client_ruby.svg [7]: https://coveralls.io/repos/prometheus/client_ruby/badge.svg?branch=master [8]: https://github.com/prometheus/pushgateway [9]: lib/prometheus/middleware/exporter.rb From 3656cb01a8abfdbe5dbe7f1c6e0262a3f236e7a6 Mon Sep 17 00:00:00 2001 From: beorn7 Date: Fri, 25 Jan 2019 15:04:43 +0100 Subject: [PATCH 009/189] Update push paths Legacy push paths have been removed from PGW 0.7. Signed-off-by: beorn7 --- lib/prometheus/client/push.rb | 4 ++-- spec/prometheus/client/push_spec.rb | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index dd48fef2..fe8cc2ab 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -14,8 +14,8 @@ module Client # Pushgateway. class Push DEFAULT_GATEWAY = 'http://localhost:9091'.freeze - PATH = '/metrics/jobs/%s'.freeze - INSTANCE_PATH = '/metrics/jobs/%s/instances/%s'.freeze + PATH = '/metrics/job/%s'.freeze + INSTANCE_PATH = '/metrics/job/%s/instance/%s'.freeze SUPPORTED_SCHEMES = %w(http https).freeze attr_reader :job, :instance, :gateway, :path diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 3e908eec..dc35e060 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -61,19 +61,19 @@ it 'uses the default metrics path if no instance value given' do push = Prometheus::Client::Push.new('test-job') - expect(push.path).to eql('/metrics/jobs/test-job') + expect(push.path).to eql('/metrics/job/test-job') end it 'uses the full metrics path if an instance value is given' do push = Prometheus::Client::Push.new('bar-job', 'foo') - expect(push.path).to eql('/metrics/jobs/bar-job/instances/foo') + expect(push.path).to eql('/metrics/job/bar-job/instance/foo') end it 'escapes non-URL characters' do push = Prometheus::Client::Push.new('bar job', 'foo ') - expected = '/metrics/jobs/bar%20job/instances/foo%20%3Cmy%20instance%3E' + expected = '/metrics/job/bar%20job/instance/foo%20%3Cmy%20instance%3E' expect(push.path).to eql(expected) end end @@ -81,7 +81,7 @@ describe '#request' do let(:content_type) { Prometheus::Client::Formats::Text::CONTENT_TYPE } let(:data) { Prometheus::Client::Formats::Text.marshal(registry) } - let(:uri) { URI.parse("#{gateway}/metrics/jobs/test-job") } + let(:uri) { URI.parse("#{gateway}/metrics/job/test-job") } it 'sends marshalled registry to the specified gateway' do request = double(:request) From c126bd41b2adc7b9b07f7a94acc433318257546d Mon Sep 17 00:00:00 2001 From: Joe Francis Date: Fri, 25 Jan 2019 09:18:47 -0800 Subject: [PATCH 010/189] Test client_ruby against latest ruby versions (#100) Signed-off-by: Joe Francis --- .travis.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9d4a92a2..6ebf1139 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,8 @@ before_install: if [[ "$(ruby -e 'puts RUBY_VERSION')" != 1.* ]]; then gem update --system; fi rvm: - 1.9.3 - - 2.3.3 - - 2.4.0 + - 2.3.8 + - 2.4.5 + - 2.5.3 + - 2.6.0 - jruby-9.1.5.0 From 9bae72520e7fdadab983ddd2cd40832e37489793 Mon Sep 17 00:00:00 2001 From: Tobias Schmidt Date: Fri, 25 Jan 2019 18:19:23 +0100 Subject: [PATCH 011/189] Release v0.9.0 Signed-off-by: Tobias Schmidt --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 06161002..c50f46e3 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '0.8.0' + VERSION = '0.9.0' end end From 577a388facb683c5e01f5cd68cca6f458649e770 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 4 Mar 2019 09:46:32 +0000 Subject: [PATCH 012/189] Update MAINTAINERS.md (#103) Following conversation with @grobie, @lzap and @SuperQ, update maintainers for the project. Signed-off-by: Daniel Magliola --- MAINTAINERS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 35993c41..c0ca7782 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1 +1,3 @@ -* Tobias Schmidt +* Ben Kochie +* Chris Sinjakli +* Daniel Magliola From d85f7dccd54e21aa2faab3af855e52862a32f5a9 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 10 Sep 2018 14:13:35 +0100 Subject: [PATCH 013/189] Remove deprecation notice from 2 years ago Signed-off-by: Daniel Magliola --- lib/prometheus/client/summary.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index 7b78610e..0af0397c 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -8,8 +8,6 @@ module Client # Summary is an accumulator for samples. It captures Numeric data and # provides an efficient quantile calculation mechanism. class Summary < Metric - extend Gem::Deprecate - # Value represents the state of a Summary at a given point. class Value < Hash attr_accessor :sum, :total @@ -33,8 +31,6 @@ def observe(labels, value) label_set = label_set_for(labels) synchronize { @values[label_set].observe(value) } end - alias add observe - deprecate :add, :observe, 2016, 10 # Returns the value for the given label set def get(labels = {}) From 457d6747dbdb4a45a32e8c804f2219c7eebb048c Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Thu, 18 Oct 2018 17:36:29 +0100 Subject: [PATCH 014/189] Remove support for Ruby 1.9.3 and update current versions Ruby < 2.0 has been EOL'd over 3 years ago, and we want to use kwargs, so we're dropping support for it. Signed-off-by: Daniel Magliola --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 6ebf1139..b602a263 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ before_install: - | if [[ "$(ruby -e 'puts RUBY_VERSION')" != 1.* ]]; then gem update --system; fi rvm: - - 1.9.3 - 2.3.8 - 2.4.5 - 2.5.3 From 160c25e855dd6bb7918c92718bef85d216d60a46 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 10 Sep 2018 14:59:50 +0100 Subject: [PATCH 015/189] Add keyword arguments to the API where it makes sense This should make it cleaner and more obvious to call into these methods. Signed-off-by: Daniel Magliola --- README.md | 34 ++++++++++---------- lib/prometheus/client/counter.rb | 2 +- lib/prometheus/client/gauge.rb | 6 ++-- lib/prometheus/client/histogram.rb | 11 +++---- lib/prometheus/client/metric.rb | 4 +-- lib/prometheus/client/registry.rb | 21 ++++++------ lib/prometheus/client/summary.rb | 10 +++--- lib/prometheus/middleware/collector.rb | 13 ++++---- spec/examples/metric_example.rb | 14 ++++---- spec/prometheus/client/counter_spec.rb | 12 ++++--- spec/prometheus/client/gauge_spec.rb | 30 ++++++++--------- spec/prometheus/client/histogram_spec.rb | 27 ++++++++-------- spec/prometheus/client/registry_spec.rb | 8 ++--- spec/prometheus/client/summary_spec.rb | 26 ++++++++------- spec/prometheus/middleware/collector_spec.rb | 16 ++++----- spec/prometheus/middleware/exporter_spec.rb | 2 +- 16 files changed, 124 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index bd6e963a..80580f84 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ require 'prometheus/client' prometheus = Prometheus::Client.registry # create a new counter metric -http_requests = Prometheus::Client::Counter.new(:http_requests, 'A counter of HTTP requests made') +http_requests = Prometheus::Client::Counter.new(:http_requests, docstring: 'A counter of HTTP requests made') # register the metric prometheus.register(http_requests) # equivalent helper function -http_requests = prometheus.counter(:http_requests, 'A counter of HTTP requests made') +http_requests = prometheus.counter(:http_requests, docstring: 'A counter of HTTP requests made') # start using the counter http_requests.increment @@ -99,16 +99,16 @@ The following metric types are currently supported. Counter is a metric that exposes merely a sum or tally of things. ```ruby -counter = Prometheus::Client::Counter.new(:service_requests_total, '...') +counter = Prometheus::Client::Counter.new(:service_requests_total, docstring: '...') # increment the counter for a given label set -counter.increment({ service: 'foo' }) +counter.increment(labels: { service: 'foo' }) # increment by a given value -counter.increment({ service: 'bar' }, 5) +counter.increment(by: 5, labels: { service: 'bar' }) # get current value for a given label set -counter.get({ service: 'bar' }) +counter.get(labels: { service: 'bar' }) # => 5 ``` @@ -118,21 +118,21 @@ Gauge is a metric that exposes merely an instantaneous value or some snapshot thereof. ```ruby -gauge = Prometheus::Client::Gauge.new(:room_temperature_celsius, '...') +gauge = Prometheus::Client::Gauge.new(:room_temperature_celsius, docstring: '...') # set a value -gauge.set({ room: 'kitchen' }, 21.534) +gauge.set(21.534, labels: { room: 'kitchen' }) # retrieve the current value for a given label set -gauge.get({ room: 'kitchen' }) +gauge.get(labels: { room: 'kitchen' }) # => 21.534 # increment the value (default is 1) -gauge.increment({ room: 'kitchen' }) +gauge.increment(labels: { room: 'kitchen' }) # => 22.534 # decrement the value by a given value -gauge.decrement({ room: 'kitchen' }, 5) +gauge.decrement(by: 5, labels: { room: 'kitchen' }) # => 17.534 ``` @@ -143,13 +143,13 @@ response sizes) and counts them in configurable buckets. It also provides a sum of all observed values. ```ruby -histogram = Prometheus::Client::Histogram.new(:service_latency_seconds, '...') +histogram = Prometheus::Client::Histogram.new(:service_latency_seconds, docstring: '...') # record a value -histogram.observe({ service: 'users' }, Benchmark.realtime { service.call(arg) }) +histogram.observe(Benchmark.realtime { service.call(arg) }, labels: { service: 'users' }) # retrieve the current bucket values -histogram.get({ service: 'users' }) +histogram.get(labels: { service: 'users' }) # => { 0.005 => 3, 0.01 => 15, 0.025 => 18, ..., 2.5 => 42, 5 => 42, 10 = >42 } ``` @@ -159,13 +159,13 @@ Summary, similar to histograms, is an accumulator for samples. It captures Numeric data and provides an efficient percentile calculation mechanism. ```ruby -summary = Prometheus::Client::Summary.new(:service_latency_seconds, '...') +summary = Prometheus::Client::Summary.new(:service_latency_seconds, docstring: '...') # record a value -summary.observe({ service: 'database' }, Benchmark.realtime { service.call() }) +summary.observe(Benchmark.realtime { service.call() }, labels: { service: 'database' }) # retrieve the current quantile values -summary.get({ service: 'database' }) +summary.get(labels: { service: 'database' }) # => { 0.5 => 0.1233122, 0.9 => 3.4323, 0.99 => 5.3428231 } ``` diff --git a/lib/prometheus/client/counter.rb b/lib/prometheus/client/counter.rb index d1d85b48..4ed7fc45 100644 --- a/lib/prometheus/client/counter.rb +++ b/lib/prometheus/client/counter.rb @@ -10,7 +10,7 @@ def type :counter end - def increment(labels = {}, by = 1) + def increment(by: 1, labels: {}) raise ArgumentError, 'increment must be a non-negative number' if by < 0 label_set = label_set_for(labels) diff --git a/lib/prometheus/client/gauge.rb b/lib/prometheus/client/gauge.rb index ee4c0c50..1f24eb78 100644 --- a/lib/prometheus/client/gauge.rb +++ b/lib/prometheus/client/gauge.rb @@ -12,7 +12,7 @@ def type end # Sets the value for the given label set - def set(labels, value) + def set(value, labels: {}) unless value.is_a?(Numeric) raise ArgumentError, 'value must be a number' end @@ -22,7 +22,7 @@ def set(labels, value) # Increments Gauge value by 1 or adds the given value to the Gauge. # (The value can be negative, resulting in a decrease of the Gauge.) - def increment(labels = {}, by = 1) + def increment(by: 1, labels: {}) label_set = label_set_for(labels) synchronize do @values[label_set] ||= 0 @@ -32,7 +32,7 @@ def increment(labels = {}, by = 1) # Decrements Gauge value by 1 or subtracts the given value from the Gauge. # (The value can be negative, resulting in a increase of the Gauge.) - def decrement(labels = {}, by = 1) + def decrement(by: 1, labels: {}) label_set = label_set_for(labels) synchronize do @values[label_set] ||= 0 diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 23c5899f..172c4827 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -12,7 +12,7 @@ class Histogram < Metric class Value < Hash attr_accessor :sum, :total - def initialize(buckets) + def initialize(buckets:) @sum = 0.0 @total = 0.0 @@ -38,19 +38,18 @@ def observe(value) 2.5, 5, 10].freeze # Offer a way to manually specify buckets - def initialize(name, docstring, base_labels = {}, - buckets = DEFAULT_BUCKETS) + def initialize(name, docstring:, base_labels: {}, buckets: DEFAULT_BUCKETS) raise ArgumentError, 'Unsorted buckets, typo?' unless sorted? buckets @buckets = buckets - super(name, docstring, base_labels) + super(name, docstring: docstring, base_labels: base_labels) end def type :histogram end - def observe(labels, value) + def observe(value, labels: {}) if labels[:le] raise ArgumentError, 'Label with name "le" is not permitted' end @@ -62,7 +61,7 @@ def observe(labels, value) private def default - Value.new(@buckets) + Value.new(buckets: @buckets) end def sorted?(bucket) diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index 7be679df..f5fb86b8 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -9,7 +9,7 @@ module Client class Metric attr_reader :name, :docstring, :base_labels - def initialize(name, docstring, base_labels = {}) + def initialize(name, docstring:, base_labels: {}) @mutex = Mutex.new @validator = LabelSetValidator.new @values = Hash.new { |hash, key| hash[key] = default } @@ -24,7 +24,7 @@ def initialize(name, docstring, base_labels = {}) end # Returns the value for the given label set - def get(labels = {}) + def get(labels: {}) @validator.valid?(labels) @values[labels] diff --git a/lib/prometheus/client/registry.rb b/lib/prometheus/client/registry.rb index 97e8847b..b80ac5ab 100644 --- a/lib/prometheus/client/registry.rb +++ b/lib/prometheus/client/registry.rb @@ -37,21 +37,24 @@ def unregister(name) end end - def counter(name, docstring, base_labels = {}) - register(Counter.new(name, docstring, base_labels)) + def counter(name, docstring:, base_labels: {}) + register(Counter.new(name, docstring: docstring, base_labels: base_labels)) end - def summary(name, docstring, base_labels = {}) - register(Summary.new(name, docstring, base_labels)) + def summary(name, docstring:, base_labels: {}) + register(Summary.new(name, docstring: docstring, base_labels: base_labels)) end - def gauge(name, docstring, base_labels = {}) - register(Gauge.new(name, docstring, base_labels)) + def gauge(name, docstring:, base_labels: {}) + register(Gauge.new(name, docstring: docstring, base_labels: base_labels)) end - def histogram(name, docstring, base_labels = {}, - buckets = Histogram::DEFAULT_BUCKETS) - register(Histogram.new(name, docstring, base_labels, buckets)) + def histogram(name, docstring:, base_labels: {}, + buckets: Histogram::DEFAULT_BUCKETS) + register(Histogram.new(name, + docstring: docstring, + base_labels: base_labels, + buckets: buckets)) end def exist?(name) diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index 0af0397c..0793e12a 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -12,7 +12,7 @@ class Summary < Metric class Value < Hash attr_accessor :sum, :total - def initialize(estimator) + def initialize(estimator:) @sum = estimator.sum @total = estimator.observations @@ -27,17 +27,17 @@ def type end # Records a given value. - def observe(labels, value) + def observe(value, labels: {}) label_set = label_set_for(labels) synchronize { @values[label_set].observe(value) } end # Returns the value for the given label set - def get(labels = {}) + def get(labels: {}) @validator.valid?(labels) synchronize do - Value.new(@values[labels]) + Value.new(estimator: @values[labels]) end end @@ -45,7 +45,7 @@ def get(labels = {}) def values synchronize do @values.each_with_object({}) do |(labels, value), memo| - memo[labels] = Value.new(value) + memo[labels] = Value.new(estimator: value) end end end diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index bf28fe0a..0b0c8e0e 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -64,18 +64,19 @@ def call(env) # :nodoc: def init_request_metrics @requests = @registry.counter( :"#{@metrics_prefix}_requests_total", - 'The total number of HTTP requests handled by the Rack application.', + docstring: + 'The total number of HTTP requests handled by the Rack application.', ) @durations = @registry.histogram( :"#{@metrics_prefix}_request_duration_seconds", - 'The HTTP response duration of the Rack application.', + docstring: 'The HTTP response duration of the Rack application.', ) end def init_exception_metrics @exceptions = @registry.counter( :"#{@metrics_prefix}_exceptions_total", - 'The total number of exceptions raised by the Rack application.', + docstring: 'The total number of exceptions raised by the Rack application.', ) end @@ -85,13 +86,13 @@ def trace(env) record(env, response.first.to_s, duration) return response rescue => exception - @exceptions.increment(exception: exception.class.name) + @exceptions.increment(labels: { exception: exception.class.name }) raise end def record(env, code, duration) - @requests.increment(@counter_lb.call(env, code)) - @durations.observe(@duration_lb.call(env, code), duration) + @requests.increment(labels: @counter_lb.call(env, code)) + @durations.observe(duration, labels: @duration_lb.call(env, code)) rescue # TODO: log unexpected exception during request recording nil diff --git a/spec/examples/metric_example.rb b/spec/examples/metric_example.rb index c577332e..3b20f09e 100644 --- a/spec/examples/metric_example.rb +++ b/spec/examples/metric_example.rb @@ -1,7 +1,7 @@ # encoding: UTF-8 shared_examples_for Prometheus::Client::Metric do - subject { described_class.new(:foo, 'foo description') } + subject { described_class.new(:foo, docstring: 'foo description') } describe '.new' do it 'returns a new metric' do @@ -12,19 +12,21 @@ exception = Prometheus::Client::LabelSetValidator::ReservedLabelError expect do - described_class.new(:foo, 'foo docstring', __name__: 'reserved') + described_class.new(:foo, + docstring: 'foo docstring', + base_labels: { __name__: 'reserved' }) end.to raise_exception exception end it 'raises an exception if the given name is blank' do expect do - described_class.new(nil, 'foo') + described_class.new(nil, docstring: 'foo') end.to raise_exception ArgumentError end it 'raises an exception if docstring is missing' do expect do - described_class.new(:foo, '') + described_class.new(:foo, docstring: '') end.to raise_exception ArgumentError end @@ -37,7 +39,7 @@ "abc\ndef".to_sym, ].each do |name| expect do - described_class.new(name, 'foo') + described_class.new(name, docstring: 'foo') end.to raise_exception(ArgumentError) end end @@ -55,7 +57,7 @@ end it 'returns the current metric value for a given label set' do - expect(subject.get(test: 'label')).to be_a(type) + expect(subject.get(labels: { test: 'label' })).to be_a(type) end end end diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 69bf02a7..8768ddb5 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -4,7 +4,9 @@ require 'examples/metric_example' describe Prometheus::Client::Counter do - let(:counter) { Prometheus::Client::Counter.new(:foo, 'foo description') } + let(:counter) do + Prometheus::Client::Counter.new(:foo, docstring: 'foo description') + end it_behaves_like Prometheus::Client::Metric do let(:type) { Float } @@ -20,20 +22,20 @@ it 'increments the counter for a given label set' do expect do expect do - counter.increment(test: 'label') - end.to change { counter.get(test: 'label') }.by(1.0) + counter.increment(labels: { test: 'label' }) + end.to change { counter.get(labels: { test: 'label' }) }.by(1.0) end.to_not change { counter.get } end it 'increments the counter by a given value' do expect do - counter.increment({}, 5) + counter.increment(by: 5) end.to change { counter.get }.by(5.0) end it 'raises an ArgumentError on negative increments' do expect do - counter.increment({}, -1) + counter.increment(by: -1) end.to raise_error ArgumentError end diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index d3e092bb..20ebb7d5 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -4,7 +4,7 @@ require 'examples/metric_example' describe Prometheus::Client::Gauge do - let(:gauge) { Prometheus::Client::Gauge.new(:foo, 'foo description') } + let(:gauge) { Prometheus::Client::Gauge.new(:foo, docstring: 'foo description') } it_behaves_like Prometheus::Client::Metric do let(:type) { NilClass } @@ -13,22 +13,22 @@ describe '#set' do it 'sets a metric value' do expect do - gauge.set({}, 42) + gauge.set(42) end.to change { gauge.get }.from(nil).to(42) end it 'sets a metric value for a given label set' do expect do expect do - gauge.set({ test: 'value' }, 42) - end.to change { gauge.get(test: 'value') }.from(nil).to(42) + gauge.set(42, labels: { test: 'value' }) + end.to change { gauge.get(labels: { test: 'value' }) }.from(nil).to(42) end.to_not change { gauge.get } end context 'given an invalid value' do it 'raises an ArgumentError' do expect do - gauge.set({}, nil) + gauge.set(nil) end.to raise_exception(ArgumentError) end end @@ -36,7 +36,7 @@ describe '#increment' do before do - gauge.set(RSpec.current_example.metadata[:labels] || {}, 0) + gauge.set(0, labels: RSpec.current_example.metadata[:labels] || {}) end it 'increments the gauge' do @@ -48,14 +48,14 @@ it 'increments the gauge for a given label set', labels: { test: 'one' } do expect do expect do - gauge.increment(test: 'one') - end.to change { gauge.get(test: 'one') }.by(1.0) - end.to_not change { gauge.get(test: 'another') } + gauge.increment(labels: { test: 'one' }) + end.to change { gauge.get(labels: { test: 'one' }) }.by(1.0) + end.to_not change { gauge.get(labels: { test: 'another' }) } end it 'increments the gauge by a given value' do expect do - gauge.increment({}, 5) + gauge.increment(by: 5) end.to change { gauge.get }.by(5.0) end @@ -76,7 +76,7 @@ describe '#decrement' do before do - gauge.set(RSpec.current_example.metadata[:labels] || {}, 0) + gauge.set(0, labels: RSpec.current_example.metadata[:labels] || {}) end it 'increments the gauge' do @@ -88,14 +88,14 @@ it 'decrements the gauge for a given label set', labels: { test: 'one' } do expect do expect do - gauge.decrement(test: 'one') - end.to change { gauge.get(test: 'one') }.by(-1.0) - end.to_not change { gauge.get(test: 'another') } + gauge.decrement(labels: { test: 'one' }) + end.to change { gauge.get(labels: { test: 'one' }) }.by(-1.0) + end.to_not change { gauge.get(labels: { test: 'another' }) } end it 'decrements the gauge by a given value' do expect do - gauge.decrement({}, 5) + gauge.decrement(by: 5) end.to change { gauge.get }.by(-5.0) end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index cee7dbae..ed592abb 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -5,7 +5,7 @@ describe Prometheus::Client::Histogram do let(:histogram) do - described_class.new(:bar, 'bar description', {}, [2.5, 5, 10]) + described_class.new(:bar, docstring: 'bar description', buckets: [2.5, 5, 10]) end it_behaves_like Prometheus::Client::Metric do @@ -15,7 +15,7 @@ describe '#initialization' do it 'raise error for unsorted buckets' do expect do - described_class.new(:bar, 'bar description', {}, [5, 2.5, 10]) + described_class.new(:bar, docstring: 'bar description', buckets: [5, 2.5, 10]) end.to raise_error ArgumentError end end @@ -23,45 +23,46 @@ describe '#observe' do it 'records the given value' do expect do - histogram.observe({}, 5) + histogram.observe(5) end.to change { histogram.get } end it 'raise error for le labels' do expect do - histogram.observe({ le: 1 }, 5) + histogram.observe(5, labels: { le: 1 }) end.to raise_error ArgumentError end end describe '#get' do before do - histogram.observe({ foo: 'bar' }, 3) - histogram.observe({ foo: 'bar' }, 5.2) - histogram.observe({ foo: 'bar' }, 13) - histogram.observe({ foo: 'bar' }, 4) + histogram.observe(3, labels: { foo: 'bar' }) + histogram.observe(5.2, labels: { foo: 'bar' }) + histogram.observe(13, labels: { foo: 'bar' }) + histogram.observe(4, labels: { foo: 'bar' }) end it 'returns a set of buckets values' do - expect(histogram.get(foo: 'bar')).to eql(2.5 => 0.0, 5 => 2.0, 10 => 3.0) + expect(histogram.get(labels: { foo: 'bar' })) + .to eql(2.5 => 0.0, 5 => 2.0, 10 => 3.0) end it 'returns a value which responds to #sum and #total' do - value = histogram.get(foo: 'bar') + value = histogram.get(labels: { foo: 'bar' }) expect(value.sum).to eql(25.2) expect(value.total).to eql(4.0) end it 'uses zero as default value' do - expect(histogram.get({})).to eql(2.5 => 0.0, 5 => 0.0, 10 => 0.0) + expect(histogram.get).to eql(2.5 => 0.0, 5 => 0.0, 10 => 0.0) end end describe '#values' do it 'returns a hash of all recorded summaries' do - histogram.observe({ status: 'bar' }, 3) - histogram.observe({ status: 'foo' }, 6) + histogram.observe(3, labels: { status: 'bar' }) + histogram.observe(6, labels: { status: 'foo' }) expect(histogram.values).to eql( { status: 'bar' } => { 2.5 => 0.0, 5 => 1.0, 10 => 1.0 }, diff --git a/spec/prometheus/client/registry_spec.rb b/spec/prometheus/client/registry_spec.rb index 3ca9db47..32727d00 100644 --- a/spec/prometheus/client/registry_spec.rb +++ b/spec/prometheus/client/registry_spec.rb @@ -68,7 +68,7 @@ def registry.exist?(*args) describe '#counter' do it 'registers a new counter metric container and returns the counter' do - metric = registry.counter(:test, 'test docstring') + metric = registry.counter(:test, docstring: 'test docstring') expect(metric).to be_a(Prometheus::Client::Counter) end @@ -76,7 +76,7 @@ def registry.exist?(*args) describe '#gauge' do it 'registers a new gauge metric container and returns the gauge' do - metric = registry.gauge(:test, 'test docstring') + metric = registry.gauge(:test, docstring: 'test docstring') expect(metric).to be_a(Prometheus::Client::Gauge) end @@ -84,7 +84,7 @@ def registry.exist?(*args) describe '#summary' do it 'registers a new summary metric container and returns the summary' do - metric = registry.summary(:test, 'test docstring') + metric = registry.summary(:test, docstring: 'test docstring') expect(metric).to be_a(Prometheus::Client::Summary) end @@ -92,7 +92,7 @@ def registry.exist?(*args) describe '#histogram' do it 'registers a new histogram metric container and returns the histogram' do - metric = registry.histogram(:test, 'test docstring') + metric = registry.histogram(:test, docstring: 'test docstring') expect(metric).to be_a(Prometheus::Client::Histogram) end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index 627039a5..bae3327d 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -4,7 +4,10 @@ require 'examples/metric_example' describe Prometheus::Client::Summary do - let(:summary) { Prometheus::Client::Summary.new(:bar, 'bar description') } + let(:summary) do + Prometheus::Client::Summary.new(:bar, + docstring: 'bar description') + end it_behaves_like Prometheus::Client::Metric do let(:type) { Hash } @@ -13,39 +16,40 @@ describe '#observe' do it 'records the given value' do expect do - summary.observe({}, 5) + summary.observe(5) end.to change { summary.get } end end describe '#get' do before do - summary.observe({ foo: 'bar' }, 3) - summary.observe({ foo: 'bar' }, 5.2) - summary.observe({ foo: 'bar' }, 13) - summary.observe({ foo: 'bar' }, 4) + summary.observe(3, labels: { foo: 'bar' }) + summary.observe(5.2, labels: { foo: 'bar' }) + summary.observe(13, labels: { foo: 'bar' }) + summary.observe(4, labels: { foo: 'bar' }) end it 'returns a set of quantile values' do - expect(summary.get(foo: 'bar')).to eql(0.5 => 4, 0.9 => 5.2, 0.99 => 5.2) + expect(summary.get(labels: { foo: 'bar' })) + .to eql(0.5 => 4, 0.9 => 5.2, 0.99 => 5.2) end it 'returns a value which responds to #sum and #total' do - value = summary.get(foo: 'bar') + value = summary.get(labels: { foo: 'bar' }) expect(value.sum).to eql(25.2) expect(value.total).to eql(4) end it 'uses nil as default value' do - expect(summary.get({})).to eql(0.5 => nil, 0.9 => nil, 0.99 => nil) + expect(summary.get).to eql(0.5 => nil, 0.9 => nil, 0.99 => nil) end end describe '#values' do it 'returns a hash of all recorded summaries' do - summary.observe({ status: 'bar' }, 3) - summary.observe({ status: 'foo' }, 5) + summary.observe(3, labels: { status: 'bar' }) + summary.observe(5, labels: { status: 'foo' }) expect(summary.values).to eql( { status: 'bar' } => { 0.5 => 3, 0.9 => 3, 0.99 => 3 }, diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index 5dd38708..27b18ea3 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -41,11 +41,11 @@ metric = :http_server_requests_total labels = { method: 'get', path: '/foo', code: '200' } - expect(registry.get(metric).get(labels)).to eql(1.0) + expect(registry.get(metric).get(labels: labels)).to eql(1.0) metric = :http_server_request_duration_seconds labels = { method: 'get', path: '/foo' } - expect(registry.get(metric).get(labels)).to include(0.1 => 0, 0.25 => 1) + expect(registry.get(metric).get(labels: labels)).to include(0.1 => 0, 0.25 => 1) end it 'normalizes paths containing numeric IDs by default' do @@ -55,11 +55,11 @@ metric = :http_server_requests_total labels = { method: 'get', path: '/foo/:id/bars', code: '200' } - expect(registry.get(metric).get(labels)).to eql(1.0) + expect(registry.get(metric).get(labels: labels)).to eql(1.0) metric = :http_server_request_duration_seconds labels = { method: 'get', path: '/foo/:id/bars' } - expect(registry.get(metric).get(labels)).to include(0.1 => 0, 0.5 => 1) + expect(registry.get(metric).get(labels: labels)).to include(0.1 => 0, 0.5 => 1) end it 'normalizes paths containing UUIDs by default' do @@ -69,11 +69,11 @@ metric = :http_server_requests_total labels = { method: 'get', path: '/foo/:uuid/bars', code: '200' } - expect(registry.get(metric).get(labels)).to eql(1.0) + expect(registry.get(metric).get(labels: labels)).to eql(1.0) metric = :http_server_request_duration_seconds labels = { method: 'get', path: '/foo/:uuid/bars' } - expect(registry.get(metric).get(labels)).to include(0.1 => 0, 0.5 => 1) + expect(registry.get(metric).get(labels: labels)).to include(0.1 => 0, 0.5 => 1) end context 'when the app raises an exception' do @@ -94,7 +94,7 @@ metric = :http_server_exceptions_total labels = { exception: 'NoMethodError' } - expect(registry.get(metric).get(labels)).to eql(1.0) + expect(registry.get(metric).get(labels: labels)).to eql(1.0) end end @@ -117,7 +117,7 @@ metric = :http_server_requests_total labels = { method: 'get', code: '200' } - expect(registry.get(metric).get(labels)).to eql(1.0) + expect(registry.get(metric).get(labels: labels)).to eql(1.0) end end diff --git a/spec/prometheus/middleware/exporter_spec.rb b/spec/prometheus/middleware/exporter_spec.rb index b1a1a791..8916513b 100644 --- a/spec/prometheus/middleware/exporter_spec.rb +++ b/spec/prometheus/middleware/exporter_spec.rb @@ -29,7 +29,7 @@ shared_examples 'ok' do |headers, fmt| it "responds with 200 OK and Content-Type #{fmt::CONTENT_TYPE}" do - registry.counter(:foo, 'foo counter').increment({}, 9) + registry.counter(:foo, docstring: 'foo counter').increment(by: 9) get '/metrics', nil, headers From 1a7235f4528986e1438c4bd429f20652df21b49d Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 11 Sep 2018 12:02:37 +0100 Subject: [PATCH 016/189] Require declaration of labels when creating metrics Instead of allowing the first observation / setting of a metric to have any labels, and then validating that any further observations match those, we now require declaring what labels will be specified at the time of instantiating the metric. The Validator now only needs to verify that the provided labels match the expectation, without needing to keep track of the first observation. For now, it's still caching the validations, for performance, but that should disappear in a soon-to-come commit. `base_labels` now works slightly different. Instead of being a "base" value that is set when creating the metric, and then combined with the "other" labels at the time of exporting, it's now called `preset_values`, and it that get merged as part of the labels stored with every observation. This means the storage that holds the values will always have all the labels, including the common ones. This allows having a method like the `labels()` one recommended by the best practices, to have a "view" of a metric with some labels pre-applied. This will be added on a future commit, once we have a central data store. Finally, this needed a little adaptation of the Collector middleware that comes bundled in. This Collector automatically tracks metrics with some pre-defined labels, but allows the user to define which labels they want, on a per-request basis, by specifying a `proc` that will generate the labels given a Rack `env`. Given this design, we can't know what labels the custom `proc` will return at the time of initializing the metric, and we don't have an `env` to pass to them, so the interface of these procs has changed such that they now need to be able to handle an empty `env`, and still return a `hash` with all the right keys. Signed-off-by: Daniel Magliola --- README.md | 52 +++++++++++++-- examples/rack/README.md | 2 + lib/prometheus/client/formats/text.rb | 8 +-- lib/prometheus/client/histogram.rb | 19 ++++-- lib/prometheus/client/label_set_validator.rb | 18 ++++-- lib/prometheus/client/metric.rb | 23 ++++--- lib/prometheus/client/registry.rb | 26 +++++--- lib/prometheus/client/summary.rb | 4 ++ lib/prometheus/middleware/collector.rb | 12 ++++ spec/examples/metric_example.rb | 10 ++- spec/prometheus/client/counter_spec.rb | 24 +++++-- spec/prometheus/client/formats/text_spec.rb | 15 ++--- spec/prometheus/client/gauge_spec.rb | 64 +++++++++++++++---- spec/prometheus/client/histogram_spec.rb | 39 ++++++++++- .../client/label_set_validator_spec.rb | 25 ++++++-- spec/prometheus/client/summary_spec.rb | 40 +++++++++++- spec/prometheus/middleware/collector_spec.rb | 2 + 17 files changed, 301 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 80580f84..6f37040e 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The following metric types are currently supported. Counter is a metric that exposes merely a sum or tally of things. ```ruby -counter = Prometheus::Client::Counter.new(:service_requests_total, docstring: '...') +counter = Prometheus::Client::Counter.new(:service_requests_total, docstring: '...', labels: [:service]) # increment the counter for a given label set counter.increment(labels: { service: 'foo' }) @@ -118,7 +118,7 @@ Gauge is a metric that exposes merely an instantaneous value or some snapshot thereof. ```ruby -gauge = Prometheus::Client::Gauge.new(:room_temperature_celsius, docstring: '...') +gauge = Prometheus::Client::Gauge.new(:room_temperature_celsius, docstring: '...', labels: [:room]) # set a value gauge.set(21.534, labels: { room: 'kitchen' }) @@ -143,7 +143,7 @@ response sizes) and counts them in configurable buckets. It also provides a sum of all observed values. ```ruby -histogram = Prometheus::Client::Histogram.new(:service_latency_seconds, docstring: '...') +histogram = Prometheus::Client::Histogram.new(:service_latency_seconds, docstring: '...', labels: [:service]) # record a value histogram.observe(Benchmark.realtime { service.call(arg) }, labels: { service: 'users' }) @@ -159,7 +159,7 @@ Summary, similar to histograms, is an accumulator for samples. It captures Numeric data and provides an efficient percentile calculation mechanism. ```ruby -summary = Prometheus::Client::Summary.new(:service_latency_seconds, docstring: '...') +summary = Prometheus::Client::Summary.new(:service_latency_seconds, docstring: '...', labels: [:service]) # record a value summary.observe(Benchmark.realtime { service.call() }, labels: { service: 'database' }) @@ -169,6 +169,50 @@ summary.get(labels: { service: 'database' }) # => { 0.5 => 0.1233122, 0.9 => 3.4323, 0.99 => 5.3428231 } ``` +## Labels + +All metrics can have labels, allowing grouping of related time series. + +Labels are an extremely powerful feature, but one that must be used with care. +Refer to the best practices on [naming](https://prometheus.io/docs/practices/naming/) and +[labels](https://prometheus.io/docs/practices/instrumentation/#use-labels). + +Most importantly, avoid labels that can have a large number of possible values (high +cardinality). For example, an HTTP Status Code is a good label. A User ID is **not**. + +Labels are specified optionally when updating metrics, as a hash of `label_name => value`. +Refer to [the Prometheus documentation](https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels) +as to what's a valid `label_name`. + +In order for a metric to accept labels, their names must be specified when first initializing +the metric. Then, when the metric is updated, all the specified labels must be present. + +Example: + +```ruby +https_requests_total = Counter.new(:http_requests_total, docstring: '...', labels: [:service, :status_code]) + +# increment the counter for a given label set +https_requests_total.increment(labels: { service: "my_service", status_code: response.status_code }) +``` + +### Pre-set Label Values + +You can also "pre-set" some of these label values, if they'll always be the same, so you don't +need to specify them every time: + +```ruby +https_requests_total = Counter.new(:http_requests_total, + docstring: '...', + labels: [:service, :status_code], + preset_labels: { service: "my_service" }) + +# increment the counter for a given label set +https_requests_total.increment(labels: { status_code: response.status_code }) +``` + + + ## Tests Install necessary development gems with `bundle install` and run tests with diff --git a/examples/rack/README.md b/examples/rack/README.md index aecdfc6c..d96546d0 100644 --- a/examples/rack/README.md +++ b/examples/rack/README.md @@ -49,6 +49,8 @@ something like this: ```ruby use Prometheus::Middleware::Collector, counter_label_builder: ->(env, code) { + next { code: nil, method: nil, host: nil, path: nil } if env.empty? + { code: code, method: env['REQUEST_METHOD'].downcase, diff --git a/lib/prometheus/client/formats/text.rb b/lib/prometheus/client/formats/text.rb index 040435fa..a4ea75f8 100644 --- a/lib/prometheus/client/formats/text.rb +++ b/lib/prometheus/client/formats/text.rb @@ -40,14 +40,12 @@ class << self private def representation(metric, label_set, value, &block) - set = metric.base_labels.merge(label_set) - if metric.type == :summary - summary(metric.name, set, value, &block) + summary(metric.name, label_set, value, &block) elsif metric.type == :histogram - histogram(metric.name, set, value, &block) + histogram(metric.name, label_set, value, &block) else - yield metric(metric.name, labels(set), value) + yield metric(metric.name, labels(label_set), value) end end diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 172c4827..03f7cec4 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -38,11 +38,18 @@ def observe(value) 2.5, 5, 10].freeze # Offer a way to manually specify buckets - def initialize(name, docstring:, base_labels: {}, buckets: DEFAULT_BUCKETS) + def initialize(name, + docstring:, + labels: [], + preset_labels: {}, + buckets: DEFAULT_BUCKETS) raise ArgumentError, 'Unsorted buckets, typo?' unless sorted? buckets @buckets = buckets - super(name, docstring: docstring, base_labels: base_labels) + super(name, + docstring: docstring, + labels: labels, + preset_labels: preset_labels) end def type @@ -50,16 +57,16 @@ def type end def observe(value, labels: {}) - if labels[:le] - raise ArgumentError, 'Label with name "le" is not permitted' - end - label_set = label_set_for(labels) synchronize { @values[label_set].observe(value) } end private + def reserved_labels + [:le] + end + def default Value.new(buckets: @buckets) end diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index 9536f4fa..943f9a77 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -6,14 +6,18 @@ module Client # Prometheus specification. class LabelSetValidator # TODO: we might allow setting :instance in the future - RESERVED_LABELS = [:job, :instance].freeze + BASE_RESERVED_LABELS = [:job, :instance].freeze class LabelSetError < StandardError; end class InvalidLabelSetError < LabelSetError; end class InvalidLabelError < LabelSetError; end class ReservedLabelError < LabelSetError; end - def initialize + attr_reader :expected_labels, :reserved_labels + + def initialize(expected_labels:, reserved_labels: []) + @expected_labels = expected_labels.sort + @reserved_labels = BASE_RESERVED_LABELS + reserved_labels @validated = {} end @@ -34,10 +38,10 @@ def validate(labels) valid?(labels) - unless @validated.empty? || match?(labels, @validated.first.last) + unless keys_match?(labels) raise InvalidLabelSetError, "labels must have the same signature " \ "(keys given: #{labels.keys.sort} vs." \ - " keys expected: #{@validated.first.last.keys.sort}" + " keys expected: #{expected_labels}" end @validated[labels.hash] = labels @@ -45,8 +49,8 @@ def validate(labels) private - def match?(a, b) - a.keys.sort == b.keys.sort + def keys_match?(labels) + labels.keys.sort == expected_labels end def validate_symbol(key) @@ -62,7 +66,7 @@ def validate_name(key) end def validate_reserved_key(key) - return true unless RESERVED_LABELS.include?(key) + return true unless reserved_labels.include?(key) raise ReservedLabelError, "#{key} is reserved" end diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index f5fb86b8..eafce142 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -7,27 +7,28 @@ module Prometheus module Client # Metric class Metric - attr_reader :name, :docstring, :base_labels + attr_reader :name, :docstring, :preset_labels - def initialize(name, docstring:, base_labels: {}) + def initialize(name, docstring:, labels: [], preset_labels: {}) @mutex = Mutex.new - @validator = LabelSetValidator.new + @validator = LabelSetValidator.new(expected_labels: labels, + reserved_labels: reserved_labels) @values = Hash.new { |hash, key| hash[key] = default } validate_name(name) validate_docstring(docstring) - @validator.valid?(base_labels) + @validator.valid?(labels) + @validator.valid?(preset_labels) @name = name @docstring = docstring - @base_labels = base_labels + @preset_labels = preset_labels end # Returns the value for the given label set def get(labels: {}) - @validator.valid?(labels) - - @values[labels] + label_set = label_set_for(labels) + @values[label_set] end # Returns all label sets with their values @@ -41,6 +42,10 @@ def values private + def reserved_labels + [] + end + def default nil end @@ -62,7 +67,7 @@ def validate_docstring(docstring) end def label_set_for(labels) - @validator.validate(labels) + @validator.validate(preset_labels.merge(labels)) end def synchronize diff --git a/lib/prometheus/client/registry.rb b/lib/prometheus/client/registry.rb index b80ac5ab..58893b3c 100644 --- a/lib/prometheus/client/registry.rb +++ b/lib/prometheus/client/registry.rb @@ -37,23 +37,33 @@ def unregister(name) end end - def counter(name, docstring:, base_labels: {}) - register(Counter.new(name, docstring: docstring, base_labels: base_labels)) + def counter(name, docstring:, labels: [], preset_labels: {}) + register(Counter.new(name, + docstring: docstring, + labels: labels, + preset_labels: preset_labels)) end - def summary(name, docstring:, base_labels: {}) - register(Summary.new(name, docstring: docstring, base_labels: base_labels)) + def summary(name, docstring:, labels: [], preset_labels: {}) + register(Summary.new(name, + docstring: docstring, + labels: labels, + preset_labels: preset_labels)) end - def gauge(name, docstring:, base_labels: {}) - register(Gauge.new(name, docstring: docstring, base_labels: base_labels)) + def gauge(name, docstring:, labels: [], preset_labels: {}) + register(Gauge.new(name, + docstring: docstring, + labels: labels, + preset_labels: preset_labels)) end - def histogram(name, docstring:, base_labels: {}, + def histogram(name, docstring:, labels: [], preset_labels: {}, buckets: Histogram::DEFAULT_BUCKETS) register(Histogram.new(name, docstring: docstring, - base_labels: base_labels, + labels: labels, + preset_labels: preset_labels, buckets: buckets)) end diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index 0793e12a..455dee71 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -52,6 +52,10 @@ def values private + def reserved_labels + [:quantile] + end + def default Quantile::Estimator.new end diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 0b0c8e0e..08445ec3 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -20,6 +20,11 @@ module Middleware # # The request duration metric is broken down by method and path by default. # Set the `:duration_label_builder` option to use a custom label builder. + # + # Label Builder functions will receive a Rack env and a status code, and must + # return a hash with the labels for that request. They must also accept an empty + # env, and return a hash with the correct keys. This is necessary to initialize + # the metrics with the correct set of labels. class Collector attr_reader :app, :registry @@ -47,6 +52,8 @@ def call(env) # :nodoc: end COUNTER_LB = proc do |env, code| + next { code: nil, method: nil, path: nil } if env.empty? + { code: code, method: env['REQUEST_METHOD'].downcase, @@ -55,6 +62,8 @@ def call(env) # :nodoc: end DURATION_LB = proc do |env, _| + next { method: nil, path: nil } if env.empty? + { method: env['REQUEST_METHOD'].downcase, path: aggregation.call(env['PATH_INFO']), @@ -66,10 +75,12 @@ def init_request_metrics :"#{@metrics_prefix}_requests_total", docstring: 'The total number of HTTP requests handled by the Rack application.', + labels: @counter_lb.call({}, "").keys ) @durations = @registry.histogram( :"#{@metrics_prefix}_request_duration_seconds", docstring: 'The HTTP response duration of the Rack application.', + labels: @duration_lb.call({}, "").keys ) end @@ -77,6 +88,7 @@ def init_exception_metrics @exceptions = @registry.counter( :"#{@metrics_prefix}_exceptions_total", docstring: 'The total number of exceptions raised by the Rack application.', + labels: [:exception] ) end diff --git a/spec/examples/metric_example.rb b/spec/examples/metric_example.rb index 3b20f09e..ced2c317 100644 --- a/spec/examples/metric_example.rb +++ b/spec/examples/metric_example.rb @@ -14,7 +14,7 @@ expect do described_class.new(:foo, docstring: 'foo docstring', - base_labels: { __name__: 'reserved' }) + preset_labels: { __name__: 'reserved' }) end.to raise_exception exception end @@ -56,8 +56,12 @@ expect(subject.get).to be_a(type) end - it 'returns the current metric value for a given label set' do - expect(subject.get(labels: { test: 'label' })).to be_a(type) + context "with a subject that expects labels" do + subject { described_class.new(:foo, docstring: 'Labels', labels: [:test]) } + + it 'returns the current metric value for a given label set' do + expect(subject.get(labels: { test: 'label' })).to be_a(type) + end end end end diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 8768ddb5..8e5a47d1 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -4,8 +4,12 @@ require 'examples/metric_example' describe Prometheus::Client::Counter do + let(:expected_labels) { [] } + let(:counter) do - Prometheus::Client::Counter.new(:foo, docstring: 'foo description') + Prometheus::Client::Counter.new(:foo, + docstring: 'foo description', + labels: expected_labels) end it_behaves_like Prometheus::Client::Metric do @@ -19,12 +23,22 @@ end.to change { counter.get }.by(1.0) end - it 'increments the counter for a given label set' do + it 'raises an InvalidLabelSetError if sending unexpected labels' do expect do + counter.increment(labels: { test: 'label' }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError + end + + context "with a an expected label set" do + let(:expected_labels) { [:test] } + + it 'increments the counter for a given label set' do expect do - counter.increment(labels: { test: 'label' }) - end.to change { counter.get(labels: { test: 'label' }) }.by(1.0) - end.to_not change { counter.get } + expect do + counter.increment(labels: { test: 'label' }) + end.to change { counter.get(labels: { test: 'label' }) }.by(1.0) + end.to_not change { counter.get(labels: { test: 'other' }) } + end end it 'increments the counter by a given value' do diff --git a/spec/prometheus/client/formats/text_spec.rb b/spec/prometheus/client/formats/text_spec.rb index 60aa0c60..47493773 100644 --- a/spec/prometheus/client/formats/text_spec.rb +++ b/spec/prometheus/client/formats/text_spec.rb @@ -20,27 +20,24 @@ double( name: :foo, docstring: 'foo description', - base_labels: { umlauts: 'Björn', utf: '佖佥' }, type: :counter, values: { - { code: 'red' } => 42.0, - { code: 'green' } => 3.14E42, - { code: 'blue' } => -1.23e-45, + { umlauts: 'Björn', utf: '佖佥', code: 'red' } => 42.0, + { umlauts: 'Björn', utf: '佖佥', code: 'green' } => 3.14E42, + { umlauts: 'Björn', utf: '佖佥', code: 'blue' } => -1.23e-45, }, ), double( name: :bar, docstring: "bar description\nwith newline", - base_labels: { status: 'success' }, type: :gauge, values: { - { code: 'pink' } => 15.0, + { status: 'success', code: 'pink' } => 15.0, }, ), double( name: :baz, docstring: 'baz "description" \\escaping', - base_labels: {}, type: :counter, values: { { text: "with \"quotes\", \\escape \n and newline" } => 15.0, @@ -49,16 +46,14 @@ double( name: :qux, docstring: 'qux description', - base_labels: { for: 'sake' }, type: :summary, values: { - { code: '1' } => summary_value, + { for: 'sake', code: '1' } => summary_value, }, ), double( name: :xuq, docstring: 'xuq description', - base_labels: {}, type: :histogram, values: { { code: 'ah' } => histogram_value, diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index 20ebb7d5..14211c50 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -4,7 +4,13 @@ require 'examples/metric_example' describe Prometheus::Client::Gauge do - let(:gauge) { Prometheus::Client::Gauge.new(:foo, docstring: 'foo description') } + let(:expected_labels) { [] } + + let(:gauge) do + Prometheus::Client::Gauge.new(:foo, + docstring: 'foo description', + labels: expected_labels) + end it_behaves_like Prometheus::Client::Metric do let(:type) { NilClass } @@ -17,12 +23,22 @@ end.to change { gauge.get }.from(nil).to(42) end - it 'sets a metric value for a given label set' do + it 'raises an InvalidLabelSetError if sending unexpected labels' do expect do + gauge.set(42, labels: { test: 'value' }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError + end + + context "with a an expected label set" do + let(:expected_labels) { [:test] } + + it 'sets a metric value for a given label set' do expect do - gauge.set(42, labels: { test: 'value' }) - end.to change { gauge.get(labels: { test: 'value' }) }.from(nil).to(42) - end.to_not change { gauge.get } + expect do + gauge.set(42, labels: { test: 'value' }) + end.to change { gauge.get(labels: { test: 'value' }) }.from(nil).to(42) + end.to_not change { gauge.get(labels: { test: 'other' }) } + end end context 'given an invalid value' do @@ -45,12 +61,22 @@ end.to change { gauge.get }.by(1.0) end - it 'increments the gauge for a given label set', labels: { test: 'one' } do + it 'raises an InvalidLabelSetError if sending unexpected labels' do expect do + gauge.increment(labels: { test: 'value' }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError + end + + context "with a an expected label set" do + let(:expected_labels) { [:test] } + + it 'increments the gauge for a given label set', labels: { test: 'one' } do expect do - gauge.increment(labels: { test: 'one' }) - end.to change { gauge.get(labels: { test: 'one' }) }.by(1.0) - end.to_not change { gauge.get(labels: { test: 'another' }) } + expect do + gauge.increment(labels: { test: 'one' }) + end.to change { gauge.get(labels: { test: 'one' }) }.by(1.0) + end.to_not change { gauge.get(labels: { test: 'another' }) } + end end it 'increments the gauge by a given value' do @@ -79,18 +105,28 @@ gauge.set(0, labels: RSpec.current_example.metadata[:labels] || {}) end - it 'increments the gauge' do + it 'decrements the gauge' do expect do gauge.decrement end.to change { gauge.get }.by(-1.0) end - it 'decrements the gauge for a given label set', labels: { test: 'one' } do + it 'raises an InvalidLabelSetError if sending unexpected labels' do expect do + gauge.decrement(labels: { test: 'value' }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError + end + + context "with a an expected label set" do + let(:expected_labels) { [:test] } + + it 'decrements the gauge for a given label set', labels: { test: 'one' } do expect do - gauge.decrement(labels: { test: 'one' }) - end.to change { gauge.get(labels: { test: 'one' }) }.by(-1.0) - end.to_not change { gauge.get(labels: { test: 'another' }) } + expect do + gauge.decrement(labels: { test: 'one' }) + end.to change { gauge.get(labels: { test: 'one' }) }.by(-1.0) + end.to_not change { gauge.get(labels: { test: 'another' }) } + end end it 'decrements the gauge by a given value' do diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index ed592abb..32c329ac 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -4,8 +4,13 @@ require 'examples/metric_example' describe Prometheus::Client::Histogram do + let(:expected_labels) { [] } + let(:histogram) do - described_class.new(:bar, docstring: 'bar description', buckets: [2.5, 5, 10]) + described_class.new(:bar, + docstring: 'bar description', + labels: expected_labels, + buckets: [2.5, 5, 10]) end it_behaves_like Prometheus::Client::Metric do @@ -18,6 +23,12 @@ described_class.new(:bar, docstring: 'bar description', buckets: [5, 2.5, 10]) end.to raise_error ArgumentError end + + it 'raise error for `le` label' do + expect do + described_class.new(:bar, docstring: 'bar description', labels: [:le]) + end.to raise_error Prometheus::Client::LabelSetValidator::ReservedLabelError + end end describe '#observe' do @@ -30,11 +41,31 @@ it 'raise error for le labels' do expect do histogram.observe(5, labels: { le: 1 }) - end.to raise_error ArgumentError + end.to raise_error Prometheus::Client::LabelSetValidator::ReservedLabelError + end + + it 'raises an InvalidLabelSetError if sending unexpected labels' do + expect do + histogram.observe(5, labels: { foo: 'bar' }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError + end + + context "with a an expected label set" do + let(:expected_labels) { [:test] } + + it 'observes a value for a given label set' do + expect do + expect do + histogram.observe(5, labels: { test: 'value' }) + end.to change { histogram.get(labels: { test: 'value' }) } + end.to_not change { histogram.get(labels: { test: 'other' }) } + end end end describe '#get' do + let(:expected_labels) { [:foo] } + before do histogram.observe(3, labels: { foo: 'bar' }) histogram.observe(5.2, labels: { foo: 'bar' }) @@ -55,11 +86,13 @@ end it 'uses zero as default value' do - expect(histogram.get).to eql(2.5 => 0.0, 5 => 0.0, 10 => 0.0) + expect(histogram.get(labels: { foo: '' })).to eql(2.5 => 0.0, 5 => 0.0, 10 => 0.0) end end describe '#values' do + let(:expected_labels) { [:status] } + it 'returns a hash of all recorded summaries' do histogram.observe(3, labels: { status: 'bar' }) histogram.observe(6, labels: { status: 'foo' }) diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index f6071aaf..b6f959d5 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -3,7 +3,8 @@ require 'prometheus/client/label_set_validator' describe Prometheus::Client::LabelSetValidator do - let(:validator) { Prometheus::Client::LabelSetValidator.new } + let(:expected_labels) { [] } + let(:validator) { Prometheus::Client::LabelSetValidator.new(expected_labels: expected_labels) } let(:invalid) { Prometheus::Client::LabelSetValidator::InvalidLabelSetError } describe '.new' do @@ -45,25 +46,35 @@ end describe '#validate' do + let(:expected_labels) { [:method, :code] } + it 'returns a given valid label set' do - hash = { version: 'alpha' } + hash = { method: 'get', code: '200' } expect(validator.validate(hash)).to eql(hash) end - it 'raises an exception if a given label set is not valid' do + it 'raises an exception if a given label set is not `valid?`' do input = 'broken' expect(validator).to receive(:valid?).with(input).and_raise(invalid) expect { validator.validate(input) }.to raise_exception(invalid) end - it 'raises InvalidLabelSetError for varying label sets' do - validator.validate(method: 'get', code: '200') + it 'raises an exception if there are unexpected labels' do + expect do + validator.validate(method: 'get', code: '200', exception: 'NoMethodError') + end.to raise_exception(invalid, /keys given: \[:code, :exception, :method\] vs. keys expected: \[:code, :method\]/) + end + + it 'raises an exception if there are missing labels' do + expect do + validator.validate(method: 'get') + end.to raise_exception(invalid, /keys given: \[:method\] vs. keys expected: \[:code, :method\]/) expect do - validator.validate(method: 'get', exception: 'NoMethodError') - end.to raise_exception(invalid, /keys given: \[:exception, :method\] vs. keys expected: \[:code, :method\]/) + validator.validate(code: '200') + end.to raise_exception(invalid, /keys given: \[:code\] vs. keys expected: \[:code, :method\]/) end end end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index bae3327d..7b702a6c 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -4,24 +4,60 @@ require 'examples/metric_example' describe Prometheus::Client::Summary do + let(:expected_labels) { [] } + let(:summary) do Prometheus::Client::Summary.new(:bar, - docstring: 'bar description') + docstring: 'bar description', + labels: expected_labels) end it_behaves_like Prometheus::Client::Metric do let(:type) { Hash } end + describe '#initialization' do + it 'raise error for `quantile` label' do + expect do + described_class.new(:bar, docstring: 'bar description', labels: [:quantile]) + end.to raise_error Prometheus::Client::LabelSetValidator::ReservedLabelError + end + end + describe '#observe' do it 'records the given value' do expect do summary.observe(5) end.to change { summary.get } end + it 'raise error for quantile labels' do + expect do + summary.observe(5, labels: { quantile: 1 }) + end.to raise_error Prometheus::Client::LabelSetValidator::ReservedLabelError + end + + it 'raises an InvalidLabelSetError if sending unexpected labels' do + expect do + summary.observe(5, labels: { foo: 'bar' }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError + end + + context "with a an expected label set" do + let(:expected_labels) { [:test] } + + it 'observes a value for a given label set' do + expect do + expect do + summary.observe(5, labels: { test: 'value' }) + end.to change { summary.get(labels: { test: 'value' }) } + end.to_not change { summary.get(labels: { test: 'other' }) } + end + end end describe '#get' do + let(:expected_labels) { [:foo] } + before do summary.observe(3, labels: { foo: 'bar' }) summary.observe(5.2, labels: { foo: 'bar' }) @@ -47,6 +83,8 @@ end describe '#values' do + let(:expected_labels) { [:status] } + it 'returns a hash of all recorded summaries' do summary.observe(3, labels: { status: 'bar' }) summary.observe(5, labels: { status: 'foo' }) diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index 27b18ea3..bbebd8fb 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -104,6 +104,8 @@ original_app, registry: registry, counter_label_builder: lambda do |env, code| + next { code: nil, method: nil } if env.empty? + { code: code, method: env['REQUEST_METHOD'].downcase, From ce6d44dea95f8af7f4d772c68cd2775f6668ec2d Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Thu, 13 Sep 2018 13:43:19 +0100 Subject: [PATCH 017/189] Remove quantile calculation from `Summary` Expose only `sum` and `count` instead, with no quantiles / percentiles. The main reason to do this is that all this change is based on the idea of having a "Store" interface that can be shared between Ruby processes. The `quantile` gem doesn't play well with this, since we'd need to store instances of `Quantile::Estimator`, which is a complex data structure, and tricky to share between Ruby processes. Moreover, individual Summaries on different processes cannot be aggregated, so all processes would actually have to share one instance of this class, which makes it extremely tricky, particularly to do performantly. Even though this is a loss of functionality, this puts the Ruby client on par with other client libraries, like the Python one, which also only offers sum and count without quantiles. Also, this is actually more compliant with the Client Library best practices: - Summary is ENCOURAGED to offer quantiles as exports - MUST allow not having quantiles, since just count and sum is quite useful - This MUST be the default The original client didn't comply with the last 2 rules, where this one does, just like the Python client. And quantiles, while seemingly the point of summaries, are encouraged but not required. I'm not discarding the idea of adding quantiles back. I have ideas on how this could be done, but it'd probably be pretty expensive, as a single Estimator instance would have to be marshalled / unmarshalled into bytes for every call to `observe`, and it'd have a hard requirement of having some sort of low-cardinality Process ID as a label (possible with Unicorn) to avoid aggregation. Signed-off-by: Daniel Magliola --- README.md | 9 +++-- lib/prometheus/client/formats/text.rb | 4 --- lib/prometheus/client/summary.rb | 38 ++++++--------------- prometheus-client.gemspec | 2 -- spec/prometheus/client/formats/text_spec.rb | 9 ++--- spec/prometheus/client/summary_spec.rb | 30 ++++++---------- 6 files changed, 29 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 6f37040e..044ce55c 100644 --- a/README.md +++ b/README.md @@ -158,15 +158,18 @@ histogram.get(labels: { service: 'users' }) Summary, similar to histograms, is an accumulator for samples. It captures Numeric data and provides an efficient percentile calculation mechanism. +For now, only `sum` and `total` (count of observations) are supported, no actual quantiles. + ```ruby summary = Prometheus::Client::Summary.new(:service_latency_seconds, docstring: '...', labels: [:service]) # record a value summary.observe(Benchmark.realtime { service.call() }, labels: { service: 'database' }) -# retrieve the current quantile values -summary.get(labels: { service: 'database' }) -# => { 0.5 => 0.1233122, 0.9 => 3.4323, 0.99 => 5.3428231 } +# retrieve the current sum and total values +summary_value = summary.get(labels: { service: 'database' }) +summary_value.sum # => 123.45 +summary_value.count # => 100 ``` ## Labels diff --git a/lib/prometheus/client/formats/text.rb b/lib/prometheus/client/formats/text.rb index a4ea75f8..9db12542 100644 --- a/lib/prometheus/client/formats/text.rb +++ b/lib/prometheus/client/formats/text.rb @@ -50,10 +50,6 @@ def representation(metric, label_set, value, &block) end def summary(name, set, value) - value.each do |q, v| - yield metric(name, labels(set.merge(quantile: q)), v) - end - l = labels(set) yield metric("#{name}_sum", l, value.sum) yield metric("#{name}_count", l, value.total) diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index 455dee71..066ab0e8 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -1,24 +1,24 @@ # encoding: UTF-8 -require 'quantile' require 'prometheus/client/metric' module Prometheus module Client # Summary is an accumulator for samples. It captures Numeric data and - # provides an efficient quantile calculation mechanism. + # provides the total count and sum of observations. class Summary < Metric # Value represents the state of a Summary at a given point. - class Value < Hash + class Value attr_accessor :sum, :total - def initialize(estimator:) - @sum = estimator.sum - @total = estimator.observations + def initialize + @sum = 0.0 + @total = 0.0 + end - estimator.invariants.each do |invariant| - self[invariant.quantile] = estimator.query(invariant.quantile) - end + def observe(value) + @sum += value + @total += 1 end end @@ -32,24 +32,6 @@ def observe(value, labels: {}) synchronize { @values[label_set].observe(value) } end - # Returns the value for the given label set - def get(labels: {}) - @validator.valid?(labels) - - synchronize do - Value.new(estimator: @values[labels]) - end - end - - # Returns all label sets with their values - def values - synchronize do - @values.each_with_object({}) do |(labels, value), memo| - memo[labels] = Value.new(estimator: value) - end - end - end - private def reserved_labels @@ -57,7 +39,7 @@ def reserved_labels end def default - Quantile::Estimator.new + Value.new end end end diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index c9277dc2..0d5c9659 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -14,6 +14,4 @@ Gem::Specification.new do |s| s.files = %w(README.md) + Dir.glob('{lib/**/*}') s.require_paths = ['lib'] - - s.add_dependency 'quantile', '~> 0.2.1' end diff --git a/spec/prometheus/client/formats/text_spec.rb b/spec/prometheus/client/formats/text_spec.rb index 47493773..0150fdf9 100644 --- a/spec/prometheus/client/formats/text_spec.rb +++ b/spec/prometheus/client/formats/text_spec.rb @@ -4,9 +4,7 @@ describe Prometheus::Client::Formats::Text do let(:summary_value) do - { 0.5 => 4.2, 0.9 => 8.32, 0.99 => 15.3 }.tap do |value| - allow(value).to receive_messages(sum: 1243.21, total: 93) - end + Struct.new(:sum, :total).new(1243.21, 93.0) end let(:histogram_value) do @@ -79,11 +77,8 @@ baz{text="with \"quotes\", \\escape \n and newline"} 15.0 # TYPE qux summary # HELP qux qux description -qux{for="sake",code="1",quantile="0.5"} 4.2 -qux{for="sake",code="1",quantile="0.9"} 8.32 -qux{for="sake",code="1",quantile="0.99"} 15.3 qux_sum{for="sake",code="1"} 1243.21 -qux_count{for="sake",code="1"} 93 +qux_count{for="sake",code="1"} 93.0 # TYPE xuq histogram # HELP xuq xuq description xuq_bucket{code="ah",le="10"} 1.0 diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index 7b702a6c..740a4d23 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -13,7 +13,7 @@ end it_behaves_like Prometheus::Client::Metric do - let(:type) { Hash } + let(:type) { Prometheus::Client::Summary::Value } end describe '#initialization' do @@ -27,9 +27,12 @@ describe '#observe' do it 'records the given value' do expect do - summary.observe(5) - end.to change { summary.get } + expect do + summary.observe(5) + end.to change { summary.get.sum }.from(0.0).to(5.0) + end.to change { summary.get.total }.from(0.0).to(1.0) end + it 'raise error for quantile labels' do expect do summary.observe(5, labels: { quantile: 1 }) @@ -49,8 +52,8 @@ expect do expect do summary.observe(5, labels: { test: 'value' }) - end.to change { summary.get(labels: { test: 'value' }) } - end.to_not change { summary.get(labels: { test: 'other' }) } + end.to change { summary.get(labels: { test: 'value' }).total } + end.to_not change { summary.get(labels: { test: 'other' }).total } end end end @@ -65,20 +68,11 @@ summary.observe(4, labels: { foo: 'bar' }) end - it 'returns a set of quantile values' do - expect(summary.get(labels: { foo: 'bar' })) - .to eql(0.5 => 4, 0.9 => 5.2, 0.99 => 5.2) - end - it 'returns a value which responds to #sum and #total' do value = summary.get(labels: { foo: 'bar' }) expect(value.sum).to eql(25.2) - expect(value.total).to eql(4) - end - - it 'uses nil as default value' do - expect(summary.get).to eql(0.5 => nil, 0.9 => nil, 0.99 => nil) + expect(value.total).to eql(4.0) end end @@ -89,10 +83,8 @@ summary.observe(3, labels: { status: 'bar' }) summary.observe(5, labels: { status: 'foo' }) - expect(summary.values).to eql( - { status: 'bar' } => { 0.5 => 3, 0.9 => 3, 0.99 => 3 }, - { status: 'foo' } => { 0.5 => 5, 0.9 => 5, 0.99 => 5 }, - ) + expect(summary.values[{ status: 'bar' }].sum).to eql(3.0) + expect(summary.values[{ status: 'foo' }].sum).to eql(5.0) end end end From b787e37f3e93cd0464fd77280526bd8c8856327a Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 14 Sep 2018 12:08:42 +0100 Subject: [PATCH 018/189] Add `concurrent-ruby` gem The reason we need this gem is to be able to provide a good concurrency story in our "Data Stores". We want the data stores to be thread-safe to make it easy for metrics to use them, and to shield away the complexity so that the store can do what's most efficient, and metrics don't need to make assumptions on how it works. However, we also need metrics to be able to update multiple values at once so in some cases (most notably, histograms), the store does need to provide a synchronization method. Since the normal `set` / `increment` methods call a Mutex already, having a Mutex block around them means we end up calling `Mutex.synchronize` twice, which *should* work, but Ruby Mutexes are not reentrant. `concurrent-ruby` provides a Reentrant lock. It's a Read-Write Lock, not a Mutex, but by always grabbing Write locks, it's functionally the same Signed-off-by: Daniel Magliola --- prometheus-client.gemspec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 0d5c9659..9642a7d0 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -14,4 +14,6 @@ Gem::Specification.new do |s| s.files = %w(README.md) + Dir.glob('{lib/**/*}') s.require_paths = ['lib'] + + s.add_dependency 'concurrent-ruby' end From a951c225151914fd469b4a01ffabe856b8f842a6 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 14 Sep 2018 16:21:26 +0100 Subject: [PATCH 019/189] Abstract away storage of Metric data into DataStore objects Currently, each Metric object stores its data (labels and values) in a Hash object internal to itself. This change introduces the concept of a Data Store that is external to the metrics themselves, which holds all the data. The main reason to do this is that by having a standardized and simple interface that metrics use to access the store, we abstract away the details of storing the data from the specific needs of each metric. This allows us to then simply swap around the stores based on the needs of different circumstances, with no changes to the rest of the client. The main use case for this is solving the "pre-fork" server scenario, where multiple processes need to share the metric storage, to be able to report coherent numbers to Prometheus. This change provides one store, a Synchronized hash, that pretty much has the same characteristics that the existing Storage had, plus some Mutex overhead. Within a single process scenario, this should be the fastest way to operate in a multi-threaded environment, and the safest. As such, this is the default store. Future commits will introduce new ones, for specific scenarios, and also each consumer of the Prometheus Client can make their own and simply swap them for the built-in ones, if they have specific needs. The interface and requirements of Stores are specified in detail in a README.md file in the `client/data_stores` directory. This is the documentation that must be used by anyone wishing to create their own store. Signed-off-by: Daniel Magliola --- README.md | 124 ++++++++ lib/prometheus/client.rb | 5 + lib/prometheus/client/config.rb | 15 + lib/prometheus/client/counter.rb | 8 +- lib/prometheus/client/data_stores/README.md | 285 ++++++++++++++++++ .../client/data_stores/synchronized.rb | 64 ++++ lib/prometheus/client/formats/text.rb | 10 +- lib/prometheus/client/gauge.rb | 12 +- lib/prometheus/client/histogram.rb | 75 +++-- lib/prometheus/client/metric.rb | 34 +-- lib/prometheus/client/registry.rb | 21 +- lib/prometheus/client/summary.rb | 49 +-- spec/examples/data_store_example.rb | 58 ++++ spec/prometheus/client/counter_spec.rb | 6 + .../client/data_stores/synchronized_spec.rb | 19 ++ spec/prometheus/client/formats/text_spec.rb | 6 +- spec/prometheus/client/gauge_spec.rb | 12 +- spec/prometheus/client/histogram_spec.rb | 24 +- spec/prometheus/client/summary_spec.rb | 32 +- spec/prometheus/middleware/collector_spec.rb | 5 + 20 files changed, 737 insertions(+), 127 deletions(-) create mode 100644 lib/prometheus/client/config.rb create mode 100644 lib/prometheus/client/data_stores/README.md create mode 100644 lib/prometheus/client/data_stores/synchronized.rb create mode 100644 spec/examples/data_store_example.rb create mode 100644 spec/prometheus/client/data_stores/synchronized_spec.rb diff --git a/README.md b/README.md index 044ce55c..b3f2e167 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,130 @@ https_requests_total.increment(labels: { status_code: response.status_code }) + +## Data Stores + +The data for all the metrics (the internal counters associated with each labelset) +is stored in a global Data Store object, rather than in the metric objects themselves. +(This "storage" is ephemeral, generally in-memory, it's not "long-term storage") + +The main reason to do this is that different applications may have different requirements +for their metrics storage. Application running in pre-fork servers (like Unicorn, for +example), require a shared store between all the processes, to be able to report coherent +numbers. At the same time, other applications may not have this requirement but be very +sensitive to performance, and would prefer instead a simpler, faster store. + +By having a standardized and simple interface that metrics use to access this store, +we abstract away the details of storing the data from the specific needs of each metric. +This allows us to then simply swap around the stores based on the needs of different +applications, with no changes to the rest of the client. + +The client provides 3 built-in stores, but if neither of these is ideal for your +requirements, you can easily make your own store and use that instead. More on this below. + +### Configuring which store to use. + +By default, the Client uses the `Synchronized` store, which is a simple, thread-safe Store +for single-process scenarios. + +If you need to use a different store, set it in the Client Config: + +```ruby +Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DataStore.new(store_specific_params) +``` + +NOTE: You **must** make sure to set the `data_store` before initializing any metrics. +If using Rails, you probably want to set up your Data Store on `config/application.rb`, +or `config/environments/*`, both of which run before `config/initializers/*` + +Also note that `config.data_store` is set to an *instance* of a `DataStore`, not to the +class. This is so that the stores can receive parameters. Most of the built-in stores +don't require any, but `DirectFileStore` does, for example. + +When instantiating metrics, there is an optional `store_settings` attribute. This is used +to set up store-specific settings for each metric. For most stores, this is not used, but +for multi-process stores, this is used to specify how to aggregate the values of each +metric across multiple processes. For the most part, this is used for Gauges, to specify +whether you want to report the `SUM`, `MAX` or `MIN` value observed across all processes. +For almost all other cases, you'd leave the default (`SUM`). More on this on the +*Aggregation* section below. + +Other custom stores may also require or accept extra parameters besides `:aggregation`. +See the documentation of each store for more details. + +### Built-in stores + +There are 3 built-in stores, with different trade-offs: + +- **Synchronized**: Default store. Thread safe, but not suitable for multi-process + scenarios (e.g. pre-fork servers, like Unicorn). Stores data in Hashes, with all accesses + protected by Mutexes. +- **SingleThreaded**: Fastest store, but only suitable for single-threaded scenarios. + This store does not make any effort to synchronize access to its internal hashes, so + it's absolutely not thread safe. +- **DirectFileStore**: Stores data in binary files, one file per process and per metric. + This is generally the recommended store to use with pre-fork servers and other + "multi-process" scenarios. + + Each metric gets a file for each process, and manages its contents by storing keys and + binary floats next to them, and updating the offsets of those Floats directly. When + exporting metrics, it will find all the files that apply to each metric, read them, + and aggregate them. + + In order to do this, each Metric needs an `:aggregation` setting, specifying how + to aggregate the multiple possible values we can get for each labelset. By default, + they are `SUM`med, which is what most use-cases call for (counters and histograms, + for example). However, for Gauges, it's possible to set `MAX` or `MIN` as aggregation, + to get the highest/lowest value of all the processes / threads. + + Even though this store saves data on disk, it's still much faster than would probably be + expected, because the files are never actually `fsync`ed, so the store never blocks + while waiting for disk. FS caching is incredibly efficient in this regard. + + If in doubt, check the benchmark scripts described in the documentation for creating + your own stores and run them in your particular runtime environment to make sure this + provides adequate performance. + +### Building your own store, and stores other than the built-in ones. + +If none of these stores is suitable for your requirements, you can easily make your own. + +The interface and requirements of Stores are specified in detail in the `README.md` +in the `client/data_stores` directory. This thoroughly documents how to make your own +store. + +There are also links there to non-built-in stores created by others that may be useful, +either as they are, or as a starting point for making your own. + +### Aggregation settings for multi-process stores + +If you are in a multi-process environment (such as pre-fork servers like Unicorn), each +process will probably keep their own counters, which need to be aggregated when receiving +a Prometheus scrape, to report coherent total numbers. + +For Counters and Histograms (and quantile-less Summaries), this is simply a matter of +summing the values of each process. + +For Gauges, however, this may not be the right thing to do, depending on what they're +measuring. You might want to take the maximum or minimum value observed in any process, +rather than the sum of all of them. + +In those cases, you should use the `store_settings` parameter when registering the +metric, to specify an `:aggregation` setting. + +```ruby +free_disk_space = registry.gauge(:free_disk_space_bytes, + docstring: "Free disk space, in bytes", + store_settings: { aggregation: :max }) +``` + +NOTE: This will only work if the store you're using supports the `:aggregation` setting. +Of the built-in stores, only `DirectFileStore` does. + +Also note that the `:aggregation` setting works for all metric types, not just for gauges. +It would be unusual to use it for anything other than gauges, but if your use-case +requires it, the store will respect your aggregation wishes. + ## Tests Install necessary development gems with `bundle install` and run tests with diff --git a/lib/prometheus/client.rb b/lib/prometheus/client.rb index a7e9acaa..fe09d9ba 100644 --- a/lib/prometheus/client.rb +++ b/lib/prometheus/client.rb @@ -1,6 +1,7 @@ # encoding: UTF-8 require 'prometheus/client/registry' +require 'prometheus/client/config' module Prometheus # Client is a ruby implementation for a Prometheus compatible client. @@ -9,5 +10,9 @@ module Client def self.registry @registry ||= Registry.new end + + def self.config + @config ||= Config.new + end end end diff --git a/lib/prometheus/client/config.rb b/lib/prometheus/client/config.rb new file mode 100644 index 00000000..7f76c2a0 --- /dev/null +++ b/lib/prometheus/client/config.rb @@ -0,0 +1,15 @@ +# encoding: UTF-8 + +require 'prometheus/client/data_stores/synchronized' + +module Prometheus + module Client + class Config + attr_accessor :data_store + + def initialize + @data_store = Prometheus::Client::DataStores::Synchronized.new + end + end + end +end diff --git a/lib/prometheus/client/counter.rb b/lib/prometheus/client/counter.rb index 4ed7fc45..28ec2f1e 100644 --- a/lib/prometheus/client/counter.rb +++ b/lib/prometheus/client/counter.rb @@ -14,13 +14,7 @@ def increment(by: 1, labels: {}) raise ArgumentError, 'increment must be a non-negative number' if by < 0 label_set = label_set_for(labels) - synchronize { @values[label_set] += by } - end - - private - - def default - 0.0 + @store.increment(labels: label_set, by: by) end end end diff --git a/lib/prometheus/client/data_stores/README.md b/lib/prometheus/client/data_stores/README.md new file mode 100644 index 00000000..e4243839 --- /dev/null +++ b/lib/prometheus/client/data_stores/README.md @@ -0,0 +1,285 @@ +# Custom Data Stores + +Stores are basically an abstraction over a Hash, whose keys are in turn a Hash of labels +plus a metric name. The intention behind having different data stores is solving +different requirements for different production scenarios, or performance trade-offs. + +The most common of these scenarios are pre-fork servers like Unicorn, which have multiple +separate processes gathering metrics. If each of these had their own store, the metrics +reported on each Prometheus scrape would be different, depending on which process handles +the request. Solving this requires some sort of shared storage between these processes, +and there are many ways to solve this problem, each with their own trade-offs. + +This abstraction allows us to easily plug in the most adequate store for each scenario. + +## Interface + +`Store` exposes a `for_metric` method, which returns a store-specific and metric-specific +`MetricStore` object, which represents a "view" onto the actual internal storage for one +particular metric. Each metric / collector object will have a references to this +`MetricStore` and interact with it directly. + +The `MetricStore` class must expose `synchronize`, `set`, `increment`, `get` and `all_values` +methods, which are explained in the code sample below. Its initializer should be called +only by `Store#for_metric`, not directly. + +All values stored are `Float`s. + +Internally, a `Store` can store the data however it needs to, based on its requirements. +For example, a store that needs to work in a multi-process environment needs to have a +shared section of memory, via either Files, an MMap, an external database, or whatever the +implementor chooses for their particular use case. + +Each `Store` / `MetricStore` will also choose how to divide responsibilities over the +storage of values. For some use cases, each `MetricStore` may have their own individual +storage, whereas for others, the `Store` may own a central storage, and `MetricStore` +objects will access it through the `Store`. This depends on the design choices of each `Store`. + +`Store` and `MetricStore` MUST be thread safe. This applies not only to operations on +stored values (`set`, `increment`), but `MetricStore` must also expose a `synchronize` +method that would allow a Metric to increment multiple values atomically (Histograms need +this, for example). + +Ideally, multiple keys should be modifiable simultaneously, but this is not a +hard requirement. + +This is what the interface looks like, in practice: + +```ruby +module Prometheus + module Client + module DataStores + class CustomStore + + # Return a MetricStore, which provides a view of the internal data store, + # catering specifically to that metric. + # + # - `metric_settings` specifies configuration parameters for this metric + # specifically. These may or may not be necessary, depending on each specific + # store and metric. The most obvious example of this is for gauges in + # multi-process environments, where the developer needs to choose how those + # gauges will get aggregated between all the per-process values. + # + # The settings that the store will accept, and what it will do with them, are + # 100% Store-specific. Each store should document what settings it will accept + # and how to use them, so the developer using that store can pass the appropriate + # instantiating the Store itself, and the Metrics they declare. + # + # - `metric_type` is specified in case a store wants to validate that the settings + # are valid for the metric being set up. It may go unused by most Stores + # + # Even if your store doesn't need these two parameters, the Store must expose them + # to make them swappable. + def for_metric(metric_name, metric_type:, metric_settings: {}) + # Generally, here a Store would validate that the settings passed in are valid, + # and raise if they aren't. + validate_metric_settings(metric_type: metric_type, + metric_settings: metric_settings) + MetricStore.new(store: self, + metric_name: metric_name, + metric_type: metric_type, + metric_settings: metric_settings) + end + + + # MetricStore manages the data for one specific metric. It's generally a view onto + # the central store shared by all metrics, but it could also hold the data itself + # if that's better for the specific scenario + class MetricStore + # This constructor is internal to this store, so the signature doesn't need to + # be this. No one other than the Store should be creating MetricStores + def initialize(store:, metric_name:, metric_type:, metric_settings:) + end + + # Metrics may need to modify multiple values at once (Histograms do this, for + # example). MetricStore needs to provide a way to synchronize those, in addition + # to all of the value modifications being thread-safe without a need for simple + # Metrics to call `synchronize` + def synchronize + raise NotImplementedError + end + + # Store a value for this metric and a set of labels + # Internally, may add extra "labels" to disambiguate values between, + # for example, different processes + def set(labels:, val:) + raise NotImplementedError + end + + def increment(labels:, by: 1) + raise NotImplementedError + end + + # Return a value for a set of labels + # Will return the same value stored by `set`, as opposed to `all_values`, which + # may aggregate multiple values. + # + # For example, in a multi-process scenario, `set` may add an extra internal + # label tagging the value with the process id. `get` will return the value for + # "this" process ID. `all_values` will return an aggregated value for all + # process IDs. + def get(labels:) + raise NotImplementedError + end + + # Returns all the sets of labels seen by the Store, and the aggregated value for + # each. + # + # In some cases, this is just a matter of returning the stored value. + # + # In other cases, the store may need to aggregate multiple values for the same + # set of labels. For example, in a multiple process it may need to `sum` the + # values of counters from each process. Or for `gauges`, it may need to take the + # `max`. This is generally specified in `metric_settings` when calling + # `Store#for_metric`. + def all_values + raise NotImplementedError + end + end + end + end + end +end +``` + +## Conventions + +- Your store MAY require or accept extra settings for each metric on the call to `for_metric`. +- You SHOULD validate these parameters to make sure they are correct, and raise if they aren't. +- If your store needs to aggregate multiple values for the same metric (for example, in + a multi-process scenario), you MUST accept a setting to define how values are aggregated. + - This setting MUST be called `:aggregation` + - It MUST support, at least, `:sum`, `:max` and `:min`. + - It MAY support other aggregation modes that may apply to your requirements. + - It MUST default to `:sum` + +## Testing your Store + +In order to make it easier to test your store, the basic functionality is tested using +`shared_examples`: + +`it_behaves_like Prometheus::Client::DataStores` + +Follow the simple structure in `synchronized_spec.rb` for a starting point. + +Note that if your store stores data somewhere other than in-memory (in files, Redis, +databases, etc), you will need to do cleanup between tests in a `before` block. + +The tests for `DirectFileStore` have a good example at the top of the file. This file also +has some examples on testing multi-process stores, checking that aggregation between +processes works correctly. + +## Sample, imaginary multi-process Data Store + +This is just an example of how one could implement a data store, and a clarification on +the "aggregation" point + +Important: This is **VAPORWARE**, intended simply to show how this could work / how to +implement these interfaces. + +There are some key pieces of code missing, which are fairly uninteresting, this only shows +the parts that illustrate the idea of storing multiple different values, and aggregate +them + +```ruby +module Prometheus + module Client + module DataStores + # Stores all the data in a magic data structure that keeps cross-process data, in a + # way that all processes can read it, but each can write only to their own set of + # keys. + # It doesn't care how that works, this is not an actual solution to anything, + # just an example of how the interface would work with something like that. + # + # Metric Settings have one possible key, `aggregation`, which must be one of + # `AGGREGATION_MODES` + class SampleMagicMultiprocessStore + AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum] + DEFAULT_METRIC_SETTINGS = { aggregation: SUM } + + def initialize + @internal_store = MagicHashSharedBetweenProcesses.new # PStore, for example + end + + def for_metric(metric_name, metric_type:, metric_settings: {}) + settings = DEFAULT_METRIC_SETTINGS.merge(metric_settings) + validate_metric_settings(metric_settings: settings) + MetricStore.new(store: self, + metric_name: metric_name, + metric_type: metric_type, + metric_settings: settings) + end + + private + + def validate_metric_settings(metric_settings:) + raise unless metric_settings.has_key?(:aggregation) + raise unless metric_settings[:aggregation].in?(AGGREGATION_MODES) + end + + class MetricStore + def initialize(store:, metric_name:, metric_type:, metric_settings:) + @store = store + @internal_store = store.internal_store + @metric_name = metric_name + @aggregation_mode = metric_settings[:aggregation] + end + + def set(labels:, val:) + @internal_store[store_key(labels)] = val.to_f + end + + def get(labels:) + @internal_store[store_key(labels)] + end + + def all_values + non_aggregated_values = all_store_values.each_with_object({}) do |(labels, v), acc| + if labels["__metric_name"] == @metric_name + label_set = labels.reject { |k,_| k.in?("__metric_name", "__pid") } + acc[label_set] ||= [] + acc[label_set] << v + end + end + + # Aggregate all the different values for each label_set + non_aggregated_values.each_with_object({}) do |(label_set, values), acc| + acc[label_set] = aggregate(values) + end + end + + private + + def all_store_values + # This assumes there's a something common that all processes can write to, and + # it's magically synchronized (which is not true of a PStore, for example, but + # would of some sort of external data store like Redis, Memcached, SQLite) + + # This could also have some sort of: + # file_list = Dir.glob(File.join(path, '*.db')).sort + # which reads all the PStore files / MMapped files, etc, and returns a hash + # with all of them together, which then `values` and `label_sets` can use + end + + # This method holds most of the key to how this Store works. Adding `_pid` as + # one of the labels, we hold each process's value separately, which we can + # aggregate later + def store_key(labels) + labels.merge( + { + "__metric_name" => @metric_name, + "__pid" => Process.pid + } + ) + end + + def aggregate(values) + # This is a horrible way to do this, just illustrating the point + values.send(@aggregation_mode) + end + end + end + end + end +end +``` diff --git a/lib/prometheus/client/data_stores/synchronized.rb b/lib/prometheus/client/data_stores/synchronized.rb new file mode 100644 index 00000000..17e2b715 --- /dev/null +++ b/lib/prometheus/client/data_stores/synchronized.rb @@ -0,0 +1,64 @@ +require 'concurrent' + +module Prometheus + module Client + module DataStores + # Stores all the data in simple hashes, one per metric. Each of these metrics + # synchronizes access to their hash, but multiple metrics can run observations + # concurrently. + class Synchronized + class InvalidStoreSettingsError < StandardError; end + + def for_metric(metric_name, metric_type:, metric_settings: {}) + # We don't need `metric_type` or `metric_settings` for this particular store + validate_metric_settings(metric_settings: metric_settings) + MetricStore.new + end + + private + + def validate_metric_settings(metric_settings:) + unless metric_settings.empty? + raise InvalidStoreSettingsError, + "Synchronized doesn't allow any metric_settings" + end + end + + class MetricStore + def initialize + @internal_store = Hash.new { |hash, key| hash[key] = 0.0 } + @rwlock = Concurrent::ReentrantReadWriteLock.new + end + + def synchronize + @rwlock.with_write_lock { yield } + end + + def set(labels:, val:) + synchronize do + @internal_store[labels] = val.to_f + end + end + + def increment(labels:, by: 1) + synchronize do + @internal_store[labels] += by + end + end + + def get(labels:) + synchronize do + @internal_store[labels] + end + end + + def all_values + synchronize { @internal_store.dup } + end + end + + private_constant :MetricStore + end + end + end +end diff --git a/lib/prometheus/client/formats/text.rb b/lib/prometheus/client/formats/text.rb index 9db12542..b735389c 100644 --- a/lib/prometheus/client/formats/text.rb +++ b/lib/prometheus/client/formats/text.rb @@ -51,20 +51,20 @@ def representation(metric, label_set, value, &block) def summary(name, set, value) l = labels(set) - yield metric("#{name}_sum", l, value.sum) - yield metric("#{name}_count", l, value.total) + yield metric("#{name}_sum", l, value["sum"]) + yield metric("#{name}_count", l, value["count"]) end def histogram(name, set, value) bucket = "#{name}_bucket" value.each do |q, v| + next if q == "sum" yield metric(bucket, labels(set.merge(le: q)), v) end - yield metric(bucket, labels(set.merge(le: '+Inf')), value.total) l = labels(set) - yield metric("#{name}_sum", l, value.sum) - yield metric("#{name}_count", l, value.total) + yield metric("#{name}_sum", l, value["sum"]) + yield metric("#{name}_count", l, value["+Inf"]) end def metric(name, labels, value) diff --git a/lib/prometheus/client/gauge.rb b/lib/prometheus/client/gauge.rb index 1f24eb78..e0f76521 100644 --- a/lib/prometheus/client/gauge.rb +++ b/lib/prometheus/client/gauge.rb @@ -17,27 +17,21 @@ def set(value, labels: {}) raise ArgumentError, 'value must be a number' end - @values[label_set_for(labels)] = value.to_f + @store.set(labels: label_set_for(labels), val: value) end # Increments Gauge value by 1 or adds the given value to the Gauge. # (The value can be negative, resulting in a decrease of the Gauge.) def increment(by: 1, labels: {}) label_set = label_set_for(labels) - synchronize do - @values[label_set] ||= 0 - @values[label_set] += by - end + @store.increment(labels: label_set, by: by) end # Decrements Gauge value by 1 or subtracts the given value from the Gauge. # (The value can be negative, resulting in a increase of the Gauge.) def decrement(by: 1, labels: {}) label_set = label_set_for(labels) - synchronize do - @values[label_set] ||= 0 - @values[label_set] -= by - end + @store.increment(labels: label_set, by: -by) end end end diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 03f7cec4..236cf3a6 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -8,48 +8,29 @@ module Client # or response sizes) and counts them in configurable buckets. It also # provides a sum of all observed values. class Histogram < Metric - # Value represents the state of a Histogram at a given point. - class Value < Hash - attr_accessor :sum, :total - - def initialize(buckets:) - @sum = 0.0 - @total = 0.0 - - buckets.each do |bucket| - self[bucket] = 0.0 - end - end - - def observe(value) - @sum += value - @total += 1 - - each_key do |bucket| - self[bucket] += 1 if value <= bucket - end - end - end - # DEFAULT_BUCKETS are the default Histogram buckets. The default buckets # are tailored to broadly measure the response time (in seconds) of a # network service. (From DefBuckets client_golang) DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze + attr_reader :buckets + # Offer a way to manually specify buckets def initialize(name, docstring:, labels: [], preset_labels: {}, - buckets: DEFAULT_BUCKETS) - raise ArgumentError, 'Unsorted buckets, typo?' unless sorted? buckets + buckets: DEFAULT_BUCKETS, + store_settings: {}) + raise ArgumentError, 'Unsorted buckets, typo?' unless sorted?(buckets) @buckets = buckets super(name, docstring: docstring, labels: labels, - preset_labels: preset_labels) + preset_labels: preset_labels, + store_settings: store_settings) end def type @@ -57,8 +38,42 @@ def type end def observe(value, labels: {}) - label_set = label_set_for(labels) - synchronize { @values[label_set].observe(value) } + base_label_set = label_set_for(labels) + + @store.synchronize do + buckets.each do |upper_limit| + next if value > upper_limit + @store.increment(labels: base_label_set.merge(le: upper_limit), by: 1) + end + @store.increment(labels: base_label_set.merge(le: "+Inf"), by: 1) + @store.increment(labels: base_label_set.merge(le: "sum"), by: value) + end + end + + # Returns a hash with all the buckets plus +Inf (count) plus Sum for the given label set + def get(labels: {}) + base_label_set = label_set_for(labels) + + all_buckets = buckets + ["+Inf", "sum"] + + @store.synchronize do + all_buckets.each_with_object({}) do |upper_limit, acc| + acc[upper_limit] = @store.get(labels: base_label_set.merge(le: upper_limit)) + end.tap do |acc| + acc["count"] = acc["+Inf"] + end + end + end + + # Returns all label sets with their values expressed as hashes with their buckets + def values + v = @store.all_values + + v.each_with_object({}) do |(label_set, v), acc| + actual_label_set = label_set.reject{|l| l == :le } + acc[actual_label_set] ||= @buckets.map{|b| [b, 0.0]}.to_h + acc[actual_label_set][label_set[:le]] = v + end end private @@ -67,10 +82,6 @@ def reserved_labels [:le] end - def default - Value.new(buckets: @buckets) - end - def sorted?(bucket) bucket.each_cons(2).all? { |i, j| i <= j } end diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index eafce142..d89069f1 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -9,35 +9,39 @@ module Client class Metric attr_reader :name, :docstring, :preset_labels - def initialize(name, docstring:, labels: [], preset_labels: {}) - @mutex = Mutex.new - @validator = LabelSetValidator.new(expected_labels: labels, - reserved_labels: reserved_labels) - @values = Hash.new { |hash, key| hash[key] = default } + def initialize(name, + docstring:, + labels: [], + preset_labels: {}, + store_settings: {}) validate_name(name) validate_docstring(docstring) + @validator = LabelSetValidator.new(expected_labels: labels, + reserved_labels: reserved_labels) @validator.valid?(labels) @validator.valid?(preset_labels) @name = name @docstring = docstring @preset_labels = preset_labels + + @store = Prometheus::Client.config.data_store.for_metric( + name, + metric_type: type, + metric_settings: store_settings + ) end # Returns the value for the given label set def get(labels: {}) label_set = label_set_for(labels) - @values[label_set] + @store.get(labels: label_set) end # Returns all label sets with their values def values - synchronize do - @values.each_with_object({}) do |(labels, value), memo| - memo[labels] = value - end - end + @store.all_values end private @@ -46,10 +50,6 @@ def reserved_labels [] end - def default - nil - end - def validate_name(name) unless name.is_a?(Symbol) raise ArgumentError, 'metric name must be a symbol' @@ -69,10 +69,6 @@ def validate_docstring(docstring) def label_set_for(labels) @validator.validate(preset_labels.merge(labels)) end - - def synchronize - @mutex.synchronize { yield } - end end end end diff --git a/lib/prometheus/client/registry.rb b/lib/prometheus/client/registry.rb index 58893b3c..6f13b1e0 100644 --- a/lib/prometheus/client/registry.rb +++ b/lib/prometheus/client/registry.rb @@ -37,34 +37,39 @@ def unregister(name) end end - def counter(name, docstring:, labels: [], preset_labels: {}) + def counter(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) register(Counter.new(name, docstring: docstring, labels: labels, - preset_labels: preset_labels)) + preset_labels: preset_labels, + store_settings: {})) end - def summary(name, docstring:, labels: [], preset_labels: {}) + def summary(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) register(Summary.new(name, docstring: docstring, labels: labels, - preset_labels: preset_labels)) + preset_labels: preset_labels, + store_settings: {})) end - def gauge(name, docstring:, labels: [], preset_labels: {}) + def gauge(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) register(Gauge.new(name, docstring: docstring, labels: labels, - preset_labels: preset_labels)) + preset_labels: preset_labels, + store_settings: {})) end def histogram(name, docstring:, labels: [], preset_labels: {}, - buckets: Histogram::DEFAULT_BUCKETS) + buckets: Histogram::DEFAULT_BUCKETS, + store_settings: {}) register(Histogram.new(name, docstring: docstring, labels: labels, preset_labels: preset_labels, - buckets: buckets)) + buckets: buckets, + store_settings: {})) end def exist?(name) diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index 066ab0e8..9f65faa8 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -7,29 +7,42 @@ module Client # Summary is an accumulator for samples. It captures Numeric data and # provides the total count and sum of observations. class Summary < Metric - # Value represents the state of a Summary at a given point. - class Value - attr_accessor :sum, :total + def type + :summary + end - def initialize - @sum = 0.0 - @total = 0.0 - end + # Records a given value. + def observe(value, labels: {}) + base_label_set = label_set_for(labels) - def observe(value) - @sum += value - @total += 1 + @store.synchronize do + @store.increment(labels: base_label_set.merge(quantile: "count"), by: 1) + @store.increment(labels: base_label_set.merge(quantile: "sum"), by: value) end end - def type - :summary + # Returns a hash with "sum" and "count" as keys + def get(labels: {}) + base_label_set = label_set_for(labels) + + internal_counters = ["count", "sum"] + + @store.synchronize do + internal_counters.each_with_object({}) do |counter, acc| + acc[counter] = @store.get(labels: base_label_set.merge(quantile: counter)) + end + end end - # Records a given value. - def observe(value, labels: {}) - label_set = label_set_for(labels) - synchronize { @values[label_set].observe(value) } + # Returns all label sets with their values expressed as hashes with their sum/count + def values + v = @store.all_values + + v.each_with_object({}) do |(label_set, v), acc| + actual_label_set = label_set.reject{|l| l == :quantile } + acc[actual_label_set] ||= { "count" => 0.0, "sum" => 0.0 } + acc[actual_label_set][label_set[:quantile]] = v + end end private @@ -37,10 +50,6 @@ def observe(value, labels: {}) def reserved_labels [:quantile] end - - def default - Value.new - end end end end diff --git a/spec/examples/data_store_example.rb b/spec/examples/data_store_example.rb new file mode 100644 index 00000000..23e76d3d --- /dev/null +++ b/spec/examples/data_store_example.rb @@ -0,0 +1,58 @@ +# encoding: UTF-8 + +shared_examples_for Prometheus::Client::DataStores do + describe "MetricStore#set and #get" do + it "returns the value set for each labelset" do + metric_store.set(labels: { foo: "bar" }, val: 5) + metric_store.set(labels: { foo: "baz" }, val: 2) + expect(metric_store.get(labels: { foo: "bar" })).to eq(5) + expect(metric_store.get(labels: { foo: "baz" })).to eq(2) + expect(metric_store.get(labels: { foo: "bat" })).to eq(0) + end + end + + describe "MetricStore#increment" do + it "returns the value set for each labelset" do + metric_store.set(labels: { foo: "bar" }, val: 5) + metric_store.set(labels: { foo: "baz" }, val: 2) + + metric_store.increment(labels: { foo: "bar" }) + metric_store.increment(labels: { foo: "baz" }, by: 7) + metric_store.increment(labels: { foo: "zzz" }, by: 3) + + expect(metric_store.get(labels: { foo: "bar" })).to eq(6) + expect(metric_store.get(labels: { foo: "baz" })).to eq(9) + expect(metric_store.get(labels: { foo: "zzz" })).to eq(3) + end + end + + describe "MetricStore#synchronize" do + # I'm not sure it's possible to actually test that this synchronizes, but at least + # it should run the passed block + it "accepts a block and runs it" do + a = 0 + metric_store.synchronize{ a += 1 } + expect(a).to eq(1) + end + + # This is just a safety check that we're not getting "nested transaction" issues + it "allows modifying the store while in synchronized block" do + metric_store.synchronize do + metric_store.increment(labels: { foo: "bar" }) + metric_store.increment(labels: { foo: "baz" }) + end + end + end + + describe "MetricStore#all_values" do + it "returns all specified labelsets, with their associated value" do + metric_store.set(labels: { foo: "bar" }, val: 5) + metric_store.set(labels: { foo: "baz" }, val: 2) + + expect(metric_store.all_values).to eq( + { foo: "bar" } => 5.0, + { foo: "baz" } => 2.0, + ) + end + end +end diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 8e5a47d1..29164cac 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -1,9 +1,15 @@ # encoding: UTF-8 +require 'prometheus/client' require 'prometheus/client/counter' require 'examples/metric_example' describe Prometheus::Client::Counter do + # Reset the data store + before do + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::Synchronized.new + end + let(:expected_labels) { [] } let(:counter) do diff --git a/spec/prometheus/client/data_stores/synchronized_spec.rb b/spec/prometheus/client/data_stores/synchronized_spec.rb new file mode 100644 index 00000000..b69bdfa2 --- /dev/null +++ b/spec/prometheus/client/data_stores/synchronized_spec.rb @@ -0,0 +1,19 @@ +# encoding: UTF-8 + +require 'prometheus/client/data_stores/synchronized' +require 'examples/data_store_example' + +describe Prometheus::Client::DataStores::Synchronized do + subject { described_class.new } + let(:metric_store) { subject.for_metric(:metric_name, metric_type: :counter) } + + it_behaves_like Prometheus::Client::DataStores + + it "does not accept Metric Settings" do + expect do + subject.for_metric(:metric_name, + metric_type: :counter, + metric_settings: { some_setting: true }) + end.to raise_error(Prometheus::Client::DataStores::Synchronized::InvalidStoreSettingsError) + end +end diff --git a/spec/prometheus/client/formats/text_spec.rb b/spec/prometheus/client/formats/text_spec.rb index 0150fdf9..147f7d9a 100644 --- a/spec/prometheus/client/formats/text_spec.rb +++ b/spec/prometheus/client/formats/text_spec.rb @@ -4,13 +4,11 @@ describe Prometheus::Client::Formats::Text do let(:summary_value) do - Struct.new(:sum, :total).new(1243.21, 93.0) + { "count" => 93.0, "sum" => 1243.21 } end let(:histogram_value) do - { 10 => 1.0, 20 => 2.0, 30 => 2.0 }.tap do |value| - allow(value).to receive_messages(sum: 15.2, total: 2.0) - end + { 10 => 1.0, 20 => 2.0, 30 => 2.0, "+Inf" => 2.0, "sum" => 15.2 } end let(:registry) do diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index 14211c50..d8518956 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -1,9 +1,15 @@ # encoding: UTF-8 +require 'prometheus/client' require 'prometheus/client/gauge' require 'examples/metric_example' describe Prometheus::Client::Gauge do + # Reset the data store + before do + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::Synchronized.new + end + let(:expected_labels) { [] } let(:gauge) do @@ -13,14 +19,14 @@ end it_behaves_like Prometheus::Client::Metric do - let(:type) { NilClass } + let(:type) { Float } end describe '#set' do it 'sets a metric value' do expect do gauge.set(42) - end.to change { gauge.get }.from(nil).to(42) + end.to change { gauge.get }.from(0).to(42) end it 'raises an InvalidLabelSetError if sending unexpected labels' do @@ -36,7 +42,7 @@ expect do expect do gauge.set(42, labels: { test: 'value' }) - end.to change { gauge.get(labels: { test: 'value' }) }.from(nil).to(42) + end.to change { gauge.get(labels: { test: 'value' }) }.from(0).to(42) end.to_not change { gauge.get(labels: { test: 'other' }) } end end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index 32c329ac..b4472684 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -1,9 +1,15 @@ # encoding: UTF-8 +require 'prometheus/client' require 'prometheus/client/histogram' require 'examples/metric_example' describe Prometheus::Client::Histogram do + # Reset the data store + before do + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::Synchronized.new + end + let(:expected_labels) { [] } let(:histogram) do @@ -75,18 +81,22 @@ it 'returns a set of buckets values' do expect(histogram.get(labels: { foo: 'bar' })) - .to eql(2.5 => 0.0, 5 => 2.0, 10 => 3.0) + .to eql( + 2.5 => 0.0, 5 => 2.0, 10 => 3.0, "+Inf" => 4.0, "count" => 4.0, "sum" => 25.2 + ) end - it 'returns a value which responds to #sum and #total' do + it 'returns a value which includes sum and count' do value = histogram.get(labels: { foo: 'bar' }) - expect(value.sum).to eql(25.2) - expect(value.total).to eql(4.0) + expect(value["sum"]).to eql(25.2) + expect(value["count"]).to eql(4.0) end it 'uses zero as default value' do - expect(histogram.get(labels: { foo: '' })).to eql(2.5 => 0.0, 5 => 0.0, 10 => 0.0) + expect(histogram.get(labels: { foo: '' })).to eql( + 2.5 => 0.0, 5 => 0.0, 10 => 0.0, "+Inf" => 0.0, "count" => 0.0, "sum" => 0.0 + ) end end @@ -98,8 +108,8 @@ histogram.observe(6, labels: { status: 'foo' }) expect(histogram.values).to eql( - { status: 'bar' } => { 2.5 => 0.0, 5 => 1.0, 10 => 1.0 }, - { status: 'foo' } => { 2.5 => 0.0, 5 => 0.0, 10 => 1.0 }, + { status: 'bar' } => { 2.5 => 0.0, 5 => 1.0, 10 => 1.0, "+Inf" => 1.0, "sum" => 3.0 }, + { status: 'foo' } => { 2.5 => 0.0, 5 => 0.0, 10 => 1.0, "+Inf" => 1.0, "sum" => 6.0 }, ) end end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index 740a4d23..e01fa632 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -1,9 +1,15 @@ # encoding: UTF-8 +require 'prometheus/client' require 'prometheus/client/summary' require 'examples/metric_example' describe Prometheus::Client::Summary do + # Reset the data store + before do + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::Synchronized.new + end + let(:expected_labels) { [] } let(:summary) do @@ -13,7 +19,7 @@ end it_behaves_like Prometheus::Client::Metric do - let(:type) { Prometheus::Client::Summary::Value } + let(:type) { Hash } end describe '#initialization' do @@ -27,10 +33,10 @@ describe '#observe' do it 'records the given value' do expect do - expect do - summary.observe(5) - end.to change { summary.get.sum }.from(0.0).to(5.0) - end.to change { summary.get.total }.from(0.0).to(1.0) + summary.observe(5) + end.to change { summary.get }. + from({ "count" => 0.0, "sum" => 0.0 }). + to({ "count" => 1.0, "sum" => 5.0 }) end it 'raise error for quantile labels' do @@ -52,8 +58,8 @@ expect do expect do summary.observe(5, labels: { test: 'value' }) - end.to change { summary.get(labels: { test: 'value' }).total } - end.to_not change { summary.get(labels: { test: 'other' }).total } + end.to change { summary.get(labels: { test: 'value' })["count"] } + end.to_not change { summary.get(labels: { test: 'other' })["count"] } end end end @@ -69,10 +75,8 @@ end it 'returns a value which responds to #sum and #total' do - value = summary.get(labels: { foo: 'bar' }) - - expect(value.sum).to eql(25.2) - expect(value.total).to eql(4.0) + expect(summary.get(labels: { foo: 'bar' })). + to eql({ "count" => 4.0, "sum" => 25.2 }) end end @@ -83,8 +87,10 @@ summary.observe(3, labels: { status: 'bar' }) summary.observe(5, labels: { status: 'foo' }) - expect(summary.values[{ status: 'bar' }].sum).to eql(3.0) - expect(summary.values[{ status: 'foo' }].sum).to eql(5.0) + expect(summary.values).to eql( + { status: 'bar' } => { "count" => 1.0, "sum" => 3.0 }, + { status: 'foo' } => { "count" => 1.0, "sum" => 5.0 }, + ) end end end diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index bbebd8fb..87ce2b60 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -6,6 +6,11 @@ describe Prometheus::Middleware::Collector do include Rack::Test::Methods + # Reset the data store + before do + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::Synchronized.new + end + let(:registry) do Prometheus::Client::Registry.new end From 3178104710a912f282d0b13122483cb4d555a65f Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 23 Oct 2018 17:43:26 +0100 Subject: [PATCH 020/189] Add `DataStores::SingleThreaded` This is the simplest possible store we can have, which simply has a hash for each metric, and no synchronization code whatsoever. It can only be used in single-threaded, single-process scenarios, but for those, it should be the fastest option. Signed-off-by: Daniel Magliola --- .../client/data_stores/single_threaded.rb | 58 +++++++++++++++++++ .../data_stores/single_threaded_spec.rb | 19 ++++++ 2 files changed, 77 insertions(+) create mode 100644 lib/prometheus/client/data_stores/single_threaded.rb create mode 100644 spec/prometheus/client/data_stores/single_threaded_spec.rb diff --git a/lib/prometheus/client/data_stores/single_threaded.rb b/lib/prometheus/client/data_stores/single_threaded.rb new file mode 100644 index 00000000..e4cb6a06 --- /dev/null +++ b/lib/prometheus/client/data_stores/single_threaded.rb @@ -0,0 +1,58 @@ +require 'concurrent' + +module Prometheus + module Client + module DataStores + # Stores all the data in a simple Hash for each Metric + # + # Has *no* synchronization primitives, making it the fastest store for single-threaded + # scenarios, but must absolutely not be used in multi-threaded scenarios. + class SingleThreaded + class InvalidStoreSettingsError < StandardError; end + + def for_metric(metric_name, metric_type:, metric_settings: {}) + # We don't need `metric_type` or `metric_settings` for this particular store + validate_metric_settings(metric_settings: metric_settings) + MetricStore.new + end + + private + + def validate_metric_settings(metric_settings:) + unless metric_settings.empty? + raise InvalidStoreSettingsError, + "SingleThreaded doesn't allow any metric_settings" + end + end + + class MetricStore + def initialize + @internal_store = Hash.new { |hash, key| hash[key] = 0.0 } + end + + def synchronize + yield + end + + def set(labels:, val:) + @internal_store[labels] = val.to_f + end + + def increment(labels:, by: 1) + @internal_store[labels] += by + end + + def get(labels:) + @internal_store[labels] + end + + def all_values + @internal_store.dup + end + end + + private_constant :MetricStore + end + end + end +end diff --git a/spec/prometheus/client/data_stores/single_threaded_spec.rb b/spec/prometheus/client/data_stores/single_threaded_spec.rb new file mode 100644 index 00000000..681eeb1e --- /dev/null +++ b/spec/prometheus/client/data_stores/single_threaded_spec.rb @@ -0,0 +1,19 @@ +# encoding: UTF-8 + +require 'prometheus/client/data_stores/single_threaded' +require 'examples/data_store_example' + +describe Prometheus::Client::DataStores::SingleThreaded do + subject { described_class.new } + let(:metric_store) { subject.for_metric(:metric_name, metric_type: :counter) } + + it_behaves_like Prometheus::Client::DataStores + + it "does not accept Metric Settings" do + expect do + subject.for_metric(:metric_name, + metric_type: :counter, + metric_settings: { some_setting: true }) + end.to raise_error(Prometheus::Client::DataStores::SingleThreaded::InvalidStoreSettingsError) + end +end From 5917ad849a46e9805297741df830b3d799323ab5 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 13 Nov 2018 15:52:53 +0000 Subject: [PATCH 021/189] Add `DataStores::DirectFileStore` This store keeps all its data in files, one file per process and per metric. These files have binary data mapping keys to Floats, and they're read/written at the specific offsets of these Floats. This is generally the recommended store to use to deal with pre-fork servers and other "multi-process" scenarios, at least until we crack `mmap`. This seems to be, for now, the fastest possible way to safely share data between all the processes. Because we never actually `fsync`, we never actually touch the disk, and FS caching makes this extremely fast. Almost as fast as the `mmaps` without all the added risk, or the burden of the C extensions. Each process gets their own file for a metric. When a Prometheus scrape is received in one of the processes, it finds all the files for a metric, reads their values and aggregates them. We use `flock` to guarantee consistency. Signed-off-by: Daniel Magliola --- .../client/data_stores/direct_file_store.rb | 313 ++++++++++++++++++ .../data_stores/direct_file_store_spec.rb | 169 ++++++++++ 2 files changed, 482 insertions(+) create mode 100644 lib/prometheus/client/data_stores/direct_file_store.rb create mode 100644 spec/prometheus/client/data_stores/direct_file_store_spec.rb diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb new file mode 100644 index 00000000..62e72310 --- /dev/null +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -0,0 +1,313 @@ +require 'concurrent' +require 'fileutils' +require "cgi" + +module Prometheus + module Client + module DataStores + # Stores data in binary files, one file per process and per metric. + # This is generally the recommended store to use to deal with pre-fork servers and + # other "multi-process" scenarios. + # + # Each process will get a file for a metric, and it will manage its contents by + # storing keys next to binary-encoded Floats, and keeping track of the offsets of + # those Floats, to be able to update them directly as they increase. + # + # When exporting metrics, the process that gets scraped by Prometheus will find + # all the files that apply to a metric, read their contents, and aggregate them + # (generally that means SUMming the values for each labelset). + # + # In order to do this, each Metric needs an `:aggregation` setting, specifying how + # to aggregate the multiple possible values we can get for each labelset. By default, + # they are `SUM`med, which is what most use cases call for (counters and histograms, + # for example). + # However, for Gauges, it's possible to set `MAX` or `MIN` as aggregation, to get + # the highest value of all the processes / threads. + + class DirectFileStore + class InvalidStoreSettingsError < StandardError; end + AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum] + DEFAULT_METRIC_SETTINGS = { aggregation: SUM } + + def initialize(dir:) + @store_settings = { dir: dir } + FileUtils.mkdir_p(dir) + end + + def for_metric(metric_name, metric_type:, metric_settings: {}) + settings = DEFAULT_METRIC_SETTINGS.merge(metric_settings) + validate_metric_settings(settings) + + MetricStore.new(metric_name: metric_name, + store_settings: @store_settings, + metric_settings: settings) + end + + private + + def validate_metric_settings(metric_settings) + unless metric_settings.has_key?(:aggregation) && + AGGREGATION_MODES.include?(metric_settings[:aggregation]) + raise InvalidStoreSettingsError, + "Metrics need a valid :aggregation key" + end + + unless (metric_settings.keys - [:aggregation]).empty? + raise InvalidStoreSettingsError, + "Only :aggregation setting can be specified" + end + end + + class MetricStore + attr_reader :metric_name, :store_settings + + def initialize(metric_name:, store_settings:, metric_settings:) + @metric_name = metric_name + @store_settings = store_settings + @values_aggregation_mode = metric_settings[:aggregation] + + @rwlock = Concurrent::ReentrantReadWriteLock.new + end + + # Synchronize is used to do a multi-process Mutex, when incrementing multiple + # values at once, so that the other process, reading the file for export, doesn't + # get incomplete increments. + # + # `in_process_sync`, instead, is just used so that two threads don't increment + # the same value and get a context switch between read and write leading to an + # inconsistency + def synchronize + in_process_sync do + internal_store.with_file_lock do + yield + end + end + end + + def set(labels:, val:) + in_process_sync do + internal_store.write_value(store_key(labels), val.to_f) + end + end + + def increment(labels:, by: 1) + key = store_key(labels) + in_process_sync do + value = internal_store.read_value(key) + internal_store.write_value(key, value + by.to_f) + end + end + + def get(labels:) + in_process_sync do + internal_store.read_value(store_key(labels)) + end + end + + def all_values + stores_data = Hash.new{ |hash, key| hash[key] = [] } + + # There's no need to call `synchronize` here. We're opening a second handle to + # the file, and `flock`ing it, which prevents inconsistent reads + stores_for_metric.each do |file_path| + begin + store = FileMappedDict.new(file_path, true) + store.all_values.each do |(labelset_qs, v)| + # Labels come as a query string, and CGI::parse returns arrays for each key + # "foo=bar&x=y" => { "foo" => ["bar"], "x" => ["y"] } + # Turn the keys back into symbols, and remove the arrays + label_set = CGI::parse(labelset_qs).map do |k, vs| + [k.to_sym, vs.first] + end.to_h + + stores_data[label_set] << v + end + ensure + store.close if store + end + end + + # Aggregate all the different values for each label_set + stores_data.each_with_object({}) do |(label_set, values), acc| + acc[label_set] = aggregate_values(values) + end + end + + private + + def in_process_sync + @rwlock.with_write_lock { yield } + end + + def store_key(labels) + labels.map{|k,v| "#{CGI::escape(k.to_s)}=#{CGI::escape(v.to_s)}"}.join('&') + end + + def internal_store + @internal_store ||= FileMappedDict.new(filemap_filename) + end + + # Filename for this metric's PStore (one per process) + def filemap_filename + filename = "metric_#{ metric_name }___#{ process_id }.bin" + File.join(@store_settings[:dir], filename) + end + + def stores_for_metric + Dir.glob(File.join(@store_settings[:dir], "metric_#{ metric_name }___*")) + end + + def process_id + Process.pid + end + + def aggregate_values(values) + if @values_aggregation_mode == SUM + values.inject { |sum, element| sum + element } + elsif @values_aggregation_mode == MAX + values.max + elsif @values_aggregation_mode == MIN + values.min + else + raise InvalidStoreSettingsError, + "Invalid Aggregation Mode: #{ @values_aggregation_mode }" + end + end + end + + private_constant :MetricStore + + # A dict of doubles, backed by an file we access directly a a byte array. + # + # The file starts with a 4 byte int, indicating how much of it is used. + # Then 4 bytes of padding. + # There's then a number of entries, consisting of a 4 byte int which is the + # size of the next field, a utf-8 encoded string key, padding to an 8 byte + # alignment, and then a 8 byte float which is the value. + class FileMappedDict + INITIAL_FILE_SIZE = 1024*1024 + + attr_reader :capacity, :used, :positions + + def initialize(filename, readonly = false) + @positions = {} + @used = 0 + + open_file(filename, readonly) + @used = @f.read(4).unpack('l')[0] if @capacity > 0 + + if @used > 0 + # File already has data. Read the existing values + with_file_lock do + read_all_values.each do |key, _, pos| + @positions[key] = pos + end + end + else + # File is empty. Init the `used` counter, if we're in write mode + if !readonly + @used = 8 + @f.seek(0) + @f.write([@used].pack('l')) + end + end + end + + # Yield (key, value, pos). No locking is performed. + def all_values + with_file_lock do + read_all_values.map { |k, v, p| [k, v] } + end + end + + def read_value(key) + if !@positions.has_key?(key) + init_value(key) + end + + pos = @positions[key] + @f.seek(pos) + @f.read(8).unpack('d')[0] + end + + def write_value(key, value) + if !@positions.has_key?(key) + init_value(key) + end + + pos = @positions[key] + @f.seek(pos) + @f.write([value].pack('d')) + @f.flush + end + + def close + @f.close + end + + def with_file_lock + @f.flock(File::LOCK_EX) + yield + ensure + @f.flock(File::LOCK_UN) + end + + private + + def open_file(filename, readonly) + mode = if readonly + "r" + elsif File.exist?(filename) + "r+b" + else + "w+b" + end + + @f = File.open(filename, mode) + if @f.size == 0 && !readonly + resize_file(INITIAL_FILE_SIZE) + end + @capacity = @f.size + end + + def resize_file(new_capacity) + @f.truncate(new_capacity) + end + + # Initialize a value. Lock must be held by caller. + def init_value(key) + # Pad to be 8-byte aligned. + padded = key + (' ' * (8 - (key.length + 4) % 8)) + value = [padded.length, padded, 0.0].pack("lA#{padded.length}d") + while @used + value.length > @capacity + @capacity *= 2 + resize_file(@capacity) + end + @f.seek(@used) + @f.write(value) + @used += value.length + @f.seek(0) + @f.write([@used].pack('l')) + @f.flush + @positions[key] = @used - 8 + end + + # Yield (key, value, pos). No locking is performed. + def read_all_values + @f.seek(8) + values = [] + while @f.pos < @used + padded_len = @f.read(4).unpack('l')[0] + encoded = @f.read(padded_len).unpack("A#{padded_len}")[0] + value = @f.read(8).unpack('d')[0] + values << [encoded.strip, value, @f.pos - 8] + end + values + end + end + end + end + end +end + + diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb new file mode 100644 index 00000000..fa1d335b --- /dev/null +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -0,0 +1,169 @@ +# encoding: UTF-8 + +require 'prometheus/client/data_stores/direct_file_store' +require 'examples/data_store_example' + +describe Prometheus::Client::DataStores::DirectFileStore do + subject { described_class.new(dir: "/tmp/prometheus_test") } + let(:metric_store) { subject.for_metric(:metric_name, metric_type: :counter) } + + # Reset the PStores + before do + Dir.glob('/tmp/prometheus_test/*').each { |file| File.delete(file) } + end + + it_behaves_like Prometheus::Client::DataStores + + it "only accepts valid :aggregation as Metric Settings" do + expect do + subject.for_metric(:metric_name, + metric_type: :counter, + metric_settings: { aggregation: Prometheus::Client::DataStores::DirectFileStore::SUM }) + end.not_to raise_error + + expect do + subject.for_metric(:metric_name, + metric_type: :counter, + metric_settings: { aggregation: :invalid }) + end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + + expect do + subject.for_metric(:metric_name, + metric_type: :counter, + metric_settings: { some_setting: true }) + end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + end + + it "raises when aggregating if we get to that that point with an invalid aggregation mode" do + # This is basically just for coverage of a safety clause that can never be reached + allow(subject).to receive(:validate_metric_settings) # turn off validation + + metric = subject.for_metric(:metric_name, + metric_type: :counter, + metric_settings: { aggregation: :invalid }) + metric.increment(labels: {}, by: 1) + + expect do + metric.all_values + end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + end + + it "opens the same file twice, if it already exists" do + # Testing this simply for coverage + ms = metric_store + ms.increment(labels: {}, by: 1) + + ms2 = subject.for_metric(:metric_name, metric_type: :counter) + ms2.increment(labels: {}, by: 1) + end + + + it "sums values from different processes" do + allow(Process).to receive(:pid).and_return(12345) + metric_store1 = subject.for_metric(:metric_name, metric_type: :counter) + metric_store1.set(labels: { foo: "bar" }, val: 1) + metric_store1.set(labels: { foo: "baz" }, val: 7) + metric_store1.set(labels: { foo: "yyy" }, val: 3) + + allow(Process).to receive(:pid).and_return(23456) + metric_store2 = subject.for_metric(:metric_name, metric_type: :counter) + metric_store2.set(labels: { foo: "bar" }, val: 3) + metric_store2.set(labels: { foo: "baz" }, val: 2) + metric_store2.set(labels: { foo: "zzz" }, val: 1) + + expect(metric_store2.all_values).to eq( + { foo: "bar" } => 4.0, + { foo: "baz" } => 9.0, + { foo: "yyy" } => 3.0, + { foo: "zzz" } => 1.0, + ) + + # Both processes should return the same value + expect(metric_store1.all_values).to eq(metric_store2.all_values) + end + + context "with a metric that takes MAX instead of SUM" do + it "reports the maximum values from different processes" do + allow(Process).to receive(:pid).and_return(12345) + metric_store1 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :max } + ) + metric_store1.set(labels: { foo: "bar" }, val: 1) + metric_store1.set(labels: { foo: "baz" }, val: 7) + metric_store1.set(labels: { foo: "yyy" }, val: 3) + + allow(Process).to receive(:pid).and_return(23456) + metric_store2 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :max } + ) + metric_store2.set(labels: { foo: "bar" }, val: 3) + metric_store2.set(labels: { foo: "baz" }, val: 2) + metric_store2.set(labels: { foo: "zzz" }, val: 1) + + expect(metric_store1.all_values).to eq( + { foo: "bar" } => 3.0, + { foo: "baz" } => 7.0, + { foo: "yyy" } => 3.0, + { foo: "zzz" } => 1.0, + ) + + # Both processes should return the same value + expect(metric_store1.all_values).to eq(metric_store2.all_values) + end + end + + context "with a metric that takes MIN instead of SUM" do + it "reports the minimum values from different processes" do + allow(Process).to receive(:pid).and_return(12345) + metric_store1 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :min } + ) + metric_store1.set(labels: { foo: "bar" }, val: 1) + metric_store1.set(labels: { foo: "baz" }, val: 7) + metric_store1.set(labels: { foo: "yyy" }, val: 3) + + allow(Process).to receive(:pid).and_return(23456) + metric_store2 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :min } + ) + metric_store2.set(labels: { foo: "bar" }, val: 3) + metric_store2.set(labels: { foo: "baz" }, val: 2) + metric_store2.set(labels: { foo: "zzz" }, val: 1) + + expect(metric_store1.all_values).to eq( + { foo: "bar" } => 1.0, + { foo: "baz" } => 2.0, + { foo: "yyy" } => 3.0, + { foo: "zzz" } => 1.0, + ) + + # Both processes should return the same value + expect(metric_store1.all_values).to eq(metric_store2.all_values) + end + end + + it "resizes the File if metrics get too big" do + truncate_calls_count = 0 + allow_any_instance_of(Prometheus::Client::DataStores::DirectFileStore::FileMappedDict). + to receive(:resize_file).and_wrap_original do |original_method, *args, &block| + + truncate_calls_count += 1 + original_method.call(*args, &block) + end + + really_long_string = "a" * 500_000 + 10.times do |i| + metric_store.set(labels: { foo: "#{ really_long_string }#{ i }" }, val: 1) + end + + expect(truncate_calls_count).to be >= 3 + end +end From 403b31a7e783768810ebd244b06a0feb3c74eada Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 16 Oct 2018 15:34:03 +0100 Subject: [PATCH 022/189] Change `text_spec.rb` to be more like an integration test The existing spec on `Formats::Text` is based on mocking a registry with fake metrics and values inside, which means if anything changes in the interface for metrics, the test will not catch it. And there's no test validating that interface. This test is more realistic, and it actually catches the kind of bugs we introduced in the process of this refactor Signed-off-by: Daniel Magliola --- spec/prometheus/client/formats/text_spec.rb | 96 ++++++++++----------- 1 file changed, 44 insertions(+), 52 deletions(-) diff --git a/spec/prometheus/client/formats/text_spec.rb b/spec/prometheus/client/formats/text_spec.rb index 147f7d9a..d200ec1c 100644 --- a/spec/prometheus/client/formats/text_spec.rb +++ b/spec/prometheus/client/formats/text_spec.rb @@ -1,62 +1,54 @@ # encoding: UTF-8 +require 'prometheus/client' +require 'prometheus/client/registry' require 'prometheus/client/formats/text' describe Prometheus::Client::Formats::Text do - let(:summary_value) do - { "count" => 93.0, "sum" => 1243.21 } + # Reset the data store + before do + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::Synchronized.new end - let(:histogram_value) do - { 10 => 1.0, 20 => 2.0, 30 => 2.0, "+Inf" => 2.0, "sum" => 15.2 } - end + let(:registry) { Prometheus::Client::Registry.new } + + before do + foo = registry.counter(:foo, + docstring: 'foo description', + labels: [:umlauts, :utf, :code], + preset_labels: {umlauts: 'Björn', utf: '佖佥'}) + foo.increment(labels: { code: 'red'}, by: 42) + foo.increment(labels: { code: 'green'}, by: 3.14E42) + foo.increment(labels: { code: 'blue'}, by: 1.23e-45) + + + bar = registry.gauge(:bar, + docstring: "bar description\nwith newline", + labels: [:status, :code]) + bar.set(15, labels: { status: 'success', code: 'pink'}) + + + baz = registry.counter(:baz, + docstring: 'baz "description" \\escaping', + labels: [:text]) + baz.increment(labels: { text: "with \"quotes\", \\escape \n and newline" }, by: 15.0) + + + qux = registry.summary(:qux, + docstring: 'qux description', + labels: [:for, :code], + preset_labels: { for: 'sake', code: '1' }) + 92.times { qux.observe(0) } + qux.observe(1243.21) + - let(:registry) do - metrics = [ - double( - name: :foo, - docstring: 'foo description', - type: :counter, - values: { - { umlauts: 'Björn', utf: '佖佥', code: 'red' } => 42.0, - { umlauts: 'Björn', utf: '佖佥', code: 'green' } => 3.14E42, - { umlauts: 'Björn', utf: '佖佥', code: 'blue' } => -1.23e-45, - }, - ), - double( - name: :bar, - docstring: "bar description\nwith newline", - type: :gauge, - values: { - { status: 'success', code: 'pink' } => 15.0, - }, - ), - double( - name: :baz, - docstring: 'baz "description" \\escaping', - type: :counter, - values: { - { text: "with \"quotes\", \\escape \n and newline" } => 15.0, - }, - ), - double( - name: :qux, - docstring: 'qux description', - type: :summary, - values: { - { for: 'sake', code: '1' } => summary_value, - }, - ), - double( - name: :xuq, - docstring: 'xuq description', - type: :histogram, - values: { - { code: 'ah' } => histogram_value, - }, - ), - ] - double(metrics: metrics) + xuq = registry.histogram(:xuq, + docstring: 'xuq description', + labels: [:code], + preset_labels: {code: 'ah'}, + buckets: [10, 20, 30]) + xuq.observe(12) + xuq.observe(3.2) end describe '.marshal' do @@ -66,7 +58,7 @@ # HELP foo foo description foo{umlauts="Björn",utf="佖佥",code="red"} 42.0 foo{umlauts="Björn",utf="佖佥",code="green"} 3.14e+42 -foo{umlauts="Björn",utf="佖佥",code="blue"} -1.23e-45 +foo{umlauts="Björn",utf="佖佥",code="blue"} 1.23e-45 # TYPE bar gauge # HELP bar bar description\nwith newline bar{status="success",code="pink"} 15.0 From ac9a5149a14421f0800526ff0183adc1ea18fb23 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Thu, 8 Nov 2018 16:23:23 +0000 Subject: [PATCH 023/189] Update Histogram to return buckets as strings In the Text export format, the `le` label is reported as a string. Moreover, some of our stores may coerce Float label values into Strings. This is fine, since Labelsets should be a hash of symbols to strings. It also makes sense since one of those "buckets" will be "+Inf". It's also somewhat unavoidable, since neither the Store nor the Metric should be coercing those back into Floats, so report the buckets as strings instead of Floats. Signed-off-by: Daniel Magliola --- lib/prometheus/client/histogram.rb | 6 +++--- spec/prometheus/client/histogram_spec.rb | 8 ++++---- spec/prometheus/middleware/collector_spec.rb | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 236cf3a6..2ee94e9e 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -58,7 +58,7 @@ def get(labels: {}) @store.synchronize do all_buckets.each_with_object({}) do |upper_limit, acc| - acc[upper_limit] = @store.get(labels: base_label_set.merge(le: upper_limit)) + acc[upper_limit.to_s] = @store.get(labels: base_label_set.merge(le: upper_limit)) end.tap do |acc| acc["count"] = acc["+Inf"] end @@ -71,8 +71,8 @@ def values v.each_with_object({}) do |(label_set, v), acc| actual_label_set = label_set.reject{|l| l == :le } - acc[actual_label_set] ||= @buckets.map{|b| [b, 0.0]}.to_h - acc[actual_label_set][label_set[:le]] = v + acc[actual_label_set] ||= @buckets.map{|b| [b.to_s, 0.0]}.to_h + acc[actual_label_set][label_set[:le].to_s] = v end end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index b4472684..f90bce72 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -82,7 +82,7 @@ it 'returns a set of buckets values' do expect(histogram.get(labels: { foo: 'bar' })) .to eql( - 2.5 => 0.0, 5 => 2.0, 10 => 3.0, "+Inf" => 4.0, "count" => 4.0, "sum" => 25.2 + "2.5" => 0.0, "5" => 2.0, "10" => 3.0, "+Inf" => 4.0, "count" => 4.0, "sum" => 25.2 ) end @@ -95,7 +95,7 @@ it 'uses zero as default value' do expect(histogram.get(labels: { foo: '' })).to eql( - 2.5 => 0.0, 5 => 0.0, 10 => 0.0, "+Inf" => 0.0, "count" => 0.0, "sum" => 0.0 + "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "count" => 0.0, "sum" => 0.0 ) end end @@ -108,8 +108,8 @@ histogram.observe(6, labels: { status: 'foo' }) expect(histogram.values).to eql( - { status: 'bar' } => { 2.5 => 0.0, 5 => 1.0, 10 => 1.0, "+Inf" => 1.0, "sum" => 3.0 }, - { status: 'foo' } => { 2.5 => 0.0, 5 => 0.0, 10 => 1.0, "+Inf" => 1.0, "sum" => 6.0 }, + { status: 'bar' } => { "2.5" => 0.0, "5" => 1.0, "10" => 1.0, "+Inf" => 1.0, "sum" => 3.0 }, + { status: 'foo' } => { "2.5" => 0.0, "5" => 0.0, "10" => 1.0, "+Inf" => 1.0, "sum" => 6.0 }, ) end end diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index 87ce2b60..1e916c72 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -50,7 +50,7 @@ metric = :http_server_request_duration_seconds labels = { method: 'get', path: '/foo' } - expect(registry.get(metric).get(labels: labels)).to include(0.1 => 0, 0.25 => 1) + expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.25" => 1) end it 'normalizes paths containing numeric IDs by default' do @@ -64,7 +64,7 @@ metric = :http_server_request_duration_seconds labels = { method: 'get', path: '/foo/:id/bars' } - expect(registry.get(metric).get(labels: labels)).to include(0.1 => 0, 0.5 => 1) + expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1) end it 'normalizes paths containing UUIDs by default' do @@ -78,7 +78,7 @@ metric = :http_server_request_duration_seconds labels = { method: 'get', path: '/foo/:uuid/bars' } - expect(registry.get(metric).get(labels: labels)).to include(0.1 => 0, 0.5 => 1) + expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1) end context 'when the app raises an exception' do From 190f7706e6dc6827dab53832c6b05a77d8ba63b2 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 9 Nov 2018 13:35:54 +0000 Subject: [PATCH 024/189] Accumulate Histogram Buckets on Export, not Observe In a Histogram, each bucket's count includes also the counts of all buckets smaller than it. The original code for Histograms would handle this at `observe` time, by incrementing all buckets bigger than the observed sample. Since calls to the Data Store may be expensive, and also all these increments need to be done atomically to guarantee the export doesn't have inconsistent values, this can have a big effect on performance. With this change, at `observe` time, we only increase the bucket the sample falls in, and the `sum`, and do the accumulation when reading the Histogram, which makes observations about 30% to 40% faster with a "uniform" distribution of sample. A sample skewed towards smaller buckets should show a much larger performance gain. With this change, we also remove the `count` key from the result of calling `get`. This key is redundant (it's the same value as `+Inf`, and rendering this as `count` is the job of the Text Exporter already, `get` shouldn't be returning it. Signed-off-by: Daniel Magliola --- lib/prometheus/client/histogram.rb | 31 ++++++++++++++++++------ spec/prometheus/client/histogram_spec.rb | 7 +++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 2ee94e9e..25f137da 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -39,13 +39,11 @@ def type def observe(value, labels: {}) base_label_set = label_set_for(labels) + bucket = buckets.find {|upper_limit| upper_limit > value } + bucket = "+Inf" if bucket.nil? @store.synchronize do - buckets.each do |upper_limit| - next if value > upper_limit - @store.increment(labels: base_label_set.merge(le: upper_limit), by: 1) - end - @store.increment(labels: base_label_set.merge(le: "+Inf"), by: 1) + @store.increment(labels: base_label_set.merge(le: bucket.to_s), by: 1) @store.increment(labels: base_label_set.merge(le: "sum"), by: value) end end @@ -58,9 +56,9 @@ def get(labels: {}) @store.synchronize do all_buckets.each_with_object({}) do |upper_limit, acc| - acc[upper_limit.to_s] = @store.get(labels: base_label_set.merge(le: upper_limit)) + acc[upper_limit.to_s] = @store.get(labels: base_label_set.merge(le: upper_limit.to_s)) end.tap do |acc| - acc["count"] = acc["+Inf"] + accumulate_buckets(acc) end end end @@ -69,15 +67,32 @@ def get(labels: {}) def values v = @store.all_values - v.each_with_object({}) do |(label_set, v), acc| + result = v.each_with_object({}) do |(label_set, v), acc| actual_label_set = label_set.reject{|l| l == :le } acc[actual_label_set] ||= @buckets.map{|b| [b.to_s, 0.0]}.to_h acc[actual_label_set][label_set[:le].to_s] = v end + + result.each do |(label_set, v)| + accumulate_buckets(v) + end end private + # Modifies the passed in parameter + def accumulate_buckets(h) + bucket_acc = 0 + buckets.each do |upper_limit| + bucket_value = h[upper_limit.to_s] + h[upper_limit.to_s] += bucket_acc + bucket_acc += bucket_value + end + + inf_value = h["+Inf"] || 0.0 + h["+Inf"] = inf_value + bucket_acc + end + def reserved_labels [:le] end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index f90bce72..85327f7d 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -82,20 +82,19 @@ it 'returns a set of buckets values' do expect(histogram.get(labels: { foo: 'bar' })) .to eql( - "2.5" => 0.0, "5" => 2.0, "10" => 3.0, "+Inf" => 4.0, "count" => 4.0, "sum" => 25.2 + "2.5" => 0.0, "5" => 2.0, "10" => 3.0, "+Inf" => 4.0, "sum" => 25.2 ) end - it 'returns a value which includes sum and count' do + it 'returns a value which includes sum' do value = histogram.get(labels: { foo: 'bar' }) expect(value["sum"]).to eql(25.2) - expect(value["count"]).to eql(4.0) end it 'uses zero as default value' do expect(histogram.get(labels: { foo: '' })).to eql( - "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "count" => 0.0, "sum" => 0.0 + "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 ) end end From 9d39313db55eaf863f9822650112686f20337c3a Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 9 Nov 2018 14:02:03 +0000 Subject: [PATCH 025/189] Improve performance of Histograms Merging the `le` key into the `base_label_set` is where most time is spent, surprisingly. Also somewhat surprisingly, `dup` + setting a key is faster than `.merge`. This version is less pretty, but makes `observe` significantly faster For some stores (the ones that serialize the hash into a string), we could simply set the key without the `dup`, which makes this method about 3x faster. However, this would require calling `dup` into all Hash-based stores (or serializing the Hash into a string), which would make THEM significantly slower. Signed-off-by: Daniel Magliola --- lib/prometheus/client/histogram.rb | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 25f137da..37f83e56 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -38,13 +38,20 @@ def type end def observe(value, labels: {}) - base_label_set = label_set_for(labels) bucket = buckets.find {|upper_limit| upper_limit > value } bucket = "+Inf" if bucket.nil? + base_label_set = label_set_for(labels) + + # This is basically faster than doing `.merge` + bucket_label_set = base_label_set.dup + bucket_label_set[:le] = bucket.to_s + sum_label_set = base_label_set.dup + sum_label_set[:le] = "sum" + @store.synchronize do - @store.increment(labels: base_label_set.merge(le: bucket.to_s), by: 1) - @store.increment(labels: base_label_set.merge(le: "sum"), by: value) + @store.increment(labels: bucket_label_set, by: 1) + @store.increment(labels: sum_label_set, by: value) end end From c1d6126d28de457854d45f7c1e29d5183bb7ebb8 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 16 Oct 2018 15:52:03 +0100 Subject: [PATCH 026/189] Add `Metric#with_labels` method `with_labels` returns a metric object, augmented with some pre-set labels. This is useful to be able to reduce repetition if a certain part of the code is passing the same label over and over again, but the label can have different values in different parts of the codebase (so passing in `preset_labels` when declaring the metric is not appropriate. Signed-off-by: Daniel Magliola --- README.md | 40 ++++++++++++++++++++++++ lib/prometheus/client/histogram.rb | 9 ++++++ lib/prometheus/client/metric.rb | 11 +++++++ spec/prometheus/client/counter_spec.rb | 6 ++++ spec/prometheus/client/gauge_spec.rb | 6 ++++ spec/prometheus/client/histogram_spec.rb | 6 ++++ spec/prometheus/client/summary_spec.rb | 6 ++++ 7 files changed, 84 insertions(+) diff --git a/README.md b/README.md index b3f2e167..0df41d1c 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,47 @@ https_requests_total = Counter.new(:http_requests_total, https_requests_total.increment(labels: { status_code: response.status_code }) ``` +### `with_labels` +Similar to pre-setting labels, you can get a new instance of an existing metric object, +with a subset (or full set) of labels set, so that you can increment / observe the metric +without having to specify the labels for every call. + +Moreover, if all the labels the metric can take have been pre-set, validation of the labels +is done on the call to `with_labels`, and then skipped for each observation, which can +lead to performance improvements. If you are incrementing a counter in a fast loop, you +definitely want to be doing this. + + +Examples: + +**Pre-setting labels for ease of use:** + +```ruby +# in the file where you define your metrics: +records_processed_total = registry.counter.new(:records_processed_total, + docstring: '...', + labels: [:service, :component], + preset_labels: { service: "my_service" }) + +# in one-off calls, you'd specify the missing labels (component in this case) +records_processed_total.increment(labels: { component: 'a_component' }) + +# you can also have a "view" on this metric for a specific component where this label is +# pre-set: +class MyComponent + def metric + @metric ||= records_processed_total.with_labels(component: "my_component") + end + + def process + records.each do |record| + # process the record + metric.increment + end + end +end +``` ## Data Stores diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 37f83e56..893282ef 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -33,6 +33,15 @@ def initialize(name, store_settings: store_settings) end + def with_labels(labels) + self.class.new(name, + docstring: docstring, + labels: @labels, + preset_labels: preset_labels.merge(labels), + buckets: @buckets, + store_settings: @store_settings) + end + def type :histogram end diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index d89069f1..45ed5700 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -22,6 +22,9 @@ def initialize(name, @validator.valid?(labels) @validator.valid?(preset_labels) + @labels = labels + @store_settings = store_settings + @name = name @docstring = docstring @preset_labels = preset_labels @@ -39,6 +42,14 @@ def get(labels: {}) @store.get(labels: label_set) end + def with_labels(labels) + self.class.new(name, + docstring: docstring, + labels: @labels, + preset_labels: preset_labels.merge(labels), + store_settings: @store_settings) + end + # Returns all label sets with their values def values @store.all_values diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 29164cac..806c55b6 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -45,6 +45,12 @@ end.to change { counter.get(labels: { test: 'label' }) }.by(1.0) end.to_not change { counter.get(labels: { test: 'other' }) } end + + it 'can pre-set labels using `with_labels`' do + expect { counter.increment } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { counter.with_labels(test: 'label').increment }.not_to raise_error + end end it 'increments the counter by a given value' do diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index d8518956..9476272f 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -45,6 +45,12 @@ end.to change { gauge.get(labels: { test: 'value' }) }.from(0).to(42) end.to_not change { gauge.get(labels: { test: 'other' }) } end + + it 'can pre-set labels using `with_labels`' do + expect { gauge.set(10) } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { gauge.with_labels(test: 'value').set(10) }.not_to raise_error + end end context 'given an invalid value' do diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index 85327f7d..c64c6218 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -66,6 +66,12 @@ end.to change { histogram.get(labels: { test: 'value' }) } end.to_not change { histogram.get(labels: { test: 'other' }) } end + + it 'can pre-set labels using `with_labels`' do + expect { histogram.observe(2) } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { histogram.with_labels(test: 'value').observe(2) }.not_to raise_error + end end end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index e01fa632..c69f754f 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -61,6 +61,12 @@ end.to change { summary.get(labels: { test: 'value' })["count"] } end.to_not change { summary.get(labels: { test: 'other' })["count"] } end + + it 'can pre-set labels using `with_labels`' do + expect { summary.observe(2) } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { summary.with_labels(test: 'value').observe(2) }.not_to raise_error + end end end From 3862c1cbd92072272f677c9e914ff7f9a7b16f4d Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 16 Oct 2018 15:54:25 +0100 Subject: [PATCH 027/189] Short-circuit label validation on Metric observations Every time a metric gets observed / incremented, we are composing the labels hash, merging the labels that were just passed in as part of the observation with the metric's `preset_labels`, and validating that the labelset is valid. If all labels are pre-set already, however (either as `preset_labels` when declaring the metric, or by use of `with_labels`), we can validate when the labels are pre-set, and then skip the validation on every observation, which can lead to some decent time savings if a metric is observed often. Signed-off-by: Daniel Magliola --- lib/prometheus/client/metric.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index 45ed5700..00c47ee0 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -34,6 +34,11 @@ def initialize(name, metric_type: type, metric_settings: store_settings ) + + if preset_labels.keys.length == labels.length + @validator.validate(preset_labels) + @all_labels_preset = true + end end # Returns the value for the given label set @@ -78,6 +83,8 @@ def validate_docstring(docstring) end def label_set_for(labels) + # We've already validated, and there's nothing to merge. Save some cycles + return preset_labels if @all_labels_preset && labels.empty? @validator.validate(preset_labels.merge(labels)) end end From 2347e2aea1678dbbf57424806eb5e5df15571a0a Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 16 Oct 2018 16:14:17 +0100 Subject: [PATCH 028/189] Rename confusing methods in `LabelSetValidator` LabelSetValidator has methods `valid?` and `validate`. It's not very clear what these do, what's the difference between them, and the name `valid?` would imply (by convention) that it returns a Boolean, rather than raising an exception on invalid input. Renamed these to `validate_symbols!` and `validate_labelset!`, to make it clearer what each of them do. The `!` also follows the usual Ruby convention of "do X, and if that doesn't work, Raise" This commit also starts drawing the distinction between an array of `labels`, and a hash of label keys and values (which we call a `labelset`). The term `labels` is used interchangeably for both concepts throughout the code. This commit doesn't fix all instances, but it's a step in that direction. Signed-off-by: Daniel Magliola --- lib/prometheus/client/label_set_validator.rb | 18 ++++++------ lib/prometheus/client/metric.rb | 8 +++--- .../client/label_set_validator_spec.rb | 28 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index 943f9a77..aae97e03 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -21,7 +21,7 @@ def initialize(expected_labels:, reserved_labels: []) @validated = {} end - def valid?(labels) + def validate_symbols!(labels) unless labels.respond_to?(:all?) raise InvalidLabelSetError, "#{labels} is not a valid label set" end @@ -33,24 +33,24 @@ def valid?(labels) end end - def validate(labels) - return labels if @validated.key?(labels.hash) + def validate_labelset!(labelset) + return labelset if @validated.key?(labelset.hash) - valid?(labels) + validate_symbols!(labelset) - unless keys_match?(labels) + unless keys_match?(labelset) raise InvalidLabelSetError, "labels must have the same signature " \ - "(keys given: #{labels.keys.sort} vs." \ + "(keys given: #{labelset.keys.sort} vs." \ " keys expected: #{expected_labels}" end - @validated[labels.hash] = labels + @validated[labelset.hash] = labelset end private - def keys_match?(labels) - labels.keys.sort == expected_labels + def keys_match?(labelset) + labelset.keys.sort == expected_labels end def validate_symbol(key) diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index 00c47ee0..1bb43347 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -19,8 +19,8 @@ def initialize(name, validate_docstring(docstring) @validator = LabelSetValidator.new(expected_labels: labels, reserved_labels: reserved_labels) - @validator.valid?(labels) - @validator.valid?(preset_labels) + @validator.validate_symbols!(labels) + @validator.validate_symbols!(preset_labels) @labels = labels @store_settings = store_settings @@ -36,7 +36,7 @@ def initialize(name, ) if preset_labels.keys.length == labels.length - @validator.validate(preset_labels) + @validator.validate_labelset!(preset_labels) @all_labels_preset = true end end @@ -85,7 +85,7 @@ def validate_docstring(docstring) def label_set_for(labels) # We've already validated, and there's nothing to merge. Save some cycles return preset_labels if @all_labels_preset && labels.empty? - @validator.validate(preset_labels.merge(labels)) + @validator.validate_labelset!(preset_labels.merge(labels)) end end end diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index b6f959d5..770b9d38 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -13,67 +13,67 @@ end end - describe '#valid?' do + describe '#validate_symbols!' do it 'returns true for a valid label check' do - expect(validator.valid?(version: 'alpha')).to eql(true) + expect(validator.validate_symbols!(version: 'alpha')).to eql(true) end it 'raises Invaliddescribed_classError if a label set is not a hash' do expect do - validator.valid?('invalid') + validator.validate_symbols!('invalid') end.to raise_exception invalid end it 'raises InvalidLabelError if a label key is not a symbol' do expect do - validator.valid?('key' => 'value') + validator.validate_symbols!('key' => 'value') end.to raise_exception(described_class::InvalidLabelError) end it 'raises InvalidLabelError if a label key starts with __' do expect do - validator.valid?(__reserved__: 'key') + validator.validate_symbols!(__reserved__: 'key') end.to raise_exception(described_class::ReservedLabelError) end it 'raises ReservedLabelError if a label key is reserved' do [:job, :instance].each do |label| expect do - validator.valid?(label => 'value') + validator.validate_symbols!(label => 'value') end.to raise_exception(described_class::ReservedLabelError) end end end - describe '#validate' do + describe '#validate_labelset!' do let(:expected_labels) { [:method, :code] } it 'returns a given valid label set' do hash = { method: 'get', code: '200' } - expect(validator.validate(hash)).to eql(hash) + expect(validator.validate_labelset!(hash)).to eql(hash) end - it 'raises an exception if a given label set is not `valid?`' do + it 'raises an exception if a given label set is not `validate_symbols!`' do input = 'broken' - expect(validator).to receive(:valid?).with(input).and_raise(invalid) + expect(validator).to receive(:validate_symbols!).with(input).and_raise(invalid) - expect { validator.validate(input) }.to raise_exception(invalid) + expect { validator.validate_labelset!(input) }.to raise_exception(invalid) end it 'raises an exception if there are unexpected labels' do expect do - validator.validate(method: 'get', code: '200', exception: 'NoMethodError') + validator.validate_labelset!(method: 'get', code: '200', exception: 'NoMethodError') end.to raise_exception(invalid, /keys given: \[:code, :exception, :method\] vs. keys expected: \[:code, :method\]/) end it 'raises an exception if there are missing labels' do expect do - validator.validate(method: 'get') + validator.validate_labelset!(method: 'get') end.to raise_exception(invalid, /keys given: \[:method\] vs. keys expected: \[:code, :method\]/) expect do - validator.validate(code: '200') + validator.validate_labelset!(code: '200') end.to raise_exception(invalid, /keys given: \[:code\] vs. keys expected: \[:code, :method\]/) end end From 5cae3b47ead3a050efba7089db8ac9fd964f8237 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 13 Nov 2018 18:35:07 +0000 Subject: [PATCH 029/189] Add Performance Benchmarks These benchmarks are useful to know what kind of performance to expect from metrics, in scenarios where consumers may be sensitive to performance, and also to help teams that need to build their own stores, to benchmark them and test them. Signed-off-by: Daniel Magliola --- lib/prometheus/client/data_stores/README.md | 21 ++ prometheus-client.gemspec | 2 + spec/benchmarks/README.md | 67 ++++ spec/benchmarks/data_stores.rb | 329 ++++++++++++++++++++ spec/benchmarks/labels.rb | 127 ++++++++ 5 files changed, 546 insertions(+) create mode 100644 spec/benchmarks/README.md create mode 100644 spec/benchmarks/data_stores.rb create mode 100644 spec/benchmarks/labels.rb diff --git a/lib/prometheus/client/data_stores/README.md b/lib/prometheus/client/data_stores/README.md index e4243839..72250958 100644 --- a/lib/prometheus/client/data_stores/README.md +++ b/lib/prometheus/client/data_stores/README.md @@ -169,6 +169,27 @@ The tests for `DirectFileStore` have a good example at the top of the file. This has some examples on testing multi-process stores, checking that aggregation between processes works correctly. +## Benchmarking your custom data store + +If you are developing your own data store, you probably want to benchmark it to see how +it compares to the built-in ones, and to make sure it achieves the performance you want. + +The Prometheus Ruby Client includes some benchmarks (in the `spec/benchmarks` directory) +to help you with this, and also with validating that your store works correctly. + +The `README` in that directory contains more information what these benchmarks are for, +and how to use them. + +## Extra Stores and Research + +In the process of abstracting stores away, and creating the built-in ones, GoCardless +has created a good amount of research, benchmarks, and experimental stores, which +weren't useful to include in this repo, but may be a useful resource or starting point +if you are building your own store. + +Check out the [GoCardless Data Stores Experiments](gocardless/prometheus-client-ruby-data-stores-experiments) +repository for these. + ## Sample, imaginary multi-process Data Store This is just an example of how one could implement a data store, and a clarification on diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 9642a7d0..434f5651 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -16,4 +16,6 @@ Gem::Specification.new do |s| s.require_paths = ['lib'] s.add_dependency 'concurrent-ruby' + + s.add_development_dependency 'benchmark-ips' end diff --git a/spec/benchmarks/README.md b/spec/benchmarks/README.md new file mode 100644 index 00000000..5d9d96fc --- /dev/null +++ b/spec/benchmarks/README.md @@ -0,0 +1,67 @@ +# Performance Benchmarks + +The intention behind these benchmarks is twofold: + +- On the one hand, if you have performance concerns for your counters, they'll allow you + to simulate a reasonably realistic scenario, with your particular runtime characteristics, + so you can know what kind of performance to expect under different circumstances, and pick + settings accordingly. + +- On the other hand, if you are developing your own Custom Data Store (more on this in + `/lib/prometheus/client/data_stores/README.md), this will allow you to test how it + performs compared to the built-in ones, and also "system test" it to validate that it + behaves appropriately. + +## Benchmarks included + +### Data Stores Performance + +The Prometheus Ruby Client ships with different built-in data stores, optimized for +different common scenarios (more on this on the repo's main README, under `Data Stores`). + +This benchmark can show you, for your particular runtime environment, what kind of +performance you can expect from each, to pick the one that's best for you. + +More importantly, in a case where the built-in stores may not be useful for your +particular circumstances, you might want to make your own Data Store. If that is the case, +this benchmark will help you compare its performance characteristics to the built-in +stores, and will also run an export after the observations are made, and compare it with +the built-in ones, helping you catch potential bugs in your store, if the output doesn't +match. + +The benchmark was made to try and simulate a somewhat realistic scenario, with plenty of +high-cardinality metrics, which is what you should be aiming for. It has a balance of +counters and histograms, different label counts for different metrics, different thread +counts, etc. All this should be easy to customize to your particular needs by modifying +the constants in the benchmark to tailor to what you need to measure. + +In particular, if going for the goal of "how long it should take to increment a counter", +you probably want to have no labels and no histograms, since that's the reference +performance measurement we use. + +### Labels Performance + +Adding labels to your metrics can have significant performance impact, on two fronts: + +- Labels passed in on every observation need to be validated. This may be alleviated by + using `with_labels`. If used to pre-set *all* labels, you can save a good + amount of processing time, by skipping validation on each observation. This may be + important if you're incrementing metrics on a tight loop, and this benchmark can help + with establishing what's to be expected. + +- Even when caching them, these labels are keys to Hashes, they need to sometimes be + serialized into strings, sometimes merged into other hashes. All this incurs performance + costs. This benchmark will allow you to estimate how much impact they can have. Again, + if incrementing metrics on a tight loop, this will let you estimate whether you might + want to have fewer labels instead. + +It should be easy to modify the constants in this benchmark to your particular situation, +if necessary. + +## Running the benchmarks + +Simply run, from the repo's root directory: + +`bundle exec ruby spec/benchmarks/labels.rb` +`bundle exec ruby spec/benchmarks/data_stores.rb` + diff --git a/spec/benchmarks/data_stores.rb b/spec/benchmarks/data_stores.rb new file mode 100644 index 00000000..940d38a1 --- /dev/null +++ b/spec/benchmarks/data_stores.rb @@ -0,0 +1,329 @@ +require 'benchmark' +require 'prometheus/client' +require 'prometheus/client/counter' +require 'prometheus/client/histogram' +require 'prometheus/client/formats/text' +require 'prometheus/client/data_stores/single_threaded' +require 'prometheus/client/data_stores/synchronized' +require 'prometheus/client/data_stores/direct_file_store' + +# Compare the time it takes different stores to observe a large number of data points, in +# a multi-threaded environment. +# +# If you create a new store and want to benchmark it, add it to the `STORES` array, +# and run the benchmark to see how it compares to the other options. +# +# Each test instantiates a number of Histograms and Counters, with a random number of +# labels, instantiates a number of threads, and then prepares a a large number of +# observations, which it distributes randomly between the different metrics and threads +# created. +# +# It does this for each of the STORES specified and different THREAD_COUNTS, then once +# all that is ready, it starts the benchmark test and lets the threads run to observe +# those data points. +# +# In addition to timing the observation of data points, the benchmark also runs the Text +# Exporter on the results, and compares them between stores to make sure all stores +# result in the same output being generated. If this output doesn't match exactly, +# something is going wrong, and it probably indicates a bug in the store, so this +# benchmark also acts as a sort of system test for stores. If a mismatch is found, a +# WARNING will show up in the output, and both the expected and actual results will be +# dumped to text files, for help in debugging. +# +# Data generation involves randomness, but the RNG is seeded so that different stores are +# exposed to the same pattern of access (as long as two test cases have the same number +# of threads), reducing the effects on the result of randomness in lock contention. +# +# NOTE: If you leave the default of 1_000_000 DATA_POINTS, then the timing result is +# showing "microseconds per observation", which is the unit we care about. +# We're aiming for 1 microsecond per observation, which is not quite achievable in Ruby, +# but that's what we're trying to approach. If you're trying to compare against this +# goal, set NUM_HISTOGRAMS and MAX_LABELS to 0, for a fair comparison, as both labels +# and histograms are much slower than label-less counters. +#----------------------------------------------------------------------------------- + +# Store class that follows the required interface but does nothing. Used as a baseline +# of how much time is spent outside the store. +class NoopStore + def for_metric(metric_name, metric_type:, metric_settings: {}) + MetricStore.new + end + + class MetricStore + def synchronize + yield + end + + def set(labels:, val:); end + def increment(labels:, by: 1); end + def get(labels:); end + def all_values; {}; end + end +end + +#----------------------------------------------------------------------------------- + +RANDOM_SEED = 12345678 +NUM_COUNTERS = 80 +NUM_HISTOGRAMS = 20 +DATA_POINTS = 1_000_000 +MIN_LABELS = 0 +MAX_LABELS = 4 +THREAD_COUNTS = [1, 2, 4, 8, 12, 16, 20] + +TMP_DIR = "/tmp/prometheus_benchmark" + +STORES = [ + { store: NoopStore.new }, + { store: Prometheus::Client::DataStores::SingleThreaded.new, max_threads: 1 }, + { store: Prometheus::Client::DataStores::Synchronized.new }, + { + store: Prometheus::Client::DataStores::DirectFileStore.new(dir: TMP_DIR), + before: -> () { cleanup_dir(TMP_DIR) }, + } +] + +#----------------------------------------------------------------------------------- + +class TestSetup + attr_reader :random, :num_threads, :registry + attr_reader :metrics, :threads # Simple arrays + attr_reader :data_points # Hash, indexed by Thread ID, with an array of points to observe + attr_reader :start_event + + def initialize(store, num_threads) + Prometheus::Client.config.data_store = @store = store + + @random = Random.new(RANDOM_SEED) # Repeatable random numbers for each test + @start_event = Concurrent::Event.new # Event all threads wait on to start, once set up + @num_threads = num_threads + @threads = [] + @metrics = [] + @data_points = {} + @registry = Prometheus::Client::Registry.new + + setup_threads + setup_metrics + create_datapoints + end + + def observe! + start_event.set # Release the threads to process their events + threads.each { |thr| thr.join } # Wait for all threads to finish and die + end + + def export!(expected_output) + output = Prometheus::Client::Formats::Text.marshal(registry) + + # Output validation doesn't work for NoopStore + return nil if @store.is_a?(NoopStore) + + puts "\nWARNING: Empty output" if !output || output.empty? + + # If this is the first store to run for this number of threads, store expected_output + return output if expected_output.nil? + + # Otherwise, make sure this store's output was the same as the previous one. + # If it isn't, there's probably a bug in the store + return output if output == expected_output + + # Outputs don't match. Report + expected_filename = "data_mismatch_#{ @store.class.name }_#{ num_threads }thr_expected.txt" + actual_filename = "data_mismatch_#{ @store.class.name }_#{ num_threads }thr_actual.txt" + puts "\nWARNING: Output Mismatch.\nSee #{ expected_filename }\nand #{ actual_filename }" + + File.open(expected_filename, 'w') {|f| f.write(expected_output) } + File.open(actual_filename, 'w') {|f| f.write(output) } + + return expected_output + end + + private + + def setup_threads + latch = Concurrent::CountDownLatch.new(num_threads) + + num_threads.times do |i| + threads << Thread.new(i) do |thread_id| + latch.count_down + start_event.wait # Wait for the test to start + thread_run(thread_id) # Process this thread's events + end + end + + latch.wait # Wait for all threads to have started + end + + def setup_metrics + NUM_COUNTERS.times do |i| + labelset = generate_labelset + counter = Prometheus::Client::Counter.new( + "counter#{ i }".to_sym, + docstring: "Counter #{ i }", + labels: labelset.keys, + preset_labels: labelset + ) + + metrics << counter + end + + NUM_HISTOGRAMS.times do |i| + labelset = generate_labelset + histogram = Prometheus::Client::Histogram.new( + "histogram#{ i }".to_sym, + docstring: "Histogram #{ i }", + labels: labelset.keys, + preset_labels: labelset + ) + + metrics << histogram + end + + metrics.each { |metric| registry.register(metric) } + end + + def create_datapoints + num_threads.times do |i| + data_points[i] = [] + end + + thread_id = 0 + DATA_POINTS.times do |i| + thread_id = (thread_id + 1) % num_threads + metric = random_metric + + if metric.type == :counter + data_points[thread_id] << [metric] + else + data_points[thread_id] << [metric, random.rand * 10] + end + end + end + + def thread_run(thread_id) + thread_points = data_points[thread_id] + thread_points.each do |point| + metric = point[0] + if metric.type == :counter + metric.increment + else + metric.observe(point[1]) + end + end + end + + def generate_labelset + num_labels = random.rand(MAX_LABELS - MIN_LABELS + 1) + MIN_LABELS + (1..num_labels).map {|j| ["label#{ j }".to_sym, "foo"] }.to_h + end + + def random_metric + metrics[random.rand(metrics.count)] + end +end + +def cleanup_dir(dir) + Dir.glob("#{ dir }/*").each { |file| File.delete(file) } +end + +#----------------------------------------------------------------------------------- + +# Monkey-patch the exporter to round Float numbers +# This is necessary in order to compare outputs from different stores, and make sure +# the user-built stores are working correctly. +# +# In multi-threaded scenarios, adding up a large amount of floats in different orders +# results in small rounding errors when adding the same numbers. This is not a bug +# in the store, or anywhere, it's the nature of Floats. +# E.g.: 4909.026018536727 +# vs 4909.026018536722 +# +# In the real exporter, this is not a problem, because the exported numbers are still +# correct, but when comparing one to the other, these tiny deltas result in false +# alarms for *all* stores under multiple threads. +# +# Monkey-patching the output line to round the number allows us to compare these outputs +# without any noticeable downside. +module Prometheus + module Client + module Formats + module Text + def self.metric(name, labels, value) + format(METRIC_LINE, name, labels, value.round(6)) + end + end + end + end +end + +#----------------------------------------------------------------------------------- + +Benchmark.bm(45) do |bm| + THREAD_COUNTS.each do |num_threads| + expected_exporter_output = nil + + STORES.each do |store_test| + # Single Threaded stores can't run in multiple threads + next if store_test[:max_threads] && num_threads > store_test[:max_threads] + + # Cleanup before test + store_test[:before].call if store_test[:before] + + test_setup = TestSetup.new(store_test[:store], num_threads) + store_name = store_test[:store].class.name.split('::').last + test_name ="#{ (store_test[:name] || store_name).ljust(25) } x#{ num_threads }" + + bm.report("Observe #{test_name}") { test_setup.observe! } + bm.report("Export #{test_name}") do + expected_exporter_output = test_setup.export!(expected_exporter_output) + end + end + + puts "-" * 80 + end +end + + +#-------------------------------------------------------------------------------------- +# Sample Results: +# +# Only counters, no labels, DirectFileStore stored in TMPFS, Ruby 2.5.1 +# ---------------------------------------------------------------- +# user system total real +# Observe NoopStore x1 0.390845 0.019915 0.410760 ( 0.413240) +# Export NoopStore x1 0.000462 0.000029 0.000491 ( 0.000489) +# Observe SingleThreaded x1 0.946516 0.044122 0.990638 ( 0.990801) +# Export SingleThreaded x1 0.000837 0.000000 0.000837 ( 0.000838) +# Observe Synchronized x1 4.038891 0.000000 4.038891 ( 4.039304) +# Export Synchronized x1 0.001227 0.000000 0.001227 ( 0.001229) +# Observe DirectFileStore x1 7.414242 1.732539 9.146781 ( 9.147389) +# Export DirectFileStore x1 0.009920 0.000243 0.010163 ( 0.010170) +# -------------------------------------------------------------------------------- +# Observe NoopStore x2 0.337919 0.000000 0.337919 ( 0.337575) +# Export NoopStore x2 0.000404 0.000000 0.000404 ( 0.000379) +# Observe Synchronized x2 4.313595 0.008714 4.322309 ( 4.314901) +# Export Synchronized x2 0.001649 0.000155 0.001804 ( 0.001809) +# Observe DirectFileStore x2 22.193105 12.739370 34.932475 ( 21.503215) +# Export DirectFileStore x2 0.005982 0.008480 0.014462 ( 0.014471) +# +# +# +# Default benchmark (Mix of Counters and Histograms, and up to 4 labels), +# DirectFileStore stored in TMPFS, Ruby 2.5.1 +# ------------------------------------------ +# user system total real +# Observe NoopStore x1 0.994314 0.027816 1.022130 ( 1.025121) +# Export NoopStore x1 0.000537 0.000032 0.000569 ( 0.000574) +# Observe SingleThreaded x1 4.439427 0.027929 4.467356 ( 4.470777) +# Export SingleThreaded x1 0.006244 0.000000 0.006244 ( 0.006250) +# Observe Synchronized x1 8.292962 0.000000 8.292962 ( 8.293737) +# Export Synchronized x1 0.006698 0.000000 0.006698 ( 0.006706) +# Observe DirectFileStore x1 13.448161 2.517563 15.965724 ( 15.967281) +# Export DirectFileStore x1 0.020115 0.004012 0.024127 ( 0.024135) +# -------------------------------------------------------------------------------- +# Observe NoopStore x2 1.342963 0.020541 1.363504 ( 1.354383) +# Export NoopStore x2 0.002923 0.000000 0.002923 ( 0.002927) +# Observe Synchronized x2 8.810914 0.029352 8.840266 ( 8.828600) +# Export Synchronized x2 0.007535 0.000000 0.007535 ( 0.007540) +# Observe DirectFileStore x2 41.483649 19.362639 60.846288 ( 39.026703) +# Export DirectFileStore x2 0.010133 0.013159 0.023292 ( 0.023302) diff --git a/spec/benchmarks/labels.rb b/spec/benchmarks/labels.rb new file mode 100644 index 00000000..42f8ebda --- /dev/null +++ b/spec/benchmarks/labels.rb @@ -0,0 +1,127 @@ +require 'benchmark/ips' +require 'prometheus/client' +require 'prometheus/client/counter' +require 'prometheus/client/data_stores/single_threaded' + +# Compare the time it takes to observe metrics that have labels (disregarding the actual +# data store) +# +# This benchmark compares 3 different metrics, with 0, 2 and 100 labels respectively, +# and how using `with_values` for some, or all their label values affects performance. +# +# The hypothesis here is that, once labels are introduced, we're validating those labels +# in every observation, but if those labels are "cached" using `with_labels`, we skip that +# validation which should be *considerably* faster. +# +# This completely disregards the storage of this data in memory, and it's highly likely +# that more labels will make things slower in the data store, even if the metrics themselves +# don't add overhead. So the fact that using `with_labels` with all labels adds no overhead +# to the metric itself doesn't mean labels have no overhead. +# +# To see what it looks like with the best-case scenario data store, uncomment the line +# that sets the `data_store` to `SingleThreaded` +#------------------------------------------------------------------------------------- +# Store that doesn't do anything, so we can focus as much as possible on the timings of +# the Metric itself +class NoopStore + def for_metric(metric_name, metric_type:, metric_settings: {}) + MetricStore.new + end + + class MetricStore + def synchronize + yield + end + + def set(labels:, val:); end + def increment(labels:, by: 1); end + def get(labels:); end + def all_values; end + end +end + +Prometheus::Client.config.data_store = NoopStore.new # No data storage +# Prometheus::Client.config.data_store = Prometheus::Client::DataStores::SingleThreaded.new # Simple data storage + +#------------------------------------------------------------------------------------- +# Set up of the 3 metrics, plus their half-cached and full-cached versions +NO_LABELS_COUNTER = Prometheus::Client::Counter.new( + :no_labels, + docstring: "Counter with no labels" +) + +TWO_LABELSET = { label1: "a", label2: "b"} +LAST_ONE_LABELSET = { label2: "b"} +TWO_LABELS_COUNTER = Prometheus::Client::Counter.new( + :two_labels, + docstring: "Counter with 2 labels", + labels: [:label1, :label2] +) +TWO_LABELS_ONE_CACHED = TWO_LABELS_COUNTER.with_labels(label1: "a") +TWO_LABELS_ALL_CACHED = TWO_LABELS_COUNTER.with_labels(label1: "a", label2: "b") + + +HUNDRED_LABELS = (1..100).map{|i| "label#{ i }".to_sym } +HUNDRED_LABELSET = (1..100).map{|i| ["label#{ i }".to_sym, i.to_s] }.to_h +FIRST_FIFTY_LABELSET = (1..50).map{|i| ["label#{ i }".to_sym, i.to_s] }.to_h +LAST_FIFTY_LABELSET = (51..100).map{|i| ["label#{ i }".to_sym, i.to_s] }.to_h + +HUNDRED_LABELS_COUNTER = Prometheus::Client::Counter.new( + :hundred_labels, + docstring: "Counter with 100 labels", + labels: HUNDRED_LABELS +) +HUNDRED_LABELS_HALF_CACHED = HUNDRED_LABELS_COUNTER.with_labels(FIRST_FIFTY_LABELSET) +HUNDRED_LABELS_ALL_CACHED = HUNDRED_LABELS_COUNTER.with_labels(HUNDRED_LABELSET) + +#------------------------------------------------------------------------------------- +# Actual Benchmark + +Benchmark.ips do |x| + x.config(:time => 5, :warmup => 2) + + x.report("0 labels") { NO_LABELS_COUNTER.increment } + x.report("2 labels") { TWO_LABELS_COUNTER.increment(labels: TWO_LABELSET) } + x.report("100 labels") { HUNDRED_LABELS_COUNTER.increment(labels: HUNDRED_LABELSET) } + + x.report("2 lab, half cached") { TWO_LABELS_ONE_CACHED.increment(labels: LAST_ONE_LABELSET) } + x.report("100 lab, half cached") { HUNDRED_LABELS_HALF_CACHED.increment(labels: LAST_FIFTY_LABELSET) } + + x.report("2 lab, all cached") { TWO_LABELS_ALL_CACHED.increment } + x.report("100 lab, all cached") { HUNDRED_LABELS_ALL_CACHED.increment } +end + +#------------------------------------------------------------------------------------- +# Conclusion: +# +# Without a data store: +# +# 0 labels 3.592M (± 3.7%) i/s - 18.081M in 5.039832s +# 2 labels 502.898k (± 3.2%) i/s - 2.536M in 5.048618s +# 100 labels 19.467k (± 4.8%) i/s - 98.280k in 5.061444s +# 2 lab, half cached 432.844k (± 3.0%) i/s - 2.180M in 5.041123s +# 100 lab, half cached 20.444k (± 3.4%) i/s - 103.636k in 5.075070s +# 2 lab, all cached 3.668M (± 3.3%) i/s - 18.338M in 5.004442s +# 100 lab, all cached 3.711M (± 4.0%) i/s - 18.544M in 5.005362s +# +# As we expected, labels introduce a significant overhead, even in small numbers, but +# if they are all pre-set, the effect is negligible. +# Pre-setting *some* labels, however, has no performance impact. It may still be desirable +# to avoid repetition, though. +# +# So, if observing measurements in a tight loop, it's highly recommended to use `with_labels` +# and pre-set all labels. +# +# +# With the simplest possible data store: +# +# 0 labels 1.275M (± 3.1%) i/s - 6.419M in 5.038946s +# 2 labels 195.293k (± 4.3%) i/s - 974.600k in 5.000375s +# 100 labels 6.410k (± 7.5%) i/s - 32.022k in 5.028417s +# 2 lab, half cached 187.255k (± 3.5%) i/s - 948.618k in 5.072189s +# 100 lab, half cached 6.846k (± 2.7%) i/s - 34.424k in 5.031776s +# 2 lab, all cached 376.353k (± 3.3%) i/s - 1.890M in 5.025963s +# 100 lab, all cached 11.669k (± 3.0%) i/s - 58.752k in 5.039468s +# +# As mentioned above, once we're storing the data, labels *can* have a serious impact, +# and that impact will be highly store dependent. \ No newline at end of file From 5dce3e5855f2aa67c65874c953878e124c23edcd Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 12 Mar 2019 20:11:34 +0000 Subject: [PATCH 030/189] Improve wording around example data store implementation Signed-off-by: Chris Sinjakli --- lib/prometheus/client/data_stores/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/client/data_stores/README.md b/lib/prometheus/client/data_stores/README.md index 72250958..920e5833 100644 --- a/lib/prometheus/client/data_stores/README.md +++ b/lib/prometheus/client/data_stores/README.md @@ -195,11 +195,11 @@ repository for these. This is just an example of how one could implement a data store, and a clarification on the "aggregation" point -Important: This is **VAPORWARE**, intended simply to show how this could work / how to +Important: This is a **toy example**, intended simply to show how this could work / how to implement these interfaces. There are some key pieces of code missing, which are fairly uninteresting, this only shows -the parts that illustrate the idea of storing multiple different values, and aggregate +the parts that illustrate the idea of storing multiple different values, and aggregating them ```ruby From c085906159ef9a96d13324b8dca64f3af7e46047 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 3 May 2019 15:17:43 +0100 Subject: [PATCH 031/189] Make suggested tweaks to README from feedback in #95 - Don't suggest defining metrics outside of file they're used in - Don't allow stores to require extra parameters in `for_metric` - Correct note on kernel page cache Fixes #113, #114 Signed-off-by: Chris Sinjakli --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0df41d1c..c230afbd 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Examples: **Pre-setting labels for ease of use:** ```ruby -# in the file where you define your metrics: +# in the metric definition: records_processed_total = registry.counter.new(:records_processed_total, docstring: '...', labels: [:service, :component], @@ -304,8 +304,8 @@ whether you want to report the `SUM`, `MAX` or `MIN` value observed across all p For almost all other cases, you'd leave the default (`SUM`). More on this on the *Aggregation* section below. -Other custom stores may also require or accept extra parameters besides `:aggregation`. -See the documentation of each store for more details. +Other custom stores may also accept extra parameters besides `:aggregation`. See the +documentation of each store for more details. ### Built-in stores @@ -334,7 +334,7 @@ There are 3 built-in stores, with different trade-offs: Even though this store saves data on disk, it's still much faster than would probably be expected, because the files are never actually `fsync`ed, so the store never blocks - while waiting for disk. FS caching is incredibly efficient in this regard. + while waiting for disk. The kernel's page cache is incredibly efficient in this regard. If in doubt, check the benchmark scripts described in the documentation for creating your own stores and run them in your particular runtime environment to make sure this From 0bc912e9ae888d27a58f1f842346e59bcf2e3c8a Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 3 May 2019 18:07:29 +0100 Subject: [PATCH 032/189] Remove support for flexible labels in collector middleware We decided in #111 that the current interface for this is confusing to the point where it's more trouble than it's worth. The alternatives we came up with result in the middleware doing almost nothing and delegating to user-provided lambdas. Since we're pre-1.0, we're removing this in the belief that if it turns out to be a widely-requested feature, we can come up with something better. Leaving this implementation in would commit us to an interface we don't like. Fixes #111 Signed-off-by: Chris Sinjakli --- lib/prometheus/middleware/collector.rb | 42 ++++++++------------ spec/prometheus/middleware/collector_spec.rb | 25 ------------ 2 files changed, 16 insertions(+), 51 deletions(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 08445ec3..0f971595 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -32,8 +32,6 @@ def initialize(app, options = {}) @app = app @registry = options[:registry] || Client.registry @metrics_prefix = options[:metrics_prefix] || 'http_server' - @counter_lb = options[:counter_label_builder] || COUNTER_LB - @duration_lb = options[:duration_label_builder] || DURATION_LB init_request_metrics init_exception_metrics @@ -45,42 +43,23 @@ def call(env) # :nodoc: protected - aggregation = lambda do |str| + AGGREGATION = lambda do |str| str .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)}, '/:uuid\\1') .gsub(%r{/\d+(/|$)}, '/:id\\1') end - COUNTER_LB = proc do |env, code| - next { code: nil, method: nil, path: nil } if env.empty? - - { - code: code, - method: env['REQUEST_METHOD'].downcase, - path: aggregation.call(env['PATH_INFO']), - } - end - - DURATION_LB = proc do |env, _| - next { method: nil, path: nil } if env.empty? - - { - method: env['REQUEST_METHOD'].downcase, - path: aggregation.call(env['PATH_INFO']), - } - end - def init_request_metrics @requests = @registry.counter( :"#{@metrics_prefix}_requests_total", docstring: 'The total number of HTTP requests handled by the Rack application.', - labels: @counter_lb.call({}, "").keys + labels: %i[code method path] ) @durations = @registry.histogram( :"#{@metrics_prefix}_request_duration_seconds", docstring: 'The HTTP response duration of the Rack application.', - labels: @duration_lb.call({}, "").keys + labels: %i[method path] ) end @@ -103,8 +82,19 @@ def trace(env) end def record(env, code, duration) - @requests.increment(labels: @counter_lb.call(env, code)) - @durations.observe(duration, labels: @duration_lb.call(env, code)) + counter_labels = { + code: code, + method: env['REQUEST_METHOD'].downcase, + path: AGGREGATION.call(env['PATH_INFO']), + } + + duration_labels = { + method: env['REQUEST_METHOD'].downcase, + path: AGGREGATION.call(env['PATH_INFO']), + } + + @requests.increment(labels: counter_labels) + @durations.observe(duration, labels: duration_labels) rescue # TODO: log unexpected exception during request recording nil diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index 1e916c72..c1accc06 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -103,31 +103,6 @@ end end - context 'when using a custom counter label builder' do - let(:app) do - described_class.new( - original_app, - registry: registry, - counter_label_builder: lambda do |env, code| - next { code: nil, method: nil } if env.empty? - - { - code: code, - method: env['REQUEST_METHOD'].downcase, - } - end, - ) - end - - it 'allows labels configuration' do - get '/foo/bar' - - metric = :http_server_requests_total - labels = { method: 'get', code: '200' } - expect(registry.get(metric).get(labels: labels)).to eql(1.0) - end - end - context 'when provided a custom metrics_prefix' do let!(:app) do described_class.new( From 23686bec9020458dd12868c6203109afc7291d0f Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 7 May 2019 11:27:44 +0100 Subject: [PATCH 033/189] Convert lambda to regular method in Prometheus::Middleware::Collector Signed-off-by: Chris Sinjakli --- lib/prometheus/middleware/collector.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 0f971595..3f6c293d 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -43,12 +43,6 @@ def call(env) # :nodoc: protected - AGGREGATION = lambda do |str| - str - .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)}, '/:uuid\\1') - .gsub(%r{/\d+(/|$)}, '/:id\\1') - end - def init_request_metrics @requests = @registry.counter( :"#{@metrics_prefix}_requests_total", @@ -85,12 +79,12 @@ def record(env, code, duration) counter_labels = { code: code, method: env['REQUEST_METHOD'].downcase, - path: AGGREGATION.call(env['PATH_INFO']), + path: strip_ids_from_path(env['PATH_INFO']), } duration_labels = { method: env['REQUEST_METHOD'].downcase, - path: AGGREGATION.call(env['PATH_INFO']), + path: strip_ids_from_path(env['PATH_INFO']), } @requests.increment(labels: counter_labels) @@ -99,6 +93,12 @@ def record(env, code, duration) # TODO: log unexpected exception during request recording nil end + + def strip_ids_from_path(path) + path + .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)}, '/:uuid\\1') + .gsub(%r{/\d+(/|$)}, '/:id\\1') + end end end end From 11a2813b09aa5d9b7f336d5153bf3d1127e85bfb Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 7 May 2019 11:36:29 +0100 Subject: [PATCH 034/189] Make it clear when error comes from mocking in tests This is a small quality of life enhancement when debugging test failures. When you comment out the catch-all `rescue` in the production code of Prometheus::Middleware::Collector, you see a `NoMethodError` with no stack or message attached. Initially, this is pretty confusing, and you may think it was the reason for your tests failing! In fact, it's to test the catch-all rescue. This commit makes it really explicit that the error is deliberately coming from the tests. Signed-off-by: Chris Sinjakli --- spec/prometheus/middleware/collector_spec.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index c1accc06..e2d53164 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -23,6 +23,8 @@ described_class.new(original_app, registry: registry) end + let(:dummy_error) { RuntimeError.new("Dummy error from tests") } + it 'returns the app response' do get '/foo' @@ -32,7 +34,7 @@ it 'handles errors in the registry gracefully' do counter = registry.get(:http_server_requests_total) - expect(counter).to receive(:increment).and_raise(NoMethodError) + expect(counter).to receive(:increment).and_raise(dummy_error) get '/foo' @@ -84,7 +86,7 @@ context 'when the app raises an exception' do let(:original_app) do lambda do |env| - raise NoMethodError if env['PATH_INFO'] == '/broken' + raise dummy_error if env['PATH_INFO'] == '/broken' [200, { 'Content-Type' => 'text/html' }, ['OK']] end @@ -95,10 +97,10 @@ end it 'traces exceptions' do - expect { get '/broken' }.to raise_error NoMethodError + expect { get '/broken' }.to raise_error RuntimeError metric = :http_server_exceptions_total - labels = { exception: 'NoMethodError' } + labels = { exception: 'RuntimeError' } expect(registry.get(metric).get(labels: labels)).to eql(1.0) end end From f9f97d9811ac114fd8e207ef57c169cee7576b32 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Mon, 20 May 2019 18:02:51 +0100 Subject: [PATCH 035/189] Update authors in gemspec to match MAINTAINERS.md Signed-off-by: Chris Sinjakli --- prometheus-client.gemspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 434f5651..6e1f5e8b 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -7,8 +7,8 @@ Gem::Specification.new do |s| s.version = Prometheus::Client::VERSION s.summary = 'A suite of instrumentation metric primitives' \ 'that can be exposed through a web services interface.' - s.authors = ['Tobias Schmidt'] - s.email = ['ts@soundcloud.com'] + s.authors = ['Ben Kochie', 'Chris Sinjakli', 'Daniel Magliola'] + s.email = ['superq@gmail.com', 'chris@gocardless.com', 'dmagliola@crystalgears.com'] s.homepage = 'https://github.com/prometheus/client_ruby' s.license = 'Apache 2.0' From 6ecd2907849c564900cf2e77a84ee5ec111e736c Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Mon, 20 May 2019 18:57:45 +0100 Subject: [PATCH 036/189] Bump version to 0.10.0-alpha.1 This prepares us to cut our first alpha release with multi-process support, as requested in #95. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index c50f46e3..c8bf057f 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '0.9.0' + VERSION = '0.10.0-alpha.1' end end From 661fa880a87cd18f93cd533dd519f8ecc91d68d4 Mon Sep 17 00:00:00 2001 From: Sean Walberg Date: Mon, 27 May 2019 15:10:36 -0400 Subject: [PATCH 037/189] Update example README for #111 I was trying to modify the path that's exported and didn't realize it was _just_ changed. The documentation in the example project still reflects the old way. Gave a short example of how I did it -- I realize subclassing a protected method is dangerous but there are no other hooks. FWIW my use case was that the default regular expressions did not cover my url structure and I needed to add another one. I did not need to add more labels. Signed-off-by: Sean Walberg --- examples/rack/README.md | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/examples/rack/README.md b/examples/rack/README.md index d96546d0..ee6472d5 100644 --- a/examples/rack/README.md +++ b/examples/rack/README.md @@ -44,21 +44,20 @@ Prometheus server can be used to [play around with the metrics][rate-query]. The example shown in [`config.ru`](config.ru) is a trivial rack application using the default collector and exporter middlewares. -In order to use custom label builders in the collector, change the line to -something like this: - -```ruby -use Prometheus::Middleware::Collector, counter_label_builder: ->(env, code) { - next { code: nil, method: nil, host: nil, path: nil } if env.empty? - - { - code: code, - method: env['REQUEST_METHOD'].downcase, - # Include the HTTP Host header as label. - host: env['HTTP_HOST'].to_s, - # Include path, but replace all numeric IDs to keep cardinality low. - # Think '/users/1234/comments' -> '/users/:id/comments' - path: env['PATH_INFO'].to_s.gsub(/\/\d+(\/|$)/, '/:id\\1'), - } -} +Modifying the labels is a subject under development (see #111) but one option is to subclass `Prometheus::Middleware::Collector` and override the methods you need. For example, if you want to [strip IDs from the path](https://github.com/prometheus/client_ruby/blob/982fe2e3c37e2940d281573c7689224152dd791f/lib/prometheus/middleware/collector.rb#L97-L101) you could override the appropriate method: + +```Ruby +require 'prometheus/middleware/collector' +module Prometheus + module Middleware + class MyCollector < Collector + def strip_ids_from_path(path) + super(path) + .gsub(/8675309/, ':jenny\\1') + end + end + end +end ``` + +and in `config.ru` use your class instead. From 6ea675ef633c40a45643dedd76eae0b87c32dbcf Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 31 May 2019 12:25:16 +0100 Subject: [PATCH 038/189] Make some tweaks to wording of collector README --- examples/rack/README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/examples/rack/README.md b/examples/rack/README.md index ee6472d5..8d980044 100644 --- a/examples/rack/README.md +++ b/examples/rack/README.md @@ -44,7 +44,15 @@ Prometheus server can be used to [play around with the metrics][rate-query]. The example shown in [`config.ru`](config.ru) is a trivial rack application using the default collector and exporter middlewares. -Modifying the labels is a subject under development (see #111) but one option is to subclass `Prometheus::Middleware::Collector` and override the methods you need. For example, if you want to [strip IDs from the path](https://github.com/prometheus/client_ruby/blob/982fe2e3c37e2940d281573c7689224152dd791f/lib/prometheus/middleware/collector.rb#L97-L101) you could override the appropriate method: +Currently, the collector middleware doesn't offer any flexibility around label +keys or values (see #111). If you have more sophisticated requirements, we +recommend creating your own collector middleware. + +If your requirements are minimal, one option is to subclass +`Prometheus::Middleware::Collector` and override the methods you need to. For +example, if you want to [change the way IDs are stripped from the +path](https://github.com/prometheus/client_ruby/blob/982fe2e3c37e2940d281573c7689224152dd791f/lib/prometheus/middleware/collector.rb#L97-L101) +you could override the appropriate method: ```Ruby require 'prometheus/middleware/collector' @@ -60,4 +68,8 @@ module Prometheus end ``` -and in `config.ru` use your class instead. +and use your class in `config.ru` instead. + +**Note:** `Prometheus::Middleware::Collector` isn't explicitly designed to be +subclassed, so the internals are liable to change at any time, including in +patch releases. Overriding its methods is done at your own risk! From 22e6965c163ce28f73201be0a36f29626fc99e97 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 31 May 2019 14:14:18 +0100 Subject: [PATCH 039/189] Support :all as an aggregation mode in DirectFileStore We want to support exporting each process's individual value for gauges. To enable this, DirectFileStore needs a new aggregation mode - :all. Signed-off-by: Chris Sinjakli --- .../client/data_stores/direct_file_store.rb | 8 ++++- .../data_stores/direct_file_store_spec.rb | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 62e72310..8c653e31 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -26,7 +26,7 @@ module DataStores class DirectFileStore class InvalidStoreSettingsError < StandardError; end - AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum] + AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum, ALL = :all] DEFAULT_METRIC_SETTINGS = { aggregation: SUM } def initialize(dir:) @@ -140,6 +140,10 @@ def in_process_sync end def store_key(labels) + if @values_aggregation_mode == ALL + labels[:pid] = process_id + end + labels.map{|k,v| "#{CGI::escape(k.to_s)}=#{CGI::escape(v.to_s)}"}.join('&') end @@ -168,6 +172,8 @@ def aggregate_values(values) values.max elsif @values_aggregation_mode == MIN values.min + elsif @values_aggregation_mode == ALL + values.first else raise InvalidStoreSettingsError, "Invalid Aggregation Mode: #{ @values_aggregation_mode }" diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb index fa1d335b..9285f941 100644 --- a/spec/prometheus/client/data_stores/direct_file_store_spec.rb +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -150,6 +150,42 @@ end end + context "with a metric that takes ALL instead of SUM" do + it "reports all the values from different processes" do + allow(Process).to receive(:pid).and_return(12345) + metric_store1 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :all } + ) + metric_store1.set(labels: { foo: "bar" }, val: 1) + metric_store1.set(labels: { foo: "baz" }, val: 7) + metric_store1.set(labels: { foo: "yyy" }, val: 3) + + allow(Process).to receive(:pid).and_return(23456) + metric_store2 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :all } + ) + metric_store2.set(labels: { foo: "bar" }, val: 3) + metric_store2.set(labels: { foo: "baz" }, val: 2) + metric_store2.set(labels: { foo: "zzz" }, val: 1) + + expect(metric_store1.all_values).to eq( + { foo: "bar", pid: "12345" } => 1.0, + { foo: "bar", pid: "23456" } => 3.0, + { foo: "baz", pid: "12345" } => 7.0, + { foo: "baz", pid: "23456" } => 2.0, + { foo: "yyy", pid: "12345" } => 3.0, + { foo: "zzz", pid: "23456" } => 1.0, + ) + + # Both processes should return the same value + expect(metric_store1.all_values).to eq(metric_store2.all_values) + end + end + it "resizes the File if metrics get too big" do truncate_calls_count = 0 allow_any_instance_of(Prometheus::Client::DataStores::DirectFileStore::FileMappedDict). From b0a49cd582649ed08e89d237f0d783e63b6fdd20 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 12 Jun 2019 15:02:37 +0100 Subject: [PATCH 040/189] Make 'pid' a reserved label Signed-off-by: Chris Sinjakli --- lib/prometheus/client/label_set_validator.rb | 2 +- spec/prometheus/client/label_set_validator_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index aae97e03..3ec101ad 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -6,7 +6,7 @@ module Client # Prometheus specification. class LabelSetValidator # TODO: we might allow setting :instance in the future - BASE_RESERVED_LABELS = [:job, :instance].freeze + BASE_RESERVED_LABELS = [:job, :instance, :pid].freeze class LabelSetError < StandardError; end class InvalidLabelSetError < LabelSetError; end diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index 770b9d38..a17e56b5 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -37,7 +37,7 @@ end it 'raises ReservedLabelError if a label key is reserved' do - [:job, :instance].each do |label| + [:job, :instance, :pid].each do |label| expect do validator.validate_symbols!(label => 'value') end.to raise_exception(described_class::ReservedLabelError) From e66ab1bd4152d0ebc22bf6649abb0a6a3c726047 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 12 Jun 2019 15:22:30 +0100 Subject: [PATCH 041/189] Update README with info on reserved labels and :all aggregation Signed-off-by: Chris Sinjakli --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c230afbd..64062e48 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,15 @@ class MyComponent end ``` +### Reserved labels + +The following labels are reserved by the client library, and attempting to use them in a +metric definition will result in a +`Prometheus::Client::LabelSetValidator::ReservedLabelError` being raised: + + - `:job` + - `:instance` + - `:pid` ## Data Stores @@ -362,7 +371,8 @@ summing the values of each process. For Gauges, however, this may not be the right thing to do, depending on what they're measuring. You might want to take the maximum or minimum value observed in any process, -rather than the sum of all of them. +rather than the sum of all of them. You may also want to export each process's individual +value. In those cases, you should use the `store_settings` parameter when registering the metric, to specify an `:aggregation` setting. From 34555b7555d9dcb457bd196a2fd54ef218917d0a Mon Sep 17 00:00:00 2001 From: Yuta Iwama Date: Fri, 14 Jun 2019 16:32:38 +0900 Subject: [PATCH 042/189] Update README Summary#get returns Hash instance https://github.com/prometheus/client_ruby/blob/3652cf70c9f026d59d64594aa5fa03e4a740128a/lib/prometheus/client/summary.rb#L31-L33 Signed-off-by: Yuta Iwama --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c230afbd..df480fbf 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,8 @@ summary.observe(Benchmark.realtime { service.call() }, labels: { service: 'datab # retrieve the current sum and total values summary_value = summary.get(labels: { service: 'database' }) -summary_value.sum # => 123.45 -summary_value.count # => 100 +summary_value['sum'] # => 123.45 +summary_value['count'] # => 100 ``` ## Labels From cbecc2514ffa40dee8b73d8b8a1fc7198832e178 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 14 Jun 2019 17:55:03 +0100 Subject: [PATCH 043/189] Export per-pid values from gauges by default Signed-off-by: Chris Sinjakli --- .../client/data_stores/direct_file_store.rb | 8 +- .../data_stores/direct_file_store_spec.rb | 84 +++++++++++++------ 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 8c653e31..59b6e53f 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -28,6 +28,7 @@ class DirectFileStore class InvalidStoreSettingsError < StandardError; end AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum, ALL = :all] DEFAULT_METRIC_SETTINGS = { aggregation: SUM } + DEFAULT_GAUGE_SETTINGS = { aggregation: ALL } def initialize(dir:) @store_settings = { dir: dir } @@ -35,7 +36,12 @@ def initialize(dir:) end def for_metric(metric_name, metric_type:, metric_settings: {}) - settings = DEFAULT_METRIC_SETTINGS.merge(metric_settings) + default_settings = DEFAULT_METRIC_SETTINGS + if metric_type == :gauge + default_settings = DEFAULT_GAUGE_SETTINGS + end + + settings = default_settings.merge(metric_settings) validate_metric_settings(settings) MetricStore.new(metric_name: metric_name, diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb index 9285f941..52611adb 100644 --- a/spec/prometheus/client/data_stores/direct_file_store_spec.rb +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -58,28 +58,64 @@ end - it "sums values from different processes" do - allow(Process).to receive(:pid).and_return(12345) - metric_store1 = subject.for_metric(:metric_name, metric_type: :counter) - metric_store1.set(labels: { foo: "bar" }, val: 1) - metric_store1.set(labels: { foo: "baz" }, val: 7) - metric_store1.set(labels: { foo: "yyy" }, val: 3) - - allow(Process).to receive(:pid).and_return(23456) - metric_store2 = subject.for_metric(:metric_name, metric_type: :counter) - metric_store2.set(labels: { foo: "bar" }, val: 3) - metric_store2.set(labels: { foo: "baz" }, val: 2) - metric_store2.set(labels: { foo: "zzz" }, val: 1) - - expect(metric_store2.all_values).to eq( - { foo: "bar" } => 4.0, - { foo: "baz" } => 9.0, - { foo: "yyy" } => 3.0, - { foo: "zzz" } => 1.0, - ) - - # Both processes should return the same value - expect(metric_store1.all_values).to eq(metric_store2.all_values) + context "for a non-gauge metric" do + it "sums values from different processes by default" do + allow(Process).to receive(:pid).and_return(12345) + metric_store1 = subject.for_metric(:metric_name, metric_type: :counter) + metric_store1.set(labels: { foo: "bar" }, val: 1) + metric_store1.set(labels: { foo: "baz" }, val: 7) + metric_store1.set(labels: { foo: "yyy" }, val: 3) + + allow(Process).to receive(:pid).and_return(23456) + metric_store2 = subject.for_metric(:metric_name, metric_type: :counter) + metric_store2.set(labels: { foo: "bar" }, val: 3) + metric_store2.set(labels: { foo: "baz" }, val: 2) + metric_store2.set(labels: { foo: "zzz" }, val: 1) + + expect(metric_store2.all_values).to eq( + { foo: "bar" } => 4.0, + { foo: "baz" } => 9.0, + { foo: "yyy" } => 3.0, + { foo: "zzz" } => 1.0, + ) + + # Both processes should return the same value + expect(metric_store1.all_values).to eq(metric_store2.all_values) + end + end + + context "for a gauge metric" do + it "exposes each process's individual value by default" do + allow(Process).to receive(:pid).and_return(12345) + metric_store1 = subject.for_metric( + :metric_name, + metric_type: :gauge, + ) + metric_store1.set(labels: { foo: "bar" }, val: 1) + metric_store1.set(labels: { foo: "baz" }, val: 7) + metric_store1.set(labels: { foo: "yyy" }, val: 3) + + allow(Process).to receive(:pid).and_return(23456) + metric_store2 = subject.for_metric( + :metric_name, + metric_type: :gauge, + ) + metric_store2.set(labels: { foo: "bar" }, val: 3) + metric_store2.set(labels: { foo: "baz" }, val: 2) + metric_store2.set(labels: { foo: "zzz" }, val: 1) + + expect(metric_store1.all_values).to eq( + { foo: "bar", pid: "12345" } => 1.0, + { foo: "bar", pid: "23456" } => 3.0, + { foo: "baz", pid: "12345" } => 7.0, + { foo: "baz", pid: "23456" } => 2.0, + { foo: "yyy", pid: "12345" } => 3.0, + { foo: "zzz", pid: "23456" } => 1.0, + ) + + # Both processes should return the same value + expect(metric_store1.all_values).to eq(metric_store2.all_values) + end end context "with a metric that takes MAX instead of SUM" do @@ -155,7 +191,7 @@ allow(Process).to receive(:pid).and_return(12345) metric_store1 = subject.for_metric( :metric_name, - metric_type: :gauge, + metric_type: :counter, metric_settings: { aggregation: :all } ) metric_store1.set(labels: { foo: "bar" }, val: 1) @@ -165,7 +201,7 @@ allow(Process).to receive(:pid).and_return(23456) metric_store2 = subject.for_metric( :metric_name, - metric_type: :gauge, + metric_type: :counter, metric_settings: { aggregation: :all } ) metric_store2.set(labels: { foo: "bar" }, val: 3) From 640194536647081cdca213609cb21c02bbfb0574 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 17 Jun 2019 17:52:20 +0100 Subject: [PATCH 044/189] Remove `concurrent-ruby` dependency We added this dependency to be able to use `ReadWriteLock`, which is a reentract lock. We did not need the Read/Write locking distinction, and at the time, we didn't know that `Monitor` was reentrant, so this seemed to be the best solution. Knowing that `Monitor` is reentrant, we can get rid of the dependency, and simply use `Monitor` instead. Note that we keep `concurrent-ruby` as a *dev* dependency, because the performance benchmarks use other primitives provided by it to synvhronize their threads, but it's not needed for specs to pass, or for production use. Signed-off-by: Daniel Magliola --- lib/prometheus/client/data_stores/direct_file_store.rb | 5 ++--- lib/prometheus/client/data_stores/single_threaded.rb | 2 -- lib/prometheus/client/data_stores/synchronized.rb | 6 ++---- prometheus-client.gemspec | 3 +-- spec/benchmarks/data_stores.rb | 1 + 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 59b6e53f..5f06eff0 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -1,4 +1,3 @@ -require 'concurrent' require 'fileutils' require "cgi" @@ -72,7 +71,7 @@ def initialize(metric_name:, store_settings:, metric_settings:) @store_settings = store_settings @values_aggregation_mode = metric_settings[:aggregation] - @rwlock = Concurrent::ReentrantReadWriteLock.new + @lock = Monitor.new end # Synchronize is used to do a multi-process Mutex, when incrementing multiple @@ -142,7 +141,7 @@ def all_values private def in_process_sync - @rwlock.with_write_lock { yield } + @lock.synchronize { yield } end def store_key(labels) diff --git a/lib/prometheus/client/data_stores/single_threaded.rb b/lib/prometheus/client/data_stores/single_threaded.rb index e4cb6a06..f05cf813 100644 --- a/lib/prometheus/client/data_stores/single_threaded.rb +++ b/lib/prometheus/client/data_stores/single_threaded.rb @@ -1,5 +1,3 @@ -require 'concurrent' - module Prometheus module Client module DataStores diff --git a/lib/prometheus/client/data_stores/synchronized.rb b/lib/prometheus/client/data_stores/synchronized.rb index 17e2b715..d0a74608 100644 --- a/lib/prometheus/client/data_stores/synchronized.rb +++ b/lib/prometheus/client/data_stores/synchronized.rb @@ -1,5 +1,3 @@ -require 'concurrent' - module Prometheus module Client module DataStores @@ -27,11 +25,11 @@ def validate_metric_settings(metric_settings:) class MetricStore def initialize @internal_store = Hash.new { |hash, key| hash[key] = 0.0 } - @rwlock = Concurrent::ReentrantReadWriteLock.new + @lock = Monitor.new end def synchronize - @rwlock.with_write_lock { yield } + @lock.synchronize { yield } end def set(labels:, val:) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 6e1f5e8b..20ad18bf 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -15,7 +15,6 @@ Gem::Specification.new do |s| s.files = %w(README.md) + Dir.glob('{lib/**/*}') s.require_paths = ['lib'] - s.add_dependency 'concurrent-ruby' - s.add_development_dependency 'benchmark-ips' + s.add_development_dependency 'concurrent-ruby' end diff --git a/spec/benchmarks/data_stores.rb b/spec/benchmarks/data_stores.rb index 940d38a1..773604df 100644 --- a/spec/benchmarks/data_stores.rb +++ b/spec/benchmarks/data_stores.rb @@ -1,4 +1,5 @@ require 'benchmark' +require 'concurrent' require 'prometheus/client' require 'prometheus/client/counter' require 'prometheus/client/histogram' From 62c30339a9e671c403ac53e7eb1fd60f4d3ae375 Mon Sep 17 00:00:00 2001 From: Joao Bernardo Date: Tue, 18 Jun 2019 13:17:11 +0100 Subject: [PATCH 045/189] Fixes bug that prevented store settings from being passed to the metrics This commit fixes a bug in the registry that prevented the user from configuring the store settings of new metrics. Signed-off-by: Joao Bernardo --- lib/prometheus/client/registry.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/prometheus/client/registry.rb b/lib/prometheus/client/registry.rb index 6f13b1e0..4bf63aa4 100644 --- a/lib/prometheus/client/registry.rb +++ b/lib/prometheus/client/registry.rb @@ -42,7 +42,7 @@ def counter(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) docstring: docstring, labels: labels, preset_labels: preset_labels, - store_settings: {})) + store_settings: store_settings)) end def summary(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) @@ -50,7 +50,7 @@ def summary(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) docstring: docstring, labels: labels, preset_labels: preset_labels, - store_settings: {})) + store_settings: store_settings)) end def gauge(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) @@ -58,7 +58,7 @@ def gauge(name, docstring:, labels: [], preset_labels: {}, store_settings: {}) docstring: docstring, labels: labels, preset_labels: preset_labels, - store_settings: {})) + store_settings: store_settings)) end def histogram(name, docstring:, labels: [], preset_labels: {}, @@ -69,7 +69,7 @@ def histogram(name, docstring:, labels: [], preset_labels: {}, labels: labels, preset_labels: preset_labels, buckets: buckets, - store_settings: {})) + store_settings: store_settings)) end def exist?(name) From 1b23a47a84766c4628400d9b15c4e62c66392f69 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 25 Jun 2019 13:22:50 +0100 Subject: [PATCH 046/189] Prepare for 0.10.0-alpha.2 --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index c8bf057f..15a6b38e 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '0.10.0-alpha.1' + VERSION = '0.10.0-alpha.2' end end From 9e31202879527c118d2332a162003e05dde896ab Mon Sep 17 00:00:00 2001 From: Lawrence Jones Date: Mon, 17 Jun 2019 08:35:29 +0100 Subject: [PATCH 047/189] COMPATIBILITY.md Create a starting compatibility guide that can help maintainers support the on-going development of this client. Signed-off-by: Lawrence Jones --- COMPATIBILITY.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 COMPATIBILITY.md diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 00000000..1bbf5511 --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,25 @@ +# Compatibility + +We aim for the Prometheus Ruby client to be compatible with all supported +versions of Ruby, across the MRI and JRuby platforms. + +Any Ruby version that has not received an End-of-Life notice (e.g. +[this notice for Ruby 2.1](https://www.ruby-lang.org/en/news/2017/04/01/support-of-ruby-2-1-has-ended/)) +is supported. + +To ensure we're meeting these guidelines, we test the client against all +supported versions, as specified in our [build matrix](.travis.yml). + +# Deprecation + +Whenever a version of Ruby falls out of support we will mirror that change in +the Prometheus Ruby client by updating the build matrix and releasing a new +major version. + +At that point we will close any issues that affect only the unsupported version, +and may choose to remove any workarounds from the code that are only necessary +for the unsupported version. + +The major version bump signals the break in compatibility. If the client happens +to work on unsupported versions of Ruby this is by chance, and we wouldn't +consider that version to be officially supported. From 500aa48cdebc6f58348e4de1af52f49c72969993 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 16 Jul 2019 18:49:21 +0100 Subject: [PATCH 048/189] Remove Labelset Validator Cache Currently, when we validate a labelset on increment, we cache that we've seen this particular labelset, so we can avoid revalidating it next time we see it. This is currently not thread-safe, since we're not synchronizing access to the has we use for that cache. This is only a problem in non-MRI, but we are supporting those. Removing this cache has a slight performance hit for metrics with labels, but removing the cache, in conjunction with the next commit in this PR, makes the code *way* faster than the current version, while simplifying the code, so we're opting for that. The other alternative would have veen to add a Mutex around that Hash. However, that makes it slower than the original, even if we do the change from the next commit, so we're going for the simpler, much faster, much more obvious path of not caching this validation. This is probably also going to have a positive effect on memory usage. That cache is definitely not free. Signed-off-by: Daniel Magliola --- lib/prometheus/client/label_set_validator.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index 3ec101ad..39f59deb 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -18,7 +18,6 @@ class ReservedLabelError < LabelSetError; end def initialize(expected_labels:, reserved_labels: []) @expected_labels = expected_labels.sort @reserved_labels = BASE_RESERVED_LABELS + reserved_labels - @validated = {} end def validate_symbols!(labels) @@ -34,8 +33,6 @@ def validate_symbols!(labels) end def validate_labelset!(labelset) - return labelset if @validated.key?(labelset.hash) - validate_symbols!(labelset) unless keys_match?(labelset) @@ -44,7 +41,7 @@ def validate_labelset!(labelset) " keys expected: #{expected_labels}" end - @validated[labelset.hash] = labelset + labelset end private From a76c45d3a2c7897367cba1eab8413ea9c094e440 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 16 Jul 2019 19:16:35 +0100 Subject: [PATCH 049/189] Avoid revalidating labelset keys on each metric observation When declaring a metric, we declare what label keys will be used for it. At that point, we validate that those are all valid keys (symbols, match a given regex, etc). Then, on each observation of the metric, we validate that the keys passed in for the labels match the ones we were originally expecting, to make sure all of those labels were set, but no others. We were also, at that point, validating that the keys passed in are valid. This validation is pretty slow, and it's redundant, since keys that aren't valid won't match the expected ones anyway, so we can just compare just that those match. This has quite a pronounced effect on performance. Signed-off-by: Daniel Magliola --- lib/prometheus/client/label_set_validator.rb | 16 +++++++++------- spec/prometheus/client/histogram_spec.rb | 2 +- .../client/label_set_validator_spec.rb | 10 +++++----- spec/prometheus/client/summary_spec.rb | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index 39f59deb..8b28a42d 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -33,15 +33,17 @@ def validate_symbols!(labels) end def validate_labelset!(labelset) - validate_symbols!(labelset) - - unless keys_match?(labelset) - raise InvalidLabelSetError, "labels must have the same signature " \ - "(keys given: #{labelset.keys.sort} vs." \ - " keys expected: #{expected_labels}" + begin + return labelset if keys_match?(labelset) + rescue ArgumentError + # If labelset contains keys that are a mixture of strings and symbols, this will + # raise when trying to sort them, but the error should be the same: + # InvalidLabelSetError end - labelset + raise InvalidLabelSetError, "labels must have the same signature " \ + "(keys given: #{labelset.keys} vs." \ + " keys expected: #{expected_labels}" end private diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index c64c6218..5fca9731 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -47,7 +47,7 @@ it 'raise error for le labels' do expect do histogram.observe(5, labels: { le: 1 }) - end.to raise_error Prometheus::Client::LabelSetValidator::ReservedLabelError + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError end it 'raises an InvalidLabelSetError if sending unexpected labels' do diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index a17e56b5..4e8d9b1b 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -54,17 +54,17 @@ expect(validator.validate_labelset!(hash)).to eql(hash) end - it 'raises an exception if a given label set is not `validate_symbols!`' do - input = 'broken' - expect(validator).to receive(:validate_symbols!).with(input).and_raise(invalid) + it 'returns an exception if there are malformed labels' do + expect do + validator.validate_labelset!('method' => 'get', :code => '200') + end.to raise_exception(invalid, /keys given: \["method", :code\] vs. keys expected: \[:code, :method\]/) - expect { validator.validate_labelset!(input) }.to raise_exception(invalid) end it 'raises an exception if there are unexpected labels' do expect do validator.validate_labelset!(method: 'get', code: '200', exception: 'NoMethodError') - end.to raise_exception(invalid, /keys given: \[:code, :exception, :method\] vs. keys expected: \[:code, :method\]/) + end.to raise_exception(invalid, /keys given: \[:method, :code, :exception\] vs. keys expected: \[:code, :method\]/) end it 'raises an exception if there are missing labels' do diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index c69f754f..5e052fe6 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -42,7 +42,7 @@ it 'raise error for quantile labels' do expect do summary.observe(5, labels: { quantile: 1 }) - end.to raise_error Prometheus::Client::LabelSetValidator::ReservedLabelError + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError end it 'raises an InvalidLabelSetError if sending unexpected labels' do From 9f7603bef92dd5f382bb47c509454feef957dd00 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 16 Jul 2019 19:38:31 +0100 Subject: [PATCH 050/189] Convert label values to strings in base Metric class Currently, you get an inconsistent experience when using different store implementations (e.g. `SingleThreaded` vs `DirectFileStore`). Because `DirectFileStore` has to write your metrics into a file, it has to convert label values into strings. That means that when you access them they're strings. Other stores don't do this, so you can access them as symbols if you provided them as symbols. To give users a consistent experience when swapping between stores, this commit changes the Metric base class to convert the label values to strings before they get anywhere near the underlying store. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/metric.rb | 7 +++++- spec/examples/metric_example.rb | 26 +++++++++++++++++++++ spec/prometheus/client/counter_spec.rb | 23 +++++++++++++++++++ spec/prometheus/client/gauge_spec.rb | 23 +++++++++++++++++++ spec/prometheus/client/histogram_spec.rb | 29 ++++++++++++++++++++++++ spec/prometheus/client/summary_spec.rb | 27 ++++++++++++++++++++++ 6 files changed, 134 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index 1bb43347..fe08db3a 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -27,7 +27,7 @@ def initialize(name, @name = name @docstring = docstring - @preset_labels = preset_labels + @preset_labels = stringify_values(preset_labels) @store = Prometheus::Client.config.data_store.for_metric( name, @@ -85,8 +85,13 @@ def validate_docstring(docstring) def label_set_for(labels) # We've already validated, and there's nothing to merge. Save some cycles return preset_labels if @all_labels_preset && labels.empty? + labels = stringify_values(labels) @validator.validate_labelset!(preset_labels.merge(labels)) end + + def stringify_values(labels) + labels.map { |k,v| [k, v.to_s] }.to_h + end end end end diff --git a/spec/examples/metric_example.rb b/spec/examples/metric_example.rb index ced2c317..ec9fd0da 100644 --- a/spec/examples/metric_example.rb +++ b/spec/examples/metric_example.rb @@ -1,5 +1,31 @@ # encoding: UTF-8 +# TODO: Convert these tests to use a fake metric class rather than shared examples +# +# Right now, we're using shared examples that we include in every metric type's tests +# to validate the behaviour of the base metric class. +# +# This makes it difficult to test certain behaviour, as the interfaces of those metric +# types differ and these tests can end up needing to know about them. +# +# You can see that in the tests for #get, which depend on `type` which isn't defined in +# this file. The test files that include these shared examples have to do so with a block +# that provides the `type` variable. +# +# This cropped up in a much worse way when trying to test the code that makes sure label +# values are all strings. Writing a test here that gets included in all the real metric +# implementations is near impossible. You need your test to call a different method to +# alter a metric value (e.g. `set`, `increment` or `observe` depending on the metric type) +# which means having each concrete metric type's tests passing us a lambda that we can +# call agnostically of the metric type. +# +# The resultant code is confusing to follow, so we opted to duplicate those tests in each +# metric type's test file. +# +# Changing this file to implement a fake metric class (e.g. `FakeTestCounter`) would let +# us easily test the functionality of the base `Prometheus::Client::Metric` without +# getting caught up in the specifics of the real metric types. + shared_examples_for Prometheus::Client::Metric do subject { described_class.new(:foo, docstring: 'foo description') } diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 806c55b6..8953d3c5 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -78,5 +78,28 @@ end.each(&:join) end.to change { counter.get }.by(100.0) end + + context "with non-string label values" do + subject { described_class.new(:foo, docstring: 'Labels', labels: [:foo]) } + + it "converts labels to strings for consistent storage" do + subject.increment(labels: { foo: :label }) + expect(subject.get(labels: { foo: 'label' })).to eq(1.0) + end + + context "and some labels preset" do + subject do + described_class.new(:foo, + docstring: 'Labels', + labels: [:foo, :bar], + preset_labels: { foo: :label }) + end + + it "converts labels to strings for consistent storage" do + subject.increment(labels: { bar: :label }) + expect(subject.get(labels: { foo: 'label', bar: 'label' })).to eq(1.0) + end + end + end end end diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index 9476272f..90d60c91 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -110,6 +110,29 @@ end.each(&:join) end.to change { gauge.get }.by(100.0) end + + context "with non-string label values" do + subject { described_class.new(:foo, docstring: 'Labels', labels: [:foo]) } + + it "converts labels to strings for consistent storage" do + subject.increment(labels: { foo: :label }) + expect(subject.get(labels: { foo: 'label' })).to eq(1.0) + end + + context "and some labels preset" do + subject do + described_class.new(:foo, + docstring: 'Labels', + labels: [:foo, :bar], + preset_labels: { foo: :label }) + end + + it "converts labels to strings for consistent storage" do + subject.increment(labels: { bar: :label }) + expect(subject.get(labels: { foo: 'label', bar: 'label' })).to eq(1.0) + end + end + end end describe '#decrement' do diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index 5fca9731..4338882d 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -73,6 +73,35 @@ expect { histogram.with_labels(test: 'value').observe(2) }.not_to raise_error end end + + context "with non-string label values" do + let(:histogram) do + described_class.new(:foo, + docstring: 'foo description', + labels: [:foo], + buckets: [2.5, 5, 10]) + end + + it "converts labels to strings for consistent storage" do + histogram.observe(5, labels: { foo: :label }) + expect(histogram.get(labels: { foo: 'label' })["10"]).to eq(1.0) + end + + context "and some labels preset" do + let(:histogram) do + described_class.new(:foo, + docstring: 'foo description', + labels: [:foo, :bar], + preset_labels: { foo: :label }, + buckets: [2.5, 5, 10]) + end + + it "converts labels to strings for consistent storage" do + histogram.observe(5, labels: { bar: :label }) + expect(histogram.get(labels: { foo: 'label', bar: 'label' })["10"]).to eq(1.0) + end + end + end end describe '#get' do diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index 5e052fe6..04898f67 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -68,6 +68,33 @@ expect { summary.with_labels(test: 'value').observe(2) }.not_to raise_error end end + + context "with non-string label values" do + let(:summary) do + described_class.new(:foo, + docstring: 'foo description', + labels: [:foo]) + end + + it "converts labels to strings for consistent storage" do + summary.observe(5, labels: { foo: :label }) + expect(summary.get(labels: { foo: 'label' })["count"]).to eq(1.0) + end + + context "and some labels preset" do + let(:summary) do + described_class.new(:foo, + docstring: 'foo description', + labels: [:foo, :bar], + preset_labels: { foo: :label }) + end + + it "converts labels to strings for consistent storage" do + summary.observe(5, labels: { bar: :label }) + expect(summary.get(labels: { foo: 'label', bar: 'label' })["count"]).to eq(1.0) + end + end + end end describe '#get' do From df4d28b5d32011aecc091b5674be7f79fde3ec2a Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 17 Jul 2019 18:48:18 +0100 Subject: [PATCH 051/189] Optimise stringification of label values Signed-off-by: Chris Sinjakli --- lib/prometheus/client/metric.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index fe08db3a..b9b7ed09 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -90,7 +90,10 @@ def label_set_for(labels) end def stringify_values(labels) - labels.map { |k,v| [k, v.to_s] }.to_h + stringified = {} + labels.each { |k,v| stringified[k] = v.to_s } + + stringified end end end From 9d3fb56e462eb4e2f1172b4f38680bf83f42bfd9 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 23 Jul 2019 18:17:51 +0100 Subject: [PATCH 052/189] Test that stores return floats and fix DirectFileStore to do so Up to now, we've had some subtle behavioural differences between stores; one of which was that stores weren't consistent about what they returned for label sets that hadn't been initialised. This commit adds some shared examples that enforce consistent behaviour between the stores and fixes DirectFileStore to follow that behaviour. Signed-off-by: Chris Sinjakli --- .../client/data_stores/direct_file_store.rb | 3 ++- spec/examples/data_store_example.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 5f06eff0..e9b0dcd3 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -133,7 +133,8 @@ def all_values end # Aggregate all the different values for each label_set - stores_data.each_with_object({}) do |(label_set, values), acc| + aggregate_hash = Hash.new { |hash, key| hash[key] = 0.0 } + stores_data.each_with_object(aggregate_hash) do |(label_set, values), acc| acc[label_set] = aggregate_values(values) end end diff --git a/spec/examples/data_store_example.rb b/spec/examples/data_store_example.rb index 23e76d3d..d3b0702b 100644 --- a/spec/examples/data_store_example.rb +++ b/spec/examples/data_store_example.rb @@ -1,6 +1,12 @@ # encoding: UTF-8 shared_examples_for Prometheus::Client::DataStores do + describe "MetricStore#set and #get" do + it "returns the value set for each labelset" do + expect(metric_store.get(labels: { foo: "bar" })).to eq(0.0) + end + end + describe "MetricStore#set and #get" do it "returns the value set for each labelset" do metric_store.set(labels: { foo: "bar" }, val: 5) @@ -54,5 +60,11 @@ { foo: "baz" } => 2.0, ) end + + context "for a combination of labels that hasn't had a value set" do + it "returns 0.0" do + expect(metric_store.all_values[{ foo: "bar" }]).to eq(0.0) + end + end end end From 42088b3626d90d9458550fcd57b76e067aed8f29 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 24 Jul 2019 11:49:33 +0100 Subject: [PATCH 053/189] Make DataStores shared examples enforce floats for metric values The interface of a store is a labelset (hash of hashes) to a double. It's important that we check the values are doubles rather than integers. `==`, which is what `eq` calls allows conversion between floats and integers (i.e. `5 == 5.0`). `eql` enforces that the two numbers are of the same type. Signed-off-by: Chris Sinjakli --- spec/examples/data_store_example.rb | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/spec/examples/data_store_example.rb b/spec/examples/data_store_example.rb index d3b0702b..c60d7928 100644 --- a/spec/examples/data_store_example.rb +++ b/spec/examples/data_store_example.rb @@ -1,9 +1,15 @@ # encoding: UTF-8 +# NOTE: Do not change instances of `eql` to `eq` in this file. +# +# The interface of a store is a labelset (hash of hashes) to a double. It's important +# that we check the values are doubles rather than integers. `==`, which is what `eq` +# calls allows conversion between floats and integers (i.e. `5 == 5.0`). `eql` enforces +# that the two numbers are of the same type. shared_examples_for Prometheus::Client::DataStores do describe "MetricStore#set and #get" do it "returns the value set for each labelset" do - expect(metric_store.get(labels: { foo: "bar" })).to eq(0.0) + expect(metric_store.get(labels: { foo: "bar" })).to eql(0.0) end end @@ -11,9 +17,9 @@ it "returns the value set for each labelset" do metric_store.set(labels: { foo: "bar" }, val: 5) metric_store.set(labels: { foo: "baz" }, val: 2) - expect(metric_store.get(labels: { foo: "bar" })).to eq(5) - expect(metric_store.get(labels: { foo: "baz" })).to eq(2) - expect(metric_store.get(labels: { foo: "bat" })).to eq(0) + expect(metric_store.get(labels: { foo: "bar" })).to eql(5.0) + expect(metric_store.get(labels: { foo: "baz" })).to eql(2.0) + expect(metric_store.get(labels: { foo: "bat" })).to eql(0.0) end end @@ -26,9 +32,9 @@ metric_store.increment(labels: { foo: "baz" }, by: 7) metric_store.increment(labels: { foo: "zzz" }, by: 3) - expect(metric_store.get(labels: { foo: "bar" })).to eq(6) - expect(metric_store.get(labels: { foo: "baz" })).to eq(9) - expect(metric_store.get(labels: { foo: "zzz" })).to eq(3) + expect(metric_store.get(labels: { foo: "bar" })).to eql(6.0) + expect(metric_store.get(labels: { foo: "baz" })).to eql(9.0) + expect(metric_store.get(labels: { foo: "zzz" })).to eql(3.0) end end @@ -55,7 +61,7 @@ metric_store.set(labels: { foo: "bar" }, val: 5) metric_store.set(labels: { foo: "baz" }, val: 2) - expect(metric_store.all_values).to eq( + expect(metric_store.all_values).to eql( { foo: "bar" } => 5.0, { foo: "baz" } => 2.0, ) @@ -63,7 +69,7 @@ context "for a combination of labels that hasn't had a value set" do it "returns 0.0" do - expect(metric_store.all_values[{ foo: "bar" }]).to eq(0.0) + expect(metric_store.all_values[{ foo: "bar" }]).to eql(0.0) end end end From 8a170e4afc91977a5468e2ded67a33fa9de2bf90 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 26 Jul 2019 13:33:43 +0100 Subject: [PATCH 054/189] Reopen files in DirectFileStore when the process has forked Currently, if you fork after altering a metric value when using DirectFileStore, the child process will have a handle to the original process's metric store. This commit detects checks for that condition on any instrumentation event and opens a new metric file for the new PID. Signed-off-by: Chris Sinjakli --- .../client/data_stores/direct_file_store.rb | 7 ++++++- .../client/data_stores/direct_file_store_spec.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index e9b0dcd3..9dbab76e 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -154,7 +154,12 @@ def store_key(labels) end def internal_store - @internal_store ||= FileMappedDict.new(filemap_filename) + if @store_opened_by_pid != process_id + @store_opened_by_pid = process_id + @internal_store = FileMappedDict.new(filemap_filename) + else + @internal_store + end end # Filename for this metric's PStore (one per process) diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb index 52611adb..ba9d7d15 100644 --- a/spec/prometheus/client/data_stores/direct_file_store_spec.rb +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -57,6 +57,18 @@ ms2.increment(labels: {}, by: 1) end + context "when process is forked" do + it "opens a new internal store to avoid two processes using the same file" do + allow(Process).to receive(:pid).and_return(12345) + metric_store = subject.for_metric(:metric_name, metric_type: :counter) + metric_store.increment(labels: {}, by: 1) + + allow(Process).to receive(:pid).and_return(23456) + metric_store.increment(labels: {}, by: 1) + expect(Dir.glob('/tmp/prometheus_test/*').size).to eq(2) + expect(metric_store.all_values).to eq({} => 2.0) + end + end context "for a non-gauge metric" do it "sums values from different processes by default" do From 5d7612b2c0fbf75201ed90e9efc487edc06774ea Mon Sep 17 00:00:00 2001 From: rsetia Date: Fri, 12 Apr 2019 17:02:39 -0700 Subject: [PATCH 055/189] Add upgrade documentation for 0.9 to 1.x Describe objectives described here: https://github.com/prometheus/client_ruby/pull/95 Signed-off-by: rsetia --- UPGRADE.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 UPGRADE.md diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..90db48fd --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,127 @@ +# Upgrade from 0.9 to 1.x + +## Objectives + +This major upgrade achieves the following objectives: + +1. Follow [client conventions and best practices](https://prometheus.io/docs/instrumenting/writing_clientlibs/) +2. Add the notion of Pluggable backends. Client should be configurable with different backends: thread-safe (default), thread-unsafe (lock-free for performance on single-threaded cases), multiprocess, etc. +Consumers should be able to build and plug their own backends based on their use cases. + +## Ruby + +The minimum supported Ruby version is 2.0.0. + +## Data Stores + +You can specify the data store implementation depending on your needs. + +For example, if you are running a pre-fork application using Unicorn you will need a way to aggregate the metrics from each of your workers before Prometheus scrapes them. + +This can be achieved with DirectFileStore. + +```ruby +Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir: '/tmp/direct_file_store') +``` + +## kwargs + +Certain parameters are now keyword arguments. + +### 0.9 +```ruby +counter = Prometheus::Client::Counter.new(:service_requests_total, '...') +``` + +### 1.x +```ruby +counter = Prometheus::Client::Counter.new(:service_requests_total, docstring: '...') +``` + +### Labels + +Labels are set when the metric is defined as opposed to when it is first used. + +### 0.9 + +```ruby +counter = Prometheus::Client::Counter.new(:service_requests_total, '...') +counter.increment({ service: 'foo' }) +``` + +### 1.x + +```ruby +counter = Prometheus::Client::Counter.new(:service_requests_total, docstring: '...', labels: [:service]) +counter.increment(labels: { service: 'foo' }) +``` + +## Histograms + +Keys from the get method are now strings. + +Histograms now include a "+Inf" bucket as well as the sum of all observations. + +### 0.9 + +```ruby +histogram = Prometheus::Client::Histogram.new(:service_latency_seconds, '...', {}, [0.1, 0.3, 1.2]) + +histogram.observe({ service: 'users' }, 0.1) +histogram.observe({ service: 'users' }, 0.3) +histogram.observe({ service: 'users' }, 0.4) +histogram.observe({ service: 'users' }, 1.2) +histogram.observe({ service: 'users' }, 1.5) + +histogram.get({ service: 'users' }) +# => {0.1=>1.0, 0.3=>2.0, 1.2=>4.0} +``` +### 1.x + +```ruby +histogram = Prometheus::Client::Histogram.new(:service_latency_seconds, docstring: '...', labels: [:service], buckets: [0.1, 0.3, 1.2]) + +histogram.observe(0.1, labels: { service: 'users' }) +histogram.observe(0.3, labels: { service: 'users' }) +histogram.observe(0.4, labels: { service: 'users' }) +histogram.observe(1.2, labels: { service: 'users' }) +histogram.observe(1.5, labels: { service: 'users' }) + +histogram.get(labels: { service: 'users' }) +# => {"0.1"=>0.0, "0.3"=>1.0, "1.2"=>3.0, "+Inf"=>5.0, "sum"=>3.5} +``` + +## Summaries + +Keys from the get method are now strings. + +Summaries no longer include quantiles. They include the sum and the count instead. + +### 0.9 + +```ruby +summary = Prometheus::Client::Histogram.new(:service_latency_seconds, '...', {}, [0.1, 0.3, 1.2]) + +summary.observe({ service: 'users' }, 0.1) +summary.observe({ service: 'users' }, 0.3) +summary.observe({ service: 'users' }, 0.4) +summary.observe({ service: 'users' }, 1.2) +summary.observe({ service: 'users' }, 1.5) + +summary.get({ service: 'users' }) +# => {0.1=>1.0, 0.3=>2.0, 1.2=>4.0} +``` +### 1.x + +```ruby +summary = Prometheus::Client::Summary.new(:service_latency_seconds, docstring: '...', labels: [:service]) + +summary.observe(0.1, labels: { service: 'users' }) +summary.observe(0.3, labels: { service: 'users' }) +summary.observe(0.4, labels: { service: 'users' }) +summary.observe(1.2, labels: { service: 'users' }) +summary.observe(1.5, labels: { service: 'users' }) + +summary.get(labels: { service: 'users' }) +# => {"count"=>5.0, "sum"=>3.5} +``` \ No newline at end of file From bfbc28069462ede51cedf6ec580e9a351a234473 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 20 Aug 2019 15:06:35 +0100 Subject: [PATCH 056/189] Rename UPGRADE.md to UPGRADING.md Signed-off-by: Chris Sinjakli --- UPGRADE.md => UPGRADING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename UPGRADE.md => UPGRADING.md (100%) diff --git a/UPGRADE.md b/UPGRADING.md similarity index 100% rename from UPGRADE.md rename to UPGRADING.md From cf70f80bab61b17933856134164f36cddfc890b5 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 20 Aug 2019 17:31:07 +0100 Subject: [PATCH 057/189] Add some tweaks to UPGRADING.md Signed-off-by: Chris Sinjakli --- UPGRADING.md | 79 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 90db48fd..5e024e62 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,46 +1,69 @@ -# Upgrade from 0.9 to 1.x +# Upgrading from 0.9 to 0.10.x ## Objectives -This major upgrade achieves the following objectives: +0.10.0 represents a big step forward for the Prometheus Ruby client, which comes with some +breaking changes. The objectives behind those changes are: -1. Follow [client conventions and best practices](https://prometheus.io/docs/instrumenting/writing_clientlibs/) -2. Add the notion of Pluggable backends. Client should be configurable with different backends: thread-safe (default), thread-unsafe (lock-free for performance on single-threaded cases), multiprocess, etc. -Consumers should be able to build and plug their own backends based on their use cases. +1. Bringing the Ruby client in line with [Prometheus conventions and best + practices](https://prometheus.io/docs/instrumenting/writing_clientlibs/) +2. Adding support for multi-process web servers like Unicorn. This was done by introducing + the notion of pluggable storage backends. + + The client can now be configured with different storage backends, and we provide 3 with + the gem: thread-safe (default), thread-unsafe (best performance in single-threaded use + cases), and a multi-process backend that can be used in forking web servers like + Unicorn. + + Users of the library can build their own storage backend to support different + use cases provided they conform to the same interface. ## Ruby -The minimum supported Ruby version is 2.0.0. +The minimum supported Ruby version is now 2.3. This will change over time according to our +[compatibility policy](COMPATIBILITY.md). ## Data Stores -You can specify the data store implementation depending on your needs. +The single biggest feature in this release is support for multi-process web servers. -For example, if you are running a pre-fork application using Unicorn you will need a way to aggregate the metrics from each of your workers before Prometheus scrapes them. +The way this was achieved was by introducing a standard interface for metric storage +backends and providing implementations for the most common use-cases. -This can be achieved with DirectFileStore. +If you're using a multi-process web server, you'll want `DirectFileStore`, which +aggregates metrics across the processes. ```ruby Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir: '/tmp/direct_file_store') ``` -## kwargs +The default store is the `Synchronized` store, which provides a threadsafe implementation, +but one which doesn't work in multi-process scenarios. + +If you're absolutely sure that you won't use multiple threads or processes, you can use the +`SingleThreaded` data store and avoid the locking overhead. Note that in almost all use +cases the performance overhead won't matter, which is why we use the `Synchronized` store +by default. -Certain parameters are now keyword arguments. +## Keyword arguments (kwargs) + +Many multi-parameter methods have had their arguments changed to keyword arguments for +improved clarity at the callsite. ### 0.9 ```ruby counter = Prometheus::Client::Counter.new(:service_requests_total, '...') ``` -### 1.x +### 0.10 ```ruby counter = Prometheus::Client::Counter.new(:service_requests_total, docstring: '...') ``` ### Labels -Labels are set when the metric is defined as opposed to when it is first used. +Labels must now be declared at metric initialization. Observing a value with a label that +wasn't passed in at initialization will raise an error. ### 0.9 @@ -49,7 +72,7 @@ counter = Prometheus::Client::Counter.new(:service_requests_total, '...') counter.increment({ service: 'foo' }) ``` -### 1.x +### 0.10 ```ruby counter = Prometheus::Client::Counter.new(:service_requests_total, docstring: '...', labels: [:service]) @@ -58,7 +81,7 @@ counter.increment(labels: { service: 'foo' }) ## Histograms -Keys from the get method are now strings. +Keys in the hash returned from the get method are now strings. Histograms now include a "+Inf" bucket as well as the sum of all observations. @@ -76,7 +99,7 @@ histogram.observe({ service: 'users' }, 1.5) histogram.get({ service: 'users' }) # => {0.1=>1.0, 0.3=>2.0, 1.2=>4.0} ``` -### 1.x +### 0.10 ```ruby histogram = Prometheus::Client::Histogram.new(:service_latency_seconds, docstring: '...', labels: [:service], buckets: [0.1, 0.3, 1.2]) @@ -93,8 +116,6 @@ histogram.get(labels: { service: 'users' }) ## Summaries -Keys from the get method are now strings. - Summaries no longer include quantiles. They include the sum and the count instead. ### 0.9 @@ -111,7 +132,7 @@ summary.observe({ service: 'users' }, 1.5) summary.get({ service: 'users' }) # => {0.1=>1.0, 0.3=>2.0, 1.2=>4.0} ``` -### 1.x +### 0.10 ```ruby summary = Prometheus::Client::Summary.new(:service_latency_seconds, docstring: '...', labels: [:service]) @@ -124,4 +145,22 @@ summary.observe(1.5, labels: { service: 'users' }) summary.get(labels: { service: 'users' }) # => {"count"=>5.0, "sum"=>3.5} -``` \ No newline at end of file +``` + +## Rack middleware + +Because metric labels must be declared up front, we've removed support for customising the +labels set in the default Rack middleware we provide. + +We did make an attempt to preserve that ability, but decided that the interface was too +confusing and removed it in #121. We might revisit this and have another try at a better +interface in the future. + +## Extra reserved label: `pid` + +When adding support for multi-process web servers, we realised that aggregating gauges +reported by individual processes (e.g. by summing them) is almost never what you want to +do. + +We decided to expose each process's value individually, with a `pid` label set to +differentiate between the proesses. Because of that, `pid` is now a reserved label. From b26b4a094f68dbba02788cf1a693c0cee28b3c6b Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 23 Aug 2019 10:53:17 +0100 Subject: [PATCH 058/189] Update JRuby version run by Travis We recently started getting an error in Travis for our JRuby tests. Upgrading the JRuby version seems to fix it Signed-off-by: Daniel Magliola --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b602a263..2be7586f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,4 +9,4 @@ rvm: - 2.4.5 - 2.5.3 - 2.6.0 - - jruby-9.1.5.0 + - jruby-9.1.9.0 From f38e4532eb6aa548d744ba18f22560e42654dc17 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 5 Oct 2019 14:18:22 +0100 Subject: [PATCH 059/189] Bump version to 0.10.0 Signed-off-by: Chris Sinjakli --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 15a6b38e..176aaf93 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '0.10.0-alpha.2' + VERSION = '0.10.0' end end From 0e6c4a3f9da01a48d13003a64d950f27b19e8005 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 5 Oct 2019 14:51:17 +0100 Subject: [PATCH 060/189] Remove dependency pins for unsupported Ruby versions Signed-off-by: Chris Sinjakli --- Gemfile | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index 7b1bb8d5..3f36d705 100644 --- a/Gemfile +++ b/Gemfile @@ -2,17 +2,13 @@ source 'https://rubygems.org' gemspec -def ruby_version?(constraint) - Gem::Dependency.new('', constraint).match?('', RUBY_VERSION) -end - group :test do gem 'coveralls' - gem 'json', '< 2.0' if ruby_version?('< 2.0') - gem 'rack', '< 2.0' if ruby_version?('< 2.2.2') + gem 'json' + gem 'rack' gem 'rack-test' gem 'rake' gem 'rspec' - gem 'term-ansicolor', '< 1.4' if ruby_version?('< 2.0') - gem 'tins', '< 1.7' if ruby_version?('< 2.0') + gem 'term-ansicolor' + gem 'tins' end From c0260ff7aebd057d4bb550685e6032db11786577 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sun, 6 Oct 2019 16:29:02 +0100 Subject: [PATCH 061/189] Reduce memory bloating in FileMappedDict when reading metrics. During the read path (metrics export) we open all existing metric files and read through them in order to aggregate metrics across different processes. When reading non-empty files we end up parsing file content twice: first during FileMappedDict initialisation, then again in the caller site (`all_values`). This commit refactors FileMappedDict so that while the `@positions` map is populated at creation time, the actual metric values are read only when explicitly requested. This avoids the memory bloat of unpacking file content twice. Signed-off-by: Cristian Greco --- .../client/data_stores/direct_file_store.rb | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 9dbab76e..48ac528d 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -215,11 +215,7 @@ def initialize(filename, readonly = false) if @used > 0 # File already has data. Read the existing values - with_file_lock do - read_all_values.each do |key, _, pos| - @positions[key] = pos - end - end + with_file_lock { populate_positions } else # File is empty. Init the `used` counter, if we're in write mode if !readonly @@ -230,10 +226,14 @@ def initialize(filename, readonly = false) end end - # Yield (key, value, pos). No locking is performed. + # Return a list of key-value pairs def all_values with_file_lock do - read_all_values.map { |k, v, p| [k, v] } + @positions.map do |key, pos| + @f.seek(pos) + value = @f.read(8).unpack('d')[0] + [key, value] + end end end @@ -309,22 +309,18 @@ def init_value(key) @positions[key] = @used - 8 end - # Yield (key, value, pos). No locking is performed. - def read_all_values + # Read position of all keys. No locking is performed. + def populate_positions @f.seek(8) - values = [] while @f.pos < @used padded_len = @f.read(4).unpack('l')[0] - encoded = @f.read(padded_len).unpack("A#{padded_len}")[0] - value = @f.read(8).unpack('d')[0] - values << [encoded.strip, value, @f.pos - 8] + key = @f.read(padded_len).unpack("A#{padded_len}")[0].strip + @positions[key] = @f.pos + @f.seek(8, :CUR) end - values end end end end end end - - From 239302265473196bb048680687f9ef25126d6ea8 Mon Sep 17 00:00:00 2001 From: David Worth Date: Tue, 15 Oct 2019 13:58:37 -0600 Subject: [PATCH 062/189] readme: remove warning of legacy pushgateway API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As of 0.9.0 (and in particular #102) Björn has made the warning of the legacy API no longer accurate. Let's update the README too. Signed-off-by: David Worth --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 818ebcf3..c1ee497f 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,6 @@ The Ruby client can also be used to push its collected metrics to a where it's not possible or feasible to let a Prometheus server scrape a Ruby process. TLS and basic access authentication are supported. -**Attention**: The implementation still uses the legacy API of the pushgateway. - ```ruby require 'prometheus/client' require 'prometheus/client/push' From 48eb0026f6bae47d58ae60c90e7574889e5fec0b Mon Sep 17 00:00:00 2001 From: Shouichi Kamiya Date: Wed, 16 Oct 2019 11:09:13 +0900 Subject: [PATCH 063/189] Remove outdated doc from collector middleware Also describe `:metrics_prefix` option. Signed-off-by: Shouichi Kamiya Co-Authored-By: Daniel Magliola --- lib/prometheus/middleware/collector.rb | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 3f6c293d..65de3d17 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -11,20 +11,11 @@ module Middleware # By default metrics are registered on the global registry. Set the # `:registry` option to use a custom registry. # - # By default metrics all have the prefix "http_server". Set to something - # else if you like. + # By default metrics all have the prefix "http_server". Set + # `:metrics_prefix` to something else if you like. # - # The request counter metric is broken down by code, method and path by - # default. Set the `:counter_label_builder` option to use a custom label - # builder. - # - # The request duration metric is broken down by method and path by default. - # Set the `:duration_label_builder` option to use a custom label builder. - # - # Label Builder functions will receive a Rack env and a status code, and must - # return a hash with the labels for that request. They must also accept an empty - # env, and return a hash with the correct keys. This is necessary to initialize - # the metrics with the correct set of labels. + # The request counter metric is broken down by code, method and path. + # The request duration metric is broken down by method and path. class Collector attr_reader :app, :registry From c59713d0832b7a8fb7c11b51edf10b95deadc00c Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 28 Oct 2019 11:58:47 +0000 Subject: [PATCH 064/189] Fix bug where different label orders lead to different results In the DirectFileStore, in order to store the labels hash on disk, we turn it into a "querystring". The way this is done, we'll end up with different strings if the same hash is passed in with its keys in different order. Since this string is used to index into the file where we store the data, this will lead to two different values being stored for the same hash. This is fine in cases where the aggregation is :SUM, because they end up getting summed when the Client is summarizing. But in :ALL aggregation, for example, you will end up with one value or the other, randomly. The test in this commit reproduces this problem. This way of serializing the labels is a bit slower (see PR for details), but it's not a huge impact in the big scheme of things, and it leads to the correct result. Signed-off-by: Daniel Magliola --- .../client/data_stores/direct_file_store.rb | 2 +- .../data_stores/direct_file_store_spec.rb | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 48ac528d..073024dd 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -150,7 +150,7 @@ def store_key(labels) labels[:pid] = process_id end - labels.map{|k,v| "#{CGI::escape(k.to_s)}=#{CGI::escape(v.to_s)}"}.join('&') + labels.to_a.sort.map{|k,v| "#{CGI::escape(k.to_s)}=#{CGI::escape(v.to_s)}"}.join('&') end def internal_store diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb index ba9d7d15..f39ff10e 100644 --- a/spec/prometheus/client/data_stores/direct_file_store_spec.rb +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -70,6 +70,22 @@ end end + it "coalesces values irrespective of the order of labels" do + metric_store1 = subject.for_metric(:metric_name, metric_type: :counter) + metric_store1.increment(labels: { foo: "1", bar: "1" }, by: 1) + metric_store1.increment(labels: { foo: "1", bar: "2" }, by: 7) + metric_store1.increment(labels: { foo: "2", bar: "1" }, by: 3) + + metric_store1.increment(labels: { foo: "1", bar: "1" }, by: 10) + metric_store1.increment(labels: { bar: "1", foo: "1" }, by: 10) + + expect(metric_store1.all_values).to eq( + { foo: "1", bar: "1" } => 21.0, + { foo: "1", bar: "2" } => 7.0, + { foo: "2", bar: "1" } => 3.0, + ) + end + context "for a non-gauge metric" do it "sums values from different processes by default" do allow(Process).to receive(:pid).and_return(12345) @@ -128,6 +144,23 @@ # Both processes should return the same value expect(metric_store1.all_values).to eq(metric_store2.all_values) end + + it "coalesces values irrespective of the order of labels" do + allow(Process).to receive(:pid).and_return(12345) + metric_store1 = subject.for_metric(:metric_name, metric_type: :gauge) + metric_store1.set(labels: { foo: "1", bar: "1" }, val: 1) + metric_store1.set(labels: { foo: "1", bar: "2" }, val: 7) + metric_store1.set(labels: { foo: "2", bar: "1" }, val: 3) + + metric_store1.set(labels: { bar: "1", foo: "1" }, val: 10) + + expect(metric_store1.all_values).to eq( + { foo: "1", bar: "1", pid: "12345" } => 10.0, + { foo: "1", bar: "2", pid: "12345" } => 7.0, + { foo: "2", bar: "1", pid: "12345" } => 3.0, + ) + + end end context "with a metric that takes MAX instead of SUM" do From 5b4dafbfab53233b069ff4d0b973b062c402a826 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 28 Oct 2019 16:56:33 +0000 Subject: [PATCH 065/189] Bump version to 0.11.0-alpha.1 Bumping this to put it in production for a while. This is a very solid candidate for a 1.0. If we don't find anything, we will release 1.0 next week Signed-off-by: Daniel Magliola --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 176aaf93..ef332a2a 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '0.10.0' + VERSION = '0.11.0-alpha.1' end end From 27697559a2915b6ef4755331f0a594090641a2d2 Mon Sep 17 00:00:00 2001 From: David Worth Date: Mon, 28 Oct 2019 12:32:40 -0600 Subject: [PATCH 066/189] add DCO to CONTRIBUTING.md I was caught off guard after being asked to sign off on the DCO because it was not in the contribution guide. Adding it here. Signed-off-by: David Worth --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bbe74bc..997f15cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,3 +10,5 @@ Prometheus uses GitHub to manage reviews of pull requests. on our [mailing list](https://groups.google.com/forum/?fromgroups#!forum/prometheus-developers). This will avoid unnecessary work and surely give you and us a good deal of inspiration. + +* Be sure to sign your commits off (per the [DCO](https://github.com/probot/dco#how-it-works)) by including `--signoff` as a parameter to your `git commit` commands. From 81222d3cf8d80adf0ea31dacc7b3506d03fdef04 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 1 Nov 2019 16:05:08 +0000 Subject: [PATCH 067/189] Improve documentation for DirectFileStore The main changes are documenting some important caveats that are relevant when running DirectFileStore in production, and the `ALL` aggregation method for Gauges Signed-off-by: Daniel Magliola --- README.md | 74 ++++++++++++------- .../client/data_stores/direct_file_store.rb | 12 ++- 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index c1ee497f..0dc2a263 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ is stored in a global Data Store object, rather than in the metric objects thems (This "storage" is ephemeral, generally in-memory, it's not "long-term storage") The main reason to do this is that different applications may have different requirements -for their metrics storage. Application running in pre-fork servers (like Unicorn, for +for their metrics storage. Applications running in pre-fork servers (like Unicorn, for example), require a shared store between all the processes, to be able to report coherent numbers. At the same time, other applications may not have this requirement but be very sensitive to performance, and would prefer instead a simpler, faster store. @@ -311,7 +311,7 @@ whether you want to report the `SUM`, `MAX` or `MIN` value observed across all p For almost all other cases, you'd leave the default (`SUM`). More on this on the *Aggregation* section below. -Other custom stores may also accept extra parameters besides `:aggregation`. See the +Custom stores may also accept extra parameters besides `:aggregation`. See the documentation of each store for more details. ### Built-in stores @@ -326,26 +326,46 @@ There are 3 built-in stores, with different trade-offs: it's absolutely not thread safe. - **DirectFileStore**: Stores data in binary files, one file per process and per metric. This is generally the recommended store to use with pre-fork servers and other - "multi-process" scenarios. - - Each metric gets a file for each process, and manages its contents by storing keys and - binary floats next to them, and updating the offsets of those Floats directly. When - exporting metrics, it will find all the files that apply to each metric, read them, - and aggregate them. - - In order to do this, each Metric needs an `:aggregation` setting, specifying how - to aggregate the multiple possible values we can get for each labelset. By default, - they are `SUM`med, which is what most use-cases call for (counters and histograms, - for example). However, for Gauges, it's possible to set `MAX` or `MIN` as aggregation, - to get the highest/lowest value of all the processes / threads. - - Even though this store saves data on disk, it's still much faster than would probably be - expected, because the files are never actually `fsync`ed, so the store never blocks - while waiting for disk. The kernel's page cache is incredibly efficient in this regard. - - If in doubt, check the benchmark scripts described in the documentation for creating - your own stores and run them in your particular runtime environment to make sure this - provides adequate performance. + "multi-process" scenarios. There are some important caveats to using this store, so + please read on the section below. + +### `DirectFileStore` caveats and things to keep in mind + +Each metric gets a file for each process, and manages its contents by storing keys and +binary floats next to them, and updating the offsets of those Floats directly. When +exporting metrics, it will find all the files that apply to each metric, read them, +and aggregate them. + +**Aggregation of metrics**: Since there will be several files per metrics (one per process), +these need to be aggregated to present a coherent view to Prometheus. Depending on your +use case, you may need to control how this works. When using this store, +each Metric allows you to specify an `:aggregation` setting, defining how +to aggregate the multiple possible values we can get for each labelset. By default, +Counters, Histograms and Summaries are `SUM`med, and Gauges report all their values (one +for each process), tagged with a `pid` label. You can also select `SUM`, `MAX` or `MIN` +for your gauges, depending on your use case. + +**Memory Usage**: When scraped by Prometheus, this store will read all these files, get all +the values and aggregate them. We have notice this can have a noticeable effect on memory +usage for your app. We recommend you test this in a realistic usage scenario to make sure +you won't hit any memory limits your app may have. + +**Resetting your metrics on each run**: You should also make sure that the directory where +you store your metric files (specified when initializing the `DirectFileStore`) is emptied +when your app starts. Otherwise, each app run will continue exporting the metrics from the +previous run. + +**Large numbers of files**: Because there is an individual file per metric and per process +(which is done to optimize for observation performance), you may end up with a large number +of files. We don't currently have a solution for this problem, but we're working on it. + +**Performance**: Even though this store saves data on disk, it's still much faster than +would probably be expected, because the files are never actually `fsync`ed, so the store +never blocks while waiting for disk. The kernel's page cache is incredibly efficient in +this regard. If in doubt, check the benchmark scripts described in the documentation for +creating your own stores and run them in your particular runtime environment to make sure +this provides adequate performance. + ### Building your own store, and stores other than the built-in ones. @@ -364,16 +384,16 @@ If you are in a multi-process environment (such as pre-fork servers like Unicorn process will probably keep their own counters, which need to be aggregated when receiving a Prometheus scrape, to report coherent total numbers. -For Counters and Histograms (and quantile-less Summaries), this is simply a matter of +For Counters, Histograms and quantile-less Summaries this is simply a matter of summing the values of each process. For Gauges, however, this may not be the right thing to do, depending on what they're measuring. You might want to take the maximum or minimum value observed in any process, -rather than the sum of all of them. You may also want to export each process's individual -value. +rather than the sum of all of them. By default, we export each process's individual +value, with a `pid` label identifying each one. -In those cases, you should use the `store_settings` parameter when registering the -metric, to specify an `:aggregation` setting. +If these defaults don't work for your use case, you should use the `store_settings` +parameter when registering the metric, to specify an `:aggregation` setting. ```ruby free_disk_space = registry.gauge(:free_disk_space_bytes, diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 073024dd..de8d9784 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -18,10 +18,14 @@ module DataStores # # In order to do this, each Metric needs an `:aggregation` setting, specifying how # to aggregate the multiple possible values we can get for each labelset. By default, - # they are `SUM`med, which is what most use cases call for (counters and histograms, - # for example). - # However, for Gauges, it's possible to set `MAX` or `MIN` as aggregation, to get - # the highest value of all the processes / threads. + # Counters, Histograms and Summaries get `SUM`med, and Gauges will report `ALL` + # values, tagging each one with a `pid` label. + # For Gauges, it's also possible to set `SUM`, MAX` or `MIN` as aggregation, to get + # the highest / lowest value / or the sum of all the processes / threads. + # + # Before using this Store, please read the "`DirectFileStore` caveats and things to + # keep in mind" section of the main README in this repository. It includes a number + # of important things to keep in mind. class DirectFileStore class InvalidStoreSettingsError < StandardError; end From 78cc309d5ef5a60cafb45906386315b655767b0d Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 4 Nov 2019 11:24:57 +0000 Subject: [PATCH 068/189] Release v1.0 After almost a year with this code in production, all our backward incompatible changes addressed, and much improved documentation, we are now in a good place to call this "ready for everyone to use" Signed-off-by: Daniel Magliola --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index ef332a2a..bb44f1a6 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '0.11.0-alpha.1' + VERSION = '1.0.0' end end From 0e0f17fb38f2e703f8adfaf16d75b26af32b5053 Mon Sep 17 00:00:00 2001 From: Joao Bernardo Date: Tue, 1 Oct 2019 22:27:25 +0100 Subject: [PATCH 069/189] Implements #init_label_set method for all metric classes Signed-off-by: Joao Bernardo --- README.md | 6 ++++++ lib/prometheus/client/histogram.rb | 10 ++++++++++ lib/prometheus/client/metric.rb | 4 ++++ lib/prometheus/client/summary.rb | 9 +++++++++ spec/prometheus/client/counter_spec.rb | 12 ++++++++++++ spec/prometheus/client/gauge_spec.rb | 12 ++++++++++++ spec/prometheus/client/histogram_spec.rb | 16 ++++++++++++++++ spec/prometheus/client/summary_spec.rb | 16 ++++++++++++++++ 8 files changed, 85 insertions(+) diff --git a/README.md b/README.md index 818ebcf3..6dfcbf30 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,12 @@ class MyComponent end ``` +### `init_label_set` + +The time series of a metric are not initialized until something happens. For counters, for example, this means that the time series do not exist until the counter is incremented for the first time. + +To get around this problem the client provides the `init_label_set` method that can be used to initialise the time series of a metric for a given label set. + ### Reserved labels The following labels are reserved by the client library, and attempting to use them in a diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 893282ef..7c32d016 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -94,6 +94,16 @@ def values end end + def init_label_set(labels) + base_label_set = label_set_for(labels) + + @store.synchronize do + (buckets + ["+Inf", "sum"]).each do |bucket| + @store.set(labels: base_label_set.merge(le: bucket.to_s), val: 0) + end + end + end + private # Modifies the passed in parameter diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index 1bb43347..92de2530 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -55,6 +55,10 @@ def with_labels(labels) store_settings: @store_settings) end + def init_label_set(labels) + @store.set(labels: label_set_for(labels), val: 0) + end + # Returns all label sets with their values def values @store.all_values diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index 9f65faa8..43a15126 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -45,6 +45,15 @@ def values end end + def init_label_set(labels) + base_label_set = label_set_for(labels) + + @store.synchronize do + @store.set(labels: base_label_set.merge(quantile: "count"), val: 0) + @store.set(labels: base_label_set.merge(quantile: "sum"), val: 0) + end + end + private def reserved_labels diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 806c55b6..bd7d794f 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -79,4 +79,16 @@ end.to change { counter.get }.by(100.0) end end + + describe '#init_label_set' do + let(:expected_labels) { [:test] } + + it 'initializes the metric for a given label set' do + expect(counter.values).to eql({}) + + counter.init_label_set(test: 'value') + + expect(counter.values).to eql({test: 'value'} => 0.0) + end + end end diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index 9476272f..fedf3221 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -161,4 +161,16 @@ end.to change { gauge.get }.by(-100.0) end end + + describe '#init_label_set' do + let(:expected_labels) { [:test] } + + it 'initializes the metric for a given label set' do + expect(gauge.values).to eql({}) + + gauge.init_label_set(test: 'value') + + expect(gauge.values).to eql({test: 'value'} => 0.0) + end + end end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index c64c6218..473a6b5c 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -118,4 +118,20 @@ ) end end + + describe '#init_label_set' do + let(:expected_labels) { [:status] } + + it 'initializes the metric for a given label set' do + expect(histogram.values).to eql({}) + + histogram.init_label_set(status: 'bar') + histogram.init_label_set(status: 'foo') + + expect(histogram.values).to eql( + { status: 'bar' } => { "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 }, + { status: 'foo' } => { "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 }, + ) + end + end end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index c69f754f..514cf8dc 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -99,4 +99,20 @@ ) end end + + describe '#init_label_set' do + let(:expected_labels) { [:status] } + + it 'initializes the metric for a given label set' do + expect(summary.values).to eql({}) + + summary.init_label_set(status: 'bar') + summary.init_label_set(status: 'foo') + + expect(summary.values).to eql( + { status: 'bar' } => { "count" => 0.0, "sum" => 0.0 }, + { status: 'foo' } => { "count" => 0.0, "sum" => 0.0 }, + ) + end + end end From c16d38583f1e465b5076af9ac870bf3494361d32 Mon Sep 17 00:00:00 2001 From: David Kovsky Date: Fri, 22 Nov 2019 16:24:05 -0700 Subject: [PATCH 070/189] fixes broken link to gocardless experiments Signed-off-by: David Kovsky --- lib/prometheus/client/data_stores/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/data_stores/README.md b/lib/prometheus/client/data_stores/README.md index 920e5833..3e396a77 100644 --- a/lib/prometheus/client/data_stores/README.md +++ b/lib/prometheus/client/data_stores/README.md @@ -187,7 +187,7 @@ has created a good amount of research, benchmarks, and experimental stores, whic weren't useful to include in this repo, but may be a useful resource or starting point if you are building your own store. -Check out the [GoCardless Data Stores Experiments](gocardless/prometheus-client-ruby-data-stores-experiments) +Check out the [GoCardless Data Stores Experiments](https://github.com/gocardless/prometheus-client-ruby-data-stores-experiments) repository for these. ## Sample, imaginary multi-process Data Store From b552e1064466eb48d369cf61b2060c8d2d636333 Mon Sep 17 00:00:00 2001 From: Chris Rohrer Date: Sun, 29 Dec 2019 15:55:09 +0100 Subject: [PATCH 071/189] Change prometheus command to use double-dash for flags Signed-off-by: Chris Rohrer --- examples/rack/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/rack/README.md b/examples/rack/README.md index 8d980044..75541716 100644 --- a/examples/rack/README.md +++ b/examples/rack/README.md @@ -23,7 +23,7 @@ output of `/metrics` and terminate. Start a Prometheus server with the provided config: ```bash -prometheus -config.file ./prometheus.yml +prometheus --config.file ./prometheus.yml ``` In another terminal, start the application server: From c931a049b293cdfde1d73465badd77cd92834884 Mon Sep 17 00:00:00 2001 From: Yuta Iwama Date: Wed, 8 Jan 2020 17:51:46 +0900 Subject: [PATCH 072/189] observed value should be stored the bucket whose value is same as the observed value Signed-off-by: Yuta Iwama --- lib/prometheus/client/histogram.rb | 2 +- spec/prometheus/client/histogram_spec.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 7c32d016..29f540b6 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -47,7 +47,7 @@ def type end def observe(value, labels: {}) - bucket = buckets.find {|upper_limit| upper_limit > value } + bucket = buckets.find {|upper_limit| upper_limit >= value } bucket = "+Inf" if bucket.nil? base_label_set = label_set_for(labels) diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index 0f871703..48aaa62a 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -140,10 +140,12 @@ it 'returns a hash of all recorded summaries' do histogram.observe(3, labels: { status: 'bar' }) histogram.observe(6, labels: { status: 'foo' }) + histogram.observe(10, labels: { status: 'baz' }) expect(histogram.values).to eql( { status: 'bar' } => { "2.5" => 0.0, "5" => 1.0, "10" => 1.0, "+Inf" => 1.0, "sum" => 3.0 }, { status: 'foo' } => { "2.5" => 0.0, "5" => 0.0, "10" => 1.0, "+Inf" => 1.0, "sum" => 6.0 }, + { status: 'baz' } => { "2.5" => 0.0, "5" => 0.0, "10" => 1.0, "+Inf" => 1.0, "sum" => 10.0 }, ) end end From 16c44a747a88e13f63e1d14a0754e6bd38b71265 Mon Sep 17 00:00:00 2001 From: Christopher Guess Date: Wed, 22 Jan 2020 14:54:25 -0500 Subject: [PATCH 073/189] Add Gem & Bundler instructions to README Previously there was no mention of actually installing the gem, which is usually useful for a lot of more junior devs. This PR just adds a few general lines making the name of the gem `prometheus-client` explicit and giving Gemfile and global installation examples. Signed-off-by: Christopher Guess --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 9be8a520..6bcbb698 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ through a HTTP interface. Intended to be used together with a ## Usage +### Installation + +For a global installation run `gem install prometheus-client`. + +If you're using [Bundler](https://bundler.io/) add `gem "prometheus-client"` to your `Gemfile`. +Make sure to run `bundle install` afterwards. + ### Overview ```ruby From d61654653c06f173ff043b5ca8f0b61c09fc0042 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 28 Jan 2020 12:58:49 +0000 Subject: [PATCH 074/189] Release v2.0 This includes a bug fix that is a breaking change, so this is a new major version. Signed-off-by: Daniel Magliola --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0e355c01 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# CHANGELOG + +# 2.0.0 / 2020-01-28 + +## Breaking changes + +- [#176](https://github.com/prometheus/client_ruby/pull/176) BUGFIX: Values observed at + the upper limit of a histogram bucket are now counted in that bucket, not the following + one. This is unlikely to break functionality and you probably don't need to make code + changes, but it may break tests. + +## New features + +- [#156](https://github.com/prometheus/client_ruby/pull/156) Added `init_label_set` method, + which allows declaration of time series on app startup, starting at 0. + + +# 1.0.0 / 2019-11-04 + +## Breaking changes + +- This release saw a number of breaking changes to better comply with latest best practices + for naming and client behaviour. Please refer to [UPGRADING.md](UPGRADING.md) for details + if upgrading from `<= 0.9`. + +- The main feature of this release was adding support for multi-process environments such + as pre-fork servers (Unicorn, Puma). diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index bb44f1a6..147472ae 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '1.0.0' + VERSION = '2.0.0' end end From 209db7c86ce884685bfb69a15c29284524ac0f83 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 24 Feb 2020 15:03:01 -0300 Subject: [PATCH 075/189] Document how to clean up work dir for DirectFileStore on startup Add an example on how to solve the "files are already there on startup" problem, based on @stefansundin 's proposal in PR #173 Signed-off-by: Daniel Magliola --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 6bcbb698..fd17ce1a 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,19 @@ you store your metric files (specified when initializing the `DirectFileStore`) when your app starts. Otherwise, each app run will continue exporting the metrics from the previous run. +If you have this issue, one way to do this is to run code similar to this as part of you +initialization: + +```ruby +Dir["#{app_path}/tmp/prometheus/*.bin"].each do |file_path| + File.unlink(file_path) +end +``` + +If you are running in pre-fork servers (such as Unicorn or Puma with multiple processes), +make sure you do this **before** the server forks. Otherwise, each child process may delete +files created by other processes on *this* run, instead of deleting old files. + **Large numbers of files**: Because there is an individual file per metric and per process (which is done to optimize for observation performance), you may end up with a large number of files. We don't currently have a solution for this problem, but we're working on it. From 844e451baffecba5785197b76c4bfafc70fcca16 Mon Sep 17 00:00:00 2001 From: Lawrence Jones Date: Tue, 14 Jan 2020 09:49:07 +0000 Subject: [PATCH 076/189] Histogram bucket helpers Most client libraries provide helpers for generating linear/exponential histogram buckets. This provides the same interface as the golang client. Signed-off-by: Lawrence Jones --- README.md | 5 +++++ lib/prometheus/client/histogram.rb | 8 ++++++++ spec/prometheus/client/histogram_spec.rb | 14 ++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 64062e48..f7db15a8 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,11 @@ histogram.get(labels: { service: 'users' }) # => { 0.005 => 3, 0.01 => 15, 0.025 => 18, ..., 2.5 => 42, 5 => 42, 10 = >42 } ``` +Histograms provide default buckets of `[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]` + +You can specify your own buckets, either explicitly, or using the `Histogram.linear_buckets` +or `Histogram.exponential_buckets` methods to define regularly spaced buckets. + ### Summary Summary, similar to histograms, is an accumulator for samples. It captures diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 893282ef..e72c40dc 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -33,6 +33,14 @@ def initialize(name, store_settings: store_settings) end + def self.linear_buckets(start:, width:, count:) + count.times.map { |idx| start.to_f + idx * width } + end + + def self.exponential_buckets(start:, factor: 2, count:) + count.times.map { |idx| start.to_f * factor ** idx } + end + def with_labels(labels) self.class.new(name, docstring: docstring, diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index c64c6218..d4e9da2a 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -37,6 +37,20 @@ end end + describe ".linear_buckets" do + it "generates buckets" do + expect(described_class.linear_buckets(start: 1, width: 2, count: 5)). + to eql([1.0, 3.0, 5.0, 7.0, 9.0]) + end + end + + describe ".exponential_buckets" do + it "generates buckets" do + expect(described_class.exponential_buckets(start: 1, factor: 2, count: 5)). + to eql([1.0, 2.0, 4.0, 8.0, 16.0]) + end + end + describe '#observe' do it 'records the given value' do expect do From c80c550968ec88a94ecb0f294ed76f9347a8fc94 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Thu, 9 Apr 2020 09:44:45 +0100 Subject: [PATCH 077/189] Remove suggestion to @ the maintainers We're subscribed to the repo already so there's no need. --- CONTRIBUTING.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 997f15cc..e35531c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,9 +2,7 @@ Prometheus uses GitHub to manage reviews of pull requests. -* If you have a trivial fix or improvement, go ahead and create a pull request, - addressing (with `@...`) the maintainer of this repository (see - [MAINTAINERS.md](MAINTAINERS.md)) in the description of the pull request. +* If you have a trivial fix or improvement, go ahead and create a pull request. * If you plan to do something more involved, first discuss your ideas on our [mailing list](https://groups.google.com/forum/?fromgroups#!forum/prometheus-developers). From 7526c92043a60e34f2c1a848184af6a9e25faa42 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Thu, 21 May 2020 10:42:47 +0100 Subject: [PATCH 078/189] Support Latest Ruby versions 2.3 and 2.4 are EOL now, and 2.7 officially exists Signed-off-by: Daniel Magliola --- .travis.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2be7586f..425587fc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,7 @@ before_install: - | if [[ "$(ruby -e 'puts RUBY_VERSION')" != 1.* ]]; then gem update --system; fi rvm: - - 2.3.8 - - 2.4.5 - - 2.5.3 - - 2.6.0 + - 2.5.8 + - 2.6.6 + - 2.7.1 - jruby-9.1.9.0 From f31bdcb8eda943f8ddf720e0b9d65ac22124cc93 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Thu, 21 May 2020 10:40:46 +0100 Subject: [PATCH 079/189] Remove call to deprecated `URI.escape` This method has been deprecated for a while, and it gives us warnings in Ruby 2.7 Signed-off-by: Daniel Magliola --- lib/prometheus/client/push.rb | 4 ++-- spec/prometheus/client/push_spec.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index fe8cc2ab..03efb9f7 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -66,9 +66,9 @@ def parse(url) def build_path(job, instance) if instance - format(INSTANCE_PATH, URI.escape(job), URI.escape(instance)) + format(INSTANCE_PATH, CGI::escape(job), CGI::escape(instance)) else - format(PATH, URI.escape(job)) + format(PATH, CGI::escape(job)) end end diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index dc35e060..f84748e3 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -73,7 +73,7 @@ it 'escapes non-URL characters' do push = Prometheus::Client::Push.new('bar job', 'foo ') - expected = '/metrics/job/bar%20job/instance/foo%20%3Cmy%20instance%3E' + expected = '/metrics/job/bar+job/instance/foo+%3Cmy+instance%3E' expect(push.path).to eql(expected) end end From db572f64b5c9b1f43776822e336b366de1d9aee9 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 26 May 2020 19:48:12 +0100 Subject: [PATCH 080/189] Fix Ruby Warnings Our tests are running without outputting warnings, so we don't see a number of things Ruby was warning us about. All these code changes are effectively a NOOP. Signed-off-by: Daniel Magliola --- .../client/data_stores/direct_file_store.rb | 1 + lib/prometheus/client/histogram.rb | 6 +++--- lib/prometheus/client/metric.rb | 11 ++++++----- lib/prometheus/client/summary.rb | 4 ++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index de8d9784..197bb562 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -74,6 +74,7 @@ def initialize(metric_name:, store_settings:, metric_settings:) @metric_name = metric_name @store_settings = store_settings @values_aggregation_mode = metric_settings[:aggregation] + @store_opened_by_pid = nil @lock = Monitor.new end diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 795477f1..12d1ed78 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -89,15 +89,15 @@ def get(labels: {}) # Returns all label sets with their values expressed as hashes with their buckets def values - v = @store.all_values + values = @store.all_values - result = v.each_with_object({}) do |(label_set, v), acc| + result = values.each_with_object({}) do |(label_set, v), acc| actual_label_set = label_set.reject{|l| l == :le } acc[actual_label_set] ||= @buckets.map{|b| [b.to_s, 0.0]}.to_h acc[actual_label_set][label_set[:le].to_s] = v end - result.each do |(label_set, v)| + result.each do |(_label_set, v)| accumulate_buckets(v) end end diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index 03fd828c..100e455b 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -29,16 +29,17 @@ def initialize(name, @docstring = docstring @preset_labels = stringify_values(preset_labels) + @all_labels_preset = false + if preset_labels.keys.length == labels.length + @validator.validate_labelset!(preset_labels) + @all_labels_preset = true + end + @store = Prometheus::Client.config.data_store.for_metric( name, metric_type: type, metric_settings: store_settings ) - - if preset_labels.keys.length == labels.length - @validator.validate_labelset!(preset_labels) - @all_labels_preset = true - end end # Returns the value for the given label set diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index 43a15126..ee8dd0a3 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -36,9 +36,9 @@ def get(labels: {}) # Returns all label sets with their values expressed as hashes with their sum/count def values - v = @store.all_values + values = @store.all_values - v.each_with_object({}) do |(label_set, v), acc| + values.each_with_object({}) do |(label_set, v), acc| actual_label_set = label_set.reject{|l| l == :quantile } acc[actual_label_set] ||= { "count" => 0.0, "sum" => 0.0 } acc[actual_label_set][label_set[:quantile]] = v From cadd3f953de2cefd610285614e9d6116b28cfd18 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 26 May 2020 20:03:23 +0100 Subject: [PATCH 081/189] Turn on Ruby Warnings on Test Suite We were running our tests with warnings off, which meant we didn't notice a bunch of warnings we were emitting. Signed-off-by: Daniel Magliola --- .rspec | 1 + spec/spec_helper.rb | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 .rspec diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..c99d2e73 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8cf81717..80d04fb1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,10 @@ require 'simplecov' require 'coveralls' +RSpec.configure do |c| + c.warnings = true +end + SimpleCov.formatter = if ENV['CI'] Coveralls::SimpleCov::Formatter From 4bb5b4c00a58412600c753a5b3abfb6552686db1 Mon Sep 17 00:00:00 2001 From: Stefan Sundin Date: Sun, 22 Dec 2019 20:58:02 -0800 Subject: [PATCH 082/189] Add :most_recent aggregation to DirectFileStore This reports the value that was set by a process most recently. The way this works is by tagging each value in the files with the timestamp of when they were set. For all existing aggregations, we ignore that timestamp and do what we've been doing so far. For `:most_recent`, we take the "maximum" entry according to its timestamp (i.e. the latest) and then return its value Signed-off-by: Stefan Sundin Signed-off-by: Daniel Magliola --- README.md | 10 ++-- .../client/data_stores/direct_file_store.rb | 52 ++++++++++++------- .../data_stores/direct_file_store_spec.rb | 35 +++++++++++++ 3 files changed, 72 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9be8a520..e19c1f41 100644 --- a/README.md +++ b/README.md @@ -313,9 +313,9 @@ When instantiating metrics, there is an optional `store_settings` attribute. Thi to set up store-specific settings for each metric. For most stores, this is not used, but for multi-process stores, this is used to specify how to aggregate the values of each metric across multiple processes. For the most part, this is used for Gauges, to specify -whether you want to report the `SUM`, `MAX` or `MIN` value observed across all processes. -For almost all other cases, you'd leave the default (`SUM`). More on this on the -*Aggregation* section below. +whether you want to report the `SUM`, `MAX`, `MIN`, or `MOST_RECENT` value observed across +all processes. For almost all other cases, you'd leave the default (`SUM`). More on this +on the *Aggregation* section below. Custom stores may also accept extra parameters besides `:aggregation`. See the documentation of each store for more details. @@ -348,8 +348,8 @@ use case, you may need to control how this works. When using this store, each Metric allows you to specify an `:aggregation` setting, defining how to aggregate the multiple possible values we can get for each labelset. By default, Counters, Histograms and Summaries are `SUM`med, and Gauges report all their values (one -for each process), tagged with a `pid` label. You can also select `SUM`, `MAX` or `MIN` -for your gauges, depending on your use case. +for each process), tagged with a `pid` label. You can also select `SUM`, `MAX`, `MIN`, or +`MOST_RECENT` for your gauges, depending on your use case. **Memory Usage**: When scraped by Prometheus, this store will read all these files, get all the values and aggregate them. We have notice this can have a noticeable effect on memory diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index de8d9784..cd995f76 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -29,7 +29,7 @@ module DataStores class DirectFileStore class InvalidStoreSettingsError < StandardError; end - AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum, ALL = :all] + AGGREGATION_MODES = [MAX = :max, MIN = :min, SUM = :sum, ALL = :all, MOST_RECENT = :most_recent] DEFAULT_METRIC_SETTINGS = { aggregation: SUM } DEFAULT_GAUGE_SETTINGS = { aggregation: ALL } @@ -121,7 +121,7 @@ def all_values stores_for_metric.each do |file_path| begin store = FileMappedDict.new(file_path, true) - store.all_values.each do |(labelset_qs, v)| + store.all_values.each do |(labelset_qs, v, ts)| # Labels come as a query string, and CGI::parse returns arrays for each key # "foo=bar&x=y" => { "foo" => ["bar"], "x" => ["y"] } # Turn the keys back into symbols, and remove the arrays @@ -129,7 +129,7 @@ def all_values [k.to_sym, vs.first] end.to_h - stores_data[label_set] << v + stores_data[label_set] << [v, ts] end ensure store.close if store @@ -181,30 +181,41 @@ def process_id end def aggregate_values(values) - if @values_aggregation_mode == SUM - values.inject { |sum, element| sum + element } - elsif @values_aggregation_mode == MAX - values.max - elsif @values_aggregation_mode == MIN - values.min - elsif @values_aggregation_mode == ALL - values.first + # Each entry in the `values` array is a tuple of `value` and `timestamp`, + # so for all aggregations except `MOST_RECENT`, we need to only take the + # first value in each entry and ignore the second. + if @values_aggregation_mode == MOST_RECENT + latest_tuple = values.max { |a,b| a[1] <=> b[1] } + latest_tuple.first # return the value without the timestamp else - raise InvalidStoreSettingsError, - "Invalid Aggregation Mode: #{ @values_aggregation_mode }" + values = values.map(&:first) # Discard timestamps + + if @values_aggregation_mode == SUM + values.inject { |sum, element| sum + element } + elsif @values_aggregation_mode == MAX + values.max + elsif @values_aggregation_mode == MIN + values.min + elsif @values_aggregation_mode == ALL + values.first + else + raise InvalidStoreSettingsError, + "Invalid Aggregation Mode: #{ @values_aggregation_mode }" + end end end end private_constant :MetricStore - # A dict of doubles, backed by an file we access directly a a byte array. + # A dict of doubles, backed by an file we access directly as a byte array. # # The file starts with a 4 byte int, indicating how much of it is used. # Then 4 bytes of padding. # There's then a number of entries, consisting of a 4 byte int which is the # size of the next field, a utf-8 encoded string key, padding to an 8 byte - # alignment, and then a 8 byte float which is the value. + # alignment, and then a 8 byte float which is the value, and then a 8 byte + # float which is the unix timestamp when the value was set. class FileMappedDict INITIAL_FILE_SIZE = 1024*1024 @@ -236,7 +247,8 @@ def all_values @positions.map do |key, pos| @f.seek(pos) value = @f.read(8).unpack('d')[0] - [key, value] + timestamp = @f.read(8).unpack('d')[0] + [key, value, timestamp] end end end @@ -258,7 +270,7 @@ def write_value(key, value) pos = @positions[key] @f.seek(pos) - @f.write([value].pack('d')) + @f.write([value, Time.now.to_f].pack('dd')) @f.flush end @@ -299,7 +311,7 @@ def resize_file(new_capacity) def init_value(key) # Pad to be 8-byte aligned. padded = key + (' ' * (8 - (key.length + 4) % 8)) - value = [padded.length, padded, 0.0].pack("lA#{padded.length}d") + value = [padded.length, padded, 0.0, 0.0].pack("lA#{padded.length}dd") while @used + value.length > @capacity @capacity *= 2 resize_file(@capacity) @@ -310,7 +322,7 @@ def init_value(key) @f.seek(0) @f.write([@used].pack('l')) @f.flush - @positions[key] = @used - 8 + @positions[key] = @used - 16 end # Read position of all keys. No locking is performed. @@ -320,7 +332,7 @@ def populate_positions padded_len = @f.read(4).unpack('l')[0] key = @f.read(padded_len).unpack("A#{padded_len}")[0].strip @positions[key] = @f.pos - @f.seek(8, :CUR) + @f.seek(16, :CUR) end end end diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb index f39ff10e..8fedd4a2 100644 --- a/spec/prometheus/client/data_stores/direct_file_store_spec.rb +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -267,6 +267,41 @@ end end + context "with a metric that takes MOST_RECENT instead of SUM" do + it "reports the most recently written value from different processes" do + metric_store1 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :most_recent } + ) + metric_store2 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :most_recent } + ) + + allow(Process).to receive(:pid).and_return(12345) + metric_store1.set(labels: { foo: "bar" }, val: 1) + + allow(Process).to receive(:pid).and_return(23456) + metric_store2.set(labels: { foo: "bar" }, val: 3) # Supercedes 'bar' in PID 12345 + metric_store2.set(labels: { foo: "baz" }, val: 2) + metric_store2.set(labels: { foo: "zzz" }, val: 1) + + allow(Process).to receive(:pid).and_return(12345) + metric_store1.set(labels: { foo: "baz" }, val: 4) # Supercedes 'baz' in PID 23456 + + expect(metric_store1.all_values).to eq( + { foo: "bar" } => 3.0, + { foo: "baz" } => 4.0, + { foo: "zzz" } => 1.0, + ) + + # Both processes should return the same value + expect(metric_store1.all_values).to eq(metric_store2.all_values) + end + end + it "resizes the File if metrics get too big" do truncate_calls_count = 0 allow_any_instance_of(Prometheus::Client::DataStores::DirectFileStore::FileMappedDict). From f40a541d145832d41a0103ab0cf4cd0e96c518c8 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 24 Feb 2020 15:37:39 -0300 Subject: [PATCH 083/189] Only allow Gauges to use :most_recent aggregation Using this aggregation with any other metric type is almost certainly not what the user intended, and it'll result in counters that go up and down, and completely inconsistent histograms. Signed-off-by: Daniel Magliola --- .../client/data_stores/direct_file_store.rb | 9 ++++-- .../data_stores/direct_file_store_spec.rb | 31 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index cd995f76..87d1b3eb 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -45,7 +45,7 @@ def for_metric(metric_name, metric_type:, metric_settings: {}) end settings = default_settings.merge(metric_settings) - validate_metric_settings(settings) + validate_metric_settings(metric_type, settings) MetricStore.new(metric_name: metric_name, store_settings: @store_settings, @@ -54,7 +54,7 @@ def for_metric(metric_name, metric_type:, metric_settings: {}) private - def validate_metric_settings(metric_settings) + def validate_metric_settings(metric_type, metric_settings) unless metric_settings.has_key?(:aggregation) && AGGREGATION_MODES.include?(metric_settings[:aggregation]) raise InvalidStoreSettingsError, @@ -65,6 +65,11 @@ def validate_metric_settings(metric_settings) raise InvalidStoreSettingsError, "Only :aggregation setting can be specified" end + + if metric_settings[:aggregation] == MOST_RECENT && metric_type != :gauge + raise InvalidStoreSettingsError, + "Only :gauge metrics support :most_recent aggregation" + end end class MetricStore diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb index 8fedd4a2..20efd567 100644 --- a/spec/prometheus/client/data_stores/direct_file_store_spec.rb +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -14,7 +14,7 @@ it_behaves_like Prometheus::Client::DataStores - it "only accepts valid :aggregation as Metric Settings" do + it "only accepts valid :aggregation values as Metric Settings" do expect do subject.for_metric(:metric_name, metric_type: :counter, @@ -26,7 +26,10 @@ metric_type: :counter, metric_settings: { aggregation: :invalid }) end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + end + it "only accepts valid keys as Metric Settings" do + # the only valid key at the moment is :aggregation expect do subject.for_metric(:metric_name, metric_type: :counter, @@ -34,6 +37,32 @@ end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) end + it "only accepts :most_recent aggregation for gauges" do + expect do + subject.for_metric(:metric_name, + metric_type: :gauge, + metric_settings: { aggregation: Prometheus::Client::DataStores::DirectFileStore::MOST_RECENT }) + end.not_to raise_error + + expect do + subject.for_metric(:metric_name, + metric_type: :counter, + metric_settings: { aggregation: Prometheus::Client::DataStores::DirectFileStore::MOST_RECENT }) + end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + + expect do + subject.for_metric(:metric_name, + metric_type: :histogram, + metric_settings: { aggregation: Prometheus::Client::DataStores::DirectFileStore::MOST_RECENT }) + end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + + expect do + subject.for_metric(:metric_name, + metric_type: :summary, + metric_settings: { aggregation: Prometheus::Client::DataStores::DirectFileStore::MOST_RECENT }) + end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + end + it "raises when aggregating if we get to that that point with an invalid aggregation mode" do # This is basically just for coverage of a safety clause that can never be reached allow(subject).to receive(:validate_metric_settings) # turn off validation From dddba2f43532262a802b25ddd40932ec4dec8127 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 24 Feb 2020 15:44:28 -0300 Subject: [PATCH 084/189] Do now allow `DirectFileStore#increment` when using :most_recent aggregation If we do this, we'd be incrementing the value for *this* process, not the global one, which is almost certainly not what the user wants to do. This is not very pretty because we may end up raising an exception in production (as test/dev tend to not use DirectFileStore), but we consider it better than letting the user mangle their numbers and end up with incorrect metrics. Signed-off-by: Daniel Magliola --- README.md | 3 +++ .../client/data_stores/direct_file_store.rb | 6 ++++++ .../client/data_stores/direct_file_store_spec.rb | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index e19c1f41..ecdcc68f 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,9 @@ Counters, Histograms and Summaries are `SUM`med, and Gauges report all their val for each process), tagged with a `pid` label. You can also select `SUM`, `MAX`, `MIN`, or `MOST_RECENT` for your gauges, depending on your use case. +Please note that that the `MOST_RECENT` aggregation only works for gauges, and it does not +allow the use of `increment` / `decrement`, you can only use `set`. + **Memory Usage**: When scraped by Prometheus, this store will read all these files, get all the values and aggregate them. We have notice this can have a noticeable effect on memory usage for your app. We recommend you test this in a realistic usage scenario to make sure diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 87d1b3eb..fd1886a7 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -105,6 +105,12 @@ def set(labels:, val:) end def increment(labels:, by: 1) + if @values_aggregation_mode == DirectFileStore::MOST_RECENT + raise InvalidStoreSettingsError, + "The :most_recent aggregation does not support the use of increment"\ + "/decrement" + end + key = store_key(labels) in_process_sync do value = internal_store.read_value(key) diff --git a/spec/prometheus/client/data_stores/direct_file_store_spec.rb b/spec/prometheus/client/data_stores/direct_file_store_spec.rb index 20efd567..c140546e 100644 --- a/spec/prometheus/client/data_stores/direct_file_store_spec.rb +++ b/spec/prometheus/client/data_stores/direct_file_store_spec.rb @@ -329,6 +329,18 @@ # Both processes should return the same value expect(metric_store1.all_values).to eq(metric_store2.all_values) end + + it "does now allow `increment`, only `set`" do + metric_store1 = subject.for_metric( + :metric_name, + metric_type: :gauge, + metric_settings: { aggregation: :most_recent } + ) + + expect do + metric_store1.increment(labels: {}) + end.to raise_error(Prometheus::Client::DataStores::DirectFileStore::InvalidStoreSettingsError) + end end it "resizes the File if metrics get too big" do From 6bb714422bc38c8ae70b58ac1050916dbb1aa54e Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 25 Feb 2020 08:20:20 -0300 Subject: [PATCH 085/189] Use monotonic clock when timestamping observed values The Monotonic clock is going to be more accurate on the few cases where the distinction matters, but it's also somehow faster than `Time.now`. Signed-off-by: Daniel Magliola --- lib/prometheus/client/data_stores/direct_file_store.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index fd1886a7..717d8d5c 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -279,9 +279,10 @@ def write_value(key, value) init_value(key) end + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) pos = @positions[key] @f.seek(pos) - @f.write([value, Time.now.to_f].pack('dd')) + @f.write([value, now].pack('dd')) @f.flush end From 826b32e34e8bc6e4ca40c88b5144c745bc86b07f Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Tue, 25 Feb 2020 08:32:51 -0300 Subject: [PATCH 086/189] Small file read performance improvement for DirectFileStore Instead of two `read` operations, we can do both together at once Signed-off-by: Daniel Magliola --- lib/prometheus/client/data_stores/direct_file_store.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index 717d8d5c..7bb5c1f4 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -257,8 +257,7 @@ def all_values with_file_lock do @positions.map do |key, pos| @f.seek(pos) - value = @f.read(8).unpack('d')[0] - timestamp = @f.read(8).unpack('d')[0] + value, timestamp = @f.read(16).unpack('dd') [key, value, timestamp] end end From 7278793a9d001ef3bf715c4ffb16b60956fa7f5e Mon Sep 17 00:00:00 2001 From: prombot Date: Tue, 23 Jun 2020 00:09:12 +0000 Subject: [PATCH 087/189] Update common Prometheus files Signed-off-by: prombot --- CODE_OF_CONDUCT.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..9a1aff41 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +## Prometheus Community Code of Conduct + +Prometheus follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). From b6d13dd3380b88fa70f935b22d8cf2bd0763c490 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Mon, 29 Jun 2020 13:37:33 +0100 Subject: [PATCH 088/189] Release v2.1 There's new features, so it's a minor version bump. Signed-off-by: Daniel Magliola --- CHANGELOG.md | 13 +++++++++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e355c01..b43e4cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +# 2.1.0 / 2020-06-29 + +## New Features + +- [#177](https://github.com/prometheus/client_ruby/pull/177) Added Histogram helpers to + generate linear and exponential buckets, as the Client Library Guidelines recommend. +- [#172](https://github.com/prometheus/client_ruby/pull/172) Added :most_recent + aggregation for gauges on DirectFileStore. + +## Code improvements + +- Fixed several warnings that started firing in the latest versions of Ruby. + # 2.0.0 / 2020-01-28 ## Breaking changes diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 147472ae..ec33bff5 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '2.0.0' + VERSION = '2.1.0' end end From c3836b2d0251bad38c391b51954b9a4ddf870f75 Mon Sep 17 00:00:00 2001 From: Matthieu Prat Date: Tue, 25 Aug 2020 14:38:27 +0100 Subject: [PATCH 089/189] Make all registry methods thread safe The registry is backed by a Hash, which is not guaranteed to be thread safe on all interpreters. For peace of mind, this change synchronizes all accesses to the metrics hash. Another option would have been to use a [thread-safe Hash][1] instead of a Hash but this would have meant adding Ruby Concurrent as a dependency, which I'm assuming we don't want. Ref: https://github.com/prometheus/client_ruby/pull/184#discussion_r406038191 [1]: https://github.com/ruby-concurrency/concurrent-ruby/blob/v1.1.7/lib/concurrent-ruby/concurrent/hash.rb Signed-off-by: Matthieu Prat --- lib/prometheus/client/registry.rb | 8 ++++---- spec/prometheus/client/registry_spec.rb | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/prometheus/client/registry.rb b/lib/prometheus/client/registry.rb index 4bf63aa4..0b2f6e9a 100644 --- a/lib/prometheus/client/registry.rb +++ b/lib/prometheus/client/registry.rb @@ -22,7 +22,7 @@ def register(metric) name = metric.name @mutex.synchronize do - if exist?(name.to_sym) + if @metrics.key?(name.to_sym) raise AlreadyRegisteredError, "#{name} has already been registered" end @metrics[name.to_sym] = metric @@ -73,15 +73,15 @@ def histogram(name, docstring:, labels: [], preset_labels: {}, end def exist?(name) - @metrics.key?(name) + @mutex.synchronize { @metrics.key?(name) } end def get(name) - @metrics[name.to_sym] + @mutex.synchronize { @metrics[name.to_sym] } end def metrics - @metrics.values + @mutex.synchronize { @metrics.values } end end end diff --git a/spec/prometheus/client/registry_spec.rb b/spec/prometheus/client/registry_spec.rb index 32727d00..3c4da190 100644 --- a/spec/prometheus/client/registry_spec.rb +++ b/spec/prometheus/client/registry_spec.rb @@ -33,10 +33,6 @@ mutex = Mutex.new containers = [] - def registry.exist?(*args) - super.tap { sleep(0.01) } - end - Array.new(5) do Thread.new do result = begin From 0761f5924173bd273028b5143e44321eb23d6f8a Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Wed, 14 Oct 2020 11:09:55 +0100 Subject: [PATCH 090/189] Include SCRIPT_NAME when determining path in Collector When determining the path for a request, `Rack::Request` prefixes the `SCRIPT_NAME`, [as seen here][1]. This is a problem with our current code when using mountable engines, where the engine part of the path gets lost. This patch fixes that to include `SCRIPT_NAME` as part of the path. NOTE: This is not backwards compatible. Labels will change in existing metrics. We will cut a new major version once we ship this. [1]: https://github.com/rack/rack/blob/294fd239a71aab805877790f0a92ee3c72e67d79/lib/rack/request.rb#L512 Co-authored-by: Ian Ker-Seymer Co-authored-by: Ruslan Kornev Signed-off-by: Daniel Magliola --- lib/prometheus/middleware/collector.rb | 6 ++++-- spec/prometheus/middleware/collector_spec.rb | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 65de3d17..48e3ddd1 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -67,15 +67,17 @@ def trace(env) end def record(env, code, duration) + path = [env["SCRIPT_NAME"], env['PATH_INFO']].join + counter_labels = { code: code, method: env['REQUEST_METHOD'].downcase, - path: strip_ids_from_path(env['PATH_INFO']), + path: strip_ids_from_path(path), } duration_labels = { method: env['REQUEST_METHOD'].downcase, - path: strip_ids_from_path(env['PATH_INFO']), + path: strip_ids_from_path(path), } @requests.increment(labels: counter_labels) diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index e2d53164..7809bcf9 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -55,6 +55,18 @@ expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.25" => 1) end + it 'includes SCRIPT_NAME in the path if provided' do + metric = :http_server_requests_total + + get '/foo' + expect(registry.get(metric).values.keys.last[:path]).to eql("/foo") + + env('SCRIPT_NAME', '/engine') + get '/foo' + env('SCRIPT_NAME', nil) + expect(registry.get(metric).values.keys.last[:path]).to eql("/engine/foo") + end + it 'normalizes paths containing numeric IDs by default' do expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3) From feccff3429cac056913ad7414b8744e61db57d85 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Thu, 24 Dec 2020 18:29:54 +0000 Subject: [PATCH 091/189] Automatically initialize time series without labels According to [Prometheus Best Practices](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics), client libraries are expected to automatically export a 0 value when declaring a metric that has no labels. We missed this so far in the Ruby client, this PR rectifies that. NOTE: This can be considered a breaking change. On the one hand, it's a bug fix, but on the other, it will make many time series materialize when scraping apps that use the client, that previously wouldn't have. Depending on how many label-less metrics the app is declaring, this may have a significant impact, so we should probably cut a new major version and warn about this in the Release Notes. Signed-off-by: Daniel Magliola --- lib/prometheus/client/metric.rb | 2 ++ spec/prometheus/client/counter_spec.rb | 18 ++++++++++----- spec/prometheus/client/gauge_spec.rb | 18 ++++++++++----- spec/prometheus/client/histogram_spec.rb | 28 ++++++++++++++++-------- spec/prometheus/client/summary_spec.rb | 28 ++++++++++++++++-------- 5 files changed, 66 insertions(+), 28 deletions(-) diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index 100e455b..c492b08d 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -40,6 +40,8 @@ def initialize(name, metric_type: type, metric_settings: store_settings ) + + init_label_set({}) if labels.empty? end # Returns the value for the given label set diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 856a60b8..338b1cfe 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -104,14 +104,22 @@ end describe '#init_label_set' do - let(:expected_labels) { [:test] } + context "with labels" do + let(:expected_labels) { [:test] } - it 'initializes the metric for a given label set' do - expect(counter.values).to eql({}) + it 'initializes the metric for a given label set' do + expect(counter.values).to eql({}) - counter.init_label_set(test: 'value') + counter.init_label_set(test: 'value') - expect(counter.values).to eql({test: 'value'} => 0.0) + expect(counter.values).to eql({test: 'value'} => 0.0) + end + end + + context "without labels" do + it 'automatically initializes the metric' do + expect(counter.values).to eql({} => 0.0) + end end end end diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index 37568aa0..318e0552 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -186,14 +186,22 @@ end describe '#init_label_set' do - let(:expected_labels) { [:test] } + context "with labels" do + let(:expected_labels) { [:test] } - it 'initializes the metric for a given label set' do - expect(gauge.values).to eql({}) + it 'initializes the metric for a given label set' do + expect(gauge.values).to eql({}) - gauge.init_label_set(test: 'value') + gauge.init_label_set(test: 'value') - expect(gauge.values).to eql({test: 'value'} => 0.0) + expect(gauge.values).to eql({test: 'value'} => 0.0) + end + end + + context "without labels" do + it 'automatically initializes the metric' do + expect(gauge.values).to eql({} => 0.0) + end end end end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index 780875f9..b0988b36 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -165,18 +165,28 @@ end describe '#init_label_set' do - let(:expected_labels) { [:status] } + context "with labels" do + let(:expected_labels) { [:status] } - it 'initializes the metric for a given label set' do - expect(histogram.values).to eql({}) + it 'initializes the metric for a given label set' do + expect(histogram.values).to eql({}) - histogram.init_label_set(status: 'bar') - histogram.init_label_set(status: 'foo') + histogram.init_label_set(status: 'bar') + histogram.init_label_set(status: 'foo') - expect(histogram.values).to eql( - { status: 'bar' } => { "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 }, - { status: 'foo' } => { "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 }, - ) + expect(histogram.values).to eql( + { status: 'bar' } => { "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 }, + { status: 'foo' } => { "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 }, + ) + end + end + + context "without labels" do + it 'automatically initializes the metric' do + expect(histogram.values).to eql( + {} => { "2.5" => 0.0, "5" => 0.0, "10" => 0.0, "+Inf" => 0.0, "sum" => 0.0 }, + ) + end end end end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index 86883ab9..fbf8f2ce 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -128,18 +128,28 @@ end describe '#init_label_set' do - let(:expected_labels) { [:status] } + context "with labels" do + let(:expected_labels) { [:status] } - it 'initializes the metric for a given label set' do - expect(summary.values).to eql({}) + it 'initializes the metric for a given label set' do + expect(summary.values).to eql({}) - summary.init_label_set(status: 'bar') - summary.init_label_set(status: 'foo') + summary.init_label_set(status: 'bar') + summary.init_label_set(status: 'foo') - expect(summary.values).to eql( - { status: 'bar' } => { "count" => 0.0, "sum" => 0.0 }, - { status: 'foo' } => { "count" => 0.0, "sum" => 0.0 }, - ) + expect(summary.values).to eql( + { status: 'bar' } => { "count" => 0.0, "sum" => 0.0 }, + { status: 'foo' } => { "count" => 0.0, "sum" => 0.0 }, + ) + end + end + + context "without labels" do + it 'automatically initializes the metric' do + expect(summary.values).to eql( + {} => { "count" => 0.0, "sum" => 0.0 }, + ) + end end end end From cb210da334a3d3ee7c8581dd9bbe0ef7fb76adc0 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 25 Dec 2020 11:35:19 +0000 Subject: [PATCH 092/189] Support latest Ruby versions Ruby 3 was released today. And the good news is: we are already perfectly compatible with it. `rspec` runs with 0 warnings, which was a surprise. Signed-off-by: Daniel Magliola --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 425587fc..7abd8214 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,5 +7,6 @@ before_install: rvm: - 2.5.8 - 2.6.6 - - 2.7.1 + - 2.7.2 + - 3.0.0 - jruby-9.1.9.0 From 42860ced1d3ad1deff061f6b7736e53a5fdfc5fe Mon Sep 17 00:00:00 2001 From: James Turley Date: Thu, 30 Jul 2020 08:49:45 +0100 Subject: [PATCH 093/189] Add port option to exporter middleware If the port option is set, all requests for /metrics on other ports will be forwarded to the app. If it is unset or nil, or the ports match, export things as usual. Allows separate mounting of metrics and main app to enforce different security setups etc. Signed-off-by: James Turley --- lib/prometheus/middleware/exporter.rb | 7 +++++- spec/prometheus/middleware/exporter_spec.rb | 28 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/middleware/exporter.rb b/lib/prometheus/middleware/exporter.rb index 5a74d8e9..640a3985 100644 --- a/lib/prometheus/middleware/exporter.rb +++ b/lib/prometheus/middleware/exporter.rb @@ -21,11 +21,12 @@ def initialize(app, options = {}) @app = app @registry = options[:registry] || Client.registry @path = options[:path] || '/metrics' + @port = options[:port] @acceptable = build_dictionary(FORMATS, FALLBACK) end def call(env) - if env['PATH_INFO'] == @path + if metrics_port?(env['SERVER_PORT']) && env['PATH_INFO'] == @path format = negotiate(env, @acceptable) format ? respond_with(format) : not_acceptable(FORMATS) else @@ -86,6 +87,10 @@ def build_dictionary(formats, fallback) memo[format::MEDIA_TYPE] = format end end + + def metrics_port?(request_port) + @port.nil? || @port.to_s == request_port + end end end end diff --git a/spec/prometheus/middleware/exporter_spec.rb b/spec/prometheus/middleware/exporter_spec.rb index 8916513b..5299fc1e 100644 --- a/spec/prometheus/middleware/exporter_spec.rb +++ b/spec/prometheus/middleware/exporter_spec.rb @@ -6,13 +6,14 @@ describe Prometheus::Middleware::Exporter do include Rack::Test::Methods + let(:options) { { registry: registry } } let(:registry) do Prometheus::Client::Registry.new end let(:app) do app = ->(_) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } - described_class.new(app, registry: registry) + described_class.new(app, **options) end context 'when requesting app endpoints' do @@ -96,5 +97,30 @@ include_examples 'ok', { 'HTTP_ACCEPT' => accept }, text end + + context 'when a port is specified' do + let(:options) { { registry: registry, port: 9999 } } + + context 'when a request is on the specified port' do + it 'responds with 200 OK' do + registry.counter(:foo, docstring: 'foo counter').increment(by: 9) + + get 'http://example.org:9999/metrics', nil, {} + + expect(last_response.status).to eql(200) + expect(last_response.header['Content-Type']).to eql(text::CONTENT_TYPE) + expect(last_response.body).to eql(text.marshal(registry)) + end + end + + context 'when a request is not on the specified port' do + it 'returns the app response' do + get 'http://example.org:8888/metrics', nil, {} + + expect(last_response).to be_ok + expect(last_response.body).to eql('OK') + end + end + end end end From 469c9867c91296a8b0fc9d807d04ca1e89019412 Mon Sep 17 00:00:00 2001 From: prombot Date: Sun, 17 Jan 2021 00:04:25 +0000 Subject: [PATCH 094/189] Update common Prometheus files Signed-off-by: prombot --- SECURITY.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..67741f01 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Reporting a security issue + +The Prometheus security policy, including how to report vulnerabilities, can be +found here: + +https://prometheus.io/docs/operating/security/ From bd383178381aa0ab2ef32ae188155a222939bafe Mon Sep 17 00:00:00 2001 From: Emmanuel Delmas Date: Mon, 11 Jan 2021 21:31:46 +0100 Subject: [PATCH 095/189] Document metrics definition behavior with fork Signed-off-by: Emmanuel Delmas --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 7793e2f8..97ac45f6 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,11 @@ If you are running in pre-fork servers (such as Unicorn or Puma with multiple pr make sure you do this **before** the server forks. Otherwise, each child process may delete files created by other processes on *this* run, instead of deleting old files. +**Define metrics before fork**: In order to share the same definition of metrics between +your forks, you should make sure to define your metrics before to fork your process. +Indeed, even if the content of the metric is stored in a file, the list of all metrics is +stored in memory. Creating metrics after fork would lead to unexported files metrics. + **Large numbers of files**: Because there is an individual file per metric and per process (which is done to optimize for observation performance), you may end up with a large number of files. We don't currently have a solution for this problem, but we're working on it. From 9280c8b752c8f2bb7cab0ad2221f96cacae128cc Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 21 Mar 2021 14:47:07 +0000 Subject: [PATCH 096/189] Update email address in MAINTAINERS.md Signed-off-by: Chris Sinjakli --- MAINTAINERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MAINTAINERS.md b/MAINTAINERS.md index c0ca7782..7b058214 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,3 +1,3 @@ * Ben Kochie -* Chris Sinjakli +* Chris Sinjakli * Daniel Magliola From 4f95dd0b3bb0595f88a8acfa52280d5d7a58a753 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 24 Mar 2021 00:23:34 +0000 Subject: [PATCH 097/189] Add missing 'cgi' require in push client Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 03efb9f7..0ac99018 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -3,6 +3,7 @@ require 'thread' require 'net/http' require 'uri' +require 'cgi' require 'prometheus/client' require 'prometheus/client/formats/text' From db75ed05b9364aa4cb2e74cb3fbbc3d2ea132a54 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 20 Mar 2021 20:14:58 +0000 Subject: [PATCH 098/189] Minor wordsmithing in README.md Signed-off-by: Chris Sinjakli --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 97ac45f6..31d2ddce 100644 --- a/README.md +++ b/README.md @@ -389,10 +389,16 @@ If you are running in pre-fork servers (such as Unicorn or Puma with multiple pr make sure you do this **before** the server forks. Otherwise, each child process may delete files created by other processes on *this* run, instead of deleting old files. -**Define metrics before fork**: In order to share the same definition of metrics between -your forks, you should make sure to define your metrics before to fork your process. -Indeed, even if the content of the metric is stored in a file, the list of all metrics is -stored in memory. Creating metrics after fork would lead to unexported files metrics. +**Declare metrics before fork**: As well as deleting files before your process forks, you +should make sure to declare your metrics before forking too. Because the metric registry +is held in memory, any metrics declared after forking will only be present in child +processes where the code declaring them ran, and as a result may not be consistently +exported when scraped (i.e. they will only appear when a child process that declared them +is scraped). + +If you're absolutely sure that every child process will run the metric declaration code, +then you won't run into this issue, but the simplest approach is to declare the metrics +before forking. **Large numbers of files**: Because there is an individual file per metric and per process (which is done to optimize for observation performance), you may end up with a large number From f264e4489ce5e469774bae0f5e2ed0579e0bb5b7 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 27 Mar 2021 00:31:29 +0000 Subject: [PATCH 099/189] Update email in gemspec Signed-off-by: Chris Sinjakli --- prometheus-client.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 20ad18bf..21690fab 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -8,7 +8,7 @@ Gem::Specification.new do |s| s.summary = 'A suite of instrumentation metric primitives' \ 'that can be exposed through a web services interface.' s.authors = ['Ben Kochie', 'Chris Sinjakli', 'Daniel Magliola'] - s.email = ['superq@gmail.com', 'chris@gocardless.com', 'dmagliola@crystalgears.com'] + s.email = ['superq@gmail.com', 'chris@sinjakli.co.uk', 'dmagliola@crystalgears.com'] s.homepage = 'https://github.com/prometheus/client_ruby' s.license = 'Apache 2.0' From 7d16ff22bbf518ec3f94c891c92c875cec4b01ac Mon Sep 17 00:00:00 2001 From: Nick Van Wiggeren Date: Tue, 23 Mar 2021 12:49:48 -0700 Subject: [PATCH 100/189] add open/read timeout kwargs Signed-off-by: Nick Van Wiggeren --- lib/prometheus/client/push.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 03efb9f7..d5e14d80 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -20,7 +20,7 @@ class Push attr_reader :job, :instance, :gateway, :path - def initialize(job, instance = nil, gateway = nil) + def initialize(job, instance = nil, gateway = nil, **kwargs) @mutex = Mutex.new @job = job @instance = instance @@ -30,6 +30,8 @@ def initialize(job, instance = nil, gateway = nil) @http = Net::HTTP.new(@uri.host, @uri.port) @http.use_ssl = (@uri.scheme == 'https') + @http.open_timeout = kwargs[:open_timeout] if kwargs[:open_timeout] + @http.read_timeout = kwargs[:read_timeout] if kwargs[:read_timeout] end def add(registry) From 546b5352610b4d26ce341264558749805ff3c0ee Mon Sep 17 00:00:00 2001 From: Nick Van Wiggeren Date: Wed, 12 May 2021 15:46:39 -0700 Subject: [PATCH 101/189] test open/read timeout Signed-off-by: Nick Van Wiggeren --- spec/prometheus/client/push_spec.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index f84748e3..a9a4020a 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -5,7 +5,7 @@ describe Prometheus::Client::Push do let(:gateway) { 'http://localhost:9091' } let(:registry) { Prometheus::Client.registry } - let(:push) { Prometheus::Client::Push.new('test-job', nil, gateway) } + let(:push) { Prometheus::Client::Push.new('test-job', nil, gateway, open_timeout: 5, read_timeout: 30) } describe '.new' do it 'returns a new push instance' do @@ -91,6 +91,8 @@ http = double(:http) expect(http).to receive(:use_ssl=).with(false) + expect(http).to receive(:open_timeout=).with(5) + expect(http).to receive(:read_timeout=).with(30) expect(http).to receive(:request).with(request) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) @@ -104,6 +106,8 @@ http = double(:http) expect(http).to receive(:use_ssl=).with(false) + expect(http).to receive(:open_timeout=).with(5) + expect(http).to receive(:read_timeout=).with(30) expect(http).to receive(:request).with(request) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) @@ -121,6 +125,8 @@ http = double(:http) expect(http).to receive(:use_ssl=).with(true) + expect(http).to receive(:open_timeout=).with(5) + expect(http).to receive(:read_timeout=).with(30) expect(http).to receive(:request).with(request) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) @@ -140,6 +146,8 @@ http = double(:http) expect(http).to receive(:use_ssl=).with(true) + expect(http).to receive(:open_timeout=).with(5) + expect(http).to receive(:read_timeout=).with(30) expect(http).to receive(:request).with(request) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) From 5c67aa636838ffaadd906a0443365f5fd2ab071c Mon Sep 17 00:00:00 2001 From: beorn7 Date: Wed, 26 May 2021 23:48:02 +0200 Subject: [PATCH 102/189] Document implications of negative observations Fixes #218. Signed-off-by: beorn7 --- lib/prometheus/client/histogram.rb | 8 +++++++- lib/prometheus/client/summary.rb | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 12d1ed78..172083cc 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -6,7 +6,7 @@ module Prometheus module Client # A histogram samples observations (usually things like request durations # or response sizes) and counts them in configurable buckets. It also - # provides a sum of all observed values. + # provides a total count and sum of all observed values. class Histogram < Metric # DEFAULT_BUCKETS are the default Histogram buckets. The default buckets # are tailored to broadly measure the response time (in seconds) of a @@ -54,6 +54,12 @@ def type :histogram end + # Records a given value. The recorded value is usually positive + # or zero. A negative value is accepted but prevents current + # versions of Prometheus from properly detecting counter resets + # in the sum of observations. See + # https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations + # for details. def observe(value, labels: {}) bucket = buckets.find {|upper_limit| upper_limit >= value } bucket = "+Inf" if bucket.nil? diff --git a/lib/prometheus/client/summary.rb b/lib/prometheus/client/summary.rb index ee8dd0a3..dff2f360 100644 --- a/lib/prometheus/client/summary.rb +++ b/lib/prometheus/client/summary.rb @@ -11,7 +11,12 @@ def type :summary end - # Records a given value. + # Records a given value. The recorded value is usually positive + # or zero. A negative value is accepted but prevents current + # versions of Prometheus from properly detecting counter resets + # in the sum of observations. See + # https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations + # for details. def observe(value, labels: {}) base_label_set = label_set_for(labels) From fe1f105978611bcf5f5c365e5e4ef9852dbaee3d Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 26 Mar 2021 21:20:38 +0000 Subject: [PATCH 103/189] Raise ArgumentError in push.rb if job is nil Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 4 ++++ spec/prometheus/client/push_spec.rb | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 3078811a..cf85bd57 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -22,6 +22,10 @@ class Push attr_reader :job, :instance, :gateway, :path def initialize(job, instance = nil, gateway = nil, **kwargs) + unless job + raise ArgumentError, "job cannot be nil" + end + @mutex = Mutex.new @job = job @instance = instance diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index a9a4020a..9dd148e1 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -24,6 +24,12 @@ expect(push.gateway).to eql('http://pu.sh:1234') end + it 'raises an ArgumentError if the job is not provided' do + expect do + Prometheus::Client::Push.new(nil) + end.to raise_error ArgumentError + end + it 'raises an ArgumentError if the given gateway URL is invalid' do ['inva.lid:1233', 'http://[invalid]'].each do |url| expect do From cc65970b0edbc4ee6ed0cd74b0b3b558dcabb2fc Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 27 Mar 2021 01:48:29 +0000 Subject: [PATCH 104/189] Convert pushgateway client to keyword arguments Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 6 ++---- spec/prometheus/client/push_spec.rb | 18 +++++++++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index cf85bd57..3ad06416 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -21,10 +21,8 @@ class Push attr_reader :job, :instance, :gateway, :path - def initialize(job, instance = nil, gateway = nil, **kwargs) - unless job - raise ArgumentError, "job cannot be nil" - end + def initialize(job:, instance: nil, gateway: DEFAULT_GATEWAY, **kwargs) + raise ArgumentError, "job cannot be nil" if job.nil? @mutex = Mutex.new @job = job diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 9dd148e1..9f4ff6df 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -5,7 +5,7 @@ describe Prometheus::Client::Push do let(:gateway) { 'http://localhost:9091' } let(:registry) { Prometheus::Client.registry } - let(:push) { Prometheus::Client::Push.new('test-job', nil, gateway, open_timeout: 5, read_timeout: 30) } + let(:push) { Prometheus::Client::Push.new(job: 'test-job', gateway: gateway, open_timeout: 5, read_timeout: 30) } describe '.new' do it 'returns a new push instance' do @@ -13,27 +13,27 @@ end it 'uses localhost as default Pushgateway' do - push = Prometheus::Client::Push.new('test-job') + push = Prometheus::Client::Push.new(job: 'test-job') expect(push.gateway).to eql('http://localhost:9091') end it 'allows to specify a custom Pushgateway' do - push = Prometheus::Client::Push.new('test-job', nil, 'http://pu.sh:1234') + push = Prometheus::Client::Push.new(job: 'test-job', gateway: 'http://pu.sh:1234') expect(push.gateway).to eql('http://pu.sh:1234') end - it 'raises an ArgumentError if the job is not provided' do + it 'raises an ArgumentError if the job is nil' do expect do - Prometheus::Client::Push.new(nil) + Prometheus::Client::Push.new(job: nil) end.to raise_error ArgumentError end it 'raises an ArgumentError if the given gateway URL is invalid' do ['inva.lid:1233', 'http://[invalid]'].each do |url| expect do - Prometheus::Client::Push.new('test-job', nil, url) + Prometheus::Client::Push.new(job: 'test-job', gateway: url) end.to raise_error ArgumentError end end @@ -65,19 +65,19 @@ describe '#path' do it 'uses the default metrics path if no instance value given' do - push = Prometheus::Client::Push.new('test-job') + push = Prometheus::Client::Push.new(job: 'test-job') expect(push.path).to eql('/metrics/job/test-job') end it 'uses the full metrics path if an instance value is given' do - push = Prometheus::Client::Push.new('bar-job', 'foo') + push = Prometheus::Client::Push.new(job: 'bar-job', instance: 'foo') expect(push.path).to eql('/metrics/job/bar-job/instance/foo') end it 'escapes non-URL characters' do - push = Prometheus::Client::Push.new('bar job', 'foo ') + push = Prometheus::Client::Push.new(job: 'bar job', instance: 'foo ') expected = '/metrics/job/bar+job/instance/foo+%3Cmy+instance%3E' expect(push.path).to eql(expected) From 7fc1064913cc1fae07d4fc58da30a82434f89d0a Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 27 Mar 2021 02:47:55 +0000 Subject: [PATCH 105/189] Raise ArgumentError in push.rb if job is empty Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 1 + spec/prometheus/client/push_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 3ad06416..d789c8de 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -23,6 +23,7 @@ class Push def initialize(job:, instance: nil, gateway: DEFAULT_GATEWAY, **kwargs) raise ArgumentError, "job cannot be nil" if job.nil? + raise ArgumentError, "job cannot be empty" if job.empty? @mutex = Mutex.new @job = job diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 9f4ff6df..896a9e00 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -30,6 +30,12 @@ end.to raise_error ArgumentError end + it 'raises an ArgumentError if the job is empty' do + expect do + Prometheus::Client::Push.new(job: "") + end.to raise_error ArgumentError + end + it 'raises an ArgumentError if the given gateway URL is invalid' do ['inva.lid:1233', 'http://[invalid]'].each do |url| expect do From dfa5f90d41cd284004ec6dae986d1b9f963a5a5a Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 27 Mar 2021 03:33:50 +0000 Subject: [PATCH 106/189] Handle empty instance label in push.rb Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 2 +- spec/prometheus/client/push_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index d789c8de..c04e6bcd 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -71,7 +71,7 @@ def parse(url) end def build_path(job, instance) - if instance + if instance && !instance.empty? format(INSTANCE_PATH, CGI::escape(job), CGI::escape(instance)) else format(PATH, CGI::escape(job)) diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 896a9e00..b564292d 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -76,6 +76,12 @@ expect(push.path).to eql('/metrics/job/test-job') end + it 'uses the default metrics path if an empty instance value is given' do + push = Prometheus::Client::Push.new(job: 'bar-job', instance: '') + + expect(push.path).to eql('/metrics/job/bar-job') + end + it 'uses the full metrics path if an instance value is given' do push = Prometheus::Client::Push.new(job: 'bar-job', instance: 'foo') From ec5c5aa6979aa295d91fbc16e76e5eb09f82a256 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 9 Jun 2021 18:25:31 +0100 Subject: [PATCH 107/189] Fix URI escaping of spaces in push.rb When `URI.escape` was deprecated, we switched to using `CGI.escape` to construct the request path in push.rb. The problem with that is that it mishandles spaces, encoding them as `+` rather than `%20`. This leads to literal `+` characters in metric labels, which is a bug. This commit uses `ERB::Util.url_encode` instead, which gives us the behaviour we want. It's not the part of the standard library I expected to use for this, but it's the only one that seems to have the behaviour we want and that hasn't disappeared in Ruby 3.0. `WEBrick::HTTPUtils.escape` seems similar on the surface, but doesn't encode `&`. Presumably it's intended as a best-effort way to encode an entire URL, and has to assume that any `&` character is a separator between query params. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 6 +++--- spec/prometheus/client/push_spec.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index c04e6bcd..a0b4b8cf 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -3,7 +3,7 @@ require 'thread' require 'net/http' require 'uri' -require 'cgi' +require 'erb' require 'prometheus/client' require 'prometheus/client/formats/text' @@ -72,9 +72,9 @@ def parse(url) def build_path(job, instance) if instance && !instance.empty? - format(INSTANCE_PATH, CGI::escape(job), CGI::escape(instance)) + format(INSTANCE_PATH, ERB::Util::url_encode(job), ERB::Util::url_encode(instance)) else - format(PATH, CGI::escape(job)) + format(PATH, ERB::Util::url_encode(job)) end end diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index b564292d..c8cd55d4 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -91,7 +91,7 @@ it 'escapes non-URL characters' do push = Prometheus::Client::Push.new(job: 'bar job', instance: 'foo ') - expected = '/metrics/job/bar+job/instance/foo+%3Cmy+instance%3E' + expected = '/metrics/job/bar%20job/instance/foo%20%3Cmy%20instance%3E' expect(push.path).to eql(expected) end end From 8bc55115386288138e499860b22bbbe68d175c9f Mon Sep 17 00:00:00 2001 From: SuperQ Date: Sun, 20 Jun 2021 14:10:45 +0200 Subject: [PATCH 108/189] Convert to CircleCI for build testing Replace Travis with CircleCI for testing. Fixes: https://github.com/prometheus/client_ruby/issues/229 Signed-off-by: SuperQ --- .circleci/config.yml | 32 ++++++++++++++++++++++++++++++++ .travis.yml | 12 ------------ README.md | 2 +- 3 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 .circleci/config.yml delete mode 100644 .travis.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..8aca788d --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,32 @@ +--- +version: 2.1 + +jobs: + test: + parameters: + ruby_image: + type: string + + docker: + - image: << parameters.ruby_image >> + + steps: + - checkout + - run: if [[ "$(ruby -e 'puts RUBY_VERSION')" != 1.* ]]; then gem update --system; fi + - run: bundle install + - run: bundle exec rake + +workflows: + version: 2 + client_ruby: + jobs: + - test: + matrix: + parameters: + ruby_image: + - cimg/ruby:2.5 + - cimg/ruby:2.6 + - cimg/ruby:2.7 + - cimg/ruby:3.0 + - circleci/jruby:9.1 + - circleci/jruby:9.2 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7abd8214..00000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -sudo: false -language: ruby -# Needed for rainbow 2.2.1 / rubygems issues. -before_install: - - | - if [[ "$(ruby -e 'puts RUBY_VERSION')" != 1.* ]]; then gem update --system; fi -rvm: - - 2.5.8 - - 2.6.6 - - 2.7.2 - - 3.0.0 - - jruby-9.1.9.0 diff --git a/README.md b/README.md index 31d2ddce..a5378800 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ through a HTTP interface. Intended to be used together with a [Prometheus server][1]. [![Gem Version][4]](http://badge.fury.io/rb/prometheus-client) -[![Build Status][3]](http://travis-ci.org/prometheus/client_ruby) +[![Build Status][3]](https://circleci.com/gh/prometheus/client_ruby/tree/master.svg?style=svg) [![Coverage Status][7]](https://coveralls.io/r/prometheus/client_ruby) ## Usage From c8ad029e39e34cb94b3af71afa350310200c44b8 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Sat, 12 Jun 2021 18:28:49 +0100 Subject: [PATCH 109/189] Add tests to `with_labels` This reproduces the error reported in issue #225, where `with_labels` doesn't work at all the way it is intended, and in addition to that, if you're using DirectFileStore, it can very easily corrupt your files. The problem is the new metric received after calling `with_labels` is a copy of the original, with the labels pre-set, but it has **its own internal store**, so it doesn't actually impact the values of the original metric, which is what gets exported by the registry. Signed-off-by: Daniel Magliola --- spec/prometheus/client/counter_spec.rb | 60 +++++++++++++++++++++--- spec/prometheus/client/gauge_spec.rb | 25 +++++++--- spec/prometheus/client/histogram_spec.rb | 29 +++++++++--- spec/prometheus/client/summary_spec.rb | 29 +++++++++--- 4 files changed, 119 insertions(+), 24 deletions(-) diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index 338b1cfe..afa03501 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -3,6 +3,7 @@ require 'prometheus/client' require 'prometheus/client/counter' require 'examples/metric_example' +require 'prometheus/client/data_stores/direct_file_store' describe Prometheus::Client::Counter do # Reset the data store @@ -45,12 +46,6 @@ end.to change { counter.get(labels: { test: 'label' }) }.by(1.0) end.to_not change { counter.get(labels: { test: 'other' }) } end - - it 'can pre-set labels using `with_labels`' do - expect { counter.increment } - .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) - expect { counter.with_labels(test: 'label').increment }.not_to raise_error - end end it 'increments the counter by a given value' do @@ -122,4 +117,57 @@ end end end + + describe '#with_labels' do + let(:expected_labels) { [:foo] } + + it 'pre-sets labels for observations' do + expect { counter.increment } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { counter.with_labels(foo: 'label').increment }.not_to raise_error + end + + it 'registers `with_labels` observations in the original metric store' do + counter.increment(labels: { foo: 'value1'}) + counter_with_labels = counter.with_labels({ foo: 'value2'}) + counter_with_labels.increment(by: 2) + + expect(counter_with_labels.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) + expect(counter.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) + end + + context 'when using DirectFileStore' do + before do + Dir.glob('/tmp/prometheus_test/*').each { |file| File.delete(file) } + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir: '/tmp/prometheus_test') + end + + let(:expected_labels) { [:foo, :bar] } + + it "doesn't corrupt the data files" do + counter_with_labels = counter.with_labels({ foo: 'longervalue'}) + + # Initialize / read the files for both views of the metric + counter.increment(labels: { foo: 'value1', bar: 'zzz'}) + counter_with_labels.increment(by: 2, labels: {bar: 'zzz'}) + + # After both MetricStores have their files, add a new entry to both + counter.increment(labels: { foo: 'value1', bar: 'aaa'}) + counter_with_labels.increment(by: 2, labels: {bar: 'aaa'}) + + expect { counter.values }.not_to raise_error # Check it hasn't corrupted our files + expect { counter_with_labels.values }.not_to raise_error # Check it hasn't corrupted our files + + expected_values = { + {foo: 'value1', bar: 'zzz'} => 1.0, + {foo: 'value1', bar: 'aaa'} => 1.0, + {foo: 'longervalue', bar: 'zzz'} => 2.0, + {foo: 'longervalue', bar: 'aaa'} => 2.0, + } + + expect(counter.values).to eql(expected_values) + expect(counter_with_labels.values).to eql(expected_values) + end + end + end end diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index 318e0552..bad54f77 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -45,12 +45,6 @@ end.to change { gauge.get(labels: { test: 'value' }) }.from(0).to(42) end.to_not change { gauge.get(labels: { test: 'other' }) } end - - it 'can pre-set labels using `with_labels`' do - expect { gauge.set(10) } - .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) - expect { gauge.with_labels(test: 'value').set(10) }.not_to raise_error - end end context 'given an invalid value' do @@ -204,4 +198,23 @@ end end end + + describe '#with_labels' do + let(:expected_labels) { [:foo] } + + it 'pre-sets labels for observations' do + expect { gauge.set(10) } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { gauge.with_labels(foo: 'value').set(10) }.not_to raise_error + end + + it 'registers `with_labels` observations in the original metric store' do + gauge.set(1, labels: { foo: 'value1'}) + gauge_with_labels = gauge.with_labels({ foo: 'value2'}) + gauge_with_labels.set(2) + + expect(gauge_with_labels.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) + expect(gauge.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) + end + end end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index b0988b36..7e0f55d8 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -80,12 +80,6 @@ end.to change { histogram.get(labels: { test: 'value' }) } end.to_not change { histogram.get(labels: { test: 'other' }) } end - - it 'can pre-set labels using `with_labels`' do - expect { histogram.observe(2) } - .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) - expect { histogram.with_labels(test: 'value').observe(2) }.not_to raise_error - end end context "with non-string label values" do @@ -189,4 +183,27 @@ end end end + + describe '#with_labels' do + let(:expected_labels) { [:foo] } + + it 'pre-sets labels for observations' do + expect { histogram.observe(2) } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { histogram.with_labels(foo: 'value').observe(2) }.not_to raise_error + end + + it 'registers `with_labels` observations in the original metric store' do + histogram.observe(7, labels: { foo: 'value1'}) + histogram_with_labels = histogram.with_labels({ foo: 'value2'}) + histogram_with_labels.observe(20) + + expected_values = { + {foo: 'value1'} => {'2.5' => 0.0, '5' => 0.0, '10' => 1.0, '+Inf' => 1.0, 'sum' => 7.0}, + {foo: 'value2'} => {'2.5' => 0.0, '5' => 0.0, '10' => 0.0, '+Inf' => 1.0, 'sum' => 20.0} + } + expect(histogram_with_labels.values).to eql(expected_values) + expect(histogram.values).to eql(expected_values) + end + end end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index fbf8f2ce..3896f4da 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -61,12 +61,6 @@ end.to change { summary.get(labels: { test: 'value' })["count"] } end.to_not change { summary.get(labels: { test: 'other' })["count"] } end - - it 'can pre-set labels using `with_labels`' do - expect { summary.observe(2) } - .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) - expect { summary.with_labels(test: 'value').observe(2) }.not_to raise_error - end end context "with non-string label values" do @@ -152,4 +146,27 @@ end end end + + describe '#with_labels' do + let(:expected_labels) { [:foo] } + + it 'pre-sets labels for observations' do + expect { summary.observe(2) } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { summary.with_labels(foo: 'value').observe(2) }.not_to raise_error + end + + it 'registers `with_labels` observations in the original metric store' do + summary.observe(1, labels: { foo: 'value1'}) + summary_with_labels = summary.with_labels({ foo: 'value2'}) + summary_with_labels.observe(2) + + expected_values = { + {foo: 'value1'} => { 'count' => 1.0, 'sum' => 1.0 }, + {foo: 'value2'} => { 'count' => 1.0, 'sum' => 2.0 } + } + expect(summary_with_labels.values).to eql(expected_values) + expect(summary.values).to eql(expected_values) + end + end end From adf63e8cbed8f53e26bab2ca383709a7554f85a1 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Sat, 12 Jun 2021 18:53:49 +0100 Subject: [PATCH 110/189] Use the original metric's store in `with_labels` clone When calling `with_labels` on a metric object (let's call it "the original"), we instantiate a new metric (the "clone") that is identical except that it has some more pre-set labels, that allow the caller to observe it without having to specify the labels every time. "currying", if you will. The problem with the existing code (as exemplified by issue #225, and by the tests introduced in the previous commit), is that as part of making this new metric, we end up instantiating a new store for this metric. With in-memory stores, the new one will be empty. With file stores, it'll bring over the data from the original metric until the point the clone gets observed once, at which point they fork, while pointing at the same file and keeping separate internal state. An almost sure recipe for file corruption. And when exporting, only the data in the "original" metric's store will be exported, the clone's will be ignored, assuming files didn't get corrupted. The fix is not particularly elegant, but I don't see any way around it: we replace the internal store of the "clone" metric with the one from the "original", through the use of a protected method. The only real alternative is getting rid of `with_labels`, which is a nice-to-have for convenience and performance, but not a necessity. Signed-off-by: Daniel Magliola --- lib/prometheus/client/histogram.rb | 18 ++++++++++++------ lib/prometheus/client/metric.rb | 23 ++++++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 172083cc..6963f673 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -42,12 +42,18 @@ def self.exponential_buckets(start:, factor: 2, count:) end def with_labels(labels) - self.class.new(name, - docstring: docstring, - labels: @labels, - preset_labels: preset_labels.merge(labels), - buckets: @buckets, - store_settings: @store_settings) + new_metric = self.class.new(name, + docstring: docstring, + labels: @labels, + preset_labels: preset_labels.merge(labels), + buckets: @buckets, + store_settings: @store_settings) + + # The new metric needs to use the same store as the "main" declared one, otherwise + # any observations on that copy with the pre-set labels won't actually be exported. + new_metric.replace_internal_store(@store) + + new_metric end def type diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index c492b08d..74fea386 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -41,9 +41,16 @@ def initialize(name, metric_settings: store_settings ) + # WARNING: Our internal store can be replaced later by `with_labels` + # Everything we do after this point needs to still work if @store gets replaced init_label_set({}) if labels.empty? end + protected def replace_internal_store(new_store) + @store = new_store + end + + # Returns the value for the given label set def get(labels: {}) label_set = label_set_for(labels) @@ -51,11 +58,17 @@ def get(labels: {}) end def with_labels(labels) - self.class.new(name, - docstring: docstring, - labels: @labels, - preset_labels: preset_labels.merge(labels), - store_settings: @store_settings) + new_metric = self.class.new(name, + docstring: docstring, + labels: @labels, + preset_labels: preset_labels.merge(labels), + store_settings: @store_settings) + + # The new metric needs to use the same store as the "main" declared one, otherwise + # any observations on that copy with the pre-set labels won't actually be exported. + new_metric.replace_internal_store(@store) + + new_metric end def init_label_set(labels) From 097cb046393c410e0b7dc261b456f491e7efcc64 Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 18 Jun 2021 21:14:30 +0100 Subject: [PATCH 111/189] Explain the test that checks for file corruption The test that reproduces our file corruption issue follows a very specific set of steps to actually corrupt the files, and it's probably impossible to figure out why. Explaining those specific steps, however, requires explaining a lot about the internals of `FileMappedDict`, of the files we store, and how corruption happens in the first place, before we can understand why we need those specific steps to reproduce it. I hope this explanation makes sense. Signed-off-by: Daniel Magliola --- spec/prometheus/client/counter_spec.rb | 98 +++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index afa03501..bdd9a792 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -143,7 +143,99 @@ end let(:expected_labels) { [:foo, :bar] } - + + # Testing for file corruption: this is weird and complicated, so it needs explaining + # + # Files get corrupted when we have two different instances of `FileMappedDict` + # reading and writing the same file. This corruption is expected; we should never have + # two instances of `FileMappedDict` for the same file. If we do, it's a bug in our client. + # + # To clarify, the bug is that *we ended up with two instances for the same file*, not + # that the instances are now corrupting the file. + # + # This is why we're testing this in `with_labels`. It's the only use case we've found + # were we ended up with two instances (before we fixed that bug). `with_labels` is + # incidental, if we find another way to get "duplicate" instances, we should add this + # same exact test, except for the first line, where we need to instead reproduce + # whatever bug gets us that second instance. + # + # The first thing we need to understand is why having two instances of `FileMappedDict` + # corrupts the files: + # + # `FileMappedDict` keeps track, in an internal variable, of how many bytes in the file + # have been used. When adding a new "entry" (observing a new labelset), it serializes + # it and adds it at "the end" (according to its internal byte counter), and it also updates + # the counter at the beginning of the file. However, it never re-reads that counter + # from the file, because there shouldn't be any reason for it to have changed. + # + # If there are two instances pointing to the same file, initially they will both + # share that internal counter, as they do the first read of the file, but if then + # each of them adds an entry, their internal "length" counters will disagree, and + # they'll start overwriting each other's entries. + # + # Importantly, if all of the entries happen to have the same length, it will be "fine". + # Some of the labelsets will effectively disappear, but there will be no corruption, + # because all the important things will fall in the right offsets by pure chance. This + # would be very rare in production, but in a test, it's what normally happens because + # we set all labels to "foo", "bar", etc. This is the reason for "longervalue" below, + # we need to have different labelset lenghts to reproduce the corruption. + # + # With this background about the internals, we can now get to why the specific sequence of + # steps below ends up in corrupted files. + # + # For this to make sense, i'll need to describe the contents of the file at each step. + # I'll represent it like this: `27|labelset1,value1|labelset2,value2|labelset3,value3|` + # + # These are not the bytes we store in the file, but conceptually it's equivalent, + # with two caveats: + # - The counter at the beginning (27 == 3 * 9) here shows the combined length of labelsets. + # It'd normally also include the length of values, but doing that makes this explanation + # much harder to follow. + # - Each entry also starts with a 4-byte int specifying the length of its labelset, so + # we know how much to read. Again, I'm omitting that for readability. + # + # + # Steps to reproduce: + # - We declare `counter` and `counter_with_labels` as a clone. Neither has read the file. + # - We increment `counter`, which creates the file and adds the entry ("labelset1") + # - File: `9|labelset1,value1|` + # - We increment `counter_with_labels`, which reads the file, and adds the new entry + # to it ("muchlongerlabelset2"). + # - File: `28|labelset1,value1|muchlongerlabelset2, value2|` + # - `counter` and `counter_with_labels` now disagree about the length of this file + # (`counter` doesn't know the file has grown). + # - We now add a new entry to `counter` ("labelset3"), which thinks the file is shorter + # than it actually is. + # - File: `18|labelset1,value1|labelset3,value3|et2, value2|` + # - The initial counter reflects both labelsets for `counter`; then we have those + # labelsetsp; and finally some "garbage" after the "end" (the garbage is the + # last few bytes of the much longer entry added before by `counter_with_labels`) + # - so far, though, we're still good. If you read the file, all entries are "fine", + # because you're only reading up to the "18" length specified at the beginning. + # - for the problem to manifest itself, we need to increment that counter at the + # beginning, so we'll read the garbage. **BUT**, if we add a new labelset to + # `counter`, it'll overwrite the "garbage" with good data, and the file will + # continue to be fine. + # - We add a new entry to `counter_with_labels`. This updates the length counter at + # the beginning of the file. + # - File: `47|labelset1,value1|labelset3,value3|et2, value2|muchlongerlabelset4, value4|` + # + # - Now the file is properly corrupted. When reading it, `FileMappedDict` sees: + # - labelset1,value1 (cool) + # - labelset3,value3 (cool) + # - et2, value2 (boom) + # |-> the beginning of this entry is garbage because we're actually at the middle + # of an entry, not a beginning. + # + # What actually breaks is that each of these entries is expected to have, at their + # beginning, the length in bytes of its labelset, so we know how much to read. + # Now we have garbage in that position, and `FileMappedDict` will either: + # - Try to interpret those four bytes as a long, get an invalid result. + # - Try to read an invalid amount of data (maybe a negative amount). + # - After reading the labelset, try to read the float and go past the end of the file + # - Actually read what it thinks is a float, try to `unpack` it, and fail because + # it's actually garbage. + # - I'm sure there are other fun ways for it to fail. it "doesn't corrupt the data files" do counter_with_labels = counter.with_labels({ foo: 'longervalue'}) @@ -152,8 +244,8 @@ counter_with_labels.increment(by: 2, labels: {bar: 'zzz'}) # After both MetricStores have their files, add a new entry to both - counter.increment(labels: { foo: 'value1', bar: 'aaa'}) - counter_with_labels.increment(by: 2, labels: {bar: 'aaa'}) + counter.increment(labels: { foo: 'value1', bar: 'aaa'}) # If there's a bug, we partially overwrite { foo: 'longervalue', bar: 'zzz'} + counter_with_labels.increment(by: 2, labels: {bar: 'aaa'}) # Extend the file so we read past that overwrite expect { counter.values }.not_to raise_error # Check it hasn't corrupted our files expect { counter_with_labels.values }.not_to raise_error # Check it hasn't corrupted our files From 6b0edf0104e3b234f33a7857b1a377c136b3d311 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 2 Jan 2022 16:16:46 +0000 Subject: [PATCH 112/189] Update Ruby versions used in CircleCI Ruby 2.5 dropped out of support at the end of March 2021, and Ruby 3.1 was released on Christmas Day. Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8aca788d..22957a7c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,9 +24,9 @@ workflows: matrix: parameters: ruby_image: - - cimg/ruby:2.5 - cimg/ruby:2.6 - cimg/ruby:2.7 - cimg/ruby:3.0 + - cimg/ruby:3.1 - circleci/jruby:9.1 - circleci/jruby:9.2 From 661f56b83cac6e05f4b499c2be56e7d10f675d4e Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 2 Jan 2022 18:09:58 +0000 Subject: [PATCH 113/189] Fix link to build matrix in COMPATIBILITY.md We switched from Travis to CircleCI, so this needs to point at a different file now. Signed-off-by: Chris Sinjakli --- COMPATIBILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 1bbf5511..a917797d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -8,7 +8,7 @@ Any Ruby version that has not received an End-of-Life notice (e.g. is supported. To ensure we're meeting these guidelines, we test the client against all -supported versions, as specified in our [build matrix](.travis.yml). +supported versions, as specified in our [build matrix](.circleci/config.yml). # Deprecation From a7657d0dcf5f6bbc789136444ce0a8dcd947ee7f Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Fri, 7 Jan 2022 00:19:54 +0000 Subject: [PATCH 114/189] Replace Coveralls with SimpleCov We've been having errors in CI from Coveralls, when trying to upload results. (As mentioned in [this comment](https://github.com/prometheus/client_ruby/pull/230#issuecomment-864561231)) This error seems to be because our coveralls gem is pretty old and abandoned, and probably using a TLS version that is no longer supported. (Reference: https://github.com/lemurheavy/coveralls-ruby/issues/163) One option recommended in that issue is to switch to a different `coveralls-ruby-reborn` gem. However, given that we only use `coveralls` to upload results to the cloud, only so we can have a badge in our README reporting 100%, in the interest of security, I think i'd rather get rid of `coveralls` altogether, and use `simplecov` directly instead, which reports the coverage when running the tests and doesn't upload them anywhere. Signed-off-by: Daniel Magliola --- Gemfile | 2 +- README.md | 1 - spec/spec_helper.rb | 8 +------- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Gemfile b/Gemfile index 3f36d705..ffd82038 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' gemspec group :test do - gem 'coveralls' + gem 'simplecov' gem 'json' gem 'rack' gem 'rack-test' diff --git a/README.md b/README.md index a5378800..a8925416 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ through a HTTP interface. Intended to be used together with a [![Gem Version][4]](http://badge.fury.io/rb/prometheus-client) [![Build Status][3]](https://circleci.com/gh/prometheus/client_ruby/tree/master.svg?style=svg) -[![Coverage Status][7]](https://coveralls.io/r/prometheus/client_ruby) ## Usage diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 80d04fb1..31000d69 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,17 +1,11 @@ # encoding: UTF-8 require 'simplecov' -require 'coveralls' RSpec.configure do |c| c.warnings = true end -SimpleCov.formatter = - if ENV['CI'] - Coveralls::SimpleCov::Formatter - else - SimpleCov::Formatter::HTMLFormatter - end +SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter SimpleCov.start From 1eb1c9ac6196b3bbb8f07277edcf32b7b32f9e59 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 21 Nov 2021 00:33:03 +0000 Subject: [PATCH 115/189] Remove `instance` as a special grouping key label in push client This is in preparation for the introduction of arbitrary labels in the grouping key. We no longer need to support `instance` as a special case, and will instead generate a path that combines the job label with everything passed in a grouping key hash. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 16 +++++----------- spec/prometheus/client/push_spec.rb | 18 +++--------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index a0b4b8cf..75bdfb88 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -16,20 +16,18 @@ module Client class Push DEFAULT_GATEWAY = 'http://localhost:9091'.freeze PATH = '/metrics/job/%s'.freeze - INSTANCE_PATH = '/metrics/job/%s/instance/%s'.freeze SUPPORTED_SCHEMES = %w(http https).freeze - attr_reader :job, :instance, :gateway, :path + attr_reader :job, :gateway, :path - def initialize(job:, instance: nil, gateway: DEFAULT_GATEWAY, **kwargs) + def initialize(job:, gateway: DEFAULT_GATEWAY, **kwargs) raise ArgumentError, "job cannot be nil" if job.nil? raise ArgumentError, "job cannot be empty" if job.empty? @mutex = Mutex.new @job = job - @instance = instance @gateway = gateway || DEFAULT_GATEWAY - @path = build_path(job, instance) + @path = build_path(job) @uri = parse("#{@gateway}#{@path}") @http = Net::HTTP.new(@uri.host, @uri.port) @@ -70,12 +68,8 @@ def parse(url) raise ArgumentError, "#{url} is not a valid URL: #{e}" end - def build_path(job, instance) - if instance && !instance.empty? - format(INSTANCE_PATH, ERB::Util::url_encode(job), ERB::Util::url_encode(instance)) - else - format(PATH, ERB::Util::url_encode(job)) - end + def build_path(job) + format(PATH, ERB::Util::url_encode(job)) end def request(req_class, registry = nil) diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index c8cd55d4..63818f2a 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -70,28 +70,16 @@ end describe '#path' do - it 'uses the default metrics path if no instance value given' do + it 'uses the default metrics path if no grouping key given' do push = Prometheus::Client::Push.new(job: 'test-job') expect(push.path).to eql('/metrics/job/test-job') end - it 'uses the default metrics path if an empty instance value is given' do - push = Prometheus::Client::Push.new(job: 'bar-job', instance: '') - - expect(push.path).to eql('/metrics/job/bar-job') - end - - it 'uses the full metrics path if an instance value is given' do - push = Prometheus::Client::Push.new(job: 'bar-job', instance: 'foo') - - expect(push.path).to eql('/metrics/job/bar-job/instance/foo') - end - it 'escapes non-URL characters' do - push = Prometheus::Client::Push.new(job: 'bar job', instance: 'foo ') + push = Prometheus::Client::Push.new(job: '') - expected = '/metrics/job/bar%20job/instance/foo%20%3Cmy%20instance%3E' + expected = '/metrics/job/%3Cbar%20job%3E' expect(push.path).to eql(expected) end end From 5be6736a8a5ce823697d04f86e3fac2fe31e84ed Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 21 Nov 2021 17:18:45 +0000 Subject: [PATCH 116/189] Support arbitrary grouping keys in push client This doesn't validate label keys or handle encoding special values in label values yet. That will be added in a later commit. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 15 +++++++++++---- spec/prometheus/client/push_spec.rb | 13 +++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 75bdfb88..dc17d1c0 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -20,14 +20,15 @@ class Push attr_reader :job, :gateway, :path - def initialize(job:, gateway: DEFAULT_GATEWAY, **kwargs) + def initialize(job:, gateway: DEFAULT_GATEWAY, grouping_key: {}, **kwargs) raise ArgumentError, "job cannot be nil" if job.nil? raise ArgumentError, "job cannot be empty" if job.empty? @mutex = Mutex.new @job = job @gateway = gateway || DEFAULT_GATEWAY - @path = build_path(job) + @grouping_key = grouping_key + @path = build_path(job, grouping_key) @uri = parse("#{@gateway}#{@path}") @http = Net::HTTP.new(@uri.host, @uri.port) @@ -68,8 +69,14 @@ def parse(url) raise ArgumentError, "#{url} is not a valid URL: #{e}" end - def build_path(job) - format(PATH, ERB::Util::url_encode(job)) + def build_path(job, grouping_key) + path = format(PATH, ERB::Util::url_encode(job)) + + grouping_key.each do |label, value| + path += "/#{label}/#{ERB::Util::url_encode(value)}" + end + + path end def request(req_class, registry = nil) diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 63818f2a..9d3e9ce0 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -76,10 +76,19 @@ expect(push.path).to eql('/metrics/job/test-job') end + it 'appends additional grouping labels to the path if specified' do + push = Prometheus::Client::Push.new( + job: 'test-job', + grouping_key: { foo: "bar", baz: "qux"}, + ) + + expect(push.path).to eql('/metrics/job/test-job/foo/bar/baz/qux') + end + it 'escapes non-URL characters' do - push = Prometheus::Client::Push.new(job: '') + push = Prometheus::Client::Push.new(job: '', grouping_key: { foo_label: '' }) - expected = '/metrics/job/%3Cbar%20job%3E' + expected = '/metrics/job/%3Cbar%20job%3E/foo_label/%3Cbar%20value%3E' expect(push.path).to eql(expected) end end From 2b3310d5b808080a5dd2907b5fbf28beee640a81 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 1 Jan 2022 17:00:45 +0000 Subject: [PATCH 117/189] Validate grouping key labels in push client This builds on the introduction of `grouping_key` in a previous commit, ensuring that the labels passed in follow the usual naming restrictions on Prometheus labels. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 3 +++ spec/prometheus/client/push_spec.rb | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index dc17d1c0..fba85c35 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -7,6 +7,7 @@ require 'prometheus/client' require 'prometheus/client/formats/text' +require 'prometheus/client/label_set_validator' module Prometheus # Client is a ruby implementation for a Prometheus compatible client. @@ -23,6 +24,8 @@ class Push def initialize(job:, gateway: DEFAULT_GATEWAY, grouping_key: {}, **kwargs) raise ArgumentError, "job cannot be nil" if job.nil? raise ArgumentError, "job cannot be empty" if job.empty? + @validator = LabelSetValidator.new(expected_labels: grouping_key.keys) + @validator.validate_symbols!(grouping_key) @mutex = Mutex.new @job = job diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 9d3e9ce0..ccb9fbc8 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -43,6 +43,12 @@ end.to raise_error ArgumentError end end + + it 'raises InvalidLabelError if a grouping key label has an invalid name' do + expect do + Prometheus::Client::Push.new(job: "test-job", grouping_key: { "not_a_symbol" => "foo" }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelError + end end describe '#add' do From 6e5b14ffd89191658b77093e52684fe87b60559e Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 1 Jan 2022 22:14:48 +0000 Subject: [PATCH 118/189] Use base64 encoding when necessary in grouping key in push client Certain label values (empty string, and anything containing a `/`) cannot be used in the grouping key without extra work to encode them properly. Labels are represented as pairs of path segments in the URL, so an unencoded `/` would be treated as a path separator. The empty string would result in two consecturive path separators (`//`), which HTTP libraries, proxies, and web servers are liable to normalise away. This commit uses the base64 encoding method supported by the pushgateway server to encode such values. See: https://github.com/prometheus/pushgateway/blob/6393a901f56d4dda62cd0f6ab1f1f07c495b6354/README.md#url Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 22 +++++++++++++++++++++- spec/prometheus/client/push_spec.rb | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index fba85c35..686b336c 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -1,5 +1,6 @@ # encoding: UTF-8 +require 'base64' require 'thread' require 'net/http' require 'uri' @@ -76,7 +77,26 @@ def build_path(job, grouping_key) path = format(PATH, ERB::Util::url_encode(job)) grouping_key.each do |label, value| - path += "/#{label}/#{ERB::Util::url_encode(value)}" + if value.include?('/') + encoded_value = Base64.urlsafe_encode64(value) + path += "/#{label}@base64/#{encoded_value}" + # While it's valid for the urlsafe_encode64 function to return an + # empty string when the input string is empty, it doesn't work for + # our specific use case as we're putting the result into a URL path + # segment. A double slash (`//`) can be normalised away by HTTP + # libraries, proxies, and web servers. + # + # For empty strings, we use a single padding character (`=`) as the + # value. + # + # See the pushgateway docs for more details: + # + # https://github.com/prometheus/pushgateway/blob/6393a901f56d4dda62cd0f6ab1f1f07c495b6354/README.md#url + elsif value.empty? + path += "/#{label}@base64/=" + else + path += "/#{label}/#{ERB::Util::url_encode(value)}" + end end path diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index ccb9fbc8..84418379 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -91,6 +91,24 @@ expect(push.path).to eql('/metrics/job/test-job/foo/bar/baz/qux') end + it 'encodes grouping key label values containing `/` in url-safe base64' do + push = Prometheus::Client::Push.new( + job: 'test-job', + grouping_key: { foo: "bar/baz"}, + ) + + expect(push.path).to eql('/metrics/job/test-job/foo@base64/YmFyL2Jheg==') + end + + it 'encodes empty grouping key label values as a single base64 padding character' do + push = Prometheus::Client::Push.new( + job: 'test-job', + grouping_key: { foo: ""}, + ) + + expect(push.path).to eql('/metrics/job/test-job/foo@base64/=') + end + it 'escapes non-URL characters' do push = Prometheus::Client::Push.new(job: '', grouping_key: { foo_label: '' }) From 84d475586f39b7bd23dc565604be4cdcbac33986 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 1 Jan 2022 23:24:02 +0000 Subject: [PATCH 119/189] Raise error when grouping key clashes with metric labels in push client The pushgateway will overwrite metric labels if the same label is used as part of the grouping key. Other Promethus clients (at least the Go one) will error if you try to do this, as it can be quite unexpected. This commit makes us follow suit. We may find that some users prefer to let those clashes through (i.e. let the labels from the grouping_key win). If that ends up being the case, we can introduce a config flag for people to opt into that behaviour. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/metric.rb | 2 +- lib/prometheus/client/push.rb | 22 ++++++++++++++++++++++ spec/prometheus/client/push_spec.rb | 26 ++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/prometheus/client/metric.rb b/lib/prometheus/client/metric.rb index c492b08d..328686e3 100644 --- a/lib/prometheus/client/metric.rb +++ b/lib/prometheus/client/metric.rb @@ -7,7 +7,7 @@ module Prometheus module Client # Metric class Metric - attr_reader :name, :docstring, :preset_labels + attr_reader :name, :docstring, :labels, :preset_labels def initialize(name, docstring:, diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 686b336c..3639ebc9 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -5,6 +5,7 @@ require 'net/http' require 'uri' require 'erb' +require 'set' require 'prometheus/client' require 'prometheus/client/formats/text' @@ -103,6 +104,8 @@ def build_path(job, grouping_key) end def request(req_class, registry = nil) + validate_no_label_clashes!(registry) if registry + req = req_class.new(@uri) req.content_type = Formats::Text::CONTENT_TYPE req.basic_auth(@uri.user, @uri.password) if @uri.user @@ -114,6 +117,25 @@ def request(req_class, registry = nil) def synchronize @mutex.synchronize { yield } end + + def validate_no_label_clashes!(registry) + # There's nothing to check if we don't have a grouping key + return if @grouping_key.empty? + + # We could be doing a lot of comparisons, so let's do them against a + # set rather than an array + grouping_key_labels = @grouping_key.keys.to_set + + registry.metrics.each do |metric| + metric.labels.each do |label| + if grouping_key_labels.include?(label) + raise LabelSetValidator::InvalidLabelSetError, + "label :#{label} from grouping key collides with label of the " \ + "same name from metric :#{metric.name} and would overwrite it" + end + end + end + end end end end diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 84418379..9b833d59 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -1,11 +1,13 @@ # encoding: UTF-8 +require 'prometheus/client/gauge' require 'prometheus/client/push' describe Prometheus::Client::Push do let(:gateway) { 'http://localhost:9091' } - let(:registry) { Prometheus::Client.registry } - let(:push) { Prometheus::Client::Push.new(job: 'test-job', gateway: gateway, open_timeout: 5, read_timeout: 30) } + let(:registry) { Prometheus::Client::Registry.new } + let(:grouping_key) { {} } + let(:push) { Prometheus::Client::Push.new(job: 'test-job', gateway: gateway, grouping_key: grouping_key, open_timeout: 5, read_timeout: 30) } describe '.new' do it 'returns a new push instance' do @@ -193,5 +195,25 @@ push.send(:request, Net::HTTP::Put, registry) end end + + context 'with a grouping key that clashes with a metric label' do + let(:grouping_key) { { foo: "bar"} } + + before do + gauge = Prometheus::Client::Gauge.new( + :test_gauge, + labels: [:foo], + docstring: "test docstring" + ) + registry.register(gauge) + gauge.set(42, labels: { foo: "bar" }) + end + + it 'raises an error when grouping key labels conflict with metric labels' do + expect { push.send(:request, Net::HTTP::Post, registry) }.to raise_error( + Prometheus::Client::LabelSetValidator::InvalidLabelSetError + ) + end + end end end From ae419d227e2b7cae6ff19f83a16716912856ac1d Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 2 Jan 2022 01:56:21 +0000 Subject: [PATCH 120/189] Raise errors for non-2xx responses in push client Currently, we don't do anything with the response from `Net::HTTP` in the push client. This means that when we get a response that indicates our metrics didn't make it to the pushgateway, we silently carry on, and the user has no ideas that their metrics weren't recorded. If users decide they don't care about this happening, they can always rescue the base `Prometheus::Client::Push::HttpError` class (or everything that extends `StandardError` if they also want to ignore things like timeouts, connection refused, DNS resolution failure, etc). Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 24 ++++++- spec/prometheus/client/push_spec.rb | 103 ++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 3639ebc9..04f4d7ae 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -17,6 +17,11 @@ module Client # Push implements a simple way to transmit a given registry to a given # Pushgateway. class Push + class HttpError < StandardError; end + class HttpRedirectError < HttpError; end + class HttpClientError < HttpError; end + class HttpServerError < HttpError; end + DEFAULT_GATEWAY = 'http://localhost:9091'.freeze PATH = '/metrics/job/%s'.freeze SUPPORTED_SCHEMES = %w(http https).freeze @@ -111,7 +116,10 @@ def request(req_class, registry = nil) req.basic_auth(@uri.user, @uri.password) if @uri.user req.body = Formats::Text.marshal(registry) if registry - @http.request(req) + response = @http.request(req) + validate_response!(response) + + response end def synchronize @@ -136,6 +144,20 @@ def validate_no_label_clashes!(registry) end end end + + def validate_response!(response) + status = Integer(response.code) + if status >= 300 + message = "status: #{response.code}, message: #{response.message}, body: #{response.body}" + if status <= 399 + raise HttpRedirectError, message + elsif status <= 499 + raise HttpClientError, message + else + raise HttpServerError, message + end + end + end end end end diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 9b833d59..95429a13 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -123,6 +123,14 @@ let(:content_type) { Prometheus::Client::Formats::Text::CONTENT_TYPE } let(:data) { Prometheus::Client::Formats::Text.marshal(registry) } let(:uri) { URI.parse("#{gateway}/metrics/job/test-job") } + let(:response) do + double( + :response, + code: '200', + message: 'OK', + body: 'Everything worked' + ) + end it 'sends marshalled registry to the specified gateway' do request = double(:request) @@ -134,12 +142,99 @@ expect(http).to receive(:use_ssl=).with(false) expect(http).to receive(:open_timeout=).with(5) expect(http).to receive(:read_timeout=).with(30) - expect(http).to receive(:request).with(request) + expect(http).to receive(:request).with(request).and_return(response) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) push.send(:request, Net::HTTP::Post, registry) end + context 'for a 3xx response' do + let(:response) do + double( + :response, + code: '301', + message: 'Moved Permanently', + body: 'Probably no body, but technically you can return one' + ) + end + + it 'raises a redirect error' do + request = double(:request) + allow(request).to receive(:content_type=) + allow(request).to receive(:body=) + allow(Net::HTTP::Post).to receive(:new).with(uri).and_return(request) + + http = double(:http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).with(request).and_return(response) + allow(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) + + expect { push.send(:request, Net::HTTP::Post, registry) }.to raise_error( + Prometheus::Client::Push::HttpRedirectError + ) + end + end + + context 'for a 4xx response' do + let(:response) do + double( + :response, + code: '400', + message: 'Bad Request', + body: 'Info on why the request was bad' + ) + end + + it 'raises a client error' do + request = double(:request) + allow(request).to receive(:content_type=) + allow(request).to receive(:body=) + allow(Net::HTTP::Post).to receive(:new).with(uri).and_return(request) + + http = double(:http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).with(request).and_return(response) + allow(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) + + expect { push.send(:request, Net::HTTP::Post, registry) }.to raise_error( + Prometheus::Client::Push::HttpClientError + ) + end + end + + context 'for a 5xx response' do + let(:response) do + double( + :response, + code: '500', + message: 'Internal Server Error', + body: 'Apology for the server code being broken' + ) + end + + it 'raises a server error' do + request = double(:request) + allow(request).to receive(:content_type=) + allow(request).to receive(:body=) + allow(Net::HTTP::Post).to receive(:new).with(uri).and_return(request) + + http = double(:http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).with(request).and_return(response) + allow(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) + + expect { push.send(:request, Net::HTTP::Post, registry) }.to raise_error( + Prometheus::Client::Push::HttpServerError + ) + end + end + it 'deletes data from the registry' do request = double(:request) expect(request).to receive(:content_type=).with(content_type) @@ -149,7 +244,7 @@ expect(http).to receive(:use_ssl=).with(false) expect(http).to receive(:open_timeout=).with(5) expect(http).to receive(:read_timeout=).with(30) - expect(http).to receive(:request).with(request) + expect(http).to receive(:request).with(request).and_return(response) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) push.send(:request, Net::HTTP::Delete) @@ -168,7 +263,7 @@ expect(http).to receive(:use_ssl=).with(true) expect(http).to receive(:open_timeout=).with(5) expect(http).to receive(:read_timeout=).with(30) - expect(http).to receive(:request).with(request) + expect(http).to receive(:request).with(request).and_return(response) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) push.send(:request, Net::HTTP::Post, registry) @@ -189,7 +284,7 @@ expect(http).to receive(:use_ssl=).with(true) expect(http).to receive(:open_timeout=).with(5) expect(http).to receive(:read_timeout=).with(30) - expect(http).to receive(:request).with(request) + expect(http).to receive(:request).with(request).and_return(response) expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) push.send(:request, Net::HTTP::Put, registry) From 2105319d119a7b195c2d75e97fc6327775483a75 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 9 Jan 2022 02:08:28 +0000 Subject: [PATCH 121/189] Tweak wording of spec for clarity Signed-off-by: Chris Sinjakli --- spec/prometheus/client/push_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 95429a13..372094ca 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -111,7 +111,7 @@ expect(push.path).to eql('/metrics/job/test-job/foo@base64/=') end - it 'escapes non-URL characters' do + it 'URL-encodes all other non-URL-safe characters' do push = Prometheus::Client::Push.new(job: '', grouping_key: { foo_label: '' }) expected = '/metrics/job/%3Cbar%20job%3E/foo_label/%3Cbar%20value%3E' From 8bdd50d7562872248f9cf2030c809554b0d8e4cc Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 25 Dec 2021 13:30:18 +0000 Subject: [PATCH 122/189] Fix typo in spec description Looks like a regex replacement was run against this file and was a little too eager. Signed-off-by: Chris Sinjakli --- spec/prometheus/client/label_set_validator_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index 4e8d9b1b..688a1397 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -18,7 +18,7 @@ expect(validator.validate_symbols!(version: 'alpha')).to eql(true) end - it 'raises Invaliddescribed_classError if a label set is not a hash' do + it 'raises InvalidLabelSetError if a label set is not a hash' do expect do validator.validate_symbols!('invalid') end.to raise_exception invalid From 3519342ccd9ea89027df53c38b980f946e7c8b21 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 25 Dec 2021 13:31:55 +0000 Subject: [PATCH 123/189] Validate label names Up to now, we've only been performing limited validation of label names. This commit validates that the characters use match the regex `/\A[a-zA-Z_][a-zA-Z0-9_]*\Z/` as specified by: https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels Signed-off-by: Chris Sinjakli --- lib/prometheus/client/label_set_validator.rb | 11 +++++++++-- spec/prometheus/client/label_set_validator_spec.rb | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index 8b28a42d..f3625233 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -7,6 +7,7 @@ module Client class LabelSetValidator # TODO: we might allow setting :instance in the future BASE_RESERVED_LABELS = [:job, :instance, :pid].freeze + LABEL_NAME_REGEX = /\A[a-zA-Z_][a-zA-Z0-9_]*\Z/ class LabelSetError < StandardError; end class InvalidLabelSetError < LabelSetError; end @@ -59,9 +60,15 @@ def validate_symbol(key) end def validate_name(key) - return true unless key.to_s.start_with?('__') + if key.to_s.start_with?('__') + raise ReservedLabelError, "label #{key} must not start with __" + end + + unless key.to_s =~ LABEL_NAME_REGEX + raise InvalidLabelError, "label name must match /#{LABEL_NAME_REGEX}/" + end - raise ReservedLabelError, "label #{key} must not start with __" + true end def validate_reserved_key(key) diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index 688a1397..f4d38987 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -36,6 +36,12 @@ end.to raise_exception(described_class::ReservedLabelError) end + it 'raises InvalidLabelError if a label key contains invalid characters' do + expect do + validator.validate_symbols!(:@foo => 'key') + end.to raise_exception(described_class::InvalidLabelError) + end + it 'raises ReservedLabelError if a label key is reserved' do [:job, :instance, :pid].each do |label| expect do From 3a70f73fde2b56b4fa189b7ca4260c766d8204dd Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 9 Jan 2022 16:51:15 +0000 Subject: [PATCH 124/189] Move to explicit method call for Basic Auth credentials in push client While URLs do support passing Basic Auth credentials using the `http://user:password@example.com/` syntax, the username and password in that syntax have to follow the usual rules for URL encoding of characters per RFC 3986 (https://datatracker.ietf.org/doc/html/rfc3986#section-2.1). Rather than place the burden of correctly performing that encoding on users of this gem, we decided to have a separate method for supplying Basic Auth credentials, with no requirement to URL encode the characters in them. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 42 ++++++++++++++++++++++++++++- spec/prometheus/client/push_spec.rb | 40 +++++++++++++++++---------- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 04f4d7ae..6d1aa8ae 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -39,7 +39,9 @@ def initialize(job:, gateway: DEFAULT_GATEWAY, grouping_key: {}, **kwargs) @gateway = gateway || DEFAULT_GATEWAY @grouping_key = grouping_key @path = build_path(job, grouping_key) + @uri = parse("#{@gateway}#{@path}") + validate_no_basic_auth!(@uri) @http = Net::HTTP.new(@uri.host, @uri.port) @http.use_ssl = (@uri.scheme == 'https') @@ -47,6 +49,11 @@ def initialize(job:, gateway: DEFAULT_GATEWAY, grouping_key: {}, **kwargs) @http.read_timeout = kwargs[:read_timeout] if kwargs[:read_timeout] end + def basic_auth(user, password) + @user = user + @password = password + end + def add(registry) synchronize do request(Net::HTTP::Post, registry) @@ -113,7 +120,7 @@ def request(req_class, registry = nil) req = req_class.new(@uri) req.content_type = Formats::Text::CONTENT_TYPE - req.basic_auth(@uri.user, @uri.password) if @uri.user + req.basic_auth(@user, @password) if @user req.body = Formats::Text.marshal(registry) if registry response = @http.request(req) @@ -126,6 +133,39 @@ def synchronize @mutex.synchronize { yield } end + def validate_no_basic_auth!(uri) + if uri.user || uri.password + raise ArgumentError, <<~EOF + Setting Basic Auth credentials in the gateway URL is not supported, please call the `basic_auth` method. + + Received username `#{uri.user}` in gateway URL. Instead of passing + Basic Auth credentials like this: + + ``` + push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://user:password@localhost:9091") + ``` + + please pass them like this instead: + + ``` + push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://localhost:9091") + push.basic_auth("user", "password") + ``` + + While URLs do support passing Basic Auth credentials using the + `http://user:password@example.com/` syntax, the username and + password in that syntax have to follow the usual rules for URL + encoding of characters per RFC 3986 + (https://datatracker.ietf.org/doc/html/rfc3986#section-2.1). + + Rather than place the burden of correctly performing that encoding + on users of this gem, we decided to have a separate method for + supplying Basic Auth credentials, with no requirement to URL encode + the characters in them. + EOF + end + end + def validate_no_label_clashes!(registry) # There's nothing to check if we don't have a grouping key return if @grouping_key.empty? diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 372094ca..e4fede99 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -271,23 +271,35 @@ end context 'Basic Auth support' do - let(:gateway) { 'https://super:secret@localhost:9091' } + context 'when credentials are passed in the gateway URL' do + let(:gateway) { 'https://super:secret@localhost:9091' } - it 'sets Basic Auth header when requested' do - request = double(:request) - expect(request).to receive(:content_type=).with(content_type) - expect(request).to receive(:basic_auth).with('super', 'secret') - expect(request).to receive(:body=).with(data) - expect(Net::HTTP::Put).to receive(:new).with(uri).and_return(request) + it "raises an ArgumentError explaining why we don't support that mechanism" do + expect { push }.to raise_error ArgumentError, /in the gateway URL.*username `super`/m + end + end - http = double(:http) - expect(http).to receive(:use_ssl=).with(true) - expect(http).to receive(:open_timeout=).with(5) - expect(http).to receive(:read_timeout=).with(30) - expect(http).to receive(:request).with(request).and_return(response) - expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) + context 'when credentials are passed to the separate `basic_auth` method' do + let(:gateway) { 'https://localhost:9091' } + + it 'passes the credentials on to the HTTP client' do + request = double(:request) + expect(request).to receive(:content_type=).with(content_type) + expect(request).to receive(:basic_auth).with('super', 'secret') + expect(request).to receive(:body=).with(data) + expect(Net::HTTP::Put).to receive(:new).with(uri).and_return(request) + + http = double(:http) + expect(http).to receive(:use_ssl=).with(true) + expect(http).to receive(:open_timeout=).with(5) + expect(http).to receive(:read_timeout=).with(30) + expect(http).to receive(:request).with(request).and_return(response) + expect(Net::HTTP).to receive(:new).with('localhost', 9091).and_return(http) + + push.basic_auth("super", "secret") - push.send(:request, Net::HTTP::Put, registry) + push.send(:request, Net::HTTP::Put, registry) + end end end From 801a787006b22821bbcf055834b9cf5e79d18884 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Mon, 10 Jan 2022 00:25:14 +0000 Subject: [PATCH 125/189] Update README.md with changes to `Prometheus::Client::Push` We've made a bunch of breaking changes recently, which will be part of the 3.0.0 release. This commit updates our README to match the new API in anticipation of that relese. Signed-off-by: Chris Sinjakli --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a5378800..965223ff 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ integrated [example application](examples/rack/README.md). The Ruby client can also be used to push its collected metrics to a [Pushgateway][8]. This comes in handy with batch jobs or in other scenarios where it's not possible or feasible to let a Prometheus server scrape a Ruby -process. TLS and basic access authentication are supported. +process. TLS and HTTP basic authentication are supported. ```ruby require 'prometheus/client' @@ -81,18 +81,59 @@ registry = Prometheus::Client.registry # ... register some metrics, set/increment/observe/etc. their values # push the registry state to the default gateway -Prometheus::Client::Push.new('my-batch-job').add(registry) +Prometheus::Client::Push.new(job: 'my-batch-job').add(registry) + +# optional: specify a grouping key that uniquely identifies a job instance, and gateway. +# +# Note: the labels you use in the grouping key must not conflict with labels set on the +# metrics being pushed. If they do, an error will be raised. +Prometheus::Client::Push.new( + job: 'my-batch-job', + gateway: 'https://example.domain:1234', + grouping_key: { instance: 'some-instance', extra_key: 'foobar' } +).add(registry) + +# If you want to replace any previously pushed metrics for a given grouping key, +# use the #replace method. +# +# Unlike #add, this will completely replace the metrics under the specified grouping key +# (i.e. anything currently present in the pushgateway for the specified grouping key, but +# not present in the registry for that grouping key will be removed). +# +# See https://github.com/prometheus/pushgateway#put-method for a full explanation. +Prometheus::Client::Push.new(job: 'my-batch-job').replace(registry) + +# If you want to delete all previously pushed metrics for a given grouping key, +# use the #delete method. +Prometheus::Client::Push.new(job: 'my-batch-job').delete +``` -# optional: specify the instance name (instead of IP) and gateway. -Prometheus::Client::Push.new('my-batch-job', 'foobar', 'https://example.domain:1234').add(registry) +#### Basic authentication -# If you want to replace any previously pushed metrics for a given instance, -# use the #replace method. -Prometheus::Client::Push.new('my-batch-job').replace(registry) +By design, `Prometheus::Client::Push` doesn't read credentials for HTTP basic +authentication when they are passed in via the gateway URL using the +`http://user:password@example.com:9091` syntax, and will in fact raise an error if they're +supplied that way. -# If you want to delete all previously pushed metrics for a given instance, -# use the #delete method. -Prometheus::Client::Push.new('my-batch-job').delete +The reason for this is that when using that syntax, the username and password +have to follow the usual rules for URL encoding of characters [per RFC +3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.1). + +Rather than place the burden of correctly performing that encoding on users of this gem, +we decided to have a separate method for supplying HTTP basic authentication credentials, +with no requirement to URL encode the characters in them. + +Instead of passing credentials like this: + +```ruby +push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://user:password@localhost:9091") +``` + +please pass them like this: + +```ruby +push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://localhost:9091") +push.basic_auth("user", "password") ``` ## Metrics From 7b1e73c38397b4c36f3ea12255005d455ef247c6 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Mon, 10 Jan 2022 00:26:24 +0000 Subject: [PATCH 126/189] Remove redundant "instead" in exception message Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 6d1aa8ae..2d3588d9 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -145,7 +145,7 @@ def validate_no_basic_auth!(uri) push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://user:password@localhost:9091") ``` - please pass them like this instead: + please pass them like this: ``` push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://localhost:9091") From 3f4ed61ff0f60543e4754e71c4a9720612bff252 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Mon, 10 Jan 2022 23:06:54 +0000 Subject: [PATCH 127/189] Update UPGRADING.md for 3.0.0 Signed-off-by: Chris Sinjakli --- UPGRADING.md | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/UPGRADING.md b/UPGRADING.md index 5e024e62..1167de2e 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,168 @@ +# Upgrading from 2.x.x to 3.x.x + +## Objectives + +Most of the breaking changes in 3.0.0 are in `Prometheus::Client::Push`, which has had a +fairly major overhaul. + +As well as that, there are a handful of smaller breaking changes. + +## Ruby + +The minimum supported Ruby version is now 2.6. This will change over time according to our +[compatibility policy](COMPATIBILITY.md). + +## Push client improvements + +### Keyword arguments + +In line with changes we made for the 0.10.0 release (see below), +`Prometheus::Client::Push` now favours the use of keyword arguments for improved clarity +at the callsites. Specifically, the constructor now takes several keyword arguments rather +than relying entirely on positional arguments. Where you would previously have written: + +```ruby +Prometheus::Client::Push.new('my-batch-job', 'some-instance', 'https://example.domain:1234') +``` + +you would now write: + +```ruby +Prometheus::Client::Push.new( + job: 'my-batch-job', + gateway: 'https://example.domain:1234', + grouping_key: { instance: 'some-instance', extra_key: 'foobar' } +).add(registry) +``` + +### Removal of `instance` in favour of `grouping_key` + +Previously, it was possible to specify the instance of a job for which metrics were being +pushed, like: + +```ruby +Prometheus::Client::Push.new('my-batch-job', 'some-instance').add(registry) +``` + +What this really did under-the-hood was set a grouping key with a single key-value pair in +it. The Pushgateway itself [supports arbitrary grouping +keys](https://github.com/prometheus/pushgateway#url) made up of many key-value pairs. We +now support submitting metrics with such grouping keys: + +```ruby +Prometheus::Client::Push.new( + job: 'my-batch-job', + grouping_key: { instance: 'some-instance', extra_key: 'foobar' } +).add(registry) +``` + +### Separate method for setting basic auth credentials + +Previously, when initializing a `Prometheus::Client::Push` instance with HTTP Basic +Authentication credentials, you would make a call like: + +```ruby +push = Prometheus::Client::Push.new("my-job", "some-instance", "http://user:password@localhost:9091") +``` + +In most cases, this was fine, but would break if the user or password contained any +non-URL-safe characters ([per RFC +3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.1)). + +While it is possible to pass those characters using percent-encoding, previous versions of +`Prometheus::Client::Push` didn't decode them before passing them into the HTTP client, +meaning that approach wouldn't work as the credentials we sent to the server would be +wrong. + +We [discussed how to fix +it](https://github.com/prometheus/client_ruby/issues/170#issuecomment-1003765815) and +decided it would be better to have a separate method for supplying HTTP Basic +Authentication credentials, with no requirement for percent-encoding, than to make users +jump through the hoops of correctly encoding the username and password in the gateway URL. + +In the 3.x.x release series, HTTP Basic Authentication credentials should be passed like +this: + +```ruby +push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://localhost:9091") +push.basic_auth("user", "password") +``` + +We also explicitly reject usernames and passwords being passed in the gateway URL, and +will raise an error if they are passed that way. + +### Presence of `job` is now validated + +We now validate that the `job` passed to the `Prometheus::Client::Push` initializer is not +`nil` and isn't the empty string. + +### Raising errors on non-2xx responses from Pushgateway + +Previously, if the Pushgateway (or a proxy between us and it) returned a non-2xx HTTP +response, we would silently fail to submit metrics to it. + +Now, an appropriate error is raised, indicating which class of non-2xx response was +received. If you want to `rescue` those errors and handle them explicitly, they are all +subclasses of `Prometheus::Client::Push::HttpError`. If you only want to handle some of +them, or want to handle each class of non-2xx response differently, you can `rescue` one +or more of: + + - `Prometheus::Client::Push::HttpRedirectError` + - `Prometheus::Client::Push::HttpClientError` + - `Prometheus::Client::Push::HttpServerError` + +_Note: `Prometheus::Client::Push` does not follow redirects. You should configure the +client to talk directly to an instance of the Pushgateway._ + +### Fixed encoding of spaces in `job` and `instance` + +In a [previous +commit](https://github.com/prometheus/client_ruby/pull/188/commits/f31bdcb8eda943f8ddf720e0b9d65ac22124cc93) +we addressed the deprecation (and later removal in Ruby 3.0) of `URI.escape` by switching +to `CGI.escape` for encoding the values of `job` and `instance` which would ultimately end +up in the grouping key. + +Unfortunately, this proved to be a subtly breaking change, as `CGI.escape` encodes spaces +(`" "`) as `"+"` rather than `"%20"`. This led to spaces in the values of `job` and +`instance` being turned into literal plus signs. + +In 3.x.x, [we have +switched](https://github.com/prometheus/client_ruby/pull/220/commits/ec5c5aa6979aa295d91fbc16e76e5eb09f82a256) +to `ERB::Util::url_encode`, which handles this case correctly. You may notice your metrics +being published under a different grouping key as a result of this change (if either your +`job` or `instance` values contained spaces). + +## Automatic initialization of time series with no labels + +The [Prometheus documentation on best +practices](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics) +recommends exporting a default value for any time series you know will exist in advance. +For series with no labels, other Prometheus clients (including Go, Java, and Python) do +this automatically, so we have matched that behaviour in the 3.x.x series. + +## Path generation fix in Collector middleware + +Previously, we did not include `Rack::Request`'s `SCRIPT_NAME` when building paths in +`Prometheus::Middleware::Collector`. We have now added this, which means that any +application using the included collector middleware with a non-empty `SCRIPT_NAME` will +generate different path labels. + +This will most typically be present when mounting several Rack applications in the same +server process, such as when using [Rails +Engines](https://guides.rubyonrails.org/engines.html). + +## Improved validation of label names + +Earlier versions of the Ruby Prometheus client performed limited validation of label names +(e.g. ensuring that they didn't start with `__`). The validation rules for label names are +specified [in the Prometheus +documentation](https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels), +and we now apply them during metric declaration. Specifically, we have added a check that +label names match the regex `[a-zA-Z_][a-zA-Z0-9_]*`. + +Any labels previously let through by the lack of validation were invalid, and likely would +have caused problems when scraped by Prometheus server. + # Upgrading from 0.9 to 0.10.x ## Objectives From f1bb82ae8fc4ae98cfb9c6d4180aef9dc034798a Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 1 Feb 2022 00:49:39 +0000 Subject: [PATCH 128/189] Use framework-specific route info in collector when available While `PATH_INFO` is framework agnostic, and works for any Rack app, some Ruby web frameworks pass a more useful piece of information into the request env - the route that the request matched. This means that rather than using our generic `:id` and `:uuid` replacements in the `path` label for any path segments that look like dynamic IDs, we can put the actual route that matched in there, with correctly named parameters. For example, if a Sinatra app defined a route like: get "/foo/:bar" do ... end instead of containing `/foo/:id`, the `path` label would contain `/foo/:bar`. Sadly, Rails is a notable exception, and (as far as I can tell at the time of writing) doesn't provide this info in the request env. Signed-off-by: Chris Sinjakli --- lib/prometheus/middleware/collector.rb | 50 +++++++++++++++++++- spec/prometheus/middleware/collector_spec.rb | 48 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 48e3ddd1..c3ff58b7 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -67,7 +67,7 @@ def trace(env) end def record(env, code, duration) - path = [env["SCRIPT_NAME"], env['PATH_INFO']].join + path = generate_path(env) counter_labels = { code: code, @@ -87,6 +87,54 @@ def record(env, code, duration) nil end + # While `PATH_INFO` is framework agnostic, and works for any Rack app, some Ruby web + # frameworks pass a more useful piece of information into the request env - the + # route that the request matched. + # + # This means that rather than using our generic `:id` and `:uuid` replacements in + # the `path` label for any path segments that look like dynamic IDs, we can put the + # actual route that matched in there, with correctly named parameters. For example, + # if a Sinatra app defined a route like: + # + # get "/foo/:bar" do + # ... + # end + # + # instead of containing `/foo/:id`, the `path` label would contain `/foo/:bar`. + # + # Sadly, Rails is a notable exception, and (as far as I can tell at the time of + # writing) doesn't provide this info in the request env. + def generate_path(env) + if env['sinatra.route'] + route = env['sinatra.route'].partition(' ').last + elsif env['grape.routing_args'] + # We are deep in the weeds of an object that Grape passes into the request env, + # but don't document any explicit guarantees about. Let's have a fallback in + # case they change it down the line. + # + # This code would be neater with the safe navigation operator (`&.`) here rather + # than the much more verbose `respond_to?` calls, but unlike Rails' `try` + # method, it still raises an error if the object is non-nil, but doesn't respond + # to the method being called on it. + route = nil + + route_info = env.dig('grape.routing_args', :route_info) + if route_info.respond_to?(:pattern) + pattern = route_info.pattern + if pattern.respond_to?(:origin) + route = pattern.origin + end + end + + # Fall back to PATH_INFO if Grape change the structure of `grape.routing_args` + route ||= env['PATH_INFO'] + else + route = env['PATH_INFO'] + end + + [env['SCRIPT_NAME'], route].join + end + def strip_ids_from_path(path) path .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)}, '/:uuid\\1') diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index 7809bcf9..49664a3a 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -95,6 +95,54 @@ expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1) end + it 'prefers sinatra.route to PATH_INFO' do + metric = :http_server_requests_total + + env('sinatra.route', 'GET /foo/:named_param') + get '/foo/7' + env('sinatra.route', nil) + expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:named_param") + end + + it 'prefers grape.routing_args to PATH_INFO' do + metric = :http_server_requests_total + + # request.env["grape.routing_args"][:route_info].pattern.origin + # + # Yes, this is the object you have to traverse to get the path. + # + # No, I'm not happy about it either. + grape_routing_args = { + route_info: double(:route_info, + pattern: double(:pattern, + origin: '/foo/:named_param' + ) + ) + } + + env('grape.routing_args', grape_routing_args) + get '/foo/7' + env('grape.routing_args', nil) + expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:named_param") + end + + it "falls back to PATH_INFO if the structure of grape.routing_args changes" do + metric = :http_server_requests_total + + grape_routing_args = { + route_info: double(:route_info, + pattern: double(:pattern, + origin_but_different: '/foo/:named_param' + ) + ) + } + + env('grape.routing_args', grape_routing_args) + get '/foo/7' + env('grape.routing_args', nil) + expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:id") + end + context 'when the app raises an exception' do let(:original_app) do lambda do |env| From 1b159e5faeaa040786642ec1d4bd82bfa0dc661b Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 1 Feb 2022 02:20:19 +0000 Subject: [PATCH 129/189] Handle consecutive path segments containing IDs in collector Previously, we would fail to strip IDs from paths if they appeared in consecutive path segments. This is because our regex that looked for IDs and UUIDs would consume the `/` character that followed them, causing the next one not to match. This commit uses a lookahead to match against the `/` without consuming it. Signed-off-by: Chris Sinjakli --- lib/prometheus/middleware/collector.rb | 4 +-- spec/prometheus/middleware/collector_spec.rb | 28 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index c3ff58b7..26893176 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -137,8 +137,8 @@ def generate_path(env) def strip_ids_from_path(path) path - .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)}, '/:uuid\\1') - .gsub(%r{/\d+(/|$)}, '/:id\\1') + .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)}, '/:uuid\\1') + .gsub(%r{/\d+(?=/|$)}, '/:id\\1') end end end diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index 49664a3a..06034289 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -95,6 +95,34 @@ expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1) end + it 'handles consecutive path segments containing IDs' do + expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3) + + get '/foo/42/24' + + metric = :http_server_requests_total + labels = { method: 'get', path: '/foo/:id/:id', code: '200' } + expect(registry.get(metric).get(labels: labels)).to eql(1.0) + + metric = :http_server_request_duration_seconds + labels = { method: 'get', path: '/foo/:id/:id' } + expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1) + end + + it 'handles consecutive path segments containing UUIDs' do + expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3) + + get '/foo/5180349d-a491-4d73-af30-4194a46bdff3/5180349d-a491-4d73-af30-4194a46bdff2' + + metric = :http_server_requests_total + labels = { method: 'get', path: '/foo/:uuid/:uuid', code: '200' } + expect(registry.get(metric).get(labels: labels)).to eql(1.0) + + metric = :http_server_request_duration_seconds + labels = { method: 'get', path: '/foo/:uuid/:uuid' } + expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1) + end + it 'prefers sinatra.route to PATH_INFO' do metric = :http_server_requests_total From 9d8ba56b4f3cb8eaec1f49fe26e497686311f796 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 1 Feb 2022 21:16:37 +0000 Subject: [PATCH 130/189] Update UPGRADING.md Add note about improvements to path label generation in `Prometheus::Middleware::Collector`. Signed-off-by: Chris Sinjakli --- UPGRADING.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/UPGRADING.md b/UPGRADING.md index 1167de2e..75b1d0cc 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -140,7 +140,7 @@ recommends exporting a default value for any time series you know will exist in For series with no labels, other Prometheus clients (including Go, Java, and Python) do this automatically, so we have matched that behaviour in the 3.x.x series. -## Path generation fix in Collector middleware +## Added `SCRIPT_NAME` to path labels in Collector middleware Previously, we did not include `Rack::Request`'s `SCRIPT_NAME` when building paths in `Prometheus::Middleware::Collector`. We have now added this, which means that any @@ -151,6 +151,22 @@ This will most typically be present when mounting several Rack applications in t server process, such as when using [Rails Engines](https://guides.rubyonrails.org/engines.html). +## Improved stripping of IDs/UUIDs from paths in Collector middleware + +Where available (currently for applications written in the Sinatra and Grape frameworks), +we now use framework-specific equivalents to `PATH_INFO` in +`Prometheus::Middleware::Collector`, which means that rather than having path segments +replaced with the generic `:id` and `:uuid` placeholders, you'll see the route as you +defined it in your framework. + +For frameworks where that information isn't available to us (most notably Rails), we still +fall back to using `PATH_INFO`, though we have also improved how we strip IDs/UUIDs from +it. Previously, we would only strip them from alternating path segments due to the way we +were matching them. We have improved that matching so it works even when there are +IDs/UUIDs in consecutive path segments. + +You may notice the path label change for some of your endpoints. + ## Improved validation of label names Earlier versions of the Ruby Prometheus client performed limited validation of label names From e0456e997f5770d990ee5bc580ebbe255d70faae Mon Sep 17 00:00:00 2001 From: Daniel Magliola Date: Sun, 20 Jun 2021 00:21:28 +0100 Subject: [PATCH 131/189] Update CHANGELOG in preparation for 2.2.0 release Includes the changes in `master` that we'll release in `2.2.0`, and the ones that we can't because of breaking changes, which we'll release in `3.0.0` Signed-off-by: Daniel Magliola --- CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b43e4cea..4d3fcbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # CHANGELOG +# Upcoming 3.0.0 / 2021-??-?? (not released yet, these are the merged PRs we'll release) + +This new major version includes some breaking changes. They should be reasonably easy to +adapt to, but please read the details below: + +## Breaking changes + +- [#206](https://github.com/prometheus/client_ruby/pull/206) Include SCRIPT_NAME when + determining path in Collector: + When determining the path for a request, Rack::Request prefixes the + SCRIPT_NAME. This was a problem with our code when using mountable engines, + where the engine part of the path gets lost. This patch fixes that to include SCRIPT_NAME as part of the path. + + **This may be a breaking change**. Labels may change in existing metrics. + +- [#209](https://github.com/prometheus/client_ruby/pull/209) Automatically initialize metrics + without labels. + Following the [Prometheus Best Practices](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics), + client libraries are expected to automatically export a 0 value when declaring a metric + that has no labels. + We missed this recommendation in the past, and this wasn't happening. Starting from this + version, all metrics without labels will be immediately exported with `0` value, without + need for an increment / observation. + + **This may be a breaking change**. Depending on your particular metrics, this may + result in a significant increase to the number of time series being exported. We + recommend you test this and make sure it doesn't cause problems. + +- [#220](https://github.com/prometheus/client_ruby/pull/220) Improvements to PushGateway client: + - The `job` parameter is now mandatory when instantiating `Prometheus::Client::Push` + and will raise `ArgumentError` if not specified, or if `nil` or an empty string/object + are passed. + - The `Prometheus::Client::Push` initializer now takes keyword arguments. + - We now correctly handle an empty value for `instance` when generating the path to + the PushGateway. + - Fixed URI escaping of spaces in the path to PushGateway. In the past, spaces were + being encoded as `+` instead of `%20`, which is invalid. + + **This is a breaking change if you use Pushgateway**. You will need to update your + code to pass keyword arguments to the `Prometheus::Client::Push` initializer. + + +# 2.2.0 / 2021-06-?? <-- TODO: update this date when we merge this and cut the new version + +## New Features + +- [#199](https://github.com/prometheus/client_ruby/pull/199) Add `port` filtering option + to Exporter middleware. + You can now specify a `port` when adding `Prometheus::Middleware::Exporter` to your + middleware chain, and metrics will only be exported if the `/metrics` request comes + through that port. + +- [#222](https://github.com/prometheus/client_ruby/pull/222) Enable configuring `Net::HTTP` + timeouts for PushGateway calls. + You can now specify `open_timeout` and `read_timeout` when instantiating + `Prometheus::Client::Push`, to control these timeouts. + +## Code improvements and bug fixes + +- [#201](https://github.com/prometheus/client_ruby/pull/201) Make all registry methods + thread safe. + +- [#227](https://github.com/prometheus/client_ruby/pull/227) Fix `with_labels` bug that + made it completely non-functional, and occasionally resulted in `DirectFileStore` file + corruption. + + # 2.1.0 / 2020-06-29 ## New Features From 0b23ea35a0be316d0b743ac685ba2e6f13d66601 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 28 Jan 2022 01:51:09 +0000 Subject: [PATCH 132/189] Update CHANGELOG.md for 3.0.0 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3fcbe7..f8ab391e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,40 @@ # CHANGELOG -# Upcoming 3.0.0 / 2021-??-?? (not released yet, these are the merged PRs we'll release) +# Unreleased changes + +_None outstanding_ + +# 3.0.0 / 2022-??-?? (TODO: edit commit to add date before merging) This new major version includes some breaking changes. They should be reasonably easy to adapt to, but please read the details below: ## Breaking changes -- [#206](https://github.com/prometheus/client_ruby/pull/206) Include SCRIPT_NAME when +Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versions +`< 3.0.0`. + +- [#206](https://github.com/prometheus/client_ruby/pull/206) Include `SCRIPT_NAME` when determining path in Collector: - When determining the path for a request, Rack::Request prefixes the - SCRIPT_NAME. This was a problem with our code when using mountable engines, - where the engine part of the path gets lost. This patch fixes that to include SCRIPT_NAME as part of the path. + When determining the path for a request, `Rack::Request` prefixes the + `SCRIPT_NAME`. This was a problem with our code when using mountable engines, + where the engine part of the path gets lost. This patch fixes that to include `SCRIPT_NAME` as part of the path. **This may be a breaking change**. Labels may change in existing metrics. +- [#245](https://github.com/prometheus/client_ruby/pull/206) Use framework-specific route + info and handle consecutive path segments containing IDs in Collector: + When generating the `path` label, we now use framework-specific information from the + request environment to produce better labels for apps written in the Sinatra and Grape + frameworks. Rails doesn't provide the information we need to do the same there, but we + hope to get such functionality added in a future release. + + Our framework-agnostic fallback (which Rails apps will use) has also been improved. It + now supports stripping IDs/UUIDs from consecutive path segments, where previously only + alternating segments would be correctly stripped. + + **This may be a breaking change**. Labels may change in existing metrics. + - [#209](https://github.com/prometheus/client_ruby/pull/209) Automatically initialize metrics without labels. Following the [Prometheus Best Practices](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics), @@ -28,21 +48,55 @@ adapt to, but please read the details below: result in a significant increase to the number of time series being exported. We recommend you test this and make sure it doesn't cause problems. -- [#220](https://github.com/prometheus/client_ruby/pull/220) Improvements to PushGateway client: +- [#220](https://github.com/prometheus/client_ruby/pull/220) and [#234](https://github.com/prometheus/client_ruby/pull/234) + Improvements to Pushgateway client: - The `job` parameter is now mandatory when instantiating `Prometheus::Client::Push` and will raise `ArgumentError` if not specified, or if `nil` or an empty string/object are passed. - The `Prometheus::Client::Push` initializer now takes keyword arguments. - - We now correctly handle an empty value for `instance` when generating the path to - the PushGateway. - - Fixed URI escaping of spaces in the path to PushGateway. In the past, spaces were - being encoded as `+` instead of `%20`, which is invalid. + - You can now pass a set of arbitrary key-value pairs (`grouping_key`) to uniquely + identify a job instance, rather than just an `instance` label. + - Fixed URI escaping of spaces in the path when pushing to to Pushgateway. In the + past, spaces were being encoded as `+` instead of `%20`, which resulted in + incorrect label values in the grouping key. + - We now correctly encode special values in `job` and `grouping_key` that can't + ordinarily be represented in the URL. This mean you can have a forward slash (`/`) + in a grouping key label value, or set one to the empty string. + - We validate that labels in your `grouping_key` don't clash with labels in the + metrics being submitted, and raise an error if they do. + - We raise an error on a non-2xx HTTP response from the Pushgateway. **This is a breaking change if you use Pushgateway**. You will need to update your code to pass keyword arguments to the `Prometheus::Client::Push` initializer. - -# 2.2.0 / 2021-06-?? <-- TODO: update this date when we merge this and cut the new version +- [#242](https://github.com/prometheus/client_ruby/pull/242) Move HTTP Basic + Authentication credentials in `Prometheus::Client::Push` to separate method call: + In earlier versions, these were provided as part of the `gateway` URL, which had some + significant downsides when it came to special characters in usernames/passwords. + + These credentials must now be passed via an explicit call to `basic_auth` on an + instance of `Prometheus::Client::Push`. + + **This is a breaking change if you use Pushgateway with HTTP Basic Authentication**. + You will need to update your code to call this method instead of including the + credentials in the URL. + +- [#236](https://github.com/prometheus/client_ruby/pull/236) Validate label names: + Previously, we didn't validate that label names match the character set required by + Prometheus (`[a-zA-Z_][a-zA-Z0-9_]*`). As of this release, we raise an error if a + metric is initialized with label names that don't match that regex. + + **This is a breaking change**. While it's likely that Prometheus server would have + been failing to scrape metrics with such labels anyway, declaring them will now cause + an error to be raised in your code. + +- [#237](https://github.com/prometheus/client_ruby/pull/237) Drop support for old Ruby versions: + Ruby versions below 2.6 are no longer supported upstream, and `client_ruby` is no + longer tested against them. + + **This may be a breaking change**. We no longer make efforts to ensure that + `client_ruby` works on older versions, and any issues filed specific to them will be + considered invalid. ## New Features @@ -53,7 +107,7 @@ adapt to, but please read the details below: through that port. - [#222](https://github.com/prometheus/client_ruby/pull/222) Enable configuring `Net::HTTP` - timeouts for PushGateway calls. + timeouts for Pushgateway calls. You can now specify `open_timeout` and `read_timeout` when instantiating `Prometheus::Client::Push`, to control these timeouts. From 39b3fa439fa5f1c83f033ff4620828874ec3f55f Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 28 Jan 2022 02:09:28 +0000 Subject: [PATCH 133/189] Strip trailing whitespace in CHANGELOG.md Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 58 ++++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ab391e..760cbd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,13 @@ adapt to, but please read the details below: Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versions `< 3.0.0`. -- [#206](https://github.com/prometheus/client_ruby/pull/206) Include `SCRIPT_NAME` when - determining path in Collector: +- [#206](https://github.com/prometheus/client_ruby/pull/206) Include `SCRIPT_NAME` when + determining path in Collector: When determining the path for a request, `Rack::Request` prefixes the `SCRIPT_NAME`. This was a problem with our code when using mountable engines, where the engine part of the path gets lost. This patch fixes that to include `SCRIPT_NAME` as part of the path. - - **This may be a breaking change**. Labels may change in existing metrics. + + **This may be a breaking change**. Labels may change in existing metrics. - [#245](https://github.com/prometheus/client_ruby/pull/206) Use framework-specific route info and handle consecutive path segments containing IDs in Collector: @@ -36,26 +36,26 @@ Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versi **This may be a breaking change**. Labels may change in existing metrics. - [#209](https://github.com/prometheus/client_ruby/pull/209) Automatically initialize metrics - without labels. - Following the [Prometheus Best Practices](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics), - client libraries are expected to automatically export a 0 value when declaring a metric - that has no labels. - We missed this recommendation in the past, and this wasn't happening. Starting from this + without labels. + Following the [Prometheus Best Practices](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics), + client libraries are expected to automatically export a 0 value when declaring a metric + that has no labels. + We missed this recommendation in the past, and this wasn't happening. Starting from this version, all metrics without labels will be immediately exported with `0` value, without - need for an increment / observation. - + need for an increment / observation. + **This may be a breaking change**. Depending on your particular metrics, this may - result in a significant increase to the number of time series being exported. We - recommend you test this and make sure it doesn't cause problems. + result in a significant increase to the number of time series being exported. We + recommend you test this and make sure it doesn't cause problems. - [#220](https://github.com/prometheus/client_ruby/pull/220) and [#234](https://github.com/prometheus/client_ruby/pull/234) - Improvements to Pushgateway client: - - The `job` parameter is now mandatory when instantiating `Prometheus::Client::Push` + Improvements to Pushgateway client: + - The `job` parameter is now mandatory when instantiating `Prometheus::Client::Push` and will raise `ArgumentError` if not specified, or if `nil` or an empty string/object are passed. - The `Prometheus::Client::Push` initializer now takes keyword arguments. - You can now pass a set of arbitrary key-value pairs (`grouping_key`) to uniquely - identify a job instance, rather than just an `instance` label. + identify a job instance, rather than just an `instance` label. - Fixed URI escaping of spaces in the path when pushing to to Pushgateway. In the past, spaces were being encoded as `+` instead of `%20`, which resulted in incorrect label values in the grouping key. @@ -65,7 +65,7 @@ Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versi - We validate that labels in your `grouping_key` don't clash with labels in the metrics being submitted, and raise an error if they do. - We raise an error on a non-2xx HTTP response from the Pushgateway. - + **This is a breaking change if you use Pushgateway**. You will need to update your code to pass keyword arguments to the `Prometheus::Client::Push` initializer. @@ -101,23 +101,23 @@ Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versi ## New Features - [#199](https://github.com/prometheus/client_ruby/pull/199) Add `port` filtering option - to Exporter middleware. + to Exporter middleware. You can now specify a `port` when adding `Prometheus::Middleware::Exporter` to your middleware chain, and metrics will only be exported if the `/metrics` request comes through that port. -- [#222](https://github.com/prometheus/client_ruby/pull/222) Enable configuring `Net::HTTP` - timeouts for Pushgateway calls. - You can now specify `open_timeout` and `read_timeout` when instantiating +- [#222](https://github.com/prometheus/client_ruby/pull/222) Enable configuring `Net::HTTP` + timeouts for Pushgateway calls. + You can now specify `open_timeout` and `read_timeout` when instantiating `Prometheus::Client::Push`, to control these timeouts. ## Code improvements and bug fixes -- [#201](https://github.com/prometheus/client_ruby/pull/201) Make all registry methods +- [#201](https://github.com/prometheus/client_ruby/pull/201) Make all registry methods thread safe. -- [#227](https://github.com/prometheus/client_ruby/pull/227) Fix `with_labels` bug that - made it completely non-functional, and occasionally resulted in `DirectFileStore` file +- [#227](https://github.com/prometheus/client_ruby/pull/227) Fix `with_labels` bug that + made it completely non-functional, and occasionally resulted in `DirectFileStore` file corruption. @@ -125,11 +125,11 @@ Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versi ## New Features -- [#177](https://github.com/prometheus/client_ruby/pull/177) Added Histogram helpers to +- [#177](https://github.com/prometheus/client_ruby/pull/177) Added Histogram helpers to generate linear and exponential buckets, as the Client Library Guidelines recommend. -- [#172](https://github.com/prometheus/client_ruby/pull/172) Added :most_recent +- [#172](https://github.com/prometheus/client_ruby/pull/172) Added :most_recent aggregation for gauges on DirectFileStore. - + ## Code improvements - Fixed several warnings that started firing in the latest versions of Ruby. @@ -138,7 +138,7 @@ Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versi ## Breaking changes -- [#176](https://github.com/prometheus/client_ruby/pull/176) BUGFIX: Values observed at +- [#176](https://github.com/prometheus/client_ruby/pull/176) BUGFIX: Values observed at the upper limit of a histogram bucket are now counted in that bucket, not the following one. This is unlikely to break functionality and you probably don't need to make code changes, but it may break tests. @@ -156,6 +156,6 @@ Please refer to [UPGRADING.md](UPGRADING.md) for details on upgrading from versi - This release saw a number of breaking changes to better comply with latest best practices for naming and client behaviour. Please refer to [UPGRADING.md](UPGRADING.md) for details if upgrading from `<= 0.9`. - + - The main feature of this release was adding support for multi-process environments such as pre-fork servers (Unicorn, Puma). From e213a7189bdab01eea09540f5006ce2c6d0e53f8 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 28 Jan 2022 01:53:35 +0000 Subject: [PATCH 134/189] Bump version for 3.0.0 release Signed-off-by: Chris Sinjakli --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index ec33bff5..75b09338 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '2.1.0' + VERSION = '3.0.0' end end From 16a8c54f91003649a70d02854ef885e4ffe241ab Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 5 Feb 2022 18:40:50 +0000 Subject: [PATCH 135/189] Fill in placeholder date in CHANGELOG.md Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760cbd81..4493dd9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ _None outstanding_ -# 3.0.0 / 2022-??-?? (TODO: edit commit to add date before merging) +# 3.0.0 / 2022-02-05 This new major version includes some breaking changes. They should be reasonably easy to adapt to, but please read the details below: From ad9133f10b3a080f6c15ab8efe6c8b04770fe9ce Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 5 Feb 2022 19:03:06 +0000 Subject: [PATCH 136/189] Fix license name in gemspec Previously, we had the license name set to "Apache 2.0", which doesn't conform to the entry in the list that `gem` validates against (https://spdx.org/licenses/). This commit replaces the space with a hyphen so that we match the format expected by `gem`, and don't get this warning when we build the gem: > WARNING: license value 'Apache 2.0' is invalid. Use a license > identifier from http://spdx.org/licenses or 'Nonstandard' for a > nonstandard license. Did you mean 'Apache-2.0'? Signed-off-by: Chris Sinjakli --- prometheus-client.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 21690fab..3a68f45b 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |s| s.authors = ['Ben Kochie', 'Chris Sinjakli', 'Daniel Magliola'] s.email = ['superq@gmail.com', 'chris@sinjakli.co.uk', 'dmagliola@crystalgears.com'] s.homepage = 'https://github.com/prometheus/client_ruby' - s.license = 'Apache 2.0' + s.license = 'Apache-2.0' s.files = %w(README.md) + Dir.glob('{lib/**/*}') s.require_paths = ['lib'] From e0dd0f9d6c00ba3cbcc3fda1929801a442709053 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 8 Mar 2022 23:04:47 +0000 Subject: [PATCH 137/189] Remove framework-specific route detection from collector middleware Sadly, with the benefit of hindsight, this wasn't a good idea. There are two reasons we're dropping this: - It doesn't play nicely with libraries like `Rack::Builder`, which dispatches requests to different Rack apps based on a path prefix in a way that isn't visible to middleware. For example, when using `Rack::Builder`, `sinatra.route` only contains the parts of the path after the prefix that `Rack::Builder` used to dispatch to the specific app, and doesn't leave any information in the request environment to indicate which prefix it dispatched to. - It turns out framework-specific route info isn't always formatted in a way that looks good in a Prometheus label. For example, when using regex-based route-matching in Sinatra, you can end up with labels that look like `\\/vapes\\/([0-9]+)\\/?`. For a really detailed dive into those two issues, see this GitHub comment: https://github.com/prometheus/client_ruby/issues/249#issuecomment-1061317511 Signed-off-by: Chris Sinjakli --- lib/prometheus/middleware/collector.rb | 52 ++------------------ spec/prometheus/middleware/collector_spec.rb | 48 ------------------ 2 files changed, 5 insertions(+), 95 deletions(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index 26893176..c785d61f 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -72,12 +72,12 @@ def record(env, code, duration) counter_labels = { code: code, method: env['REQUEST_METHOD'].downcase, - path: strip_ids_from_path(path), + path: path, } duration_labels = { method: env['REQUEST_METHOD'].downcase, - path: strip_ids_from_path(path), + path: path, } @requests.increment(labels: counter_labels) @@ -87,52 +87,10 @@ def record(env, code, duration) nil end - # While `PATH_INFO` is framework agnostic, and works for any Rack app, some Ruby web - # frameworks pass a more useful piece of information into the request env - the - # route that the request matched. - # - # This means that rather than using our generic `:id` and `:uuid` replacements in - # the `path` label for any path segments that look like dynamic IDs, we can put the - # actual route that matched in there, with correctly named parameters. For example, - # if a Sinatra app defined a route like: - # - # get "/foo/:bar" do - # ... - # end - # - # instead of containing `/foo/:id`, the `path` label would contain `/foo/:bar`. - # - # Sadly, Rails is a notable exception, and (as far as I can tell at the time of - # writing) doesn't provide this info in the request env. def generate_path(env) - if env['sinatra.route'] - route = env['sinatra.route'].partition(' ').last - elsif env['grape.routing_args'] - # We are deep in the weeds of an object that Grape passes into the request env, - # but don't document any explicit guarantees about. Let's have a fallback in - # case they change it down the line. - # - # This code would be neater with the safe navigation operator (`&.`) here rather - # than the much more verbose `respond_to?` calls, but unlike Rails' `try` - # method, it still raises an error if the object is non-nil, but doesn't respond - # to the method being called on it. - route = nil - - route_info = env.dig('grape.routing_args', :route_info) - if route_info.respond_to?(:pattern) - pattern = route_info.pattern - if pattern.respond_to?(:origin) - route = pattern.origin - end - end - - # Fall back to PATH_INFO if Grape change the structure of `grape.routing_args` - route ||= env['PATH_INFO'] - else - route = env['PATH_INFO'] - end - - [env['SCRIPT_NAME'], route].join + full_path = [env['SCRIPT_NAME'], env['PATH_INFO']].join + + strip_ids_from_path(full_path) end def strip_ids_from_path(path) diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index 06034289..ce6cc526 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -123,54 +123,6 @@ expect(registry.get(metric).get(labels: labels)).to include("0.1" => 0, "0.5" => 1) end - it 'prefers sinatra.route to PATH_INFO' do - metric = :http_server_requests_total - - env('sinatra.route', 'GET /foo/:named_param') - get '/foo/7' - env('sinatra.route', nil) - expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:named_param") - end - - it 'prefers grape.routing_args to PATH_INFO' do - metric = :http_server_requests_total - - # request.env["grape.routing_args"][:route_info].pattern.origin - # - # Yes, this is the object you have to traverse to get the path. - # - # No, I'm not happy about it either. - grape_routing_args = { - route_info: double(:route_info, - pattern: double(:pattern, - origin: '/foo/:named_param' - ) - ) - } - - env('grape.routing_args', grape_routing_args) - get '/foo/7' - env('grape.routing_args', nil) - expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:named_param") - end - - it "falls back to PATH_INFO if the structure of grape.routing_args changes" do - metric = :http_server_requests_total - - grape_routing_args = { - route_info: double(:route_info, - pattern: double(:pattern, - origin_but_different: '/foo/:named_param' - ) - ) - } - - env('grape.routing_args', grape_routing_args) - get '/foo/7' - env('grape.routing_args', nil) - expect(registry.get(metric).values.keys.last[:path]).to eql("/foo/:id") - end - context 'when the app raises an exception' do let(:original_app) do lambda do |env| From 1dfc1ad3f3bda5961b1c7d652e151e480a70b4b8 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 23 Mar 2022 22:46:05 +0000 Subject: [PATCH 138/189] Document how to override `path` label generation in collector Signed-off-by: Chris Sinjakli --- examples/rack/README.md | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/examples/rack/README.md b/examples/rack/README.md index 75541716..e3497335 100644 --- a/examples/rack/README.md +++ b/examples/rack/README.md @@ -54,22 +54,42 @@ example, if you want to [change the way IDs are stripped from the path](https://github.com/prometheus/client_ruby/blob/982fe2e3c37e2940d281573c7689224152dd791f/lib/prometheus/middleware/collector.rb#L97-L101) you could override the appropriate method: -```Ruby +```ruby require 'prometheus/middleware/collector' -module Prometheus - module Middleware - class MyCollector < Collector - def strip_ids_from_path(path) - super(path) - .gsub(/8675309/, ':jenny\\1') - end - end + +class MyCollector < Prometheus::Middleware::Collector + def strip_ids_from_path(path) + super(path) + .gsub(/8675309/, ':jenny\\1') end end ``` and use your class in `config.ru` instead. +If you want to completely customise how the `path` label is generated, you can +override `generate_path`. For example, to use +[Sinatra](https://github.com/sinatra/sinatra)'s framework-specific route info +from the request environment: + +```ruby +require 'prometheus/middleware/collector' + +class MyCollector < Prometheus::Middleware::Collector + def generate_path(env) + # `sinatra.route` contains both the request method and the route, separated + # by a space (e.g. "GET /payments/:id"). To get just the request path, you + # can partition the string on " ". + env['sinatra.route'].partition(' ').last + end +end +``` + +Just make sure that your custom path generation logic strips IDs from the path +it returns, or gets the path from a source that would never contain them in the +first place (such as `sinatra.route`), otherwise you'll generate a huge number +of label values! + **Note:** `Prometheus::Middleware::Collector` isn't explicitly designed to be subclassed, so the internals are liable to change at any time, including in patch releases. Overriding its methods is done at your own risk! From 62dcd1a54fa8a9b05616f5f6cb370f63df0eab21 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 26 Mar 2022 23:08:38 +0000 Subject: [PATCH 139/189] Bump version to 4.0.0 Signed-off-by: Chris Sinjakli --- lib/prometheus/client/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 75b09338..4f22986c 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '3.0.0' + VERSION = '4.0.0' end end From 0736ca14b503909ccae2319e20725cf3f56c529c Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 26 Mar 2022 23:23:46 +0000 Subject: [PATCH 140/189] Update CHANGELOG.md for 4.0.0 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4493dd9c..2fa16bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ _None outstanding_ +# 4.0.0 / 2022-03-27 + +_**Codename:** The "barely a release" release_ + +This version contains a single - sadly breaking - change. + +- [#251](https://github.com/prometheus/client_ruby/pull/251) Remove framework-specific + route detection from collector middleware: + In 3.0.0 [we shipped](https://github.com/prometheus/client_ruby/issues/245) a feature + that attempted to use framework-specific information to determine the path of the + request in `Prometheus::Middleware::Collector`. + + Sadly, we found out after shipping it that it was prone to multiple issues. We spent + a decent amount of time looking into them in depth, and came to the conclusion that + there wasn't any reasonable way to fix them - the issues are inherent to the feature. + + For a full, detailed write-up of our investigation, see [this + comment](https://github.com/prometheus/client_ruby/issues/249#issuecomment-1061317511). + + Almost all users will be unaffected by this change, but it is breaking per the + definition we've used for previous releases, so we've erred on the side of caution and + bumped the major version to communicate that. + + If you use Sinatra or Grape with the `Prometheus::Middleware::Collector`, you will + notice different `path` labels being generated. If not, this release will change + nothing for you. + + If you want the behaviour from 3.0.0 - or any custom path label generation you'd + prefer - we've updated [our collector middleware + documentation](https://github.com/prometheus/client_ruby/blob/master/examples/rack/README.md#collector). + + **This may be a breaking change**. Labels may change in existing metrics. + # 3.0.0 / 2022-02-05 This new major version includes some breaking changes. They should be reasonably easy to From 05823a8ea25cb1f754642b8ae5558ee6f13f339e Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 26 Mar 2022 23:35:30 +0000 Subject: [PATCH 141/189] Add instructions for 4.0.0 to UPGRADING.md Signed-off-by: Chris Sinjakli --- UPGRADING.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/UPGRADING.md b/UPGRADING.md index 75b1d0cc..8d065f21 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,26 @@ +# Upgrading from 3.x.x to 4.x.x + +## Objectives + +4.0.0 contains a single breaking change - the [removal +of](https://github.com/prometheus/client_ruby/pull/251) [framework-specific route +detection](https://github.com/prometheus/client_ruby/pull/245) from +`Prometheus::Middleware::Collector`. + +## Removal of framework-specific route detection + +In 3.0.0 we added a feature that used specific information provided by the Sinatra and +Grape web frameworks to generate the `path` label in `Prometheus::Middleware::Collector`. + +This feature turned out to be inherently flawed, due to limitations in the information we +can extract from the request environment. [This +comment](https://github.com/prometheus/client_ruby/issues/249#issuecomment-1061317511) +goes into much more depth on the investigation we did and the conclusions we came to. + +Most users will be unaffected by this change. If you use Sinatra or Grape and +`Prometheus::Middleware::Collector` you will notice that your `path` label values will be +much more similar to the ones we generated in the 2.x.x release series. + # Upgrading from 2.x.x to 3.x.x ## Objectives From fc7fe03393f42d5b1f7ee265384e801904ebe1cd Mon Sep 17 00:00:00 2001 From: prombot Date: Tue, 3 May 2022 19:50:32 +0000 Subject: [PATCH 142/189] Update common Prometheus files Signed-off-by: prombot --- CODE_OF_CONDUCT.md | 4 ++-- SECURITY.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9a1aff41..d325872b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,3 +1,3 @@ -## Prometheus Community Code of Conduct +# Prometheus Community Code of Conduct -Prometheus follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). +Prometheus follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). diff --git a/SECURITY.md b/SECURITY.md index 67741f01..fed02d85 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,4 +3,4 @@ The Prometheus security policy, including how to report vulnerabilities, can be found here: -https://prometheus.io/docs/operating/security/ + From d9cb212a4ee8bd8b34710a22f6a4e3011930b5d6 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 24 May 2022 00:17:48 +0100 Subject: [PATCH 143/189] Replace references to `master` branch with `main` Other parts of the Prometheus project (namely the Prometheus server repo itself, which sets a lot of norms for everything else) have already adopted this rename, so we should follow suit. This commit will need to be merged in tandem with a main branch name change on GitHub, and possibly some reconfiguration in the repo settings in CircleCI. Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 2 +- README.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa16bb6..1f1c2a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ This version contains a single - sadly breaking - change. If you want the behaviour from 3.0.0 - or any custom path label generation you'd prefer - we've updated [our collector middleware - documentation](https://github.com/prometheus/client_ruby/blob/master/examples/rack/README.md#collector). + documentation](https://github.com/prometheus/client_ruby/blob/v4.0.0/examples/rack/README.md#collector). **This may be a breaking change**. Labels may change in existing metrics. diff --git a/README.md b/README.md index 05080c94..9e7c10ee 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ through a HTTP interface. Intended to be used together with a [Prometheus server][1]. [![Gem Version][4]](http://badge.fury.io/rb/prometheus-client) -[![Build Status][3]](https://circleci.com/gh/prometheus/client_ruby/tree/master.svg?style=svg) +[![Build Status][3]](https://circleci.com/gh/prometheus/client_ruby/tree/main.svg?style=svg) ## Usage @@ -504,9 +504,8 @@ rake [1]: https://github.com/prometheus/prometheus [2]: http://rack.github.io/ -[3]: https://secure.travis-ci.org/prometheus/client_ruby.svg?branch=master +[3]: https://circleci.com/gh/prometheus/client_ruby/tree/main.svg?style=svg [4]: https://badge.fury.io/rb/prometheus-client.svg -[7]: https://coveralls.io/repos/prometheus/client_ruby/badge.svg?branch=master [8]: https://github.com/prometheus/pushgateway [9]: lib/prometheus/middleware/exporter.rb [10]: lib/prometheus/middleware/collector.rb From 3e8fbeca93f0095b9ab0b84671b07ab113beb2b0 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 7 Jun 2022 01:54:26 +0100 Subject: [PATCH 144/189] Remove Coveralls detritus We missed this file in the cleanup. Signed-off-by: Chris Sinjakli --- .coveralls.yml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .coveralls.yml diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 91600595..00000000 --- a/.coveralls.yml +++ /dev/null @@ -1 +0,0 @@ -service_name: travis-ci From 92337f33e973e19d38505ac9b95c5ded5d11ec1a Mon Sep 17 00:00:00 2001 From: Juan Manuel Cuello Date: Fri, 17 Jun 2022 11:30:00 -0300 Subject: [PATCH 145/189] Fix minor typo in README Remove duplicated word. Signed-off-by: Juan Manuel Cuello --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e7c10ee..01c0a1a1 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Counters, Histograms and Summaries are `SUM`med, and Gauges report all their val for each process), tagged with a `pid` label. You can also select `SUM`, `MAX`, `MIN`, or `MOST_RECENT` for your gauges, depending on your use case. -Please note that that the `MOST_RECENT` aggregation only works for gauges, and it does not +Please note that the `MOST_RECENT` aggregation only works for gauges, and it does not allow the use of `increment` / `decrement`, you can only use `set`. **Memory Usage**: When scraped by Prometheus, this store will read all these files, get all From 6bc00efefa1960eaf65798a3b901197d9d322875 Mon Sep 17 00:00:00 2001 From: Juan Manuel Cuello Date: Fri, 17 Jun 2022 11:31:23 -0300 Subject: [PATCH 146/189] Remove trailing whitespaces from README Signed-off-by: Juan Manuel Cuello --- README.md | 86 +++++++++++++++++++++++++++---------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 01c0a1a1..5eb0eff1 100644 --- a/README.md +++ b/README.md @@ -227,17 +227,17 @@ summary_value['count'] # => 100 All metrics can have labels, allowing grouping of related time series. Labels are an extremely powerful feature, but one that must be used with care. -Refer to the best practices on [naming](https://prometheus.io/docs/practices/naming/) and +Refer to the best practices on [naming](https://prometheus.io/docs/practices/naming/) and [labels](https://prometheus.io/docs/practices/instrumentation/#use-labels). -Most importantly, avoid labels that can have a large number of possible values (high +Most importantly, avoid labels that can have a large number of possible values (high cardinality). For example, an HTTP Status Code is a good label. A User ID is **not**. Labels are specified optionally when updating metrics, as a hash of `label_name => value`. -Refer to [the Prometheus documentation](https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels) +Refer to [the Prometheus documentation](https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels) as to what's a valid `label_name`. -In order for a metric to accept labels, their names must be specified when first initializing +In order for a metric to accept labels, their names must be specified when first initializing the metric. Then, when the metric is updated, all the specified labels must be present. Example: @@ -255,8 +255,8 @@ You can also "pre-set" some of these label values, if they'll always be the same need to specify them every time: ```ruby -https_requests_total = Counter.new(:http_requests_total, - docstring: '...', +https_requests_total = Counter.new(:http_requests_total, + docstring: '...', labels: [:service, :status_code], preset_labels: { service: "my_service" }) @@ -271,7 +271,7 @@ with a subset (or full set) of labels set, so that you can increment / observe t without having to specify the labels for every call. Moreover, if all the labels the metric can take have been pre-set, validation of the labels -is done on the call to `with_labels`, and then skipped for each observation, which can +is done on the call to `with_labels`, and then skipped for each observation, which can lead to performance improvements. If you are incrementing a counter in a fast loop, you definitely want to be doing this. @@ -282,8 +282,8 @@ Examples: ```ruby # in the metric definition: -records_processed_total = registry.counter.new(:records_processed_total, - docstring: '...', +records_processed_total = registry.counter.new(:records_processed_total, + docstring: '...', labels: [:service, :component], preset_labels: { service: "my_service" }) @@ -296,11 +296,11 @@ class MyComponent def metric @metric ||= records_processed_total.with_labels(component: "my_component") end - + def process records.each do |record| # process the record - metric.increment + metric.increment end end end @@ -324,7 +324,7 @@ metric definition will result in a ## Data Stores -The data for all the metrics (the internal counters associated with each labelset) +The data for all the metrics (the internal counters associated with each labelset) is stored in a global Data Store object, rather than in the metric objects themselves. (This "storage" is ephemeral, generally in-memory, it's not "long-term storage") @@ -334,12 +334,12 @@ example), require a shared store between all the processes, to be able to report numbers. At the same time, other applications may not have this requirement but be very sensitive to performance, and would prefer instead a simpler, faster store. -By having a standardized and simple interface that metrics use to access this store, +By having a standardized and simple interface that metrics use to access this store, we abstract away the details of storing the data from the specific needs of each metric. -This allows us to then simply swap around the stores based on the needs of different -applications, with no changes to the rest of the client. +This allows us to then simply swap around the stores based on the needs of different +applications, with no changes to the rest of the client. -The client provides 3 built-in stores, but if neither of these is ideal for your +The client provides 3 built-in stores, but if neither of these is ideal for your requirements, you can easily make your own store and use that instead. More on this below. ### Configuring which store to use. @@ -357,7 +357,7 @@ NOTE: You **must** make sure to set the `data_store` before initializing any met If using Rails, you probably want to set up your Data Store on `config/application.rb`, or `config/environments/*`, both of which run before `config/initializers/*` -Also note that `config.data_store` is set to an *instance* of a `DataStore`, not to the +Also note that `config.data_store` is set to an *instance* of a `DataStore`, not to the class. This is so that the stores can receive parameters. Most of the built-in stores don't require any, but `DirectFileStore` does, for example. @@ -376,27 +376,27 @@ documentation of each store for more details. There are 3 built-in stores, with different trade-offs: -- **Synchronized**: Default store. Thread safe, but not suitable for multi-process +- **Synchronized**: Default store. Thread safe, but not suitable for multi-process scenarios (e.g. pre-fork servers, like Unicorn). Stores data in Hashes, with all accesses - protected by Mutexes. + protected by Mutexes. - **SingleThreaded**: Fastest store, but only suitable for single-threaded scenarios. - This store does not make any effort to synchronize access to its internal hashes, so + This store does not make any effort to synchronize access to its internal hashes, so it's absolutely not thread safe. - **DirectFileStore**: Stores data in binary files, one file per process and per metric. - This is generally the recommended store to use with pre-fork servers and other + This is generally the recommended store to use with pre-fork servers and other "multi-process" scenarios. There are some important caveats to using this store, so please read on the section below. ### `DirectFileStore` caveats and things to keep in mind Each metric gets a file for each process, and manages its contents by storing keys and -binary floats next to them, and updating the offsets of those Floats directly. When -exporting metrics, it will find all the files that apply to each metric, read them, +binary floats next to them, and updating the offsets of those Floats directly. When +exporting metrics, it will find all the files that apply to each metric, read them, and aggregate them. **Aggregation of metrics**: Since there will be several files per metrics (one per process), these need to be aggregated to present a coherent view to Prometheus. Depending on your -use case, you may need to control how this works. When using this store, +use case, you may need to control how this works. When using this store, each Metric allows you to specify an `:aggregation` setting, defining how to aggregate the multiple possible values we can get for each labelset. By default, Counters, Histograms and Summaries are `SUM`med, and Gauges report all their values (one @@ -404,17 +404,17 @@ for each process), tagged with a `pid` label. You can also select `SUM`, `MAX`, `MOST_RECENT` for your gauges, depending on your use case. Please note that the `MOST_RECENT` aggregation only works for gauges, and it does not -allow the use of `increment` / `decrement`, you can only use `set`. +allow the use of `increment` / `decrement`, you can only use `set`. **Memory Usage**: When scraped by Prometheus, this store will read all these files, get all the values and aggregate them. We have notice this can have a noticeable effect on memory usage for your app. We recommend you test this in a realistic usage scenario to make sure you won't hit any memory limits your app may have. -**Resetting your metrics on each run**: You should also make sure that the directory where -you store your metric files (specified when initializing the `DirectFileStore`) is emptied -when your app starts. Otherwise, each app run will continue exporting the metrics from the -previous run. +**Resetting your metrics on each run**: You should also make sure that the directory where +you store your metric files (specified when initializing the `DirectFileStore`) is emptied +when your app starts. Otherwise, each app run will continue exporting the metrics from the +previous run. If you have this issue, one way to do this is to run code similar to this as part of you initialization: @@ -440,15 +440,15 @@ If you're absolutely sure that every child process will run the metric declarati then you won't run into this issue, but the simplest approach is to declare the metrics before forking. -**Large numbers of files**: Because there is an individual file per metric and per process -(which is done to optimize for observation performance), you may end up with a large number +**Large numbers of files**: Because there is an individual file per metric and per process +(which is done to optimize for observation performance), you may end up with a large number of files. We don't currently have a solution for this problem, but we're working on it. -**Performance**: Even though this store saves data on disk, it's still much faster than -would probably be expected, because the files are never actually `fsync`ed, so the store -never blocks while waiting for disk. The kernel's page cache is incredibly efficient in -this regard. If in doubt, check the benchmark scripts described in the documentation for -creating your own stores and run them in your particular runtime environment to make sure +**Performance**: Even though this store saves data on disk, it's still much faster than +would probably be expected, because the files are never actually `fsync`ed, so the store +never blocks while waiting for disk. The kernel's page cache is incredibly efficient in +this regard. If in doubt, check the benchmark scripts described in the documentation for +creating your own stores and run them in your particular runtime environment to make sure this provides adequate performance. @@ -457,7 +457,7 @@ this provides adequate performance. If none of these stores is suitable for your requirements, you can easily make your own. The interface and requirements of Stores are specified in detail in the `README.md` -in the `client/data_stores` directory. This thoroughly documents how to make your own +in the `client/data_stores` directory. This thoroughly documents how to make your own store. There are also links there to non-built-in stores created by others that may be useful, @@ -469,16 +469,16 @@ If you are in a multi-process environment (such as pre-fork servers like Unicorn process will probably keep their own counters, which need to be aggregated when receiving a Prometheus scrape, to report coherent total numbers. -For Counters, Histograms and quantile-less Summaries this is simply a matter of +For Counters, Histograms and quantile-less Summaries this is simply a matter of summing the values of each process. -For Gauges, however, this may not be the right thing to do, depending on what they're +For Gauges, however, this may not be the right thing to do, depending on what they're measuring. You might want to take the maximum or minimum value observed in any process, rather than the sum of all of them. By default, we export each process's individual value, with a `pid` label identifying each one. -If these defaults don't work for your use case, you should use the `store_settings` -parameter when registering the metric, to specify an `:aggregation` setting. +If these defaults don't work for your use case, you should use the `store_settings` +parameter when registering the metric, to specify an `:aggregation` setting. ```ruby free_disk_space = registry.gauge(:free_disk_space_bytes, @@ -489,8 +489,8 @@ free_disk_space = registry.gauge(:free_disk_space_bytes, NOTE: This will only work if the store you're using supports the `:aggregation` setting. Of the built-in stores, only `DirectFileStore` does. -Also note that the `:aggregation` setting works for all metric types, not just for gauges. -It would be unusual to use it for anything other than gauges, but if your use-case +Also note that the `:aggregation` setting works for all metric types, not just for gauges. +It would be unusual to use it for anything other than gauges, but if your use-case requires it, the store will respect your aggregation wishes. ## Tests From e1f515c8d664392769b34b57eba4b3f1ed02fa96 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 7 Jun 2022 01:17:21 +0100 Subject: [PATCH 147/189] Refactor specs for metric implementations Signed-off-by: Chris Sinjakli --- spec/examples/metric_example.rb | 42 +--- spec/prometheus/client/counter_spec.rb | 198 +----------------- spec/prometheus/client/gauge_spec.rb | 84 +------- spec/prometheus/client/histogram_spec.rb | 88 ++++---- spec/prometheus/client/metric_spec.rb | 244 +++++++++++++++++++++++ spec/prometheus/client/summary_spec.rb | 60 +----- 6 files changed, 298 insertions(+), 418 deletions(-) create mode 100644 spec/prometheus/client/metric_spec.rb diff --git a/spec/examples/metric_example.rb b/spec/examples/metric_example.rb index ec9fd0da..bbe5a454 100644 --- a/spec/examples/metric_example.rb +++ b/spec/examples/metric_example.rb @@ -1,37 +1,11 @@ # encoding: UTF-8 -# TODO: Convert these tests to use a fake metric class rather than shared examples -# -# Right now, we're using shared examples that we include in every metric type's tests -# to validate the behaviour of the base metric class. -# -# This makes it difficult to test certain behaviour, as the interfaces of those metric -# types differ and these tests can end up needing to know about them. -# -# You can see that in the tests for #get, which depend on `type` which isn't defined in -# this file. The test files that include these shared examples have to do so with a block -# that provides the `type` variable. -# -# This cropped up in a much worse way when trying to test the code that makes sure label -# values are all strings. Writing a test here that gets included in all the real metric -# implementations is near impossible. You need your test to call a different method to -# alter a metric value (e.g. `set`, `increment` or `observe` depending on the metric type) -# which means having each concrete metric type's tests passing us a lambda that we can -# call agnostically of the metric type. -# -# The resultant code is confusing to follow, so we opted to duplicate those tests in each -# metric type's test file. -# -# Changing this file to implement a fake metric class (e.g. `FakeTestCounter`) would let -# us easily test the functionality of the base `Prometheus::Client::Metric` without -# getting caught up in the specifics of the real metric types. - shared_examples_for Prometheus::Client::Metric do subject { described_class.new(:foo, docstring: 'foo description') } describe '.new' do it 'returns a new metric' do - expect(subject).to be + expect(subject).to be_a(Prometheus::Client::Metric) end it 'raises an exception if a reserved base label is used' do @@ -76,18 +50,4 @@ expect(subject.type).to be_a(Symbol) end end - - describe '#get' do - it 'returns the current metric value' do - expect(subject.get).to be_a(type) - end - - context "with a subject that expects labels" do - subject { described_class.new(:foo, docstring: 'Labels', labels: [:test]) } - - it 'returns the current metric value for a given label set' do - expect(subject.get(labels: { test: 'label' })).to be_a(type) - end - end - end end diff --git a/spec/prometheus/client/counter_spec.rb b/spec/prometheus/client/counter_spec.rb index bdd9a792..51ee15e8 100644 --- a/spec/prometheus/client/counter_spec.rb +++ b/spec/prometheus/client/counter_spec.rb @@ -19,9 +19,7 @@ labels: expected_labels) end - it_behaves_like Prometheus::Client::Metric do - let(:type) { Float } - end + it_behaves_like Prometheus::Client::Metric describe '#increment' do it 'increments the counter' do @@ -30,12 +28,6 @@ end.to change { counter.get }.by(1.0) end - it 'raises an InvalidLabelSetError if sending unexpected labels' do - expect do - counter.increment(labels: { test: 'label' }) - end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError - end - context "with a an expected label set" do let(:expected_labels) { [:test] } @@ -73,193 +65,5 @@ end.each(&:join) end.to change { counter.get }.by(100.0) end - - context "with non-string label values" do - subject { described_class.new(:foo, docstring: 'Labels', labels: [:foo]) } - - it "converts labels to strings for consistent storage" do - subject.increment(labels: { foo: :label }) - expect(subject.get(labels: { foo: 'label' })).to eq(1.0) - end - - context "and some labels preset" do - subject do - described_class.new(:foo, - docstring: 'Labels', - labels: [:foo, :bar], - preset_labels: { foo: :label }) - end - - it "converts labels to strings for consistent storage" do - subject.increment(labels: { bar: :label }) - expect(subject.get(labels: { foo: 'label', bar: 'label' })).to eq(1.0) - end - end - end - end - - describe '#init_label_set' do - context "with labels" do - let(:expected_labels) { [:test] } - - it 'initializes the metric for a given label set' do - expect(counter.values).to eql({}) - - counter.init_label_set(test: 'value') - - expect(counter.values).to eql({test: 'value'} => 0.0) - end - end - - context "without labels" do - it 'automatically initializes the metric' do - expect(counter.values).to eql({} => 0.0) - end - end - end - - describe '#with_labels' do - let(:expected_labels) { [:foo] } - - it 'pre-sets labels for observations' do - expect { counter.increment } - .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) - expect { counter.with_labels(foo: 'label').increment }.not_to raise_error - end - - it 'registers `with_labels` observations in the original metric store' do - counter.increment(labels: { foo: 'value1'}) - counter_with_labels = counter.with_labels({ foo: 'value2'}) - counter_with_labels.increment(by: 2) - - expect(counter_with_labels.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) - expect(counter.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) - end - - context 'when using DirectFileStore' do - before do - Dir.glob('/tmp/prometheus_test/*').each { |file| File.delete(file) } - Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir: '/tmp/prometheus_test') - end - - let(:expected_labels) { [:foo, :bar] } - - # Testing for file corruption: this is weird and complicated, so it needs explaining - # - # Files get corrupted when we have two different instances of `FileMappedDict` - # reading and writing the same file. This corruption is expected; we should never have - # two instances of `FileMappedDict` for the same file. If we do, it's a bug in our client. - # - # To clarify, the bug is that *we ended up with two instances for the same file*, not - # that the instances are now corrupting the file. - # - # This is why we're testing this in `with_labels`. It's the only use case we've found - # were we ended up with two instances (before we fixed that bug). `with_labels` is - # incidental, if we find another way to get "duplicate" instances, we should add this - # same exact test, except for the first line, where we need to instead reproduce - # whatever bug gets us that second instance. - # - # The first thing we need to understand is why having two instances of `FileMappedDict` - # corrupts the files: - # - # `FileMappedDict` keeps track, in an internal variable, of how many bytes in the file - # have been used. When adding a new "entry" (observing a new labelset), it serializes - # it and adds it at "the end" (according to its internal byte counter), and it also updates - # the counter at the beginning of the file. However, it never re-reads that counter - # from the file, because there shouldn't be any reason for it to have changed. - # - # If there are two instances pointing to the same file, initially they will both - # share that internal counter, as they do the first read of the file, but if then - # each of them adds an entry, their internal "length" counters will disagree, and - # they'll start overwriting each other's entries. - # - # Importantly, if all of the entries happen to have the same length, it will be "fine". - # Some of the labelsets will effectively disappear, but there will be no corruption, - # because all the important things will fall in the right offsets by pure chance. This - # would be very rare in production, but in a test, it's what normally happens because - # we set all labels to "foo", "bar", etc. This is the reason for "longervalue" below, - # we need to have different labelset lenghts to reproduce the corruption. - # - # With this background about the internals, we can now get to why the specific sequence of - # steps below ends up in corrupted files. - # - # For this to make sense, i'll need to describe the contents of the file at each step. - # I'll represent it like this: `27|labelset1,value1|labelset2,value2|labelset3,value3|` - # - # These are not the bytes we store in the file, but conceptually it's equivalent, - # with two caveats: - # - The counter at the beginning (27 == 3 * 9) here shows the combined length of labelsets. - # It'd normally also include the length of values, but doing that makes this explanation - # much harder to follow. - # - Each entry also starts with a 4-byte int specifying the length of its labelset, so - # we know how much to read. Again, I'm omitting that for readability. - # - # - # Steps to reproduce: - # - We declare `counter` and `counter_with_labels` as a clone. Neither has read the file. - # - We increment `counter`, which creates the file and adds the entry ("labelset1") - # - File: `9|labelset1,value1|` - # - We increment `counter_with_labels`, which reads the file, and adds the new entry - # to it ("muchlongerlabelset2"). - # - File: `28|labelset1,value1|muchlongerlabelset2, value2|` - # - `counter` and `counter_with_labels` now disagree about the length of this file - # (`counter` doesn't know the file has grown). - # - We now add a new entry to `counter` ("labelset3"), which thinks the file is shorter - # than it actually is. - # - File: `18|labelset1,value1|labelset3,value3|et2, value2|` - # - The initial counter reflects both labelsets for `counter`; then we have those - # labelsetsp; and finally some "garbage" after the "end" (the garbage is the - # last few bytes of the much longer entry added before by `counter_with_labels`) - # - so far, though, we're still good. If you read the file, all entries are "fine", - # because you're only reading up to the "18" length specified at the beginning. - # - for the problem to manifest itself, we need to increment that counter at the - # beginning, so we'll read the garbage. **BUT**, if we add a new labelset to - # `counter`, it'll overwrite the "garbage" with good data, and the file will - # continue to be fine. - # - We add a new entry to `counter_with_labels`. This updates the length counter at - # the beginning of the file. - # - File: `47|labelset1,value1|labelset3,value3|et2, value2|muchlongerlabelset4, value4|` - # - # - Now the file is properly corrupted. When reading it, `FileMappedDict` sees: - # - labelset1,value1 (cool) - # - labelset3,value3 (cool) - # - et2, value2 (boom) - # |-> the beginning of this entry is garbage because we're actually at the middle - # of an entry, not a beginning. - # - # What actually breaks is that each of these entries is expected to have, at their - # beginning, the length in bytes of its labelset, so we know how much to read. - # Now we have garbage in that position, and `FileMappedDict` will either: - # - Try to interpret those four bytes as a long, get an invalid result. - # - Try to read an invalid amount of data (maybe a negative amount). - # - After reading the labelset, try to read the float and go past the end of the file - # - Actually read what it thinks is a float, try to `unpack` it, and fail because - # it's actually garbage. - # - I'm sure there are other fun ways for it to fail. - it "doesn't corrupt the data files" do - counter_with_labels = counter.with_labels({ foo: 'longervalue'}) - - # Initialize / read the files for both views of the metric - counter.increment(labels: { foo: 'value1', bar: 'zzz'}) - counter_with_labels.increment(by: 2, labels: {bar: 'zzz'}) - - # After both MetricStores have their files, add a new entry to both - counter.increment(labels: { foo: 'value1', bar: 'aaa'}) # If there's a bug, we partially overwrite { foo: 'longervalue', bar: 'zzz'} - counter_with_labels.increment(by: 2, labels: {bar: 'aaa'}) # Extend the file so we read past that overwrite - - expect { counter.values }.not_to raise_error # Check it hasn't corrupted our files - expect { counter_with_labels.values }.not_to raise_error # Check it hasn't corrupted our files - - expected_values = { - {foo: 'value1', bar: 'zzz'} => 1.0, - {foo: 'value1', bar: 'aaa'} => 1.0, - {foo: 'longervalue', bar: 'zzz'} => 2.0, - {foo: 'longervalue', bar: 'aaa'} => 2.0, - } - - expect(counter.values).to eql(expected_values) - expect(counter_with_labels.values).to eql(expected_values) - end - end end end diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index bad54f77..376becd5 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -18,9 +18,7 @@ labels: expected_labels) end - it_behaves_like Prometheus::Client::Metric do - let(:type) { Float } - end + it_behaves_like Prometheus::Client::Metric describe '#set' do it 'sets a metric value' do @@ -29,12 +27,6 @@ end.to change { gauge.get }.from(0).to(42) end - it 'raises an InvalidLabelSetError if sending unexpected labels' do - expect do - gauge.set(42, labels: { test: 'value' }) - end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError - end - context "with a an expected label set" do let(:expected_labels) { [:test] } @@ -67,12 +59,6 @@ end.to change { gauge.get }.by(1.0) end - it 'raises an InvalidLabelSetError if sending unexpected labels' do - expect do - gauge.increment(labels: { test: 'value' }) - end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError - end - context "with a an expected label set" do let(:expected_labels) { [:test] } @@ -104,29 +90,6 @@ end.each(&:join) end.to change { gauge.get }.by(100.0) end - - context "with non-string label values" do - subject { described_class.new(:foo, docstring: 'Labels', labels: [:foo]) } - - it "converts labels to strings for consistent storage" do - subject.increment(labels: { foo: :label }) - expect(subject.get(labels: { foo: 'label' })).to eq(1.0) - end - - context "and some labels preset" do - subject do - described_class.new(:foo, - docstring: 'Labels', - labels: [:foo, :bar], - preset_labels: { foo: :label }) - end - - it "converts labels to strings for consistent storage" do - subject.increment(labels: { bar: :label }) - expect(subject.get(labels: { foo: 'label', bar: 'label' })).to eq(1.0) - end - end - end end describe '#decrement' do @@ -140,12 +103,6 @@ end.to change { gauge.get }.by(-1.0) end - it 'raises an InvalidLabelSetError if sending unexpected labels' do - expect do - gauge.decrement(labels: { test: 'value' }) - end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError - end - context "with a an expected label set" do let(:expected_labels) { [:test] } @@ -178,43 +135,4 @@ end.to change { gauge.get }.by(-100.0) end end - - describe '#init_label_set' do - context "with labels" do - let(:expected_labels) { [:test] } - - it 'initializes the metric for a given label set' do - expect(gauge.values).to eql({}) - - gauge.init_label_set(test: 'value') - - expect(gauge.values).to eql({test: 'value'} => 0.0) - end - end - - context "without labels" do - it 'automatically initializes the metric' do - expect(gauge.values).to eql({} => 0.0) - end - end - end - - describe '#with_labels' do - let(:expected_labels) { [:foo] } - - it 'pre-sets labels for observations' do - expect { gauge.set(10) } - .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) - expect { gauge.with_labels(foo: 'value').set(10) }.not_to raise_error - end - - it 'registers `with_labels` observations in the original metric store' do - gauge.set(1, labels: { foo: 'value1'}) - gauge_with_labels = gauge.with_labels({ foo: 'value2'}) - gauge_with_labels.set(2) - - expect(gauge_with_labels.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) - expect(gauge.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) - end - end end diff --git a/spec/prometheus/client/histogram_spec.rb b/spec/prometheus/client/histogram_spec.rb index 7e0f55d8..5335e8be 100644 --- a/spec/prometheus/client/histogram_spec.rb +++ b/spec/prometheus/client/histogram_spec.rb @@ -19,9 +19,7 @@ buckets: [2.5, 5, 10]) end - it_behaves_like Prometheus::Client::Metric do - let(:type) { Hash } - end + it_behaves_like Prometheus::Client::Metric describe '#initialization' do it 'raise error for unsorted buckets' do @@ -64,12 +62,6 @@ end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError end - it 'raises an InvalidLabelSetError if sending unexpected labels' do - expect do - histogram.observe(5, labels: { foo: 'bar' }) - end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError - end - context "with a an expected label set" do let(:expected_labels) { [:test] } @@ -81,35 +73,6 @@ end.to_not change { histogram.get(labels: { test: 'other' }) } end end - - context "with non-string label values" do - let(:histogram) do - described_class.new(:foo, - docstring: 'foo description', - labels: [:foo], - buckets: [2.5, 5, 10]) - end - - it "converts labels to strings for consistent storage" do - histogram.observe(5, labels: { foo: :label }) - expect(histogram.get(labels: { foo: 'label' })["10"]).to eq(1.0) - end - - context "and some labels preset" do - let(:histogram) do - described_class.new(:foo, - docstring: 'foo description', - labels: [:foo, :bar], - preset_labels: { foo: :label }, - buckets: [2.5, 5, 10]) - end - - it "converts labels to strings for consistent storage" do - histogram.observe(5, labels: { bar: :label }) - expect(histogram.get(labels: { foo: 'label', bar: 'label' })["10"]).to eq(1.0) - end - end - end end describe '#get' do @@ -205,5 +168,54 @@ expect(histogram_with_labels.values).to eql(expected_values) expect(histogram.values).to eql(expected_values) end + + context 'when using DirectFileStore' do + before do + Dir.glob('/tmp/prometheus_test/*').each { |file| File.delete(file) } + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir: '/tmp/prometheus_test') + end + + after do + Dir.glob('/tmp/prometheus_test/*').each { |file| File.delete(file) } + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::Synchronized.new + end + + let(:expected_labels) { [:foo, :bar] } + + # This is a slightly weird test, and largely a duplicate of one in + # spec/prometheus/client/metric_spec.rb. + # + # The reason we have this copy of the test is because histogram.rb + # implements its own fix for the issue this test guards against due to + # having slightly different constructor signature (which gets called in + # `with_labels`). + # + # See the comment in spec/prometheus/client/metric_spec.rb for an + # explanation of what this test is doing and why. + it "doesn't corrupt the data files" do + histogram_with_labels = histogram.with_labels({ foo: 'longervalue'}) + + # Initialize / read the files for both views of the metric + histogram.observe(1, labels: { foo: 'value1', bar: 'zzz'}) + histogram_with_labels.observe(1, labels: {bar: 'zzz'}) + + # After both MetricStores have their files, add a new entry to both + histogram.observe(1, labels: { foo: 'value1', bar: 'aaa'}) # If there's a bug, we partially overwrite { foo: 'longervalue', bar: 'zzz'} + histogram_with_labels.observe(1, labels: {bar: 'aaa'}) # Extend the file so we read past that overwrite + + expect { histogram.values }.not_to raise_error # Check it hasn't corrupted our files + expect { histogram_with_labels.values }.not_to raise_error # Check it hasn't corrupted our files + + expected_values = { + {foo: 'value1', bar: 'zzz'} => {"2.5" => 1.0, "5"=>1.0, "10" => 1.0, "+Inf" => 1.0, "sum"=>1.0}, + {foo: 'value1', bar: 'aaa'} => {"2.5" => 1.0, "5"=>1.0, "10" => 1.0, "+Inf" => 1.0, "sum"=>1.0}, + {foo: 'longervalue', bar: 'zzz'} => {"2.5" => 1.0, "5"=>1.0, "10" => 1.0, "+Inf" => 1.0, "sum"=>1.0}, + {foo: 'longervalue', bar: 'aaa'} => {"2.5" => 1.0, "5"=>1.0, "10" => 1.0, "+Inf" => 1.0, "sum"=>1.0}, + } + + expect(histogram.values).to eql(expected_values) + expect(histogram_with_labels.values).to eql(expected_values) + end + end end end diff --git a/spec/prometheus/client/metric_spec.rb b/spec/prometheus/client/metric_spec.rb new file mode 100644 index 00000000..7b84c67d --- /dev/null +++ b/spec/prometheus/client/metric_spec.rb @@ -0,0 +1,244 @@ +# encoding: UTF-8 + +require 'prometheus/client' +require 'prometheus/client/metric' +require 'prometheus/client/data_stores/direct_file_store' + +describe Prometheus::Client::Metric do + let(:test_counter) do + Class.new(Prometheus::Client::Metric) do + def type + :counter + end + + def increment(by: 1, labels: {}) + raise ArgumentError, 'increment must be a non-negative number' if by < 0 + + label_set = label_set_for(labels) + @store.increment(labels: label_set, by: by) + end + end + end + + let(:expected_labels) { [] } + + subject(:counter) do + test_counter.new(:foo, + docstring: 'foo description', + labels: expected_labels) + end + + describe '#get' do + it 'returns the current metric value' do + subject.increment + + expect(subject.get).to eql(1.0) + end + + context "with a subject that expects labels" do + subject { test_counter.new(:foo, docstring: 'Labels', labels: [:test]) } + + it 'returns the current metric value for a given label set' do + subject.increment(labels: { test: 'label' }) + + expect(subject.get(labels: { test: 'label' })).to eql(1.0) + end + end + end + + describe '#increment' do + it 'raises an InvalidLabelSetError if sending unexpected labels' do + expect do + counter.increment(labels: { test: 'label' }) + end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError + end + + context "with non-string label values" do + subject { test_counter.new(:foo, docstring: 'Labels', labels: [:foo]) } + + it "converts labels to strings for consistent storage" do + subject.increment(labels: { foo: :label }) + expect(subject.get(labels: { foo: 'label' })).to eq(1.0) + end + + context "and some labels preset" do + subject do + test_counter.new(:foo, + docstring: 'Labels', + labels: [:foo, :bar], + preset_labels: { foo: :label }) + end + + it "converts labels to strings for consistent storage" do + subject.increment(labels: { bar: :label }) + expect(subject.get(labels: { foo: 'label', bar: 'label' })).to eq(1.0) + end + end + end + end + + describe '#init_label_set' do + context "with labels" do + let(:expected_labels) { [:test] } + + it 'initializes the metric for a given label set' do + expect(counter.values).to eql({}) + + counter.init_label_set(test: 'value') + + expect(counter.values).to eql({test: 'value'} => 0.0) + end + end + + context "without labels" do + it 'automatically initializes the metric' do + expect(counter.values).to eql({} => 0.0) + end + end + end + + describe '#with_labels' do + let(:expected_labels) { [:foo] } + + it 'pre-sets labels for observations' do + expect { counter.increment } + .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) + expect { counter.with_labels(foo: 'label').increment }.not_to raise_error + end + + it 'registers `with_labels` observations in the original metric store' do + counter.increment(labels: { foo: 'value1'}) + counter_with_labels = counter.with_labels({ foo: 'value2'}) + counter_with_labels.increment(by: 2) + + expect(counter_with_labels.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) + expect(counter.values).to eql({foo: 'value1'} => 1.0, {foo: 'value2'} => 2.0) + end + + context 'when using DirectFileStore' do + before do + Dir.glob('/tmp/prometheus_test/*').each { |file| File.delete(file) } + Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new(dir: '/tmp/prometheus_test') + end + + let(:expected_labels) { [:foo, :bar] } + + # Testing for file corruption: this is weird and complicated, so it needs explaining + # + # Files get corrupted when we have two different instances of `FileMappedDict` + # reading and writing the same file. This corruption is expected; we should never have + # two instances of `FileMappedDict` for the same file. If we do, it's a bug in our client. + # + # To clarify, the bug is that *we ended up with two instances for the same file*, not + # that the instances are now corrupting the file. + # + # This is why we're testing this in `with_labels`. It's the only use case we've found + # were we ended up with two instances (before we fixed that bug). `with_labels` is + # incidental, if we find another way to get "duplicate" instances, we should add this + # same exact test, except for the first line, where we need to instead reproduce + # whatever bug gets us that second instance. + # + # The first thing we need to understand is why having two instances of `FileMappedDict` + # corrupts the files: + # + # `FileMappedDict` keeps track, in an internal variable, of how many bytes in the file + # have been used. When adding a new "entry" (observing a new labelset), it serializes + # it and adds it at "the end" (according to its internal byte counter), and it also updates + # the counter at the beginning of the file. However, it never re-reads that counter + # from the file, because there shouldn't be any reason for it to have changed. + # + # If there are two instances pointing to the same file, initially they will both + # share that internal counter, as they do the first read of the file, but if then + # each of them adds an entry, their internal "length" counters will disagree, and + # they'll start overwriting each other's entries. + # + # Importantly, if all of the entries happen to have the same length, it will be "fine". + # Some of the labelsets will effectively disappear, but there will be no corruption, + # because all the important things will fall in the right offsets by pure chance. This + # would be very rare in production, but in a test, it's what normally happens because + # we set all labels to "foo", "bar", etc. This is the reason for "longervalue" below, + # we need to have different labelset lenghts to reproduce the corruption. + # + # With this background about the internals, we can now get to why the specific sequence of + # steps below ends up in corrupted files. + # + # For this to make sense, i'll need to describe the contents of the file at each step. + # I'll represent it like this: `27|labelset1,value1|labelset2,value2|labelset3,value3|` + # + # These are not the bytes we store in the file, but conceptually it's equivalent, + # with two caveats: + # - The counter at the beginning (27 == 3 * 9) here shows the combined length of labelsets. + # It'd normally also include the length of values, but doing that makes this explanation + # much harder to follow. + # - Each entry also starts with a 4-byte int specifying the length of its labelset, so + # we know how much to read. Again, I'm omitting that for readability. + # + # + # Steps to reproduce: + # - We declare `counter` and `counter_with_labels` as a clone. Neither has read the file. + # - We increment `counter`, which creates the file and adds the entry ("labelset1") + # - File: `9|labelset1,value1|` + # - We increment `counter_with_labels`, which reads the file, and adds the new entry + # to it ("muchlongerlabelset2"). + # - File: `28|labelset1,value1|muchlongerlabelset2, value2|` + # - `counter` and `counter_with_labels` now disagree about the length of this file + # (`counter` doesn't know the file has grown). + # - We now add a new entry to `counter` ("labelset3"), which thinks the file is shorter + # than it actually is. + # - File: `18|labelset1,value1|labelset3,value3|et2, value2|` + # - The initial counter reflects both labelsets for `counter`; then we have those + # labelsetsp; and finally some "garbage" after the "end" (the garbage is the + # last few bytes of the much longer entry added before by `counter_with_labels`) + # - so far, though, we're still good. If you read the file, all entries are "fine", + # because you're only reading up to the "18" length specified at the beginning. + # - for the problem to manifest itself, we need to increment that counter at the + # beginning, so we'll read the garbage. **BUT**, if we add a new labelset to + # `counter`, it'll overwrite the "garbage" with good data, and the file will + # continue to be fine. + # - We add a new entry to `counter_with_labels`. This updates the length counter at + # the beginning of the file. + # - File: `47|labelset1,value1|labelset3,value3|et2, value2|muchlongerlabelset4, value4|` + # + # - Now the file is properly corrupted. When reading it, `FileMappedDict` sees: + # - labelset1,value1 (cool) + # - labelset3,value3 (cool) + # - et2, value2 (boom) + # |-> the beginning of this entry is garbage because we're actually at the middle + # of an entry, not a beginning. + # + # What actually breaks is that each of these entries is expected to have, at their + # beginning, the length in bytes of its labelset, so we know how much to read. + # Now we have garbage in that position, and `FileMappedDict` will either: + # - Try to interpret those four bytes as a long, get an invalid result. + # - Try to read an invalid amount of data (maybe a negative amount). + # - After reading the labelset, try to read the float and go past the end of the file + # - Actually read what it thinks is a float, try to `unpack` it, and fail because + # it's actually garbage. + # - I'm sure there are other fun ways for it to fail. + it "doesn't corrupt the data files" do + counter_with_labels = counter.with_labels({ foo: 'longervalue'}) + + # Initialize / read the files for both views of the metric + counter.increment(labels: { foo: 'value1', bar: 'zzz'}) + counter_with_labels.increment(by: 2, labels: {bar: 'zzz'}) + + # After both MetricStores have their files, add a new entry to both + counter.increment(labels: { foo: 'value1', bar: 'aaa'}) # If there's a bug, we partially overwrite { foo: 'longervalue', bar: 'zzz'} + counter_with_labels.increment(by: 2, labels: {bar: 'aaa'}) # Extend the file so we read past that overwrite + + expect { counter.values }.not_to raise_error # Check it hasn't corrupted our files + expect { counter_with_labels.values }.not_to raise_error # Check it hasn't corrupted our files + + expected_values = { + {foo: 'value1', bar: 'zzz'} => 1.0, + {foo: 'value1', bar: 'aaa'} => 1.0, + {foo: 'longervalue', bar: 'zzz'} => 2.0, + {foo: 'longervalue', bar: 'aaa'} => 2.0, + } + + expect(counter.values).to eql(expected_values) + expect(counter_with_labels.values).to eql(expected_values) + end + end + end +end diff --git a/spec/prometheus/client/summary_spec.rb b/spec/prometheus/client/summary_spec.rb index 3896f4da..ba02ad36 100644 --- a/spec/prometheus/client/summary_spec.rb +++ b/spec/prometheus/client/summary_spec.rb @@ -18,9 +18,7 @@ labels: expected_labels) end - it_behaves_like Prometheus::Client::Metric do - let(:type) { Hash } - end + it_behaves_like Prometheus::Client::Metric describe '#initialization' do it 'raise error for `quantile` label' do @@ -45,12 +43,6 @@ end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError end - it 'raises an InvalidLabelSetError if sending unexpected labels' do - expect do - summary.observe(5, labels: { foo: 'bar' }) - end.to raise_error Prometheus::Client::LabelSetValidator::InvalidLabelSetError - end - context "with a an expected label set" do let(:expected_labels) { [:test] } @@ -62,33 +54,6 @@ end.to_not change { summary.get(labels: { test: 'other' })["count"] } end end - - context "with non-string label values" do - let(:summary) do - described_class.new(:foo, - docstring: 'foo description', - labels: [:foo]) - end - - it "converts labels to strings for consistent storage" do - summary.observe(5, labels: { foo: :label }) - expect(summary.get(labels: { foo: 'label' })["count"]).to eq(1.0) - end - - context "and some labels preset" do - let(:summary) do - described_class.new(:foo, - docstring: 'foo description', - labels: [:foo, :bar], - preset_labels: { foo: :label }) - end - - it "converts labels to strings for consistent storage" do - summary.observe(5, labels: { bar: :label }) - expect(summary.get(labels: { foo: 'label', bar: 'label' })["count"]).to eq(1.0) - end - end - end end describe '#get' do @@ -146,27 +111,4 @@ end end end - - describe '#with_labels' do - let(:expected_labels) { [:foo] } - - it 'pre-sets labels for observations' do - expect { summary.observe(2) } - .to raise_error(Prometheus::Client::LabelSetValidator::InvalidLabelSetError) - expect { summary.with_labels(foo: 'value').observe(2) }.not_to raise_error - end - - it 'registers `with_labels` observations in the original metric store' do - summary.observe(1, labels: { foo: 'value1'}) - summary_with_labels = summary.with_labels({ foo: 'value2'}) - summary_with_labels.observe(2) - - expected_values = { - {foo: 'value1'} => { 'count' => 1.0, 'sum' => 1.0 }, - {foo: 'value2'} => { 'count' => 1.0, 'sum' => 2.0 } - } - expect(summary_with_labels.values).to eql(expected_values) - expect(summary.values).to eql(expected_values) - end - end end From 72679f9ed63b1bbfd11d6c87e61844fa129e7616 Mon Sep 17 00:00:00 2001 From: "Eric D. Helms" Date: Fri, 1 Jul 2022 09:53:25 -0400 Subject: [PATCH 148/189] Include LICENSE in gem Signed-off-by: Eric D. Helms --- prometheus-client.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 3a68f45b..6083a16e 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |s| s.homepage = 'https://github.com/prometheus/client_ruby' s.license = 'Apache-2.0' - s.files = %w(README.md) + Dir.glob('{lib/**/*}') + s.files = %w(README.md LICENSE) + Dir.glob('{lib/**/*}') s.require_paths = ['lib'] s.add_development_dependency 'benchmark-ips' From c00aaf68f43c97557a5347fe8087938bee9add11 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 21 Aug 2022 12:45:09 +0100 Subject: [PATCH 149/189] Add JRuby 9.3 to the build matrix I don't really follow JRuby any more, so this release passed me by, but I figure it's worth adding to our build matrix. Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 22957a7c..725feecf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,3 +30,4 @@ workflows: - cimg/ruby:3.1 - circleci/jruby:9.1 - circleci/jruby:9.2 + - circleci/jruby:9.3 From c78dbb041d2695eb483debce331052e6efeaa158 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Thu, 24 Nov 2022 00:31:34 +0000 Subject: [PATCH 150/189] Use lowercase response headers in Rack example Because HTTP/2 requires that headers are sent in lowercase, Rack has started validating that no header contains an uppercase letter. We were setting uppercase headers as that was a common convention in HTTP/1.1, where they were interpreted in a case-insensitive fashion. This change makes us compatible with Rack 3.0 without breaking compatibiility for older versions (not that it would matter for this example code). Signed-off-by: Chris Sinjakli --- examples/rack/config.ru | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/rack/config.ru b/examples/rack/config.ru index 4b36b777..e545444f 100755 --- a/examples/rack/config.ru +++ b/examples/rack/config.ru @@ -11,9 +11,9 @@ srand app = lambda do |_| case rand when 0..0.8 - [200, { 'Content-Type' => 'text/html' }, ['OK']] + [200, { 'content-type' => 'text/html' }, ['OK']] when 0.8..0.95 - [404, { 'Content-Type' => 'text/html' }, ['Not Found']] + [404, { 'content-type' => 'text/html' }, ['Not Found']] else raise NoMethodError, 'It is a bug!' end From 07344f9ee922b6b0a390e6fafcb551d90303a261 Mon Sep 17 00:00:00 2001 From: Hercules Merscher Date: Tue, 20 Dec 2022 17:55:19 +0100 Subject: [PATCH 151/189] using lowercase for http headers to make it compatible with rack v3 Signed-off-by: Hercules Merscher --- README.md | 2 +- lib/prometheus/middleware/exporter.rb | 4 ++-- spec/prometheus/middleware/collector_spec.rb | 4 ++-- spec/prometheus/middleware/exporter_spec.rb | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5eb0eff1..7dac8426 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ use Rack::Deflater use Prometheus::Middleware::Collector use Prometheus::Middleware::Exporter -run ->(_) { [200, {'Content-Type' => 'text/html'}, ['OK']] } +run ->(_) { [200, {'content-type' => 'text/html'}, ['OK']] } ``` Start the server and have a look at the metrics endpoint: diff --git a/lib/prometheus/middleware/exporter.rb b/lib/prometheus/middleware/exporter.rb index 640a3985..a377525c 100644 --- a/lib/prometheus/middleware/exporter.rb +++ b/lib/prometheus/middleware/exporter.rb @@ -66,7 +66,7 @@ def extract_quality(attributes, default = 1.0) def respond_with(format) [ 200, - { 'Content-Type' => format::CONTENT_TYPE }, + { 'content-type' => format::CONTENT_TYPE }, [format.marshal(@registry)], ] end @@ -76,7 +76,7 @@ def not_acceptable(formats) [ 406, - { 'Content-Type' => 'text/plain' }, + { 'content-type' => 'text/plain' }, ["Supported media types: #{types.join(', ')}"], ] end diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index ce6cc526..dcabf2e3 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -16,7 +16,7 @@ end let(:original_app) do - ->(_) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } + ->(_) { [200, { 'content-type' => 'text/html' }, ['OK']] } end let!(:app) do @@ -128,7 +128,7 @@ lambda do |env| raise dummy_error if env['PATH_INFO'] == '/broken' - [200, { 'Content-Type' => 'text/html' }, ['OK']] + [200, { 'content-type' => 'text/html' }, ['OK']] end end diff --git a/spec/prometheus/middleware/exporter_spec.rb b/spec/prometheus/middleware/exporter_spec.rb index 5299fc1e..f94ddceb 100644 --- a/spec/prometheus/middleware/exporter_spec.rb +++ b/spec/prometheus/middleware/exporter_spec.rb @@ -12,7 +12,7 @@ end let(:app) do - app = ->(_) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } + app = ->(_) { [200, { 'content-type' => 'text/html' }, ['OK']] } described_class.new(app, **options) end @@ -29,13 +29,13 @@ text = Prometheus::Client::Formats::Text shared_examples 'ok' do |headers, fmt| - it "responds with 200 OK and Content-Type #{fmt::CONTENT_TYPE}" do + it "responds with 200 OK and content-type #{fmt::CONTENT_TYPE}" do registry.counter(:foo, docstring: 'foo counter').increment(by: 9) get '/metrics', nil, headers expect(last_response.status).to eql(200) - expect(last_response.header['Content-Type']).to eql(fmt::CONTENT_TYPE) + expect(last_response.header['content-type']).to eql(fmt::CONTENT_TYPE) expect(last_response.body).to eql(fmt.marshal(registry)) end end @@ -47,7 +47,7 @@ get '/metrics', nil, headers expect(last_response.status).to eql(406) - expect(last_response.header['Content-Type']).to eql('text/plain') + expect(last_response.header['content-type']).to eql('text/plain') expect(last_response.body).to eql(message) end end @@ -108,7 +108,7 @@ get 'http://example.org:9999/metrics', nil, {} expect(last_response.status).to eql(200) - expect(last_response.header['Content-Type']).to eql(text::CONTENT_TYPE) + expect(last_response.header['content-type']).to eql(text::CONTENT_TYPE) expect(last_response.body).to eql(text.marshal(registry)) end end From 2bcf995cb8f16207fb9759dd7e71b752b7d4a5ce Mon Sep 17 00:00:00 2001 From: Hercules Merscher Date: Wed, 23 Nov 2022 11:50:05 +0100 Subject: [PATCH 152/189] go install needs version to be specified Signed-off-by: Hercules Merscher --- examples/rack/run | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/rack/run b/examples/rack/run index eaaa6929..bda1c961 100755 --- a/examples/rack/run +++ b/examples/rack/run @@ -20,8 +20,8 @@ if ! installed vegeta; then fatal "Could not find go. Either run the examples manually or install" fi - go get github.com/tsenart/vegeta - go install github.com/tsenart/vegeta + go get github.com/tsenart/vegeta # older versions of Go + go install github.com/tsenart/vegeta@latest # newer versions of Go fi PORT=5000 From 6c10ae1e8e6d84e080ed8919af683a7dc22fa679 Mon Sep 17 00:00:00 2001 From: Hercules Merscher Date: Wed, 23 Nov 2022 11:50:38 +0100 Subject: [PATCH 153/189] better to replace the port 5000 with another one since Apple took it for AirPlay Apple took the port 5000 on the latest MacOS Monterey update, to be used by AirPlay. Many people are not happy with that. It can be turned on/off easily, however, a better experience would take that into consideration and use a different port, considering that MacOS is widely by developers. Signed-off-by: Hercules Merscher --- examples/rack/prometheus.yml | 2 +- examples/rack/run | 2 +- examples/rack/unicorn.conf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/rack/prometheus.yml b/examples/rack/prometheus.yml index ab9c4477..2e57f88d 100644 --- a/examples/rack/prometheus.yml +++ b/examples/rack/prometheus.yml @@ -10,4 +10,4 @@ scrape_configs: - job_name: "rack-example" static_configs: - targets: - - "localhost:5000" + - "localhost:5123" diff --git a/examples/rack/run b/examples/rack/run index bda1c961..effd17a2 100755 --- a/examples/rack/run +++ b/examples/rack/run @@ -24,7 +24,7 @@ if ! installed vegeta; then go install github.com/tsenart/vegeta@latest # newer versions of Go fi -PORT=5000 +PORT=5123 URL=http://127.0.0.1:${PORT}/ log "starting example server" diff --git a/examples/rack/unicorn.conf b/examples/rack/unicorn.conf index 290ca789..f1ffcfdc 100644 --- a/examples/rack/unicorn.conf +++ b/examples/rack/unicorn.conf @@ -1,3 +1,3 @@ -listen 5000 +listen 5123 worker_processes 1 preload_app true From e6649d51792f4cfa2fc3bdddcffcde0af88b9b13 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 24 Dec 2022 22:36:55 +0000 Subject: [PATCH 154/189] Replace uses of port 5000 with 5123 in documentation Signed-off-by: Chris Sinjakli --- README.md | 2 +- examples/rack/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7dac8426..2a7eba7b 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ run ->(_) { [200, {'content-type' => 'text/html'}, ['OK']] } ``` Start the server and have a look at the metrics endpoint: -[http://localhost:5000/metrics](http://localhost:5000/metrics). +[http://localhost:5123/metrics](http://localhost:5123/metrics). For further instructions and other scripts to get started, have a look at the integrated [example application](examples/rack/README.md). diff --git a/examples/rack/README.md b/examples/rack/README.md index e3497335..9c6188e6 100644 --- a/examples/rack/README.md +++ b/examples/rack/README.md @@ -33,8 +33,8 @@ bundle install bundle exec unicorn -c ./unicorn.conf ``` -You can now open the [example app](http://localhost:5000/) and its [metrics -page](http://localhost:5000/metrics) to inspect the output. The running +You can now open the [example app](http://localhost:5123/) and its [metrics +page](http://localhost:5123/metrics) to inspect the output. The running Prometheus server can be used to [play around with the metrics][rate-query]. [rate-query]: http://localhost:9090/graph#%5B%7B%22range_input%22%3A%221h%22%2C%22expr%22%3A%22rate(http_server_requests_total%5B1m%5D)%22%2C%22tab%22%3A0%7D%5D From ed59f9cda1bb2761fbd399e875526fbaf68f3421 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 24 Dec 2022 22:38:22 +0000 Subject: [PATCH 155/189] Make Go version comment more specific Signed-off-by: Chris Sinjakli --- examples/rack/run | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/rack/run b/examples/rack/run index effd17a2..bf89080f 100755 --- a/examples/rack/run +++ b/examples/rack/run @@ -20,8 +20,8 @@ if ! installed vegeta; then fatal "Could not find go. Either run the examples manually or install" fi - go get github.com/tsenart/vegeta # older versions of Go - go install github.com/tsenart/vegeta@latest # newer versions of Go + go get github.com/tsenart/vegeta # versions of Go < 1.18 + go install github.com/tsenart/vegeta@latest # versions of Go >= 1.18 fi PORT=5123 From b1e7bb14eb3dc547969d18a5aa16629b1e82a7e4 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 25 Dec 2022 11:34:55 +0000 Subject: [PATCH 156/189] Fix builds for JRuby <=9.2 in CircleCI A new version of `rubygems-update` was just released, which drops support for Ruby versions below 2.6.0. These older versions of JRuby target Ruby language versions earlier than that, so have been failing their CI runs. This should get us back to green builds, and will be easy to remove once we drop support for the JRuby versions involved. Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 725feecf..e4a11c3d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,18 @@ jobs: steps: - checkout - - run: if [[ "$(ruby -e 'puts RUBY_VERSION')" != 1.* ]]; then gem update --system; fi + # JRuby <= 9.2.x is no longer supported by the `rubygems-update` gem, which now + # requires at least Ruby 2.6 compatibility. + # + # For now, we'll pin to the last supported version. We can drop this check once we + # drop all those versions of JRuby. + - run: | + running_old_ruby=$(ruby -e 'puts Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.6.0")') + if [[ "${running_old_ruby}" == "true" ]]; then + gem update --system 3.3.26 + else + gem update --system + fi - run: bundle install - run: bundle exec rake From 52a791073729c2961946836441a3de7752fd6589 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 25 Dec 2022 11:41:01 +0000 Subject: [PATCH 157/189] Add Ruby 3.2 to CI matrix Ruby 3.2 was released today, and I would truly hate for us to waste another second in adding it to our list of supported versions. Happy Christmas everyone! Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index e4a11c3d..f087ae12 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,6 +39,7 @@ workflows: - cimg/ruby:2.7 - cimg/ruby:3.0 - cimg/ruby:3.1 + - cimg/ruby:3.2 - circleci/jruby:9.1 - circleci/jruby:9.2 - circleci/jruby:9.3 From e53f7ed76f997b6237fa76006c76fe447f3df718 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 14 Feb 2023 16:23:30 +0000 Subject: [PATCH 158/189] Allow use of `instance` and `job` labels These labels were previously reserved under all circumstances, but Prometheus server handles them just fine in the metric data it scrapes. The reason we'd reserved them is that Prometheus automatically generates values for them when it scrapes a target, and we didn't want to cause a collision. It turns out Prometheus handles that collision just fine. By default, Prometheus server will prepend `exported_` to them if they're present in the scraped data (i.e. `exported_instance` and `exported_job`). Users can set `honor_labels` in their Prometheus server config if they prefer the labels from the scraped metric data to take precedence over the labels generated by the server. Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 17 +++++++++- lib/prometheus/client/label_set_validator.rb | 3 +- .../client/label_set_validator_spec.rb | 31 +++++++++++++++---- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1c2a34..cc918c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,22 @@ # Unreleased changes -_None outstanding_ +## Small improvements + +- [#277](https://github.com/prometheus/client_ruby/pull/277) Allow use of `instance` and + `job` labels: + It's now possible to set the `instance` and `job` labels on metrics, where previously + they had been reserved. + + The reason we'd reserved them is that Prometheus automatically generates values + for them when it scrapes a target, and we didn't want to cause a collision. It + turns out Prometheus handles that collision just fine. + + By default, Prometheus server will prepend `exported_` to them if they're present + in the scraped data (i.e. `exported_instance` and `exported_job`). Users can set + `honor_labels` in their Prometheus server config if they prefer the labels from + the scraped metric data to take precedence over the labels generated by the + server. # 4.0.0 / 2022-03-27 diff --git a/lib/prometheus/client/label_set_validator.rb b/lib/prometheus/client/label_set_validator.rb index f3625233..e67dde54 100644 --- a/lib/prometheus/client/label_set_validator.rb +++ b/lib/prometheus/client/label_set_validator.rb @@ -5,8 +5,7 @@ module Client # LabelSetValidator ensures that all used label sets comply with the # Prometheus specification. class LabelSetValidator - # TODO: we might allow setting :instance in the future - BASE_RESERVED_LABELS = [:job, :instance, :pid].freeze + BASE_RESERVED_LABELS = [:pid].freeze LABEL_NAME_REGEX = /\A[a-zA-Z_][a-zA-Z0-9_]*\Z/ class LabelSetError < StandardError; end diff --git a/spec/prometheus/client/label_set_validator_spec.rb b/spec/prometheus/client/label_set_validator_spec.rb index f4d38987..ffd1387c 100644 --- a/spec/prometheus/client/label_set_validator_spec.rb +++ b/spec/prometheus/client/label_set_validator_spec.rb @@ -4,7 +4,10 @@ describe Prometheus::Client::LabelSetValidator do let(:expected_labels) { [] } - let(:validator) { Prometheus::Client::LabelSetValidator.new(expected_labels: expected_labels) } + let(:additional_reserved_labels) { [] } + let(:validator) do + Prometheus::Client::LabelSetValidator.new(expected_labels: expected_labels, reserved_labels: additional_reserved_labels) + end let(:invalid) { Prometheus::Client::LabelSetValidator::InvalidLabelSetError } describe '.new' do @@ -42,11 +45,27 @@ end.to raise_exception(described_class::InvalidLabelError) end - it 'raises ReservedLabelError if a label key is reserved' do - [:job, :instance, :pid].each do |label| - expect do - validator.validate_symbols!(label => 'value') - end.to raise_exception(described_class::ReservedLabelError) + context "with only the base set of reserved labels" do + it "doesn't raise ReservedLabelError for the additional reserved label" do + expect { validator.validate_symbols!(additional: 'value') }. + to_not raise_exception + end + + it 'raises ReservedLabelError if a label key is reserved' do + expect { validator.validate_symbols!(pid: 'value') }. + to raise_exception(described_class::ReservedLabelError) + end + end + + context "with an additional reserved label" do + let(:additional_reserved_labels) { [:additional] } + + it 'raises ReservedLabelError if a label key is reserved' do + [:additional, :pid].each do |label| + expect do + validator.validate_symbols!(label => 'value') + end.to raise_exception(described_class::ReservedLabelError) + end end end end From 52e04162d6f29f97970af47f11f957987cdbed37 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 15 Feb 2023 17:18:49 +0000 Subject: [PATCH 159/189] Prepare release 4.1.0 This release includes the recent relaxation of reserved labels. On a personal note: damn it feels good to cut a point release instead of a major release due to breaking changes for once. Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 6 ++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc918c7d..c8e3c74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # Unreleased changes +_None outstanding_ + +# 4.1.0 / 2023-02-15 + +_**Codename:** They finally made a point release_ + ## Small improvements - [#277](https://github.com/prometheus/client_ruby/pull/277) Allow use of `instance` and diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 4f22986c..9d55b4c4 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '4.0.0' + VERSION = '4.1.0' end end From 3ee337eaebec2212b1e0638fdb37bbca18fdb6fe Mon Sep 17 00:00:00 2001 From: Peter Leitzen Date: Mon, 27 Feb 2023 15:03:55 +0100 Subject: [PATCH 160/189] Optimize incrementing values in DirectFileStore adapter * Introduce FileMappedDict#increment_value * Check for internal_storage only once (Process.pid is slow on Linux) * Lookup file position only once Signed-off-by: Peter Leitzen --- .../client/data_stores/direct_file_store.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/client/data_stores/direct_file_store.rb b/lib/prometheus/client/data_stores/direct_file_store.rb index bf9d6d22..1c09dc4d 100644 --- a/lib/prometheus/client/data_stores/direct_file_store.rb +++ b/lib/prometheus/client/data_stores/direct_file_store.rb @@ -114,8 +114,7 @@ def increment(labels:, by: 1) key = store_key(labels) in_process_sync do - value = internal_store.read_value(key) - internal_store.write_value(key, value + by.to_f) + internal_store.increment_value(key, by.to_f) end end @@ -286,6 +285,21 @@ def write_value(key, value) @f.flush end + def increment_value(key, by) + if !@positions.has_key?(key) + init_value(key) + end + + pos = @positions[key] + @f.seek(pos) + value = @f.read(8).unpack('d')[0] + + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @f.seek(-8, :CUR) + @f.write([value + by, now].pack('dd')) + @f.flush + end + def close @f.close end From f285c03bee2283161d111d1130fe9d3f3b1668a4 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 18 Mar 2023 15:27:46 +0000 Subject: [PATCH 161/189] Add missing entries to CHANGELOG.md I've had a look through all the commits that landed on `main` since 4.0.0. I haven't included absolutely everything here - just commits which felt user-facing enough to include in a changelog. Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e3c74a..f38a9d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ _**Codename:** They finally made a point release_ ## Small improvements +- [#264](https://github.com/prometheus/client_ruby/pull/264) Add JRuby 9.3 to build matrix: + JRuby 9.3 was released, and added as an officially supported version +- [#273](https://github.com/prometheus/client_ruby/pull/273) Add Ruby 3.2 to build matrix: + Ruby 3.2 was released, and added as an officially supported version +- [#280](https://github.com/prometheus/client_ruby/pull/280) Optimize incrementing values + in DirectFileStore adapter: + There were some expensive method calls being made multiple times when they didn't need + to be for simple increments. This PR introduces a specialised implementation for that + case. - [#277](https://github.com/prometheus/client_ruby/pull/277) Allow use of `instance` and `job` labels: It's now possible to set the `instance` and `job` labels on metrics, where previously @@ -25,6 +34,19 @@ _**Codename:** They finally made a point release_ the scraped metric data to take precedence over the labels generated by the server. +## Bug fixes + +- [#268](https://github.com/prometheus/client_ruby/pull/268) Use lowercase response headers + in Rack example: + Rack 3.0.0 started requiring this for compatibility with HTTP/2 +- [#271](https://github.com/prometheus/client_ruby/pull/271) Use lowercase for HTTP headers + in middleware: + Fixes the same issue from above in our middleware +- [#270](https://github.com/prometheus/client_ruby/pull/270) Small compatibility fixes in + Rack example: + Apple have taken port 5000 for AirPlay, so we had to move away from it. Go has changed + how you install binaries, so we updated those instructions too. + # 4.0.0 / 2022-03-27 _**Codename:** The "barely a release" release_ From 50603905d9f7838e9ead5497d20f627a94c48796 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Mon, 20 Mar 2023 17:37:41 +0000 Subject: [PATCH 162/189] Fix release date in CHANGELOG.md Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f38a9d21..3e190ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ _None outstanding_ -# 4.1.0 / 2023-02-15 +# 4.1.0 / 2023-03-20 _**Codename:** They finally made a point release_ From f543a3c4813fb7bb7e133240094257da9b58e933 Mon Sep 17 00:00:00 2001 From: duffn <3457341+duffn@users.noreply.github.com> Date: Sat, 1 Apr 2023 20:17:45 -0600 Subject: [PATCH 163/189] Use Rack::Response#headers in place of header Signed-off-by: duffn <3457341+duffn@users.noreply.github.com> --- spec/prometheus/middleware/exporter_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/prometheus/middleware/exporter_spec.rb b/spec/prometheus/middleware/exporter_spec.rb index f94ddceb..e8232fc5 100644 --- a/spec/prometheus/middleware/exporter_spec.rb +++ b/spec/prometheus/middleware/exporter_spec.rb @@ -35,7 +35,7 @@ get '/metrics', nil, headers expect(last_response.status).to eql(200) - expect(last_response.header['content-type']).to eql(fmt::CONTENT_TYPE) + expect(last_response.headers['content-type']).to eql(fmt::CONTENT_TYPE) expect(last_response.body).to eql(fmt.marshal(registry)) end end @@ -47,7 +47,7 @@ get '/metrics', nil, headers expect(last_response.status).to eql(406) - expect(last_response.header['content-type']).to eql('text/plain') + expect(last_response.headers['content-type']).to eql('text/plain') expect(last_response.body).to eql(message) end end @@ -108,7 +108,7 @@ get 'http://example.org:9999/metrics', nil, {} expect(last_response.status).to eql(200) - expect(last_response.header['content-type']).to eql(text::CONTENT_TYPE) + expect(last_response.headers['content-type']).to eql(text::CONTENT_TYPE) expect(last_response.body).to eql(text.marshal(registry)) end end From 1d96723141a98f36ed8a2648323d8e82f0051f0c Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 9 Jun 2023 16:31:43 +0100 Subject: [PATCH 164/189] Add `Gauge#set_to_current_time` I was porting some code from Python to Ruby today, and when I went to call this method, I realised we don't have it! My use-case is to track the last time some code successfully ran. I'm open to discussing the naming, but I've defaulted to the name used in `client_python` because it seems good to me. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/gauge.rb | 4 ++++ prometheus-client.gemspec | 1 + spec/prometheus/client/gauge_spec.rb | 24 ++++++++++++++++++++++++ spec/spec_helper.rb | 3 +++ 4 files changed, 32 insertions(+) diff --git a/lib/prometheus/client/gauge.rb b/lib/prometheus/client/gauge.rb index e0f76521..fbcfdd4a 100644 --- a/lib/prometheus/client/gauge.rb +++ b/lib/prometheus/client/gauge.rb @@ -20,6 +20,10 @@ def set(value, labels: {}) @store.set(labels: label_set_for(labels), val: value) end + def set_to_current_time(labels: {}) + @store.set(labels: label_set_for(labels), val: Time.now.to_f) + end + # Increments Gauge value by 1 or adds the given value to the Gauge. # (The value can be negative, resulting in a decrease of the Gauge.) def increment(by: 1, labels: {}) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 6083a16e..029ff4f1 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -17,4 +17,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'benchmark-ips' s.add_development_dependency 'concurrent-ruby' + s.add_development_dependency 'timecop' end diff --git a/spec/prometheus/client/gauge_spec.rb b/spec/prometheus/client/gauge_spec.rb index 376becd5..417daad2 100644 --- a/spec/prometheus/client/gauge_spec.rb +++ b/spec/prometheus/client/gauge_spec.rb @@ -48,6 +48,30 @@ end end + describe '#set_to_current_time' do + it 'it sets the gauge to the current Unix epoch time' do + Timecop.freeze(Time.at(12345.1)) do + expect do + gauge.set_to_current_time + end.to change { gauge.get }.from(0).to(12345.1) + end + end + + context "with a an expected label set" do + let(:expected_labels) { [:test] } + + it 'sets a metric value for a given label set' do + Timecop.freeze(Time.at(12345.1)) do + expect do + expect do + gauge.set_to_current_time(labels: { test: 'value' }) + end.to change { gauge.get(labels: { test: 'value' }) }.from(0).to(12345.1) + end.to_not change { gauge.get(labels: { test: 'other' }) } + end + end + end + end + describe '#increment' do before do gauge.set(0, labels: RSpec.current_example.metadata[:labels] || {}) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 31000d69..b6f707b1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ # encoding: UTF-8 require 'simplecov' +require 'timecop' RSpec.configure do |c| c.warnings = true @@ -9,3 +10,5 @@ SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter SimpleCov.start + +Timecop.safe_mode = true From 6ce3fdd0a9633f6e1aa6f76583c1578ddd51b912 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 25 Jul 2023 13:55:44 +0100 Subject: [PATCH 165/189] Prepare release 4.2.0 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 12 ++++++++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e190ed0..9fd8e3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ _None outstanding_ +# 4.2.0 / 2023-07-25 + +_**Codename:** Funny number_ + +## Small improvements + +- [#287](https://github.com/prometheus/client_ruby/pull/287) Add `Gauge#set_to_current_time`: + Does what you'd expect - sets a gauge to the current unix epoch timestamp (including + fractional seconds). + + Other client libraries have this and it's about time we did! + # 4.1.0 / 2023-03-20 _**Codename:** They finally made a point release_ diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 9d55b4c4..621282e5 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '4.1.0' + VERSION = '4.2.0' end end From 6f7b6cf76593274bedbf3444b49f903d67538099 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Thu, 3 Aug 2023 10:55:16 +0100 Subject: [PATCH 166/189] Handle `/` in job name in `Prometheus::Client::Push` This a subtlety I missed when overhauling the Pushgateway client. While the job name can't be empty like other grouping key labels can, it can contain `/`, which means we need to base64 encode the value in that case. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 11 +++++++++-- spec/prometheus/client/push_spec.rb | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 2d3588d9..1490c82d 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -23,7 +23,7 @@ class HttpClientError < HttpError; end class HttpServerError < HttpError; end DEFAULT_GATEWAY = 'http://localhost:9091'.freeze - PATH = '/metrics/job/%s'.freeze + PATH = '/metrics'.freeze SUPPORTED_SCHEMES = %w(http https).freeze attr_reader :job, :gateway, :path @@ -87,7 +87,14 @@ def parse(url) end def build_path(job, grouping_key) - path = format(PATH, ERB::Util::url_encode(job)) + # Job can't be empty, but it can contain `/`, so we need to base64 + # encode it in that case + if job.include?('/') + encoded_job = Base64.urlsafe_encode64(job) + path = "#{PATH}/job@base64/#{encoded_job}" + else + path = "#{PATH}/job/#{ERB::Util::url_encode(job)}" + end grouping_key.each do |label, value| if value.include?('/') diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index e4fede99..4af55ad5 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -93,6 +93,14 @@ expect(push.path).to eql('/metrics/job/test-job/foo/bar/baz/qux') end + it 'encodes the job name in url-safe base64 if it contains `/`' do + push = Prometheus::Client::Push.new( + job: 'foo/test-job', + ) + + expect(push.path).to eql('/metrics/job@base64/Zm9vL3Rlc3Qtam9i') + end + it 'encodes grouping key label values containing `/` in url-safe base64' do push = Prometheus::Client::Push.new( job: 'test-job', From a9e8a30f4c7434aab22327339d0fa31d11ae2082 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 4 Aug 2023 18:03:15 +0100 Subject: [PATCH 167/189] Prepare release 4.2.1 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 17 +++++++++++++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fd8e3ee..e9ec9845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ _None outstanding_ +# 4.2.1 / 2023-08-04 + +_**Codename:** If a bug falls in the forest_ + +## Bug fixes + +- [#291](https://github.com/prometheus/client_ruby/pull/291) Handle `/` in job name in + `Prometheus::Client::Push`: + Previously, if you included a `/` in your job name when using the Pushgateway client, + you'd get a `400` error back as we didn't encode it properly. We now base64 encode it + per the Pushgateway spec. + + It's possible that nobody has hit this bug (`/` is fairly unlikely to appear in a job + name) or that the error message (a `400` from Pushgateway with a complaint about an + odd number of path components) didn't make it look like a bug in the Ruby client. + Either way, this hopefully brings us fully in line with the spec! + # 4.2.0 / 2023-07-25 _**Codename:** Funny number_ diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 621282e5..bea05396 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '4.2.0' + VERSION = '4.2.1' end end From ca53b572f61dc40c6e5ebeae920f7a08d1970c0b Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 7 Oct 2023 15:49:04 +0100 Subject: [PATCH 168/189] Stringify non-string job names in push client We do this to all other label values in the client and we should make this one consistent. Right now it's particularly surprising that passing a symbol as the job name results in an error like: ``` undefined method `include?' for :foo:Symbol (NoMethodError) ``` Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 2 ++ spec/prometheus/client/push_spec.rb | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 1490c82d..44147ae1 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -87,6 +87,8 @@ def parse(url) end def build_path(job, grouping_key) + job = job.to_s + # Job can't be empty, but it can contain `/`, so we need to base64 # encode it in that case if job.include?('/') diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 4af55ad5..43890366 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -93,6 +93,14 @@ expect(push.path).to eql('/metrics/job/test-job/foo/bar/baz/qux') end + it 'converts non-string job names to strings' do + push = Prometheus::Client::Push.new( + job: :foo, + ) + + expect(push.path).to eql('/metrics/job/foo') + end + it 'encodes the job name in url-safe base64 if it contains `/`' do push = Prometheus::Client::Push.new( job: 'foo/test-job', From 2f5422c2310e0cc82ba6b837a494a621d3395be2 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 7 Oct 2023 15:55:59 +0100 Subject: [PATCH 169/189] Add pending change to README.md Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ec9845..2e015f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ # Unreleased changes -_None outstanding_ +## Bug fixes + +- [#296](https://github.com/prometheus/client_ruby/pull/296) Stringify non-string job + names in push client: + Previously, an error would be raised if you passed a symbol as the job name, which + is inconsistent with how we handle label values in the rest of the client. This + change converts the job name to a string before trying to use it. # 4.2.1 / 2023-08-04 From 4cd41fcb6ed60276db721bd025bf8eef4b6710b3 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 11 Oct 2023 15:02:33 +0100 Subject: [PATCH 170/189] Stringify grouping key values in push client About a day after fixing this for the job name I realised that I'd made the same mistake a whole ten lines of code further down the method. Signed-off-by: Chris Sinjakli --- lib/prometheus/client/push.rb | 2 ++ spec/prometheus/client/push_spec.rb | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/lib/prometheus/client/push.rb b/lib/prometheus/client/push.rb index 44147ae1..ca556ed1 100644 --- a/lib/prometheus/client/push.rb +++ b/lib/prometheus/client/push.rb @@ -99,6 +99,8 @@ def build_path(job, grouping_key) end grouping_key.each do |label, value| + value = value.to_s + if value.include?('/') encoded_value = Base64.urlsafe_encode64(value) path += "/#{label}@base64/#{encoded_value}" diff --git a/spec/prometheus/client/push_spec.rb b/spec/prometheus/client/push_spec.rb index 43890366..2f0b70a5 100644 --- a/spec/prometheus/client/push_spec.rb +++ b/spec/prometheus/client/push_spec.rb @@ -101,6 +101,15 @@ expect(push.path).to eql('/metrics/job/foo') end + it 'converts non-string grouping labels to strings' do + push = Prometheus::Client::Push.new( + job: 'test-job', + grouping_key: { foo: :bar, baz: :qux}, + ) + + expect(push.path).to eql('/metrics/job/test-job/foo/bar/baz/qux') + end + it 'encodes the job name in url-safe base64 if it contains `/`' do push = Prometheus::Client::Push.new( job: 'foo/test-job', From 30adf637890cface2d453f8c8039af5c48309655 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 11 Oct 2023 15:09:45 +0100 Subject: [PATCH 171/189] Update CHANGELOG.md Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e015f16..30c0906e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ Previously, an error would be raised if you passed a symbol as the job name, which is inconsistent with how we handle label values in the rest of the client. This change converts the job name to a string before trying to use it. +- [#297](https://github.com/prometheus/client_ruby/pull/297) Stringify grouping key + values in push client: + Same thing as #296, but for grouping key values. # 4.2.1 / 2023-08-04 From eedd8291905ca847bfcd4c953e9c7e2cfb29d34e Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 27 Oct 2023 09:36:36 +0100 Subject: [PATCH 172/189] Prepare release 4.2.2 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 6 ++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c0906e..225166e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # Unreleased changes +_None outstanding_ + +# 4.2.2 / 2023-10-31 + +_**Codename:** 🎃🦇 Spooky type conversion 🦇🎃_ + ## Bug fixes - [#296](https://github.com/prometheus/client_ruby/pull/296) Stringify non-string job diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index bea05396..6a6d4140 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '4.2.1' + VERSION = '4.2.2' end end From 667626b764c5c38c10761507970dc0148d40a717 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Tue, 26 Dec 2023 22:44:13 +0000 Subject: [PATCH 173/189] Fix JRuby 9.3 build The Ruby version targeted by JRuby 9.3 is now too old for Rubygems to run. Let's pin to the last version that works for now. When we next have a major version bump for one of the breaking changes we're planning to make (likely around the store interface), we'll clear out support for any Ruby versions that have fallen out of security support. Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f087ae12..b1b803b5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,15 +12,23 @@ jobs: steps: - checkout - # JRuby <= 9.2.x is no longer supported by the `rubygems-update` gem, which now - # requires at least Ruby 2.6 compatibility. + # We need to limit the version of Rubygems that we update to for some of the older + # JRuby versions. Specifically: + # + # - JRuby 9.2.x targets Ruby 2.5, and Rubygems 3.3.26 was the last version to + # support that + # - JRuby 9.3.x targets Ruby 2.6, and version 3.4.22 was the last version to + # support that # # For now, we'll pin to the last supported version. We can drop this check once we # drop all those versions of JRuby. - run: | - running_old_ruby=$(ruby -e 'puts Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.6.0")') - if [[ "${running_old_ruby}" == "true" ]]; then + running_very_old_ruby=$(ruby -e 'puts Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.6.0")') + running_moderately_old_ruby=$(ruby -e 'puts Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.0.0")') + if [[ "${running_very_old_ruby}" == "true" ]]; then gem update --system 3.3.26 + elif [[ "${running_moderately_old_ruby}" == "true" ]]; then + gem update --system 3.4.22 else gem update --system fi From 4e91ad11c813dd7183a2ad3be08bc73d8dcef128 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Mon, 25 Dec 2023 17:09:34 +0000 Subject: [PATCH 174/189] Add Ruby 3.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merry Christmas! 🎄☃️ Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index b1b803b5..c43cf01c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -48,6 +48,7 @@ workflows: - cimg/ruby:3.0 - cimg/ruby:3.1 - cimg/ruby:3.2 + - cimg/ruby:3.3 - circleci/jruby:9.1 - circleci/jruby:9.2 - circleci/jruby:9.3 From 566fa0d363db7b1f716ddb3a6e77b8d497b3ee2d Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 27 Dec 2023 16:52:59 +0000 Subject: [PATCH 175/189] Add JRuby 9.4 to build matrix We've had to switch to the main JRuby Docker image as CircleCI have stopped publishing their own CI-optimised ones with extra tools installed. It turns out this isn't a problem for us as we're not dependent on much in the image. See: https://discuss.circleci.com/t/legacy-convenience-image-deprecation/41034 This commit also switches versions 9.2 and 9.3 to the main JRuby Docker image, but not 9.1 as that breaks for some inscrutable reason. We'll drop 9.1 and all other unsupported Rubies in our next major release. Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c43cf01c..c817cda5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,6 +49,14 @@ workflows: - cimg/ruby:3.1 - cimg/ruby:3.2 - cimg/ruby:3.3 + # We've switched to JRuby's own Docker image as CircleCI decided to + # stop supporting it when they deprecated their legacy images. For + # some reason, the official JRuby image for 9.1 fails with an + # inscrutable error message that isn't worth debugging. + # + # We'll drop this version on our next major release anyway, as it's + # out of security support. - circleci/jruby:9.1 - - circleci/jruby:9.2 - - circleci/jruby:9.3 + - jruby:9.2 + - jruby:9.3 + - jruby:9.4 From ded6d7bfb28fe390f0c0510f06fe8606f4c83d2c Mon Sep 17 00:00:00 2001 From: Chris Banks Date: Wed, 15 May 2024 14:43:00 +0100 Subject: [PATCH 176/189] Declare base64 gem dependency, ready for Ruby 3.4. The base64 gem is no longer a default gem in Ruby 3.4. Add base64 to the gemspec, for forward compatibility with Ruby 3.4 and to resolve the `base64 was loaded from the standard library, but will no longer be part of the default gems since Ruby 3.4.0` warning that Ruby 3.3 emits on loading client_ruby. https://docs.ruby-lang.org/en/master/NEWS_md.html#label-Stdlib+updates Signed-off-by: Chris Banks --- prometheus-client.gemspec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 029ff4f1..a421f0ac 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -15,6 +15,8 @@ Gem::Specification.new do |s| s.files = %w(README.md LICENSE) + Dir.glob('{lib/**/*}') s.require_paths = ['lib'] + s.add_dependency 'base64' + s.add_development_dependency 'benchmark-ips' s.add_development_dependency 'concurrent-ruby' s.add_development_dependency 'timecop' From 696cc3d9e03b755dddac6271c8188063e10e8244 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 28 Jun 2024 11:05:57 +0100 Subject: [PATCH 177/189] Yeet `term-ansicolor` and `tins` from test dependencies I was trying to fix CI by pinning `term-ansicolor` to a specific version, but Daniel pointed out that we don't even seem to use it anywhere, so let's throw it (and its `tins` dependency) out. Signed-off-by: Chris Sinjakli --- Gemfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Gemfile b/Gemfile index ffd82038..8b6fee68 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,4 @@ group :test do gem 'rack-test' gem 'rake' gem 'rspec' - gem 'term-ansicolor' - gem 'tins' end From 43f9b5ff84ecd4f2bd47138c943c6821bce7487c Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Fri, 28 Jun 2024 14:35:23 +0100 Subject: [PATCH 178/189] Prepare release 4.2.3 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 11 +++++++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 225166e5..01a19312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ _None outstanding_ +# 4.2.3 / 2024-06-28 + +_**Codename:** Now with 25% fewer test dependencies!_ + +## Small improvements + +- [#308](https://github.com/prometheus/client_ruby/pull/308) Declare base64 gem + dependency, ready for Ruby 3.4: + Ruby 3.4 and above will require an explicit dependency on `base64` as it will no + longer be included with CRuby. This gets us ready for that change. + # 4.2.2 / 2023-10-31 _**Codename:** 🎃🦇 Spooky type conversion 🦇🎃_ diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 6a6d4140..f970322c 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '4.2.2' + VERSION = '4.2.3' end end From 100f46ce944ec3ebd3be4b08b8a34663deaa49d8 Mon Sep 17 00:00:00 2001 From: Konstantin Ilchenko Date: Sun, 3 Nov 2024 12:48:37 +0100 Subject: [PATCH 179/189] Use binary search for histogram buckets Signed-off-by: Konstantin Ilchenko --- lib/prometheus/client/histogram.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prometheus/client/histogram.rb b/lib/prometheus/client/histogram.rb index 6963f673..8b02f007 100644 --- a/lib/prometheus/client/histogram.rb +++ b/lib/prometheus/client/histogram.rb @@ -67,7 +67,7 @@ def type # https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations # for details. def observe(value, labels: {}) - bucket = buckets.find {|upper_limit| upper_limit >= value } + bucket = buckets.bsearch { |upper_limit| upper_limit >= value } bucket = "+Inf" if bucket.nil? base_label_set = label_set_for(labels) From 3c74b97eaf1dae0faefb1b0727a89339665b8934 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Wed, 1 Jan 2025 12:23:17 +0000 Subject: [PATCH 180/189] Add Ruby 3.4 to build matrix Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c817cda5..9ac6b8c6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,6 +49,7 @@ workflows: - cimg/ruby:3.1 - cimg/ruby:3.2 - cimg/ruby:3.3 + - cimg/ruby:3.4 # We've switched to JRuby's own Docker image as CircleCI decided to # stop supporting it when they deprecated their legacy images. For # some reason, the official JRuby image for 9.1 fails with an From 28c0145528d37a326355ce94524919d7d62273fd Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 1 Feb 2025 18:50:00 +0100 Subject: [PATCH 181/189] Prepare release 4.2.4 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 12 ++++++++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a19312..fa74184a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ _None outstanding_ +# 4.2.4 / 2025-02-02 + +_**Codename:** FOSDEM 25th Anniversary Edition_ + +## Small improvements + +- [#316](https://github.com/prometheus/client_ruby/pull/316) Use binary search for + histogram buckets: + This change speeds up observations in histogram metrics by using a binary search + rather than a sequential search through the bucket array. This is possible because + we enforce that histogram buckets are sorted at initialization. + # 4.2.3 / 2024-06-28 _**Codename:** Now with 25% fewer test dependencies!_ diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index f970322c..05f3a4a2 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '4.2.3' + VERSION = '4.2.4' end end From 845440dae039a6f9533541c0b3ee68d7c6d9e20f Mon Sep 17 00:00:00 2001 From: Juan Manuel Cuello Date: Wed, 18 Jun 2025 21:09:09 -0300 Subject: [PATCH 182/189] Replace benchmark gem dependency in production The benchmark gem is no longer a default gem in Ruby 3.5, so we've removed its use by replacing the Benchmark.realtime method with our own implementation. We still rely on the gem in our tests, though. Signed-off-by: Juan Manuel Cuello --- lib/prometheus/middleware/collector.rb | 9 +++++++-- spec/prometheus/middleware/collector_spec.rb | 10 +++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb index c785d61f..66635a4e 100644 --- a/lib/prometheus/middleware/collector.rb +++ b/lib/prometheus/middleware/collector.rb @@ -1,6 +1,5 @@ # encoding: UTF-8 -require 'benchmark' require 'prometheus/client' module Prometheus @@ -34,6 +33,12 @@ def call(env) # :nodoc: protected + def realtime + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + end + def init_request_metrics @requests = @registry.counter( :"#{@metrics_prefix}_requests_total", @@ -58,7 +63,7 @@ def init_exception_metrics def trace(env) response = nil - duration = Benchmark.realtime { response = yield } + duration = realtime { response = yield } record(env, response.first.to_s, duration) return response rescue => exception diff --git a/spec/prometheus/middleware/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb index dcabf2e3..9d65e158 100644 --- a/spec/prometheus/middleware/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -42,7 +42,7 @@ end it 'traces request information' do - expect(Benchmark).to receive(:realtime).and_yield.and_return(0.2) + expect(app).to receive(:realtime).and_yield.and_return(0.2) get '/foo' @@ -68,7 +68,7 @@ end it 'normalizes paths containing numeric IDs by default' do - expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3) + expect(app).to receive(:realtime).and_yield.and_return(0.3) get '/foo/42/bars' @@ -82,7 +82,7 @@ end it 'normalizes paths containing UUIDs by default' do - expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3) + expect(app).to receive(:realtime).and_yield.and_return(0.3) get '/foo/5180349d-a491-4d73-af30-4194a46bdff3/bars' @@ -96,7 +96,7 @@ end it 'handles consecutive path segments containing IDs' do - expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3) + expect(app).to receive(:realtime).and_yield.and_return(0.3) get '/foo/42/24' @@ -110,7 +110,7 @@ end it 'handles consecutive path segments containing UUIDs' do - expect(Benchmark).to receive(:realtime).and_yield.and_return(0.3) + expect(app).to receive(:realtime).and_yield.and_return(0.3) get '/foo/5180349d-a491-4d73-af30-4194a46bdff3/5180349d-a491-4d73-af30-4194a46bdff2' From ddfae020a30cd1c135d5e6b2ad4a3b84d8ae49d2 Mon Sep 17 00:00:00 2001 From: Juan Manuel Cuello Date: Wed, 18 Jun 2025 21:16:10 -0300 Subject: [PATCH 183/189] Add benchmark gem as dev dependency It's no longer a default gem in Ruby 3.5, so we explicitly add it to the gemspec. Signed-off-by: Juan Manuel Cuello --- prometheus-client.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index a421f0ac..549674f8 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -17,6 +17,7 @@ Gem::Specification.new do |s| s.add_dependency 'base64' + s.add_development_dependency 'benchmark' s.add_development_dependency 'benchmark-ips' s.add_development_dependency 'concurrent-ruby' s.add_development_dependency 'timecop' From 55a3e491a007558fcf9f7cbf18bdf0d8dcca1604 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sat, 5 Jul 2025 22:36:53 +0100 Subject: [PATCH 184/189] Prepare release 4.2.5 Signed-off-by: Chris Sinjakli --- CHANGELOG.md | 13 +++++++++++++ lib/prometheus/client/version.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa74184a..f2d14fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ _None outstanding_ +# 4.2.5 / 2025-07-05 + +_**Codename:** Surprise dependency_ + +## Small improvements + +- [#324](https://github.com/prometheus/client_ruby/pull/324) Do not use benchmark gem + in production: + Ruby 3.5 is moving `Benchmark` out of default gems, so we need to explicitly install + it from Rubygems in dev. It turned out we were also using it in prod code, so this PR + replaced that usage with a small method that makes calls to + `Process.clock_gettime(Process::CLOCK_MONOTONIC)`. + # 4.2.4 / 2025-02-02 _**Codename:** FOSDEM 25th Anniversary Edition_ diff --git a/lib/prometheus/client/version.rb b/lib/prometheus/client/version.rb index 05f3a4a2..150eacca 100644 --- a/lib/prometheus/client/version.rb +++ b/lib/prometheus/client/version.rb @@ -2,6 +2,6 @@ module Prometheus module Client - VERSION = '4.2.4' + VERSION = '4.2.5' end end From 169bd93761840e51511e728ffb742139a006920d Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 1 Feb 2026 22:14:45 +0100 Subject: [PATCH 185/189] Rip out support for end-of-life Ruby versions We've been carrying support for a bunch of EOL Ruby versions on the basis that it was easy. Adding support for Ruby 4.0 means adding `cgi` as an explicit dependency as it is no longer a default gem. The latest version of it is incompatible with JRuby 9.1, which targets Ruby 2.x compatibility (while JRuby targets more specific Ruby versions now, before JRuby 9.2, it followed whatever the latest version of 2.x was). We might be able to find a way to conditionally avoid installing the gem on that version of JRuby and let it use the included version, but at this point I don't want to pile on more hacks to support EOL versions of Ruby that have been out of support for a long time. There are better places to spend effort on this library. Let's take the opportunity to officially drop all currently EOL Ruby versions. I'll bump the major version in the next release. Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 35 +---------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9ac6b8c6..7da3b7b9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,26 +12,7 @@ jobs: steps: - checkout - # We need to limit the version of Rubygems that we update to for some of the older - # JRuby versions. Specifically: - # - # - JRuby 9.2.x targets Ruby 2.5, and Rubygems 3.3.26 was the last version to - # support that - # - JRuby 9.3.x targets Ruby 2.6, and version 3.4.22 was the last version to - # support that - # - # For now, we'll pin to the last supported version. We can drop this check once we - # drop all those versions of JRuby. - - run: | - running_very_old_ruby=$(ruby -e 'puts Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.6.0")') - running_moderately_old_ruby=$(ruby -e 'puts Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.0.0")') - if [[ "${running_very_old_ruby}" == "true" ]]; then - gem update --system 3.3.26 - elif [[ "${running_moderately_old_ruby}" == "true" ]]; then - gem update --system 3.4.22 - else - gem update --system - fi + - run: gem update --system - run: bundle install - run: bundle exec rake @@ -43,21 +24,7 @@ workflows: matrix: parameters: ruby_image: - - cimg/ruby:2.6 - - cimg/ruby:2.7 - - cimg/ruby:3.0 - - cimg/ruby:3.1 - cimg/ruby:3.2 - cimg/ruby:3.3 - cimg/ruby:3.4 - # We've switched to JRuby's own Docker image as CircleCI decided to - # stop supporting it when they deprecated their legacy images. For - # some reason, the official JRuby image for 9.1 fails with an - # inscrutable error message that isn't worth debugging. - # - # We'll drop this version on our next major release anyway, as it's - # out of security support. - - circleci/jruby:9.1 - - jruby:9.2 - - jruby:9.3 - jruby:9.4 From 56cdc18d9b47c705b675e0180bc8d771033d8a93 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 1 Feb 2026 14:56:21 +0100 Subject: [PATCH 186/189] Run tests against Ruby 4.0 and JRuby 10.0 These have been released since I last did any work on this repo. Let's get our tests running on them! Signed-off-by: Chris Sinjakli --- .circleci/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7da3b7b9..e5ea0304 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,4 +27,6 @@ workflows: - cimg/ruby:3.2 - cimg/ruby:3.3 - cimg/ruby:3.4 + - cimg/ruby:4.0 - jruby:9.4 + - jruby:10.0 From 644eb918ff1714aa91d64ff6825d9a9a4a1e6b92 Mon Sep 17 00:00:00 2001 From: Chris Sinjakli Date: Sun, 1 Feb 2026 16:22:19 +0100 Subject: [PATCH 187/189] Add `cgi` as an explicit dependency It's no longer a default gem in 4.0, so we need to add it explicitly. Signed-off-by: Chris Sinjakli --- prometheus-client.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/prometheus-client.gemspec b/prometheus-client.gemspec index 549674f8..29aed5cf 100644 --- a/prometheus-client.gemspec +++ b/prometheus-client.gemspec @@ -16,6 +16,7 @@ Gem::Specification.new do |s| s.require_paths = ['lib'] s.add_dependency 'base64' + s.add_dependency 'cgi' s.add_development_dependency 'benchmark' s.add_development_dependency 'benchmark-ips' From b299eba7f2a462a9c0767db211373d7eab2249c3 Mon Sep 17 00:00:00 2001 From: prombot Date: Sun, 12 Apr 2026 17:57:00 +0000 Subject: [PATCH 188/189] Update common Prometheus files Signed-off-by: prombot --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index fed02d85..5e6f976d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,4 +3,4 @@ The Prometheus security policy, including how to report vulnerabilities, can be found here: - +[https://prometheus.io/docs/operating/security/](https://prometheus.io/docs/operating/security/) From a27c48e7fe3db588d7d74d588ad5b282bd1716cf Mon Sep 17 00:00:00 2001 From: Arthur Silva Sens Date: Tue, 26 May 2026 19:41:50 -0300 Subject: [PATCH 189/189] Replace CircleCI with GitHub Actions Signed-off-by: Arthur Silva Sens --- .circleci/config.yml | 32 --------------------------- .github/workflows/ci.yml | 47 ++++++++++++++++++++++++++++++++++++++++ COMPATIBILITY.md | 2 +- README.md | 4 ++-- 4 files changed, 50 insertions(+), 35 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/ci.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index e5ea0304..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -version: 2.1 - -jobs: - test: - parameters: - ruby_image: - type: string - - docker: - - image: << parameters.ruby_image >> - - steps: - - checkout - - run: gem update --system - - run: bundle install - - run: bundle exec rake - -workflows: - version: 2 - client_ruby: - jobs: - - test: - matrix: - parameters: - ruby_image: - - cimg/ruby:3.2 - - cimg/ruby:3.3 - - cimg/ruby:3.4 - - cimg/ruby:4.0 - - jruby:9.4 - - jruby:10.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a9bfb308 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +--- +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Ruby ${{ matrix.ruby-version }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + ruby-version: + - "3.2" + - "3.3" + - "3.4" + - "4.0" + - "jruby-9.4" + - "jruby-10.0.2.0" + + steps: + - name: Check out + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + cache-version: ${{ matrix.ruby-version }} + + - name: Run specs + run: bundle exec rake diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a917797d..dea01a37 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -8,7 +8,7 @@ Any Ruby version that has not received an End-of-Life notice (e.g. is supported. To ensure we're meeting these guidelines, we test the client against all -supported versions, as specified in our [build matrix](.circleci/config.yml). +supported versions, as specified in our [build matrix](.github/workflows/ci.yml). # Deprecation diff --git a/README.md b/README.md index 2a7eba7b..e60f7de9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ through a HTTP interface. Intended to be used together with a [Prometheus server][1]. [![Gem Version][4]](http://badge.fury.io/rb/prometheus-client) -[![Build Status][3]](https://circleci.com/gh/prometheus/client_ruby/tree/main.svg?style=svg) +[![Build Status][3]](https://github.com/prometheus/client_ruby/actions/workflows/ci.yml) ## Usage @@ -504,7 +504,7 @@ rake [1]: https://github.com/prometheus/prometheus [2]: http://rack.github.io/ -[3]: https://circleci.com/gh/prometheus/client_ruby/tree/main.svg?style=svg +[3]: https://github.com/prometheus/client_ruby/actions/workflows/ci.yml/badge.svg [4]: https://badge.fury.io/rb/prometheus-client.svg [8]: https://github.com/prometheus/pushgateway [9]: lib/prometheus/middleware/exporter.rb