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.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/README.md +554 -0
- data/lib/prometheus/client/config.rb +15 -0
- data/lib/prometheus/client/counter.rb +21 -0
- data/lib/prometheus/client/data_stores/README.md +306 -0
- data/lib/prometheus/client/data_stores/direct_file_store.rb +368 -0
- data/lib/prometheus/client/data_stores/single_threaded.rb +56 -0
- data/lib/prometheus/client/data_stores/synchronized.rb +62 -0
- data/lib/prometheus/client/formats/text.rb +106 -0
- data/lib/prometheus/client/gauge.rb +42 -0
- data/lib/prometheus/client/histogram.rb +151 -0
- data/lib/prometheus/client/label_set_validator.rb +80 -0
- data/lib/prometheus/client/metric.rb +120 -0
- data/lib/prometheus/client/push.rb +226 -0
- data/lib/prometheus/client/registry.rb +100 -0
- data/lib/prometheus/client/summary.rb +69 -0
- data/lib/prometheus/client/version.rb +7 -0
- data/lib/prometheus/client/vm_histogram.rb +164 -0
- data/lib/prometheus/client.rb +18 -0
- data/lib/prometheus/middleware/collector.rb +103 -0
- data/lib/prometheus/middleware/exporter.rb +96 -0
- data/lib/prometheus.rb +5 -0
- metadata +108 -0
|
@@ -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,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
|