1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203
|
# 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
SUPPORTED_SCHEMES = %w(http https).freeze
attr_reader :job, :gateway, :path
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()
@validator.validate(grouping_key)
@mutex = Mutex.new
@job = job
@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')
@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
def replace(registry)
synchronize do
request(Net::HTTP::Put, registry)
end
end
def delete
synchronize do
request(Net::HTTP::Delete)
end
end
private
def parse(url)
uri = URI.parse(url)
unless SUPPORTED_SCHEMES.include?(uri.scheme)
raise ArgumentError, 'only HTTP gateway URLs are supported currently.'
end
uri
rescue URI::InvalidURIError => e
raise ArgumentError, "#{url} is not a valid URL: #{e}"
end
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(@user, @password) if @user
req.body = Formats::Text.marshal(registry) if registry
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.values.keys.first.keys.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
|