fast-prometheus 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf50aa0854cf98fd362294bd64ac2f07d04526428b6aa595c50c1058ffc82664
4
- data.tar.gz: 66b21fc919d3913bdda5ae6b1aff7b661eb850981a43f54f5928d67aa0dbf16e
3
+ metadata.gz: d60578f0bebf141e385a3313c32792c5310dd048a242e8f7b901585b71ab43cb
4
+ data.tar.gz: cd38790e53ff5680b72e7773b79226fbb25768bf03e4eeeba14fccb280acee6d
5
5
  SHA512:
6
- metadata.gz: cb355b90d7dd5dcc885ff0181d87611ef6c39120f613053ed9a95e15988cf04b33959c3289bec698e8c664720a85a567729c8547ad53c427aa88214ba0143868
7
- data.tar.gz: 379b37ebbccda56a8c3c7975351f709e138c4928be30ff3a8ccdac991164285db35fe1ce0758630506e36bb68199a311273de47d477b9578dd643ab2a8bc0da7
6
+ metadata.gz: efb05ec4182f3564031b9997bc2ffe840a9846ac0d652a29d6d1f118188de58d6156c49e4db703a8ca65305041bd758406b6bfe56728d767c9850f44e9382d77
7
+ data.tar.gz: 1063c21987bc816e1e41821e21ca831a8576c69d07c823fc2da1488c5800712ac75aa4360bf69cf04cb0b0c1af708a05468b8ca9b17aec1e31e94e2bdeeb2186
data/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-15
11
+
12
+ ### Added
13
+
14
+ - `Registry#exist?(name)` — true iff a metric is registered under `name`.
15
+ - `Metric#init_label_set(labels)` is now public: it resolves `labels` like any
16
+ mutator and creates that series at the type's zero value under the store
17
+ lock, only if it's absent — it never resets a live series.
18
+ - Every metric now seeds a zero-valued series at construction when it's fully
19
+ bound (no declared labels, or `preset_labels:` covers every declared
20
+ label); a fully-bound `with_labels` child seeds its own series the same
21
+ way. A seeded series shows up in `values`, `Registry#collect`, and text/
22
+ protobuf exposition at zero — for example a freshly registered counter now
23
+ renders `requests_total 0.0` instead of being absent from scrapes until
24
+ first incremented. Partially-bound metrics and zero-valued
25
+ `increment`/`decrement` still seed nothing; `get` still never creates a
26
+ series.
27
+ - `Gauge#set_to_current_time(labels: {})` — `set(Time.now.to_f, labels:
28
+ labels)`.
29
+ - `Metric#snapshot_values` — every series' value in its frozen snapshot shape
30
+ (`HistogramValue`, `SummaryValue`, `NativeHistogramValue`, or the
31
+ already-frozen `Float` for `Counter`/`Gauge`), keyed by label hash, built
32
+ under the store lock.
33
+
34
+ ### Changed
35
+
36
+ - **Breaking:** `Metric` names may now be given as a `String` or a `Symbol`
37
+ and are normalized to a `Symbol` on construction; `Metric#name` always
38
+ returns a `Symbol` where it previously echoed back whatever was passed in.
39
+ `Registry#unregister`, `#get`, `#exist?`, and `#fetch_or_register` accept
40
+ either form and symbolize it before lookup; `DuplicateMetric` now fires
41
+ across forms (registering `"requests_total"` after `:requests_total` is
42
+ already registered raises).
43
+ - **Breaking:** `Metric#label_names` is renamed to `Metric#labels`; the old
44
+ name no longer exists (no alias). `MetricSnapshot#label_names` is
45
+ unaffected.
46
+ - **Breaking:** `Histogram#get`/`#values` and `Summary#get`/`#values` now
47
+ return prometheus-client-shaped hashes instead of the internal
48
+ `HistogramSlot`/`Summary::Value` storage objects. `Histogram#get` returns a
49
+ fresh `Hash` keyed by each bucket boundary's `to_s` (ascending), then
50
+ `"+Inf"`, then `"sum"`, with cumulative `Integer` bucket counts and a
51
+ `Float` sum; `Summary#get` returns `{ "count" => Integer, "sum" => Float }`.
52
+ `Histogram#cumulative_buckets`, `#sum`, and `#count` now return zero-valued
53
+ results for an unobserved series instead of `nil`. `NativeHistogram#get`/
54
+ `#values` now return the frozen `NativeHistogramValue` snapshot type
55
+ (previously the internal `NativeHistogram::Slot`), including a zero-valued
56
+ value for an unobserved series. All of these are fresh copies per call;
57
+ mutating a returned `Hash` no longer affects the metric.
58
+ - `Fast::Prometheus.registry=` now assigns under the same lock the reader
59
+ uses, closing a race between a writer and a concurrent reader/memoizing
60
+ first read.
61
+
10
62
  ## [0.1.0] - 2026-09-11
11
63
 
12
64
  ### Added
@@ -17,6 +17,10 @@ module Fast
17
17
  store.synchronize { store[key] = value.to_f }
18
18
  end
19
19
 
20
+ def set_to_current_time(labels: {})
21
+ set(Time.now.to_f, labels: labels)
22
+ end
23
+
20
24
  def increment(by: 1, labels: {})
21
25
  raise ArgumentError, "by must be a numeric" unless by.is_a?(Numeric)
22
26
  return if by.zero?
@@ -69,25 +69,35 @@ module Fast
69
69
  end
70
70
  end
71
71
 
72
- # Cumulative bucket counts for a specific series (delegates to slot).
72
+ # Cumulative bucket counts for a specific series, ending with
73
+ # [Float::INFINITY, count]. Zero-valued pairs for an unobserved series.
73
74
  def cumulative_buckets(labels: {})
74
- store.synchronize { get(labels: labels)&.cumulative_buckets }
75
+ key = resolve(labels)
76
+ store.synchronize { slot_or_zero(key).cumulative_buckets }
75
77
  end
76
78
 
77
- # Get the slot for a label set, or nil if no observations recorded.
79
+ # Prometheus-client-shaped Hash: each bucket boundary's to_s => cumulative
80
+ # count, then "+Inf" => cumulative count, then "sum" => Float sum. A
81
+ # fresh Hash per call; zero-valued for an unobserved series.
78
82
  def get(labels: {})
79
83
  key = resolve(labels)
80
- store.synchronize { store[key] }
84
+ store.synchronize { hash_shape(slot_or_zero(key)) }
85
+ end
86
+
87
+ def values
88
+ series_map { |slot| hash_shape(slot) }
81
89
  end
82
90
 
83
- # Reader for sum of a specific series.
91
+ # Reader for sum of a specific series; 0.0 when unobserved.
84
92
  def sum(labels: {})
85
- store.synchronize { get(labels: labels)&.sum }
93
+ key = resolve(labels)
94
+ store.synchronize { slot_or_zero(key).sum }
86
95
  end
87
96
 
88
- # Reader for count of a specific series.
97
+ # Reader for count of a specific series; 0 when unobserved.
89
98
  def count(labels: {})
90
- store.synchronize { get(labels: labels)&.count }
99
+ key = resolve(labels)
100
+ store.synchronize { slot_or_zero(key).count }
91
101
  end
92
102
 
93
103
  def self.linear_buckets(start:, width:, count:)
@@ -107,6 +117,32 @@ module Fast
107
117
  def construction_options
108
118
  { buckets: @buckets }
109
119
  end
120
+
121
+ private
122
+
123
+ def zero_value
124
+ HistogramSlot.new(buckets: @buckets)
125
+ end
126
+
127
+ def slot_or_zero(key)
128
+ store[key] || zero_value
129
+ end
130
+
131
+ def snapshot_value(slot)
132
+ HistogramValue.new(
133
+ sum: slot.sum,
134
+ count: slot.count,
135
+ cumulative_buckets: slot.cumulative_buckets.map { |pair| pair.dup.freeze }.freeze
136
+ )
137
+ end
138
+
139
+ def hash_shape(slot)
140
+ pairs = slot.cumulative_buckets
141
+ hash = pairs[0..-2].to_h { |boundary, count| [boundary.to_s, count] }
142
+ hash["+Inf"] = pairs.last.last
143
+ hash["sum"] = slot.sum
144
+ hash
145
+ end
110
146
  end
111
147
  end
112
148
  end
@@ -21,27 +21,35 @@ module Fast
21
21
  validate_label_names(labels)
22
22
  validate_preset_labels(labels, preset_labels)
23
23
 
24
- @name = name
24
+ @name = name.to_s.to_sym
25
25
  @docstring = docstring
26
- @label_names = labels
26
+ @labels = labels
27
27
  @preset_labels = preset_labels.transform_values { |v| v.to_s.freeze }
28
28
  @store = store || Store.new
29
29
 
30
30
  return unless fully_bound?
31
31
 
32
32
  @resolved_key = resolve_internal(@preset_labels)
33
+ seed(@resolved_key)
33
34
  end
34
35
 
35
- attr_reader :name, :docstring, :label_names, :preset_labels
36
+ attr_reader :name, :docstring, :labels, :preset_labels
36
37
 
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.
38
+ # Returns the current value for the given label set. Scalar types
39
+ # (Counter, Gauge) return 0.0 for unobserved series. Histogram, Summary,
40
+ # and NativeHistogram override this with their own zero-valued shapes.
40
41
  def get(labels: {})
41
42
  key = resolve(labels)
42
43
  store.synchronize { store[key] || 0.0 }
43
44
  end
44
45
 
46
+ # Creates the series for +labels+ at its zero value if absent. Never
47
+ # resets a series that already exists. Raises InvalidLabelSet on an
48
+ # unknown or incomplete label set, same as any mutator.
49
+ def init_label_set(labels = {})
50
+ seed(resolve(labels))
51
+ end
52
+
45
53
  def type
46
54
  raise NotImplementedError, "subclasses must implement #type"
47
55
  end
@@ -52,7 +60,7 @@ module Fast
52
60
  self.class.new(
53
61
  @name,
54
62
  docstring: @docstring,
55
- labels: @label_names,
63
+ labels: @labels,
56
64
  preset_labels: merged,
57
65
  store: @store,
58
66
  **construction_options
@@ -60,7 +68,15 @@ module Fast
60
68
  end
61
69
 
62
70
  def values
63
- store.synchronize { store.to_h.transform_keys { |key| @label_names.zip(key).to_h } }
71
+ snapshot_values
72
+ end
73
+
74
+ # Every series' value in its frozen snapshot shape (see HistogramValue,
75
+ # SummaryValue, NativeHistogramValue; Counter/Gauge use the already-
76
+ # frozen Float), keyed by label hash, built under the store lock. The
77
+ # seam MetricSnapshot uses to build a consistent view of every series.
78
+ def snapshot_values
79
+ series_map { |slot| snapshot_value(slot) }
64
80
  end
65
81
 
66
82
  # Runs +block+ exclusively with respect to every other mutation or read
@@ -86,7 +102,7 @@ module Fast
86
102
 
87
103
  validate_label_keys(labels)
88
104
 
89
- @label_names.map do |n|
105
+ @labels.map do |n|
90
106
  if labels.key?(n)
91
107
  labels[n].to_s
92
108
  else
@@ -100,8 +116,35 @@ module Fast
100
116
 
101
117
  private
102
118
 
119
+ # Zero value for a fresh series. Counter/Gauge share this Float
120
+ # default; Histogram, Summary, and NativeHistogram override it with
121
+ # their own empty slot.
122
+ def zero_value
123
+ 0.0
124
+ end
125
+
126
+ # Turns one slot into its frozen snapshot value. Counter/Gauge slots
127
+ # are already-frozen Floats; Histogram, Summary, and NativeHistogram
128
+ # override this to build their own snapshot value type.
129
+ def snapshot_value(slot)
130
+ slot
131
+ end
132
+
133
+ # Shared shape behind #values and #snapshot_values: every series'
134
+ # storage transformed by +block+, keyed by label hash, under the lock.
135
+ def series_map(&block)
136
+ store.synchronize { store.to_h.transform_keys { |key| @labels.zip(key).to_h }.transform_values(&block) }
137
+ end
138
+
139
+ # Creates the series at +key+ at its zero value if absent. Never resets
140
+ # a series that already exists. Shared by #initialize (auto-seeding a
141
+ # fully-bound metric) and #init_label_set.
142
+ def seed(key)
143
+ store.synchronize { store[key] ||= zero_value }
144
+ end
145
+
103
146
  def fully_bound?
104
- @preset_labels.size == @label_names.size
147
+ @preset_labels.size == @labels.size
105
148
  end
106
149
 
107
150
  def validate_metric_name(name)
@@ -132,12 +175,12 @@ module Fast
132
175
 
133
176
  def validate_label_keys(labels)
134
177
  labels.each_key do |key|
135
- raise InvalidLabelSet, "unknown label name: #{key.inspect}" unless @label_names.include?(key)
178
+ raise InvalidLabelSet, "unknown label name: #{key.inspect}" unless @labels.include?(key)
136
179
  end
137
180
  end
138
181
 
139
182
  def resolve_internal(merged)
140
- @label_names.map { |n| merged.fetch(n) }.freeze
183
+ @labels.map { |n| merged.fetch(n) }.freeze
141
184
  end
142
185
  end
143
186
  end
@@ -107,10 +107,15 @@ module Fast
107
107
  end
108
108
  end
109
109
 
110
- # Get the slot for a label set, or nil if no observations recorded.
110
+ # A frozen NativeHistogramValue for a label set; zero-valued at this
111
+ # metric's schema/zero_threshold for an unobserved series.
111
112
  def get(labels: {})
112
113
  key = resolve(labels)
113
- store.synchronize { store[key] }
114
+ store.synchronize { snapshot_value(store[key] || zero_value) }
115
+ end
116
+
117
+ def values
118
+ series_map { |slot| snapshot_value(slot) }
114
119
  end
115
120
 
116
121
  protected
@@ -121,6 +126,22 @@ module Fast
121
126
 
122
127
  private
123
128
 
129
+ def zero_value
130
+ Slot.new(schema: @schema, zero_threshold: @zero_threshold)
131
+ end
132
+
133
+ def snapshot_value(slot)
134
+ NativeHistogramValue.new(
135
+ schema: slot.schema,
136
+ zero_threshold: slot.zero_threshold,
137
+ zero_count: slot.zero_count,
138
+ sum: slot.sum,
139
+ count: slot.count,
140
+ positive_buckets: slot.positive_buckets.map { |pair| pair.dup.freeze }.freeze,
141
+ negative_buckets: slot.negative_buckets.map { |pair| pair.dup.freeze }.freeze
142
+ ).freeze
143
+ end
144
+
124
145
  def index_for(value, schema)
125
146
  factor = 2.0**schema
126
147
  idx = (Math.log2(value) * factor).ceil
@@ -28,14 +28,19 @@ module Fast
28
28
  end
29
29
  end
30
30
 
31
- # Remove a metric by name.
31
+ # Remove a metric by name (String or Symbol).
32
32
  def unregister(name)
33
- @lock.synchronize { @by_name.delete(name) }
33
+ @lock.synchronize { @by_name.delete(normalize(name)) }
34
34
  end
35
35
 
36
- # Look up a metric by name, or nil.
36
+ # Look up a metric by name (String or Symbol), or nil.
37
37
  def get(name)
38
- @lock.synchronize { @by_name[name] }
38
+ @lock.synchronize { @by_name[normalize(name)] }
39
+ end
40
+
41
+ # True iff a metric is registered under +name+ (String or Symbol).
42
+ def exist?(name)
43
+ @lock.synchronize { @by_name.key?(normalize(name)) }
39
44
  end
40
45
 
41
46
  # Atomic fetch-or-register: returns the metric already registered under
@@ -45,15 +50,16 @@ module Fast
45
50
  # DuplicateMetric. Raises ArgumentError if the block's metric is not
46
51
  # named +name+.
47
52
  def fetch_or_register(name)
53
+ key = normalize(name)
48
54
  @lock.synchronize do
49
- next @by_name[name] if @by_name.key?(name)
55
+ next @by_name[key] if @by_name.key?(key)
50
56
 
51
57
  metric = yield
52
- unless metric.name == name
53
- raise ArgumentError, "fetch_or_register(#{name.inspect}) block built #{metric.name.inspect}"
58
+ unless metric.name == key
59
+ raise ArgumentError, "fetch_or_register(#{key.inspect}) block built #{metric.name.inspect}"
54
60
  end
55
61
 
56
- @by_name[name] = metric
62
+ @by_name[key] = metric
57
63
  end
58
64
  end
59
65
 
@@ -93,6 +99,12 @@ module Fast
93
99
  def collect
94
100
  Snapshot.of(metrics)
95
101
  end
102
+
103
+ private
104
+
105
+ def normalize(name)
106
+ name.to_s.to_sym
107
+ end
96
108
  end
97
109
 
98
110
  @registry_lock = Mutex.new
@@ -105,7 +117,7 @@ module Fast
105
117
  end
106
118
 
107
119
  def self.registry=(registry)
108
- @registry = registry
120
+ @registry_lock.synchronize { @registry = registry }
109
121
  end
110
122
  end
111
123
  end
@@ -24,43 +24,15 @@ module Fast
24
24
  metric.name,
25
25
  metric.docstring,
26
26
  metric.type,
27
- metric.label_names.dup.freeze,
27
+ metric.labels.dup.freeze,
28
28
  build_series(metric).freeze
29
29
  )
30
30
  end
31
31
  end
32
32
 
33
33
  def self.build_series(metric)
34
- metric.values.map do |labels, value|
35
- Series.new(
36
- labels: labels.dup.freeze,
37
- value: build_value(metric.type, value)
38
- )
39
- end
40
- end
41
-
42
- private_class_method def self.build_value(type, value)
43
- case type
44
- when :counter, :gauge
45
- value
46
- when :histogram
47
- HistogramValue.new(
48
- sum: value.sum,
49
- count: value.count,
50
- cumulative_buckets: value.cumulative_buckets.map { |pair| pair.dup.freeze }.freeze
51
- )
52
- when :summary
53
- SummaryValue.new(sum: value.sum, count: value.count)
54
- when :native_histogram
55
- NativeHistogramValue.new(
56
- schema: value.schema,
57
- zero_threshold: value.zero_threshold,
58
- zero_count: value.zero_count,
59
- sum: value.sum,
60
- count: value.count,
61
- positive_buckets: value.positive_buckets.map { |pair| pair.dup.freeze }.freeze,
62
- negative_buckets: value.negative_buckets.map { |pair| pair.dup.freeze }.freeze
63
- )
34
+ metric.snapshot_values.map do |labels, value|
35
+ Series.new(labels: labels.dup.freeze, value: value)
64
36
  end
65
37
  end
66
38
  end
@@ -30,9 +30,14 @@ module Fast
30
30
  end
31
31
  end
32
32
 
33
+ # {"count" => Integer, "sum" => Float}. Zero-valued for an unobserved series.
33
34
  def get(labels: {})
34
35
  key = resolve(labels)
35
- store.synchronize { store[key] }
36
+ store.synchronize { hash_shape(store[key] || zero_value) }
37
+ end
38
+
39
+ def values
40
+ series_map { |slot| hash_shape(slot) }
36
41
  end
37
42
 
38
43
  protected
@@ -45,6 +50,20 @@ module Fast
45
50
 
46
51
  super
47
52
  end
53
+
54
+ private
55
+
56
+ def zero_value
57
+ Value.new
58
+ end
59
+
60
+ def snapshot_value(slot)
61
+ SummaryValue.new(sum: slot.sum, count: slot.count)
62
+ end
63
+
64
+ def hash_shape(slot)
65
+ { "count" => slot.count, "sum" => slot.sum }
66
+ end
48
67
  end
49
68
  end
50
69
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  module Prometheus
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-prometheus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs