vm-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'base64'
4
+ require 'thread'
5
+ require 'net/http'
6
+ require 'uri'
7
+ require 'erb'
8
+ require 'set'
9
+ # require 'pry'
10
+
11
+ require 'prometheus/client'
12
+ require 'prometheus/client/formats/text'
13
+ require 'prometheus/client/label_set_validator'
14
+
15
+ module Prometheus
16
+ # Client is a ruby implementation for a Prometheus compatible client.
17
+ module Client
18
+ # Push implements a simple way to transmit a given registry to a given
19
+ # Pushgateway.
20
+ class Push
21
+ class HttpError < StandardError; end
22
+ class HttpRedirectError < HttpError; end
23
+ class HttpClientError < HttpError; end
24
+ class HttpServerError < HttpError; end
25
+
26
+ DEFAULT_GATEWAY = 'http://localhost:9091'.freeze
27
+ PATH = '/metrics'.freeze
28
+ SUPPORTED_SCHEMES = %w(http https).freeze
29
+
30
+ attr_reader :job, :gateway, :path
31
+
32
+ def initialize(job:, gateway: DEFAULT_GATEWAY, grouping_key: {}, **kwargs)
33
+ raise ArgumentError, "job cannot be nil" if job.nil?
34
+ raise ArgumentError, "job cannot be empty" if job.empty?
35
+ @validator = LabelSetValidator.new(expected_labels: grouping_key.keys)
36
+ @validator.validate_symbols!(grouping_key)
37
+
38
+ @mutex = Mutex.new
39
+ @job = job
40
+ @gateway = gateway || DEFAULT_GATEWAY
41
+ @grouping_key = grouping_key
42
+ @path = build_path(job, grouping_key)
43
+
44
+ @uri = parse("#{@gateway}#{@path}")
45
+ validate_no_basic_auth!(@uri)
46
+
47
+ @http = Net::HTTP.new(@uri.host, @uri.port)
48
+ @http.use_ssl = (@uri.scheme == 'https')
49
+ @http.open_timeout = kwargs[:open_timeout] if kwargs[:open_timeout]
50
+ @http.read_timeout = kwargs[:read_timeout] if kwargs[:read_timeout]
51
+ end
52
+
53
+ def basic_auth(user, password)
54
+ @user = user
55
+ @password = password
56
+ end
57
+
58
+ def add(registry)
59
+ synchronize do
60
+ request(Net::HTTP::Post, registry)
61
+ end
62
+ end
63
+
64
+ def replace(registry)
65
+ synchronize do
66
+ request(Net::HTTP::Put, registry)
67
+ end
68
+ end
69
+
70
+ def delete
71
+ synchronize do
72
+ request(Net::HTTP::Delete)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def parse(url)
79
+ uri = URI.parse(url)
80
+
81
+ unless SUPPORTED_SCHEMES.include?(uri.scheme)
82
+ raise ArgumentError, 'only HTTP gateway URLs are supported currently.'
83
+ end
84
+
85
+ uri
86
+ rescue URI::InvalidURIError => e
87
+ raise ArgumentError, "#{url} is not a valid URL: #{e}"
88
+ end
89
+
90
+ def build_path(job, grouping_key)
91
+ job = job.to_s
92
+
93
+ # Job can't be empty, but it can contain `/`, so we need to base64
94
+ # encode it in that case
95
+ if job.include?('/')
96
+ encoded_job = Base64.urlsafe_encode64(job)
97
+ path = "#{PATH}/job@base64/#{encoded_job}"
98
+ else
99
+ path = "#{PATH}/job/#{ERB::Util::url_encode(job)}"
100
+ end
101
+
102
+ grouping_key.each do |label, value|
103
+ value = value.to_s
104
+
105
+ if value.include?('/')
106
+ encoded_value = Base64.urlsafe_encode64(value)
107
+ path += "/#{label}@base64/#{encoded_value}"
108
+ # While it's valid for the urlsafe_encode64 function to return an
109
+ # empty string when the input string is empty, it doesn't work for
110
+ # our specific use case as we're putting the result into a URL path
111
+ # segment. A double slash (`//`) can be normalised away by HTTP
112
+ # libraries, proxies, and web servers.
113
+ #
114
+ # For empty strings, we use a single padding character (`=`) as the
115
+ # value.
116
+ #
117
+ # See the pushgateway docs for more details:
118
+ #
119
+ # https://github.com/prometheus/pushgateway/blob/6393a901f56d4dda62cd0f6ab1f1f07c495b6354/README.md#url
120
+ elsif value.empty?
121
+ path += "/#{label}@base64/="
122
+ else
123
+ path += "/#{label}/#{ERB::Util::url_encode(value)}"
124
+ end
125
+ end
126
+
127
+ path
128
+ end
129
+
130
+ def request(req_class, registry = nil)
131
+ validate_no_label_clashes!(registry) if registry
132
+
133
+ req = req_class.new(@uri)
134
+ req.content_type = Formats::Text::CONTENT_TYPE
135
+ req.basic_auth(@user, @password) if @user
136
+ # binding.pry
137
+ compress_enabled = false
138
+ if registry
139
+ if compress_enabled
140
+ req['Content-Encoding'] = 'gzip'
141
+ gzip = Zlib::GzipWriter.new(StringIO.new)
142
+ gzip << Formats::Text.marshal(registry)
143
+ req.body = gzip.close.string
144
+ else
145
+ req.body = Formats::Text.marshal(registry)
146
+ end
147
+ end
148
+
149
+ response = @http.request(req)
150
+ validate_response!(response)
151
+
152
+ response
153
+ end
154
+
155
+ def synchronize
156
+ @mutex.synchronize { yield }
157
+ end
158
+
159
+ def validate_no_basic_auth!(uri)
160
+ if uri.user || uri.password
161
+ raise ArgumentError, <<~EOF
162
+ Setting Basic Auth credentials in the gateway URL is not supported, please call the `basic_auth` method.
163
+
164
+ Received username `#{uri.user}` in gateway URL. Instead of passing
165
+ Basic Auth credentials like this:
166
+
167
+ ```
168
+ push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://user:password@localhost:9091")
169
+ ```
170
+
171
+ please pass them like this:
172
+
173
+ ```
174
+ push = Prometheus::Client::Push.new(job: "my-job", gateway: "http://localhost:9091")
175
+ push.basic_auth("user", "password")
176
+ ```
177
+
178
+ While URLs do support passing Basic Auth credentials using the
179
+ `http://user:password@example.com/` syntax, the username and
180
+ password in that syntax have to follow the usual rules for URL
181
+ encoding of characters per RFC 3986
182
+ (https://datatracker.ietf.org/doc/html/rfc3986#section-2.1).
183
+
184
+ Rather than place the burden of correctly performing that encoding
185
+ on users of this gem, we decided to have a separate method for
186
+ supplying Basic Auth credentials, with no requirement to URL encode
187
+ the characters in them.
188
+ EOF
189
+ end
190
+ end
191
+
192
+ def validate_no_label_clashes!(registry)
193
+ # There's nothing to check if we don't have a grouping key
194
+ return if @grouping_key.empty?
195
+
196
+ # We could be doing a lot of comparisons, so let's do them against a
197
+ # set rather than an array
198
+ grouping_key_labels = @grouping_key.keys.to_set
199
+
200
+ registry.metrics.each do |metric|
201
+ metric.labels.each do |label|
202
+ if grouping_key_labels.include?(label)
203
+ raise LabelSetValidator::InvalidLabelSetError,
204
+ "label :#{label} from grouping key collides with label of the " \
205
+ "same name from metric :#{metric.name} and would overwrite it"
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ def validate_response!(response)
212
+ status = Integer(response.code)
213
+ if status >= 300
214
+ message = "status: #{response.code}, message: #{response.message}, body: #{response.body}"
215
+ if status <= 399
216
+ raise HttpRedirectError, message
217
+ elsif status <= 499
218
+ raise HttpClientError, message
219
+ else
220
+ raise HttpServerError, message
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,100 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'thread'
4
+
5
+ require 'prometheus/client/counter'
6
+ require 'prometheus/client/summary'
7
+ require 'prometheus/client/gauge'
8
+ require 'prometheus/client/histogram'
9
+ require 'prometheus/client/vm_histogram'
10
+
11
+ module Prometheus
12
+ module Client
13
+ # Registry
14
+ class Registry
15
+ class AlreadyRegisteredError < StandardError; end
16
+
17
+ def initialize
18
+ @metrics = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def register(metric)
23
+ name = metric.name
24
+
25
+ @mutex.synchronize do
26
+ if @metrics.key?(name.to_sym)
27
+ raise AlreadyRegisteredError, "#{name} has already been registered"
28
+ end
29
+ @metrics[name.to_sym] = metric
30
+ end
31
+
32
+ metric
33
+ end
34
+
35
+ def unregister(name)
36
+ @mutex.synchronize do
37
+ @metrics.delete(name.to_sym)
38
+ end
39
+ end
40
+
41
+ def counter(name, docstring:, labels: [], preset_labels: {}, store_settings: {})
42
+ register(Counter.new(name,
43
+ docstring: docstring,
44
+ labels: labels,
45
+ preset_labels: preset_labels,
46
+ store_settings: store_settings))
47
+ end
48
+
49
+ def summary(name, docstring:, labels: [], preset_labels: {}, store_settings: {})
50
+ register(Summary.new(name,
51
+ docstring: docstring,
52
+ labels: labels,
53
+ preset_labels: preset_labels,
54
+ store_settings: store_settings))
55
+ end
56
+
57
+ def gauge(name, docstring:, labels: [], preset_labels: {}, store_settings: {})
58
+ register(Gauge.new(name,
59
+ docstring: docstring,
60
+ labels: labels,
61
+ preset_labels: preset_labels,
62
+ store_settings: store_settings))
63
+ end
64
+
65
+ def histogram(name, docstring:, labels: [], preset_labels: {},
66
+ buckets: Histogram::DEFAULT_BUCKETS,
67
+ store_settings: {})
68
+ register(Histogram.new(name,
69
+ docstring: docstring,
70
+ labels: labels,
71
+ preset_labels: preset_labels,
72
+ buckets: buckets,
73
+ store_settings: store_settings))
74
+ end
75
+
76
+ def vm_histogram(name, docstring:, labels: [], preset_labels: {},
77
+ buckets: [],
78
+ store_settings: {})
79
+ register(VmHistogram.new(name,
80
+ docstring: docstring,
81
+ labels: labels,
82
+ preset_labels: preset_labels,
83
+ buckets: buckets,
84
+ store_settings: store_settings))
85
+ end
86
+
87
+ def exist?(name)
88
+ @mutex.synchronize { @metrics.key?(name) }
89
+ end
90
+
91
+ def get(name)
92
+ @mutex.synchronize { @metrics[name.to_sym] }
93
+ end
94
+
95
+ def metrics
96
+ @mutex.synchronize { @metrics.values }
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,69 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'prometheus/client/metric'
4
+
5
+ module Prometheus
6
+ module Client
7
+ # Summary is an accumulator for samples. It captures Numeric data and
8
+ # provides the total count and sum of observations.
9
+ class Summary < Metric
10
+ def type
11
+ :summary
12
+ end
13
+
14
+ # Records a given value. The recorded value is usually positive
15
+ # or zero. A negative value is accepted but prevents current
16
+ # versions of Prometheus from properly detecting counter resets
17
+ # in the sum of observations. See
18
+ # https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations
19
+ # for details.
20
+ def observe(value, labels: {})
21
+ base_label_set = label_set_for(labels)
22
+
23
+ @store.synchronize do
24
+ @store.increment(labels: base_label_set.merge(quantile: "count"), by: 1)
25
+ @store.increment(labels: base_label_set.merge(quantile: "sum"), by: value)
26
+ end
27
+ end
28
+
29
+ # Returns a hash with "sum" and "count" as keys
30
+ def get(labels: {})
31
+ base_label_set = label_set_for(labels)
32
+
33
+ internal_counters = ["count", "sum"]
34
+
35
+ @store.synchronize do
36
+ internal_counters.each_with_object({}) do |counter, acc|
37
+ acc[counter] = @store.get(labels: base_label_set.merge(quantile: counter))
38
+ end
39
+ end
40
+ end
41
+
42
+ # Returns all label sets with their values expressed as hashes with their sum/count
43
+ def values
44
+ values = @store.all_values
45
+
46
+ values.each_with_object({}) do |(label_set, v), acc|
47
+ actual_label_set = label_set.reject{|l| l == :quantile }
48
+ acc[actual_label_set] ||= { "count" => 0.0, "sum" => 0.0 }
49
+ acc[actual_label_set][label_set[:quantile]] = v
50
+ end
51
+ end
52
+
53
+ def init_label_set(labels)
54
+ base_label_set = label_set_for(labels)
55
+
56
+ @store.synchronize do
57
+ @store.set(labels: base_label_set.merge(quantile: "count"), val: 0)
58
+ @store.set(labels: base_label_set.merge(quantile: "sum"), val: 0)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def reserved_labels
65
+ [:quantile]
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,7 @@
1
+ # encoding: UTF-8
2
+
3
+ module Prometheus
4
+ module Client
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -0,0 +1,164 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'prometheus/client/metric'
4
+
5
+ module Prometheus
6
+ module Client
7
+ # A histogram samples observations (usually things like request durations
8
+ # or response sizes) and counts them in dynamic VictoriaMetrics buckets. It also
9
+ # provides a total count and sum of all observed values.
10
+ class VmHistogram < Metric
11
+ attr_reader :buckets
12
+
13
+ E10MIN = -9
14
+ E10MAX = 18
15
+ BUCKETS_PER_DECIMAL = 18
16
+ DECIMAL_BUCKETS_COUNT = E10MAX - E10MIN
17
+ BUCKETS_COUNT = DECIMAL_BUCKETS_COUNT * BUCKETS_PER_DECIMAL
18
+ BUCKETS_MULTIPLIER = 10**(1.0 / BUCKETS_PER_DECIMAL)
19
+ VMRANGES = begin
20
+ h = {}
21
+ value = 10**E10MIN
22
+ range_start = format('%.3e', value)
23
+
24
+ BUCKETS_COUNT.times do |i|
25
+ value *= BUCKETS_MULTIPLIER
26
+ range_end = format('%.3e', value)
27
+ h[i] = "#{range_start}...#{range_end}"
28
+ range_start = range_end
29
+ end
30
+
31
+ # edge case fo zeros
32
+ h[-1] = '0...1.000e-09'
33
+
34
+ h
35
+ end
36
+ MAX_VMRANGE_BUCKET = VMRANGES.keys.max
37
+
38
+ def initialize(name,
39
+ docstring:,
40
+ labels: [],
41
+ preset_labels: {},
42
+ # VM histogram ignores passed buckets, accepts only for compatibility
43
+ buckets: [], # rubocop:disable Lint/UnusedMethodArgument
44
+ store_settings: {})
45
+
46
+ @buckets = %w[sum count]
47
+ # TODO: this should take into account labels
48
+ @non_nil_buckets = {}
49
+ @base_label_set_cache = {}
50
+
51
+ super(name,
52
+ docstring: docstring,
53
+ labels: labels,
54
+ preset_labels: preset_labels,
55
+ store_settings: store_settings)
56
+ end
57
+
58
+ def with_labels(labels)
59
+ new_metric = self.class.new(name,
60
+ docstring: docstring,
61
+ labels: @labels,
62
+ preset_labels: preset_labels.merge(labels),
63
+ buckets: @buckets,
64
+ store_settings: @store_settings)
65
+
66
+ # The new metric needs to use the same store as the "main" declared one, otherwise
67
+ # any observations on that copy with the pre-set labels won't actually be exported.
68
+ new_metric.replace_internal_store(@store)
69
+
70
+ new_metric
71
+ end
72
+
73
+ def type
74
+ :vm_histogram
75
+ end
76
+
77
+ # Records a given value. The recorded value is usually positive
78
+ # or zero. A negative value is ignored.
79
+ def observe(value, labels: {})
80
+ return if value.to_f.nan? || value.negative?
81
+
82
+ float_bucket_id = (Math.log10(value) - E10MIN) * BUCKETS_PER_DECIMAL
83
+
84
+ bucket_id = if float_bucket_id.negative?
85
+ -1
86
+ elsif float_bucket_id > MAX_VMRANGE_BUCKET
87
+ MAX_VMRANGE_BUCKET
88
+ else
89
+ float_bucket_id.to_i
90
+ end
91
+
92
+ # Edge case for 10^n values, which must go to the lower bucket
93
+ # according to Prometheus logic for `le`-based histograms
94
+ bucket_id -= 1 if (float_bucket_id - bucket_id.to_f).abs < Float::EPSILON && bucket_id.positive?
95
+
96
+ base_label_set = label_set_for(labels)
97
+
98
+ # OPTIMIZE: probably we also can use cache for vmranges to avoid using .dup every time
99
+ bucket_label_set = base_label_set.dup
100
+ bucket_label_set[:vmrange] = VMRANGES[bucket_id]
101
+
102
+ @non_nil_buckets[bucket_label_set[:vmrange]] = nil # just to track non empty buckets
103
+
104
+ unless @base_label_set_cache.key? base_label_set
105
+ @base_label_set_cache[base_label_set] = {
106
+ sum: base_label_set.merge({ le: 'sum' }),
107
+ count: base_label_set.merge({ le: 'count' })
108
+ }
109
+ end
110
+
111
+ @store.synchronize do
112
+ @store.increment(labels: bucket_label_set, by: 1)
113
+ @store.increment(labels: @base_label_set_cache[base_label_set][:sum], by: value)
114
+ @store.increment(labels: @base_label_set_cache[base_label_set][:count], by: 1)
115
+ end
116
+ end
117
+
118
+ def get(labels: {})
119
+ base_label_set = label_set_for(labels)
120
+
121
+ all_buckets = @non_nil_buckets.keys + buckets
122
+
123
+ @store.synchronize do
124
+ all_buckets.each_with_object({}) do |bucket, acc|
125
+ if @non_nil_buckets.key? bucket
126
+ value = @store.get(labels: base_label_set.merge(vmrange: bucket.to_s))
127
+ acc[bucket.to_s] = value if value.positive?
128
+ else
129
+ acc[bucket.to_s] = @store.get(labels: base_label_set.merge(le: bucket.to_s))
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # Returns all label sets with their values expressed as hashes with their buckets
136
+ def values
137
+ values = @store.all_values
138
+
139
+ values.each_with_object({}) do |(label_set, v), acc|
140
+ actual_label_set = label_set.reject { |l| [:vmrange, :le].include? l }
141
+ acc[actual_label_set] ||= {}
142
+ label_name = label_set[:vmrange] || label_set[:le]
143
+ acc[actual_label_set][label_name.to_s] = v
144
+ end
145
+ end
146
+
147
+ def init_label_set(labels)
148
+ base_label_set = label_set_for(labels)
149
+
150
+ @store.synchronize do
151
+ @buckets.each do |bucket|
152
+ @store.set(labels: base_label_set.merge(le: bucket.to_s), val: 0)
153
+ end
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def reserved_labels
160
+ [:vmrange]
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,18 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'prometheus/client/registry'
4
+ require 'prometheus/client/config'
5
+
6
+ module Prometheus
7
+ # Client is a ruby implementation for a Prometheus compatible client.
8
+ module Client
9
+ # Returns a default registry object
10
+ def self.registry
11
+ @registry ||= Registry.new
12
+ end
13
+
14
+ def self.config
15
+ @config ||= Config.new
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,103 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'benchmark'
4
+ require 'prometheus/client'
5
+
6
+ module Prometheus
7
+ module Middleware
8
+ # Collector is a Rack middleware that provides a sample implementation of a
9
+ # HTTP tracer.
10
+ #
11
+ # By default metrics are registered on the global registry. Set the
12
+ # `:registry` option to use a custom registry.
13
+ #
14
+ # By default metrics all have the prefix "http_server". Set
15
+ # `:metrics_prefix` to something else if you like.
16
+ #
17
+ # The request counter metric is broken down by code, method and path.
18
+ # The request duration metric is broken down by method and path.
19
+ class Collector
20
+ attr_reader :app, :registry
21
+
22
+ def initialize(app, options = {})
23
+ @app = app
24
+ @registry = options[:registry] || Client.registry
25
+ @metrics_prefix = options[:metrics_prefix] || 'http_server'
26
+
27
+ init_request_metrics
28
+ init_exception_metrics
29
+ end
30
+
31
+ def call(env) # :nodoc:
32
+ trace(env) { @app.call(env) }
33
+ end
34
+
35
+ protected
36
+
37
+ def init_request_metrics
38
+ @requests = @registry.counter(
39
+ :"#{@metrics_prefix}_requests_total",
40
+ docstring:
41
+ 'The total number of HTTP requests handled by the Rack application.',
42
+ labels: %i[code method path]
43
+ )
44
+ @durations = @registry.histogram(
45
+ :"#{@metrics_prefix}_request_duration_seconds",
46
+ docstring: 'The HTTP response duration of the Rack application.',
47
+ labels: %i[method path]
48
+ )
49
+ end
50
+
51
+ def init_exception_metrics
52
+ @exceptions = @registry.counter(
53
+ :"#{@metrics_prefix}_exceptions_total",
54
+ docstring: 'The total number of exceptions raised by the Rack application.',
55
+ labels: [:exception]
56
+ )
57
+ end
58
+
59
+ def trace(env)
60
+ response = nil
61
+ duration = Benchmark.realtime { response = yield }
62
+ record(env, response.first.to_s, duration)
63
+ return response
64
+ rescue => exception
65
+ @exceptions.increment(labels: { exception: exception.class.name })
66
+ raise
67
+ end
68
+
69
+ def record(env, code, duration)
70
+ path = generate_path(env)
71
+
72
+ counter_labels = {
73
+ code: code,
74
+ method: env['REQUEST_METHOD'].downcase,
75
+ path: path,
76
+ }
77
+
78
+ duration_labels = {
79
+ method: env['REQUEST_METHOD'].downcase,
80
+ path: path,
81
+ }
82
+
83
+ @requests.increment(labels: counter_labels)
84
+ @durations.observe(duration, labels: duration_labels)
85
+ rescue
86
+ # TODO: log unexpected exception during request recording
87
+ nil
88
+ end
89
+
90
+ def generate_path(env)
91
+ full_path = [env['SCRIPT_NAME'], env['PATH_INFO']].join
92
+
93
+ strip_ids_from_path(full_path)
94
+ end
95
+
96
+ def strip_ids_from_path(path)
97
+ path
98
+ .gsub(%r{/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)}, '/:uuid\\1')
99
+ .gsub(%r{/\d+(?=/|$)}, '/:id\\1')
100
+ end
101
+ end
102
+ end
103
+ end