fast-prometheus 0.1.0 → 0.3.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.
@@ -7,6 +7,10 @@ module Fast
7
7
  module Formats
8
8
  # Prometheus text exposition format 0.0.4.
9
9
  # Consumes Snapshot value objects; never reads live metrics.
10
+ #
11
+ # Every piece is appended straight to the output buffer: no line, label
12
+ # or name is built as its own String first, so a render allocates one
13
+ # String per sample (the value's to_s) and nothing per label.
10
14
  module Text
11
15
  CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
12
16
 
@@ -20,69 +24,75 @@ module Fast
20
24
  snapshot.metrics.each_with_object(String.new) do |metric, output|
21
25
  next if metric.type == :native_histogram
22
26
 
23
- output << "# HELP #{metric.name} #{escape_doc(metric.docstring)}\n"
24
- output << "# TYPE #{metric.name} #{metric.type}\n"
27
+ name = metric.name.name
28
+ output << "# HELP " << name << " " << escape_doc(metric.docstring) << "\n"
29
+ output << "# TYPE " << name << " " << metric.type.name << "\n"
25
30
 
26
- metric.series.each do |series|
27
- render_series(output, metric.type, metric.name, series)
31
+ names = metric.label_names
32
+ metric.series.each_pair do |values, value|
33
+ render_series(output, metric.type, name, names, values, value)
28
34
  end
29
35
  end
30
36
  end
31
37
 
32
- private_class_method def self.render_series(output, type, name, series)
38
+ private_class_method def self.render_series(output, type, name, names, values, value)
33
39
  case type
34
40
  when :counter, :gauge
35
- metric_line(output, name, series.labels, series.value)
41
+ sample(output, name, nil, names, values, value)
36
42
  when :histogram
37
- histogram_lines(output, name, series.labels, series.value)
43
+ histogram_lines(output, name, names, values, value)
38
44
  when :summary
39
- summary_lines(output, name, series.labels, series.value)
45
+ sample(output, name, "_sum", names, values, value.sum)
46
+ sample(output, name, "_count", names, values, value.count)
40
47
  end
41
48
  end
42
49
 
43
- private_class_method def self.escape_doc(string)
44
- return string unless DOC_ESCAPE.match?(string)
45
-
46
- string.gsub(DOC_ESCAPE, DOC_REPLACE)
50
+ # One sample line: name, optional suffix, labels (names and values
51
+ # side by side, plus an le label carrying +upper_bound+ for histogram
52
+ # buckets), value.
53
+ private_class_method def self.sample(output, name, suffix, names, values, value, upper_bound = nil)
54
+ output << name
55
+ output << suffix if suffix
56
+ append_labels(output, names, values, upper_bound)
57
+ output << " " << value.to_s << "\n"
47
58
  end
48
59
 
49
- private_class_method def self.escape_label(string)
50
- string = string.to_s
51
- return string unless LABEL_ESCAPE.match?(string)
60
+ private_class_method def self.append_labels(output, names, values, upper_bound)
61
+ return if names.empty? && upper_bound.nil?
52
62
 
53
- string.gsub(LABEL_ESCAPE, LABEL_REPLACE)
63
+ output << "{"
64
+ names.each_index { |i| output << label_name(names[i]) << "=\"" << escape_label(values[i]) << "\"," }
65
+ output << "le=\"" << upper_bound << "\"," if upper_bound
66
+ output.chop!
67
+ output << "}"
54
68
  end
55
69
 
56
- private_class_method def self.format_labels(labels)
57
- return "" if labels.empty?
58
-
59
- output = String.new("{")
60
- labels.each do |key, value|
61
- output << "#{key}=\"#{escape_label(value)}\""
62
- output << ","
70
+ private_class_method def self.histogram_lines(output, name, names, values, value)
71
+ value.cumulative_buckets.each do |boundary, count|
72
+ sample(output, name, "_bucket", names, values, count, boundary.infinite? ? "+Inf" : boundary.to_s)
63
73
  end
64
- output.chop!
65
- output << "}"
66
- output
74
+
75
+ sample(output, name, "_sum", names, values, value.sum)
76
+ sample(output, name, "_count", names, values, value.count)
67
77
  end
68
78
 
69
- private_class_method def self.metric_line(output, name, labels, value)
70
- output << "#{name}#{format_labels(labels)} #{value}\n"
79
+ # Label names are Symbols (Symbol#name is frozen and shared); a
80
+ # String name is appended as is.
81
+ private_class_method def self.label_name(key)
82
+ key.is_a?(Symbol) ? key.name : key
71
83
  end
72
84
 
73
- private_class_method def self.histogram_lines(output, name, labels, value)
74
- value.cumulative_buckets.each do |boundary, count|
75
- le = boundary.infinite? ? "+Inf" : boundary.to_s
76
- metric_line(output, "#{name}_bucket", labels.merge("le" => le), count)
77
- end
85
+ private_class_method def self.escape_doc(string)
86
+ return string unless DOC_ESCAPE.match?(string)
78
87
 
79
- metric_line(output, "#{name}_sum", labels, value.sum)
80
- metric_line(output, "#{name}_count", labels, value.count)
88
+ string.gsub(DOC_ESCAPE, DOC_REPLACE)
81
89
  end
82
90
 
83
- private_class_method def self.summary_lines(output, name, labels, value)
84
- metric_line(output, "#{name}_sum", labels, value.sum)
85
- metric_line(output, "#{name}_count", labels, value.count)
91
+ private_class_method def self.escape_label(string)
92
+ string = string.to_s
93
+ return string unless LABEL_ESCAPE.match?(string)
94
+
95
+ string.gsub(LABEL_ESCAPE, LABEL_REPLACE)
86
96
  end
87
97
  end
88
98
  end
@@ -10,14 +10,18 @@ module Fast
10
10
  :gauge
11
11
  end
12
12
 
13
- def set(value, labels: {})
13
+ def set(value, labels: NO_LABELS)
14
14
  raise ArgumentError, "value must be a numeric" unless value.is_a?(Numeric)
15
15
 
16
16
  key = resolve(labels)
17
17
  store.synchronize { store[key] = value.to_f }
18
18
  end
19
19
 
20
- def increment(by: 1, labels: {})
20
+ def set_to_current_time(labels: NO_LABELS)
21
+ set(Time.now.to_f, labels: labels)
22
+ end
23
+
24
+ def increment(by: 1, labels: NO_LABELS)
21
25
  raise ArgumentError, "by must be a numeric" unless by.is_a?(Numeric)
22
26
  return if by.zero?
23
27
 
@@ -25,7 +29,7 @@ module Fast
25
29
  store.synchronize { store[key] = (store[key] || 0.0) + by.to_f }
26
30
  end
27
31
 
28
- def decrement(by: 1, labels: {})
32
+ def decrement(by: 1, labels: NO_LABELS)
29
33
  raise ArgumentError, "by must be a numeric" unless by.is_a?(Numeric)
30
34
  return if by.zero?
31
35
 
@@ -25,15 +25,19 @@ module Fast
25
25
  @cells = [0] * (buckets.size + 1)
26
26
  end
27
27
 
28
- # Cumulative bucket counts, ending with [Float::INFINITY, count].
28
+ # Cumulative bucket counts as frozen [boundary, count] pairs, ending
29
+ # with [Float::INFINITY, count]. Frozen on the way out so a snapshot
30
+ # can hold it as is.
29
31
  def cumulative_buckets
30
32
  cumulative = 0
31
- result = @buckets.each_with_index.map do |boundary, i|
33
+ result = Array.new(@buckets.size + 1)
34
+ @buckets.each_index do |i|
32
35
  cumulative += @cells[i]
33
- [boundary, cumulative]
36
+ result[i] = [@buckets[i], cumulative].freeze
34
37
  end
35
38
  cumulative += @cells.last # overflow cell
36
- result << [Float::INFINITY, cumulative]
39
+ result[@buckets.size] = [Float::INFINITY, cumulative].freeze
40
+ result.freeze
37
41
  end
38
42
  end
39
43
 
@@ -56,7 +60,7 @@ module Fast
56
60
 
57
61
  # Record an observation. Finds the first boundary >= value (inclusive le)
58
62
  # using bsearch_index. Values above the last boundary go to the overflow cell.
59
- def observe(value, labels: {})
63
+ def observe(value, labels: NO_LABELS)
60
64
  key = resolve(labels)
61
65
  index = @buckets.bsearch_index { |boundary| boundary >= value } || @buckets.size
62
66
 
@@ -69,25 +73,35 @@ module Fast
69
73
  end
70
74
  end
71
75
 
72
- # Cumulative bucket counts for a specific series (delegates to slot).
73
- def cumulative_buckets(labels: {})
74
- store.synchronize { get(labels: labels)&.cumulative_buckets }
76
+ # Cumulative bucket counts for a specific series, ending with
77
+ # [Float::INFINITY, count]. Zero-valued pairs for an unobserved series.
78
+ def cumulative_buckets(labels: NO_LABELS)
79
+ key = resolve(labels)
80
+ store.synchronize { slot_or_zero(key).cumulative_buckets }
75
81
  end
76
82
 
77
- # Get the slot for a label set, or nil if no observations recorded.
78
- def get(labels: {})
83
+ # Prometheus-client-shaped Hash: each bucket boundary's to_s => cumulative
84
+ # count, then "+Inf" => cumulative count, then "sum" => Float sum. A
85
+ # fresh Hash per call; zero-valued for an unobserved series.
86
+ def get(labels: NO_LABELS)
79
87
  key = resolve(labels)
80
- store.synchronize { store[key] }
88
+ store.synchronize { hash_shape(slot_or_zero(key)) }
81
89
  end
82
90
 
83
- # Reader for sum of a specific series.
84
- def sum(labels: {})
85
- store.synchronize { get(labels: labels)&.sum }
91
+ def values
92
+ series_map { |slot| hash_shape(slot) }
86
93
  end
87
94
 
88
- # Reader for count of a specific series.
89
- def count(labels: {})
90
- store.synchronize { get(labels: labels)&.count }
95
+ # Reader for sum of a specific series; 0.0 when unobserved.
96
+ def sum(labels: NO_LABELS)
97
+ key = resolve(labels)
98
+ store.synchronize { slot_or_zero(key).sum }
99
+ end
100
+
101
+ # Reader for count of a specific series; 0 when unobserved.
102
+ def count(labels: NO_LABELS)
103
+ key = resolve(labels)
104
+ store.synchronize { slot_or_zero(key).count }
91
105
  end
92
106
 
93
107
  def self.linear_buckets(start:, width:, count:)
@@ -107,6 +121,28 @@ module Fast
107
121
  def construction_options
108
122
  { buckets: @buckets }
109
123
  end
124
+
125
+ private
126
+
127
+ def zero_value
128
+ HistogramSlot.new(buckets: @buckets)
129
+ end
130
+
131
+ def slot_or_zero(key)
132
+ store[key] || zero_value
133
+ end
134
+
135
+ def snapshot_value(slot)
136
+ HistogramValue.new(slot.sum, slot.count, slot.cumulative_buckets).freeze
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
@@ -15,33 +15,44 @@ module Fast
15
15
  METRIC_NAME = /\A[a-zA-Z_:][a-zA-Z0-9_:]*\z/
16
16
  LABEL_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
17
17
 
18
+ # The default label set, shared so an unlabeled call allocates nothing.
19
+ NO_LABELS = {}.freeze
20
+
18
21
  def initialize(name, docstring:, labels: [], preset_labels: {}, store: nil)
19
22
  validate_metric_name(name)
20
23
  validate_docstring(docstring)
21
24
  validate_label_names(labels)
22
25
  validate_preset_labels(labels, preset_labels)
23
26
 
24
- @name = name
27
+ @name = name.to_s.to_sym
25
28
  @docstring = docstring
26
- @label_names = labels
29
+ @labels = labels
27
30
  @preset_labels = preset_labels.transform_values { |v| v.to_s.freeze }
28
31
  @store = store || Store.new
29
32
 
30
33
  return unless fully_bound?
31
34
 
32
35
  @resolved_key = resolve_internal(@preset_labels)
36
+ seed(@resolved_key)
33
37
  end
34
38
 
35
- attr_reader :name, :docstring, :label_names, :preset_labels
39
+ attr_reader :name, :docstring, :labels, :preset_labels
36
40
 
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
+ # Returns the current value for the given label set. Scalar types
42
+ # (Counter, Gauge) return 0.0 for unobserved series. Histogram, Summary,
43
+ # and NativeHistogram override this with their own zero-valued shapes.
44
+ def get(labels: NO_LABELS)
41
45
  key = resolve(labels)
42
46
  store.synchronize { store[key] || 0.0 }
43
47
  end
44
48
 
49
+ # Creates the series for +labels+ at its zero value if absent. Never
50
+ # resets a series that already exists. Raises InvalidLabelSet on an
51
+ # unknown or incomplete label set, same as any mutator.
52
+ def init_label_set(labels = NO_LABELS)
53
+ seed(resolve(labels))
54
+ end
55
+
45
56
  def type
46
57
  raise NotImplementedError, "subclasses must implement #type"
47
58
  end
@@ -52,7 +63,7 @@ module Fast
52
63
  self.class.new(
53
64
  @name,
54
65
  docstring: @docstring,
55
- labels: @label_names,
66
+ labels: @labels,
56
67
  preset_labels: merged,
57
68
  store: @store,
58
69
  **construction_options
@@ -60,15 +71,25 @@ module Fast
60
71
  end
61
72
 
62
73
  def values
63
- store.synchronize { store.to_h.transform_keys { |key| @label_names.zip(key).to_h } }
74
+ series_map { |slot| snapshot_value(slot) }
75
+ end
76
+
77
+ # Every series' value in its frozen snapshot shape (see HistogramValue,
78
+ # SummaryValue, NativeHistogramValue; Counter/Gauge use the already-
79
+ # frozen Float), keyed by the series' label values in #labels order,
80
+ # the store's own key. The store is copied and its slots frozen under
81
+ # the lock, so this is a consistent view of every series at one
82
+ # instant that costs one Hash. The seam MetricSnapshot uses.
83
+ def snapshot_values
84
+ store.synchronize { store.to_h.transform_values! { |slot| snapshot_value(slot) } }
64
85
  end
65
86
 
66
- # Runs +block+ exclusively with respect to every other mutation or read
87
+ # Runs the block exclusively with respect to every other mutation or read
67
88
  # of this metric's store (including on metrics sharing it via
68
89
  # #with_labels). Reentrant on the same thread. This is the seam
69
90
  # MetricSnapshot uses to build a consistent snapshot of every series.
70
- def synchronize(&block)
71
- store.synchronize(&block)
91
+ def synchronize
92
+ store.synchronize { yield } # rubocop:disable Style/ExplicitBlockArgument
72
93
  end
73
94
 
74
95
  protected
@@ -86,7 +107,7 @@ module Fast
86
107
 
87
108
  validate_label_keys(labels)
88
109
 
89
- @label_names.map do |n|
110
+ @labels.map do |n|
90
111
  if labels.key?(n)
91
112
  labels[n].to_s
92
113
  else
@@ -100,8 +121,47 @@ module Fast
100
121
 
101
122
  private
102
123
 
124
+ # Zero value for a fresh series. Counter/Gauge share this Float
125
+ # default; Histogram, Summary, and NativeHistogram override it with
126
+ # their own empty slot.
127
+ def zero_value
128
+ 0.0
129
+ end
130
+
131
+ # Turns one slot into its frozen snapshot value. Counter/Gauge slots
132
+ # are already-frozen Floats; Histogram, Summary, and NativeHistogram
133
+ # override this to build their own snapshot value type.
134
+ def snapshot_value(slot)
135
+ slot
136
+ end
137
+
138
+ # Shared shape behind #values and #snapshot_values: every series'
139
+ # storage transformed by the block, keyed by a fresh frozen label hash,
140
+ # under the lock. yield, not &block: a captured block is a Proc
141
+ # allocation on every call.
142
+ def series_map
143
+ store.synchronize do
144
+ store.to_h.transform_keys { |key| label_hash(key) }.transform_values { |slot| yield slot } # rubocop:disable Style/ExplicitBlockArgument
145
+ end
146
+ end
147
+
148
+ # {name => value} for a resolved key, built without the per-pair Arrays
149
+ # Array#zip would make (or the iterator object each_with_index would).
150
+ def label_hash(key)
151
+ hash = {}
152
+ @labels.each_index { |i| hash[@labels[i]] = key[i] }
153
+ hash.freeze
154
+ end
155
+
156
+ # Creates the series at +key+ at its zero value if absent. Never resets
157
+ # a series that already exists. Shared by #initialize (auto-seeding a
158
+ # fully-bound metric) and #init_label_set.
159
+ def seed(key)
160
+ store.synchronize { store[key] ||= zero_value }
161
+ end
162
+
103
163
  def fully_bound?
104
- @preset_labels.size == @label_names.size
164
+ @preset_labels.size == @labels.size
105
165
  end
106
166
 
107
167
  def validate_metric_name(name)
@@ -132,12 +192,12 @@ module Fast
132
192
 
133
193
  def validate_label_keys(labels)
134
194
  labels.each_key do |key|
135
- raise InvalidLabelSet, "unknown label name: #{key.inspect}" unless @label_names.include?(key)
195
+ raise InvalidLabelSet, "unknown label name: #{key.inspect}" unless @labels.include?(key)
136
196
  end
137
197
  end
138
198
 
139
199
  def resolve_internal(merged)
140
- @label_names.map { |n| merged.fetch(n) }.freeze
200
+ @labels.map { |n| merged.fetch(n) }.freeze
141
201
  end
142
202
  end
143
203
  end
@@ -25,14 +25,14 @@ module Fast
25
25
  @negative = {}
26
26
  end
27
27
 
28
- # Sorted array of [index, count] pairs for positive side.
28
+ # Sorted frozen [index, count] pairs for the positive side.
29
29
  def positive_buckets
30
- @positive.sort
30
+ @positive.sort.each(&:freeze).freeze
31
31
  end
32
32
 
33
- # Sorted array of [index, count] pairs for negative side.
33
+ # Sorted frozen [index, count] pairs for the negative side.
34
34
  def negative_buckets
35
- @negative.sort
35
+ @negative.sort.each(&:freeze).freeze
36
36
  end
37
37
  end
38
38
 
@@ -64,7 +64,7 @@ module Fast
64
64
  end
65
65
 
66
66
  # Record an observation. Never raises — NaN/±Inf are handled gracefully.
67
- def observe(value, labels: {})
67
+ def observe(value, labels: NO_LABELS)
68
68
  v = value.to_f
69
69
  key = resolve(labels)
70
70
 
@@ -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.
111
- def get(labels: {})
110
+ # A frozen NativeHistogramValue for a label set; zero-valued at this
111
+ # metric's schema/zero_threshold for an unobserved series.
112
+ def get(labels: NO_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,15 @@ 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(slot.schema, slot.zero_threshold, slot.zero_count, slot.sum, slot.count,
135
+ slot.positive_buckets, slot.negative_buckets).freeze
136
+ end
137
+
124
138
  def index_for(value, schema)
125
139
  factor = 2.0**schema
126
140
  idx = (Math.log2(value) * factor).ceil