diff --git a/README.md b/README.md index 5b3c9306..cf8dfd8a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ http_requests.increment ### Rack middleware There are two [Rack][2] middlewares available, one to expose a metrics HTTP -endpoint to be scraped by a prometheus server ([Exporter][9]) and one to trace all HTTP +endpoint to be scraped by a Prometheus server ([Exporter][9]) and one to trace all HTTP requests ([Collector][10]). It's highly recommended to enable gzip compression for the metrics endpoint, @@ -45,14 +45,14 @@ for example by including the `Rack::Deflater` middleware. # config.ru require 'rack' -require 'prometheus/client/rack/collector' -require 'prometheus/client/rack/exporter' +require 'prometheus/middleware/collector' +require 'prometheus/middleware/exporter' -use Rack::Deflater, if: ->(env, status, headers, body) { body.any? && body[0].length > 512 } -use Prometheus::Client::Rack::Collector -use Prometheus::Client::Rack::Exporter +use Rack::Deflater, if: ->(_, _, _, body) { body.any? && body[0].length > 512 } +use Prometheus::Middleware::Collector +use Prometheus::Middleware::Exporter -run ->(env) { [200, {'Content-Type' => 'text/html'}, ['OK']] } +run ->(_) { [200, {'Content-Type' => 'text/html'}, ['OK']] } ``` Start the server and have a look at the metrics endpoint: @@ -179,5 +179,5 @@ rake [6]: https://codeclimate.com/github/prometheus/client_ruby.png [7]: https://coveralls.io/repos/prometheus/client_ruby/badge.png?branch=master [8]: https://github.com/prometheus/pushgateway -[9]: lib/prometheus/client/rack/exporter.rb -[10]: lib/prometheus/client/rack/collector.rb +[9]: lib/prometheus/middleware/exporter.rb +[10]: lib/prometheus/middleware/collector.rb diff --git a/examples/rack/config.ru b/examples/rack/config.ru index 5145c362..3629974f 100755 --- a/examples/rack/config.ru +++ b/examples/rack/config.ru @@ -1,9 +1,22 @@ require 'rack' -require 'prometheus/client/rack/collector' -require 'prometheus/client/rack/exporter' +require 'prometheus/middleware/collector' +require 'prometheus/middleware/exporter' use Rack::Deflater, if: ->(_, _, _, body) { body.any? && body[0].length > 512 } -use Prometheus::Client::Rack::Collector -use Prometheus::Client::Rack::Exporter +use Prometheus::Middleware::Collector +use Prometheus::Middleware::Exporter -run ->(_) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } +srand + +app = lambda do |_| + case rand + when 0..0.8 + [200, { 'Content-Type' => 'text/html' }, ['OK']] + when 0.8..0.95 + [404, { 'Content-Type' => 'text/html' }, ['Not Found']] + else + raise NoMethodError, 'It is a bug!' + end +end + +run app diff --git a/lib/prometheus/client/rack/collector.rb b/lib/prometheus/client/rack/collector.rb deleted file mode 100644 index c3cacbbd..00000000 --- a/lib/prometheus/client/rack/collector.rb +++ /dev/null @@ -1,82 +0,0 @@ -# encoding: UTF-8 - -require 'prometheus/client' - -module Prometheus - module Client - module Rack - # Collector is a Rack middleware that provides a sample implementation of - # a HTTP tracer. The default label builder can be modified to export a - # different set of labels per recorded metric. - class Collector - attr_reader :app, :registry - - def initialize(app, options = {}, &label_builder) - @app = app - @registry = options[:registry] || Client.registry - @label_builder = label_builder || DEFAULT_LABEL_BUILDER - - init_request_metrics - init_exception_metrics - end - - def call(env) # :nodoc: - trace(env) { @app.call(env) } - end - - protected - - DEFAULT_LABEL_BUILDER = proc do |env| - { - method: env['REQUEST_METHOD'].downcase, - host: env['HTTP_HOST'].to_s, - path: env['PATH_INFO'].to_s, - } - end - - def init_request_metrics - @requests = @registry.counter( - :http_requests_total, - 'A counter of the total number of HTTP requests made.', - ) - @durations = @registry.summary( - :http_request_duration_seconds, - 'A histogram of the response latency.', - ) - end - - def init_exception_metrics - @exceptions = @registry.counter( - :http_exceptions_total, - 'A counter of the total number of exceptions raised.', - ) - end - - def trace(env) - start = Time.now - yield.tap do |response| - duration = (Time.now - start).to_f - record(labels(env, response), duration) - end - rescue => exception - @exceptions.increment(exception: exception.class.name) - raise - end - - def labels(env, response) - @label_builder.call(env).tap do |labels| - labels[:code] = response.first.to_s - end - end - - def record(labels, duration) - @requests.increment(labels) - @durations.observe(labels, duration) - rescue - # TODO: log unexpected exception during request recording - nil - end - end - end - end -end diff --git a/lib/prometheus/client/rack/exporter.rb b/lib/prometheus/client/rack/exporter.rb deleted file mode 100644 index a6f96165..00000000 --- a/lib/prometheus/client/rack/exporter.rb +++ /dev/null @@ -1,91 +0,0 @@ -# encoding: UTF-8 - -require 'prometheus/client' -require 'prometheus/client/formats/text' - -module Prometheus - module Client - module Rack - # Exporter is a Rack middleware that provides a sample implementation of - # a Prometheus HTTP client API. - class Exporter - attr_reader :app, :registry, :path - - FORMATS = [Formats::Text].freeze - FALLBACK = Formats::Text - - def initialize(app, options = {}) - @app = app - @registry = options[:registry] || Client.registry - @path = options[:path] || '/metrics' - @acceptable = build_dictionary(FORMATS, FALLBACK) - end - - def call(env) - if env['PATH_INFO'] == @path - format = negotiate(env['HTTP_ACCEPT'], @acceptable) - format ? respond_with(format) : not_acceptable(FORMATS) - else - @app.call(env) - end - end - - private - - def negotiate(accept, formats) - accept = '*/*' if accept.to_s.empty? - - parse(accept).each do |content_type, _| - return formats[content_type] if formats.key?(content_type) - end - - nil - end - - def parse(header) - header.to_s.split(/\s*,\s*/).map do |type| - attributes = type.split(/\s*;\s*/) - quality = extract_quality(attributes) - - [attributes.join('; '), quality] - end.sort_by(&:last).reverse - end - - def extract_quality(attributes, default = 1.0) - quality = default - - attributes.delete_if do |attr| - quality = attr.split('q=').last.to_f if attr.start_with?('q=') - end - - quality - end - - def respond_with(format) - [ - 200, - { 'Content-Type' => format::CONTENT_TYPE }, - [format.marshal(@registry)], - ] - end - - def not_acceptable(formats) - types = formats.map { |format| format::MEDIA_TYPE } - - [ - 406, - { 'Content-Type' => 'text/plain' }, - ["Supported media types: #{types.join(', ')}"], - ] - end - - def build_dictionary(formats, fallback) - formats.each_with_object('*/*' => fallback) do |format, memo| - memo[format::CONTENT_TYPE] = format - memo[format::MEDIA_TYPE] = format - end - end - end - end - end -end diff --git a/lib/prometheus/middleware/collector.rb b/lib/prometheus/middleware/collector.rb new file mode 100644 index 00000000..8caea5ca --- /dev/null +++ b/lib/prometheus/middleware/collector.rb @@ -0,0 +1,91 @@ +# encoding: UTF-8 + +require 'prometheus/client' + +module Prometheus + module Middleware + # Collector is a Rack middleware that provides a sample implementation of a + # HTTP tracer. + # + # By default metrics are registered on the global registry. Set the + # `:registry` option to use a custom registry. + # + # 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 latency metric is broken down by method and path by default. + # Set the `:latency_label_builder` option to use a custom label builder. + class Collector + attr_reader :app, :registry + + def initialize(app, options = {}) + @app = app + @registry = options[:registry] || Client.registry + @counter_lb = options[:counter_label_builder] || COUNTER_LB + @latency_lb = options[:latency_label_builder] || LATENCY_LB + + init_request_metrics + init_exception_metrics + end + + def call(env) # :nodoc: + trace(env) { @app.call(env) } + end + + protected + + COUNTER_LB = proc do |env, code| + { + code: code, + method: env['REQUEST_METHOD'].downcase, + path: env['PATH_INFO'].to_s, + } + end + + LATENCY_LB = proc do |env, _| + { + method: env['REQUEST_METHOD'].downcase, + path: env['PATH_INFO'].to_s, + } + end + + def init_request_metrics + @requests = @registry.counter( + :http_server_requests_total, + 'The total number of HTTP requests handled by the Rack application.', + ) + @durations = @registry.histogram( + :http_server_request_latency_seconds, + 'The HTTP response latency of the Rack application.', + ) + end + + def init_exception_metrics + @exceptions = @registry.counter( + :http_server_exceptions_total, + 'The total number of exceptions raised by the Rack application.', + ) + end + + def trace(env) + start = Time.now + yield.tap do |response| + duration = (Time.now - start).to_f + record(env, response.first.to_s, duration) + end + rescue => exception + @exceptions.increment(exception: exception.class.name) + raise + end + + def record(env, code, duration) + @requests.increment(@counter_lb.call(env, code)) + @durations.observe(@latency_lb.call(env, code), duration) + rescue + # TODO: log unexpected exception during request recording + nil + end + end + end +end diff --git a/lib/prometheus/middleware/exporter.rb b/lib/prometheus/middleware/exporter.rb new file mode 100644 index 00000000..aa0ff125 --- /dev/null +++ b/lib/prometheus/middleware/exporter.rb @@ -0,0 +1,93 @@ +# encoding: UTF-8 + +require 'prometheus/client' +require 'prometheus/client/formats/text' + +module Prometheus + module Middleware + # Exporter is a Rack middleware that provides a sample implementation of a + # Prometheus HTTP exposition endpoint. + # + # By default it will export the state of the global registry and expose it + # under `/metrics`. Use the `:registry` and `:path` options to change the + # defaults. + class Exporter + attr_reader :app, :registry, :path + + FORMATS = [Client::Formats::Text].freeze + FALLBACK = Client::Formats::Text + + def initialize(app, options = {}) + @app = app + @registry = options[:registry] || Client.registry + @path = options[:path] || '/metrics' + @acceptable = build_dictionary(FORMATS, FALLBACK) + end + + def call(env) + if env['PATH_INFO'] == @path + format = negotiate(env['HTTP_ACCEPT'], @acceptable) + format ? respond_with(format) : not_acceptable(FORMATS) + else + @app.call(env) + end + end + + private + + def negotiate(accept, formats) + accept = '*/*' if accept.to_s.empty? + + parse(accept).each do |content_type, _| + return formats[content_type] if formats.key?(content_type) + end + + nil + end + + def parse(header) + header.to_s.split(/\s*,\s*/).map do |type| + attributes = type.split(/\s*;\s*/) + quality = extract_quality(attributes) + + [attributes.join('; '), quality] + end.sort_by(&:last).reverse + end + + def extract_quality(attributes, default = 1.0) + quality = default + + attributes.delete_if do |attr| + quality = attr.split('q=').last.to_f if attr.start_with?('q=') + end + + quality + end + + def respond_with(format) + [ + 200, + { 'Content-Type' => format::CONTENT_TYPE }, + [format.marshal(@registry)], + ] + end + + def not_acceptable(formats) + types = formats.map { |format| format::MEDIA_TYPE } + + [ + 406, + { 'Content-Type' => 'text/plain' }, + ["Supported media types: #{types.join(', ')}"], + ] + end + + def build_dictionary(formats, fallback) + formats.each_with_object('*/*' => fallback) do |format, memo| + memo[format::CONTENT_TYPE] = format + memo[format::MEDIA_TYPE] = format + end + end + end + end +end diff --git a/spec/prometheus/client/rack/collector_spec.rb b/spec/prometheus/middleware/collector_spec.rb similarity index 57% rename from spec/prometheus/client/rack/collector_spec.rb rename to spec/prometheus/middleware/collector_spec.rb index b0d2d1cb..5fc457f3 100644 --- a/spec/prometheus/client/rack/collector_spec.rb +++ b/spec/prometheus/middleware/collector_spec.rb @@ -1,9 +1,9 @@ # encoding: UTF-8 require 'rack/test' -require 'prometheus/client/rack/collector' +require 'prometheus/middleware/collector' -describe Prometheus::Client::Rack::Collector do +describe Prometheus::Middleware::Collector do include Rack::Test::Methods let(:registry) do @@ -26,7 +26,7 @@ end it 'handles errors in the registry gracefully' do - counter = registry.get(:http_requests_total) + counter = registry.get(:http_server_requests_total) expect(counter).to receive(:increment).and_raise(NoMethodError) get '/foo' @@ -36,16 +36,16 @@ it 'traces request information' do expect(Time).to receive(:now).twice.and_return(0.0, 0.2) - labels = { method: 'get', host: 'example.org', path: '/foo', code: '200' } get '/foo' - { - http_requests_total: 1.0, - http_request_duration_seconds: { 0.5 => 0.2, 0.9 => 0.2, 0.99 => 0.2 }, - }.each do |metric, result| - expect(registry.get(metric).get(labels)).to eql(result) - end + metric = :http_server_requests_total + labels = { method: 'get', path: '/foo', code: '200' } + expect(registry.get(metric).get(labels)).to eql(1.0) + + metric = :http_server_request_latency_seconds + labels = { method: 'get', path: '/foo' } + expect(registry.get(metric).get(labels)).to include(0.1 => 0, 0.25 => 1) end context 'when the app raises an exception' do @@ -62,27 +62,34 @@ end it 'traces exceptions' do - labels = { exception: 'NoMethodError' } - expect { get '/broken' }.to raise_error NoMethodError - expect(registry.get(:http_exceptions_total).get(labels)).to eql(1.0) + metric = :http_server_exceptions_total + labels = { exception: 'NoMethodError' } + expect(registry.get(metric).get(labels)).to eql(1.0) end end - context 'setting up with a block' do + context 'when using a custom counter label builder' do let(:app) do - described_class.new(original_app, registry: registry) do |env| - { method: env['REQUEST_METHOD'].downcase } # and ignore the path - end + described_class.new( + original_app, + registry: registry, + counter_label_builder: lambda do |env, code| + { + 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(:http_requests_total).get(labels)).to eql(1.0) + expect(registry.get(metric).get(labels)).to eql(1.0) end end end diff --git a/spec/prometheus/client/rack/exporter_spec.rb b/spec/prometheus/middleware/exporter_spec.rb similarity index 94% rename from spec/prometheus/client/rack/exporter_spec.rb rename to spec/prometheus/middleware/exporter_spec.rb index 78ce4889..b1a1a791 100644 --- a/spec/prometheus/client/rack/exporter_spec.rb +++ b/spec/prometheus/middleware/exporter_spec.rb @@ -1,9 +1,9 @@ # encoding: UTF-8 require 'rack/test' -require 'prometheus/client/rack/exporter' +require 'prometheus/middleware/exporter' -describe Prometheus::Client::Rack::Exporter do +describe Prometheus::Middleware::Exporter do include Rack::Test::Methods let(:registry) do @@ -12,7 +12,7 @@ let(:app) do app = ->(_) { [200, { 'Content-Type' => 'text/html' }, ['OK']] } - Prometheus::Client::Rack::Exporter.new(app, registry: registry) + described_class.new(app, registry: registry) end context 'when requesting app endpoints' do