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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +38 -0
  3. data/CHANGELOG.md +59 -0
  4. data/README.md +109 -0
  5. data/lib/fast/prometheus/counter.rb +22 -0
  6. data/lib/fast/prometheus/errors.rb +11 -0
  7. data/lib/fast/prometheus/exposition.rb +46 -0
  8. data/lib/fast/prometheus/formats/metrics_pb.rb +33 -0
  9. data/lib/fast/prometheus/formats/protobuf.rb +154 -0
  10. data/lib/fast/prometheus/formats/text.rb +90 -0
  11. data/lib/fast/prometheus/gauge.rb +37 -0
  12. data/lib/fast/prometheus/histogram.rb +112 -0
  13. data/lib/fast/prometheus/metric.rb +144 -0
  14. data/lib/fast/prometheus/middleware/exporter.rb +39 -0
  15. data/lib/fast/prometheus/middleware/instrumentation.rb +34 -0
  16. data/lib/fast/prometheus/native_histogram.rb +145 -0
  17. data/lib/fast/prometheus/otlp/grpc_exporter.rb +39 -0
  18. data/lib/fast/prometheus/otlp/http_exporter.rb +43 -0
  19. data/lib/fast/prometheus/otlp/mapper.rb +207 -0
  20. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.rb +27 -0
  21. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/common/v1/common_pb.rb +26 -0
  22. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/metrics/v1/metrics_pb.rb +41 -0
  23. data/lib/fast/prometheus/otlp/pb/opentelemetry/proto/resource/v1/resource_pb.rb +23 -0
  24. data/lib/fast/prometheus/otlp/push.rb +39 -0
  25. data/lib/fast/prometheus/otlp/service_interface.rb +18 -0
  26. data/lib/fast/prometheus/rack/exporter.rb +32 -0
  27. data/lib/fast/prometheus/rack/instrumentation.rb +33 -0
  28. data/lib/fast/prometheus/registry.rb +111 -0
  29. data/lib/fast/prometheus/request_metrics.rb +45 -0
  30. data/lib/fast/prometheus/snapshot.rb +78 -0
  31. data/lib/fast/prometheus/store.rb +37 -0
  32. data/lib/fast/prometheus/summary.rb +50 -0
  33. data/lib/fast/prometheus/version.rb +7 -0
  34. data/lib/fast/prometheus.rb +17 -0
  35. 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