lib/prometheus/client/push.rb in prometheus-client-2.1.0 vs lib/prometheus/client/push.rb in prometheus-client-3.0.0

- old
+ new

@@ -1,39 +1,61 @@ # encoding: UTF-8 +require 'base64' require 'thread' require 'net/http' require 'uri' +require 'erb' +require 'set' 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. 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 - 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 = nil) + 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 - @instance = instance @gateway = gateway || DEFAULT_GATEWAY - @path = build_path(job, instance) + @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') + @http.open_timeout = kwargs[:open_timeout] if kwargs[:open_timeout] + @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) end end @@ -62,28 +84,120 @@ uri rescue URI::InvalidURIError => e raise ArgumentError, "#{url} is not a valid URL: #{e}" end - def build_path(job, instance) - if instance - format(INSTANCE_PATH, CGI::escape(job), CGI::escape(instance)) - else - format(PATH, CGI::escape(job)) + def build_path(job, grouping_key) + path = format(PATH, ERB::Util::url_encode(job)) + + grouping_key.each do |label, 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 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 + req.basic_auth(@user, @password) if @user req.body = Formats::Text.marshal(registry) if registry - @http.request(req) + response = @http.request(req) + validate_response!(response) + + response end 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: + + ``` + 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? + + # 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 + + 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