fast-prometheus 0.1.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/AGENTS.md +38 -0
- data/CHANGELOG.md +59 -0
- data/README.md +109 -0
- data/lib/fast/prometheus/counter.rb +22 -0
- data/lib/fast/prometheus/errors.rb +11 -0
- data/lib/fast/prometheus/exposition.rb +46 -0
- data/lib/fast/prometheus/formats/metrics_pb.rb +33 -0
- data/lib/fast/prometheus/formats/protobuf.rb +154 -0
- data/lib/fast/prometheus/formats/text.rb +90 -0
- data/lib/fast/prometheus/gauge.rb +37 -0
- data/lib/fast/prometheus/histogram.rb +112 -0
- data/lib/fast/prometheus/metric.rb +144 -0
- data/lib/fast/prometheus/middleware/exporter.rb +39 -0
- data/lib/fast/prometheus/middleware/instrumentation.rb +34 -0
- data/lib/fast/prometheus/native_histogram.rb +145 -0
- data/lib/fast/prometheus/otlp/grpc_exporter.rb +39 -0
- data/lib/fast/prometheus/otlp/http_exporter.rb +43 -0
- data/lib/fast/prometheus/otlp/mapper.rb +207 -0
- data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.rb +27 -0
- data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/common/v1/common_pb.rb +26 -0
- data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/metrics/v1/metrics_pb.rb +41 -0
- data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/resource/v1/resource_pb.rb +23 -0
- data/lib/fast/prometheus/otlp/push.rb +39 -0
- data/lib/fast/prometheus/otlp/service_interface.rb +18 -0
- data/lib/fast/prometheus/rack/exporter.rb +32 -0
- data/lib/fast/prometheus/rack/instrumentation.rb +33 -0
- data/lib/fast/prometheus/registry.rb +111 -0
- data/lib/fast/prometheus/request_metrics.rb +45 -0
- data/lib/fast/prometheus/snapshot.rb +78 -0
- data/lib/fast/prometheus/store.rb +37 -0
- data/lib/fast/prometheus/summary.rb +50 -0
- data/lib/fast/prometheus/version.rb +7 -0
- data/lib/fast/prometheus.rb +17 -0
- metadata +158 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric"
|
|
4
|
+
|
|
5
|
+
module Fast
|
|
6
|
+
module Prometheus
|
|
7
|
+
# Histogram samples observations and counts them in configurable buckets.
|
|
8
|
+
# Also provides total count and sum of all observed values.
|
|
9
|
+
#
|
|
10
|
+
# Storage: one slot per label series in the parent @store Hash. Each slot
|
|
11
|
+
# is a HistogramSlot holding sum (Float), count (Integer), and one Integer
|
|
12
|
+
# cell per boundary plus an overflow cell — stored NON-cumulatively.
|
|
13
|
+
class Histogram < Metric
|
|
14
|
+
DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
|
|
15
|
+
|
|
16
|
+
# Small inner value object holding one series' data.
|
|
17
|
+
class HistogramSlot
|
|
18
|
+
attr_accessor :sum, :count
|
|
19
|
+
attr_reader :cells
|
|
20
|
+
|
|
21
|
+
def initialize(buckets:, sum: 0.0, count: 0)
|
|
22
|
+
@buckets = buckets
|
|
23
|
+
@sum = sum
|
|
24
|
+
@count = count
|
|
25
|
+
@cells = [0] * (buckets.size + 1)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Cumulative bucket counts, ending with [Float::INFINITY, count].
|
|
29
|
+
def cumulative_buckets
|
|
30
|
+
cumulative = 0
|
|
31
|
+
result = @buckets.each_with_index.map do |boundary, i|
|
|
32
|
+
cumulative += @cells[i]
|
|
33
|
+
[boundary, cumulative]
|
|
34
|
+
end
|
|
35
|
+
cumulative += @cells.last # overflow cell
|
|
36
|
+
result << [Float::INFINITY, cumulative]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :buckets
|
|
41
|
+
|
|
42
|
+
def initialize(name, docstring:, labels: [], preset_labels: {}, buckets: DEFAULT_BUCKETS, store: nil)
|
|
43
|
+
raise ArgumentError, "buckets must be a non-empty Array" unless buckets.is_a?(Array) && !buckets.empty?
|
|
44
|
+
raise ArgumentError, "buckets must contain only Numeric values" unless buckets.all? { |b| b.is_a?(Numeric) }
|
|
45
|
+
raise ArgumentError, "buckets must be strictly ascending" unless buckets.each_cons(2).all? { |a, b| a < b }
|
|
46
|
+
|
|
47
|
+
raise InvalidLabelName, "label :le is reserved" if labels.include?(:le)
|
|
48
|
+
|
|
49
|
+
@buckets = buckets
|
|
50
|
+
super(name, docstring: docstring, labels: labels, preset_labels: preset_labels, store: store)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def type
|
|
54
|
+
:histogram
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Record an observation. Finds the first boundary >= value (inclusive le)
|
|
58
|
+
# using bsearch_index. Values above the last boundary go to the overflow cell.
|
|
59
|
+
def observe(value, labels: {})
|
|
60
|
+
key = resolve(labels)
|
|
61
|
+
index = @buckets.bsearch_index { |boundary| boundary >= value } || @buckets.size
|
|
62
|
+
|
|
63
|
+
store.synchronize do
|
|
64
|
+
slot = store[key] ||= HistogramSlot.new(buckets: @buckets)
|
|
65
|
+
|
|
66
|
+
slot.sum += value
|
|
67
|
+
slot.count += 1
|
|
68
|
+
slot.cells[index] += 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Cumulative bucket counts for a specific series (delegates to slot).
|
|
73
|
+
def cumulative_buckets(labels: {})
|
|
74
|
+
store.synchronize { get(labels: labels)&.cumulative_buckets }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get the slot for a label set, or nil if no observations recorded.
|
|
78
|
+
def get(labels: {})
|
|
79
|
+
key = resolve(labels)
|
|
80
|
+
store.synchronize { store[key] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Reader for sum of a specific series.
|
|
84
|
+
def sum(labels: {})
|
|
85
|
+
store.synchronize { get(labels: labels)&.sum }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reader for count of a specific series.
|
|
89
|
+
def count(labels: {})
|
|
90
|
+
store.synchronize { get(labels: labels)&.count }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.linear_buckets(start:, width:, count:)
|
|
94
|
+
count.times.map { |i| start.to_f + i * width }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.exponential_buckets(start:, factor:, count:)
|
|
98
|
+
raise ArgumentError, "start must be > 0" unless start.positive?
|
|
99
|
+
raise ArgumentError, "factor must be > 1" unless factor > 1
|
|
100
|
+
raise ArgumentError, "count must be >= 1" unless count >= 1
|
|
101
|
+
|
|
102
|
+
count.times.map { |i| start.to_f * (factor**i) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
protected
|
|
106
|
+
|
|
107
|
+
def construction_options
|
|
108
|
+
{ buckets: @buckets }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "store"
|
|
5
|
+
|
|
6
|
+
module Fast
|
|
7
|
+
module Prometheus
|
|
8
|
+
# Metric is the abstract base class for all metric types.
|
|
9
|
+
#
|
|
10
|
+
# Every metric's per-series storage is a Store, guarded by its own lock.
|
|
11
|
+
# Metrics produced by #with_labels share their parent's Store (and thus
|
|
12
|
+
# its lock), so a mutation on a bound metric and a mutation on its parent
|
|
13
|
+
# are mutually exclusive. Safe to share across OS threads and fibers.
|
|
14
|
+
class Metric
|
|
15
|
+
METRIC_NAME = /\A[a-zA-Z_:][a-zA-Z0-9_:]*\z/
|
|
16
|
+
LABEL_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
17
|
+
|
|
18
|
+
def initialize(name, docstring:, labels: [], preset_labels: {}, store: nil)
|
|
19
|
+
validate_metric_name(name)
|
|
20
|
+
validate_docstring(docstring)
|
|
21
|
+
validate_label_names(labels)
|
|
22
|
+
validate_preset_labels(labels, preset_labels)
|
|
23
|
+
|
|
24
|
+
@name = name
|
|
25
|
+
@docstring = docstring
|
|
26
|
+
@label_names = labels
|
|
27
|
+
@preset_labels = preset_labels.transform_values { |v| v.to_s.freeze }
|
|
28
|
+
@store = store || Store.new
|
|
29
|
+
|
|
30
|
+
return unless fully_bound?
|
|
31
|
+
|
|
32
|
+
@resolved_key = resolve_internal(@preset_labels)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :name, :docstring, :label_names, :preset_labels
|
|
36
|
+
|
|
37
|
+
# Returns the current value for the given label set.
|
|
38
|
+
# Scalar types (Counter, Gauge) return 0.0 for unobserved series.
|
|
39
|
+
# Slot-based types (Histogram, Summary, NativeHistogram) override to return nil.
|
|
40
|
+
def get(labels: {})
|
|
41
|
+
key = resolve(labels)
|
|
42
|
+
store.synchronize { store[key] || 0.0 }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def type
|
|
46
|
+
raise NotImplementedError, "subclasses must implement #type"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def with_labels(**labels)
|
|
50
|
+
validate_label_keys(labels)
|
|
51
|
+
merged = @preset_labels.merge(labels.transform_values { |v| v.to_s.freeze })
|
|
52
|
+
self.class.new(
|
|
53
|
+
@name,
|
|
54
|
+
docstring: @docstring,
|
|
55
|
+
labels: @label_names,
|
|
56
|
+
preset_labels: merged,
|
|
57
|
+
store: @store,
|
|
58
|
+
**construction_options
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def values
|
|
63
|
+
store.synchronize { store.to_h.transform_keys { |key| @label_names.zip(key).to_h } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Runs +block+ exclusively with respect to every other mutation or read
|
|
67
|
+
# of this metric's store (including on metrics sharing it via
|
|
68
|
+
# #with_labels). Reentrant on the same thread. This is the seam
|
|
69
|
+
# MetricSnapshot uses to build a consistent snapshot of every series.
|
|
70
|
+
def synchronize(&block)
|
|
71
|
+
store.synchronize(&block)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
protected
|
|
75
|
+
|
|
76
|
+
attr_reader :store
|
|
77
|
+
|
|
78
|
+
# Subclasses override to forward their own configuration (e.g. buckets,
|
|
79
|
+
# schema) through #with_labels. See Histogram, NativeHistogram.
|
|
80
|
+
def construction_options
|
|
81
|
+
{}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve(labels)
|
|
85
|
+
return @resolved_key if @resolved_key && labels.empty?
|
|
86
|
+
|
|
87
|
+
validate_label_keys(labels)
|
|
88
|
+
|
|
89
|
+
@label_names.map do |n|
|
|
90
|
+
if labels.key?(n)
|
|
91
|
+
labels[n].to_s
|
|
92
|
+
else
|
|
93
|
+
value = @preset_labels[n]
|
|
94
|
+
raise InvalidLabelSet, "missing labels: #{n.inspect}" unless value
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
end.freeze
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def fully_bound?
|
|
104
|
+
@preset_labels.size == @label_names.size
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_metric_name(name)
|
|
108
|
+
return if name.to_s.match?(METRIC_NAME)
|
|
109
|
+
|
|
110
|
+
raise InvalidMetricName, "invalid metric name: #{name.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_docstring(docstring)
|
|
114
|
+
return if docstring.is_a?(String) && !docstring.empty?
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "docstring must be a non-empty string"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_label_names(labels)
|
|
120
|
+
labels.each do |label|
|
|
121
|
+
name = label.to_s
|
|
122
|
+
raise InvalidLabelName, "label name must not start with __: #{label.inspect}" if name.start_with?("__")
|
|
123
|
+
raise InvalidLabelName, "invalid label name: #{label.inspect}" unless name.match?(LABEL_NAME)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_preset_labels(labels, preset_labels)
|
|
128
|
+
preset_labels.each_key do |key|
|
|
129
|
+
raise InvalidLabelSet, "preset label not in declared labels: #{key.inspect}" unless labels.include?(key)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_label_keys(labels)
|
|
134
|
+
labels.each_key do |key|
|
|
135
|
+
raise InvalidLabelSet, "unknown label name: #{key.inspect}" unless @label_names.include?(key)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def resolve_internal(merged)
|
|
140
|
+
@label_names.map { |n| merged.fetch(n) }.freeze
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast/prometheus"
|
|
4
|
+
require "protocol/http/middleware"
|
|
5
|
+
require "fast/prometheus/exposition"
|
|
6
|
+
|
|
7
|
+
module Fast
|
|
8
|
+
module Prometheus
|
|
9
|
+
module Middleware
|
|
10
|
+
# Protocol::HTTP middleware that serves metrics at a configurable path.
|
|
11
|
+
# Handles content negotiation (text vs protobuf) and gzip compression.
|
|
12
|
+
class Exporter < Protocol::HTTP::Middleware
|
|
13
|
+
def initialize(delegate, registry: Fast::Prometheus.registry, path: "/metrics")
|
|
14
|
+
super(delegate)
|
|
15
|
+
@registry = registry
|
|
16
|
+
@path = path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(request)
|
|
20
|
+
return serve_metrics(request) if request.method == "GET" && request.path == @path
|
|
21
|
+
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def serve_metrics(request)
|
|
28
|
+
body, headers = Exposition.render(
|
|
29
|
+
@registry,
|
|
30
|
+
accept: request.headers["accept"],
|
|
31
|
+
accept_encoding: request.headers["accept-encoding"]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
Protocol::HTTP::Response[200, Protocol::HTTP::Headers[headers], [body]]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast/prometheus"
|
|
4
|
+
require "protocol/http/middleware"
|
|
5
|
+
require "fast/prometheus/request_metrics"
|
|
6
|
+
|
|
7
|
+
module Fast
|
|
8
|
+
module Prometheus
|
|
9
|
+
module Middleware
|
|
10
|
+
# Protocol::HTTP middleware that records RED metrics for every request.
|
|
11
|
+
# Drop one line into a Falcon app and get request counts and durations.
|
|
12
|
+
class Instrumentation < Protocol::HTTP::Middleware
|
|
13
|
+
def initialize(delegate, registry: Fast::Prometheus.registry, native: false, prefix: "http_server")
|
|
14
|
+
super(delegate)
|
|
15
|
+
@metrics = RequestMetrics.new(registry: registry, native: native, prefix: prefix)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(request)
|
|
19
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
response = super
|
|
21
|
+
@metrics.record(request.method, response.status.to_s, start)
|
|
22
|
+
response
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
begin
|
|
25
|
+
@metrics.record(request.method, "500", start)
|
|
26
|
+
rescue StandardError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
raise e
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric"
|
|
4
|
+
|
|
5
|
+
module Fast
|
|
6
|
+
module Prometheus
|
|
7
|
+
# Native (sparse exponential) histogram.
|
|
8
|
+
#
|
|
9
|
+
# Covers the full float range with sparse base-2 exponential buckets.
|
|
10
|
+
# Bucket layout matches the Prometheus native histogram model exactly.
|
|
11
|
+
class NativeHistogram < Metric
|
|
12
|
+
# Inner value object holding one label series' histogram data.
|
|
13
|
+
# Per-series because downscaling is independent per series.
|
|
14
|
+
class Slot
|
|
15
|
+
attr_reader :zero_threshold
|
|
16
|
+
attr_accessor :schema, :sum, :count, :zero_count, :positive, :negative
|
|
17
|
+
|
|
18
|
+
def initialize(schema:, zero_threshold:)
|
|
19
|
+
@schema = schema
|
|
20
|
+
@zero_threshold = zero_threshold
|
|
21
|
+
@sum = 0.0
|
|
22
|
+
@count = 0
|
|
23
|
+
@zero_count = 0
|
|
24
|
+
@positive = {}
|
|
25
|
+
@negative = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sorted array of [index, count] pairs for positive side.
|
|
29
|
+
def positive_buckets
|
|
30
|
+
@positive.sort
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Sorted array of [index, count] pairs for negative side.
|
|
34
|
+
def negative_buckets
|
|
35
|
+
@negative.sort
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# client_golang math.MaxInt32 convention for clamped ±Inf bucket index.
|
|
40
|
+
MAX_BUCKET_INDEX = (2**31) - 1
|
|
41
|
+
|
|
42
|
+
attr_reader :schema, :zero_threshold, :max_buckets
|
|
43
|
+
|
|
44
|
+
def initialize(name, docstring:, labels: [], preset_labels: {}, schema: 3, zero_threshold: 2.0**-128,
|
|
45
|
+
max_buckets: 160, store: nil)
|
|
46
|
+
valid_schema = schema.is_a?(Integer) && (-4..8).include?(schema)
|
|
47
|
+
raise ArgumentError, "schema must be an Integer in -4..8" unless valid_schema
|
|
48
|
+
|
|
49
|
+
valid_zero_threshold = zero_threshold.is_a?(Float) && zero_threshold >= 0
|
|
50
|
+
raise ArgumentError, "zero_threshold must be a Float >= 0" unless valid_zero_threshold
|
|
51
|
+
|
|
52
|
+
valid_max_buckets = max_buckets.is_a?(Integer) && max_buckets.positive?
|
|
53
|
+
raise ArgumentError, "max_buckets must be an Integer > 0" unless valid_max_buckets
|
|
54
|
+
|
|
55
|
+
@schema = schema
|
|
56
|
+
@zero_threshold = zero_threshold
|
|
57
|
+
@max_buckets = max_buckets
|
|
58
|
+
|
|
59
|
+
super(name, docstring: docstring, labels: labels, preset_labels: preset_labels, store: store)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def type
|
|
63
|
+
:native_histogram
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Record an observation. Never raises — NaN/±Inf are handled gracefully.
|
|
67
|
+
def observe(value, labels: {})
|
|
68
|
+
v = value.to_f
|
|
69
|
+
key = resolve(labels)
|
|
70
|
+
|
|
71
|
+
store.synchronize do
|
|
72
|
+
slot = store[key] ||= Slot.new(schema: @schema, zero_threshold: @zero_threshold)
|
|
73
|
+
|
|
74
|
+
# Determine bucket placement BEFORE mutating count/sum (no partial mutation).
|
|
75
|
+
if v.nan?
|
|
76
|
+
# NaN: count/sum only, no bucket.
|
|
77
|
+
side = nil
|
|
78
|
+
idx = nil
|
|
79
|
+
elsif v.infinite?
|
|
80
|
+
# ±Inf: clamp to MAX_BUCKET_INDEX.
|
|
81
|
+
side = v.positive? ? :positive : :negative
|
|
82
|
+
idx = MAX_BUCKET_INDEX
|
|
83
|
+
elsif v.abs <= slot.zero_threshold
|
|
84
|
+
side = :zero
|
|
85
|
+
idx = nil
|
|
86
|
+
elsif v.positive?
|
|
87
|
+
side = :positive
|
|
88
|
+
idx = index_for(v, slot.schema)
|
|
89
|
+
else
|
|
90
|
+
side = :negative
|
|
91
|
+
idx = index_for(-v, slot.schema)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
slot.sum += v
|
|
95
|
+
slot.count += 1
|
|
96
|
+
|
|
97
|
+
case side
|
|
98
|
+
when :zero
|
|
99
|
+
slot.zero_count += 1
|
|
100
|
+
when :positive
|
|
101
|
+
slot.positive[idx] = (slot.positive[idx] || 0) + 1
|
|
102
|
+
when :negative
|
|
103
|
+
slot.negative[idx] = (slot.negative[idx] || 0) + 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
downscale(slot) while slot.positive.size + slot.negative.size > @max_buckets && slot.schema > -4
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get the slot for a label set, or nil if no observations recorded.
|
|
111
|
+
def get(labels: {})
|
|
112
|
+
key = resolve(labels)
|
|
113
|
+
store.synchronize { store[key] }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
protected
|
|
117
|
+
|
|
118
|
+
def construction_options
|
|
119
|
+
{ schema: @schema, zero_threshold: @zero_threshold, max_buckets: @max_buckets }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def index_for(value, schema)
|
|
125
|
+
factor = 2.0**schema
|
|
126
|
+
idx = (Math.log2(value) * factor).ceil
|
|
127
|
+
idx -= 1 while 2.0**((idx - 1) / factor) >= value
|
|
128
|
+
idx += 1 while 2.0**(idx / factor) < value
|
|
129
|
+
idx
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def downscale(slot)
|
|
133
|
+
delta = 1
|
|
134
|
+
shift = 1 << delta
|
|
135
|
+
slot.positive = slot.positive.each_with_object({}) do |(idx, count), hash|
|
|
136
|
+
hash[-(-idx / shift)] = (hash[-(-idx / shift)] || 0) + count
|
|
137
|
+
end
|
|
138
|
+
slot.negative = slot.negative.each_with_object({}) do |(idx, count), hash|
|
|
139
|
+
hash[-(-idx / shift)] = (hash[-(-idx / shift)] || 0) + count
|
|
140
|
+
end
|
|
141
|
+
slot.schema -= delta
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast/prometheus"
|
|
4
|
+
require "async/grpc"
|
|
5
|
+
require "async/http/endpoint"
|
|
6
|
+
require "async/http/protocol"
|
|
7
|
+
require "protocol/http"
|
|
8
|
+
require_relative "mapper"
|
|
9
|
+
require_relative "service_interface"
|
|
10
|
+
|
|
11
|
+
module Fast
|
|
12
|
+
module Prometheus
|
|
13
|
+
module OTLP
|
|
14
|
+
# Pushes metrics over gRPC using socketry/async-grpc.
|
|
15
|
+
class GRPCExporter
|
|
16
|
+
SERVICE_NAME = "opentelemetry.proto.collector.metrics.v1.MetricsService"
|
|
17
|
+
private_constant :SERVICE_NAME
|
|
18
|
+
|
|
19
|
+
def initialize(endpoint:, registry: Fast::Prometheus.registry, resource_attributes: {}, headers: {})
|
|
20
|
+
@http_endpoint = Async::HTTP::Endpoint.parse(endpoint, protocol: Async::HTTP::Protocol::HTTP2)
|
|
21
|
+
@registry = registry
|
|
22
|
+
@mapper = Mapper.new(resource_attributes: resource_attributes)
|
|
23
|
+
@http_client = Async::HTTP::Client.new(@http_endpoint)
|
|
24
|
+
grpc_headers = Protocol::HTTP::Headers[headers]
|
|
25
|
+
@grpc_client = Async::GRPC::Client.new(@http_client, headers: grpc_headers)
|
|
26
|
+
@stub = @grpc_client.stub(MetricsServiceInterface, SERVICE_NAME)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def export(snapshot = @registry.collect)
|
|
30
|
+
@stub.export(@mapper.request(snapshot))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def close
|
|
34
|
+
@http_client.close
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast/prometheus"
|
|
4
|
+
require "async/http/internet"
|
|
5
|
+
require_relative "mapper"
|
|
6
|
+
|
|
7
|
+
module Fast
|
|
8
|
+
module Prometheus
|
|
9
|
+
module OTLP
|
|
10
|
+
# Exports metrics over HTTP to an OTLP receiver (e.g. Prometheus --web.enable-otlp-receiver).
|
|
11
|
+
class HTTPExporter
|
|
12
|
+
def initialize(endpoint:, registry: Fast::Prometheus.registry, resource_attributes: {}, headers: {})
|
|
13
|
+
@registry = registry
|
|
14
|
+
@url = "#{endpoint}/v1/metrics"
|
|
15
|
+
@headers = [["content-type", "application/x-protobuf"]].concat(
|
|
16
|
+
headers.map { |k, v| [k.to_s, v.to_s] }
|
|
17
|
+
).tap do |a|
|
|
18
|
+
a.freeze
|
|
19
|
+
a.each(&:freeze)
|
|
20
|
+
end
|
|
21
|
+
@mapper = Mapper.new(resource_attributes: resource_attributes)
|
|
22
|
+
@internet = Async::HTTP::Internet.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Export a snapshot (or collect one from the registry) via HTTP POST.
|
|
26
|
+
# Raises Error on non-2xx responses.
|
|
27
|
+
def export(snapshot = @registry.collect)
|
|
28
|
+
request = @mapper.request(snapshot)
|
|
29
|
+
|
|
30
|
+
@internet.post(@url, @headers, request.to_proto) do |response|
|
|
31
|
+
body = response.read
|
|
32
|
+
raise Error, "OTLP export failed: #{response.status} #{body}" unless (200..299).cover?(response.status)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Close the underlying HTTP client.
|
|
37
|
+
def close
|
|
38
|
+
@internet.close
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|