Skip to content

Commit 4069031

Browse files
feat: add basic exponential histogram (#1736)
* feat: add basic exponential histogram without merge * lint * try to remove fiddle * ignore truffleruby for precision-related compare * Update metrics_sdk/lib/opentelemetry/sdk/metrics/aggregation/exponential_bucket_histogram.rb Co-authored-by: Kayla Reopelle <[email protected]> * Update metrics_sdk/lib/opentelemetry/sdk/metrics/aggregation/exponential_bucket_histogram.rb Co-authored-by: Kayla Reopelle <[email protected]> * Update metrics_sdk/test/opentelemetry/sdk/metrics/aggregation/exponential_bucket_histogram_test.rb Co-authored-by: Kayla Reopelle <[email protected]> * revision * add basic test for buckets class * Update metrics_sdk/test/opentelemetry/sdk/metrics/aggregation/exponential_histogram/buckets_test.rb Co-authored-by: Kayla Reopelle <[email protected]> --------- Co-authored-by: Kayla Reopelle <[email protected]>
1 parent 86e443a commit 4069031

File tree

13 files changed

+1335
-0
lines changed

13 files changed

+1335
-0
lines changed

metrics_sdk/lib/opentelemetry/sdk/metrics/aggregation.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ module Aggregation
2121
require 'opentelemetry/sdk/metrics/aggregation/sum'
2222
require 'opentelemetry/sdk/metrics/aggregation/last_value'
2323
require 'opentelemetry/sdk/metrics/aggregation/drop'
24+
require 'opentelemetry/sdk/metrics/aggregation/exponential_histogram_data_point'
25+
require 'opentelemetry/sdk/metrics/aggregation/exponential_bucket_histogram'
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
require_relative 'exponential_histogram/buckets'
8+
require_relative 'exponential_histogram/log2e_scale_factor'
9+
require_relative 'exponential_histogram/ieee_754'
10+
require_relative 'exponential_histogram/logarithm_mapping'
11+
require_relative 'exponential_histogram/exponent_mapping'
12+
13+
module OpenTelemetry
14+
module SDK
15+
module Metrics
16+
module Aggregation
17+
# Contains the implementation of the {https://opentelemetry.io/docs/specs/otel/metrics/data-model/#exponentialhistogram ExponentialBucketHistogram} aggregation
18+
class ExponentialBucketHistogram # rubocop:disable Metrics/ClassLength
19+
attr_reader :aggregation_temporality
20+
21+
# relate to min max scale: https://opentelemetry.io/docs/specs/otel/metrics/sdk/#support-a-minimum-and-maximum-scale
22+
MAX_SCALE = 20
23+
MIN_SCALE = -10
24+
MAX_SIZE = 160
25+
26+
# The default boundaries are calculated based on default max_size and max_scale values
27+
def initialize(
28+
aggregation_temporality: ENV.fetch('OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE', :delta),
29+
max_size: MAX_SIZE,
30+
max_scale: MAX_SCALE,
31+
record_min_max: true,
32+
zero_threshold: 0
33+
)
34+
@aggregation_temporality = aggregation_temporality
35+
@record_min_max = record_min_max
36+
@min = Float::INFINITY
37+
@max = -Float::INFINITY
38+
@sum = 0
39+
@count = 0
40+
@zero_threshold = zero_threshold
41+
@zero_count = 0
42+
@size = validate_size(max_size)
43+
@scale = validate_scale(max_scale)
44+
45+
@mapping = new_mapping(@scale)
46+
end
47+
48+
def collect(start_time, end_time, data_points)
49+
if @aggregation_temporality == :delta
50+
# Set timestamps and 'move' data point values to result.
51+
hdps = data_points.values.map! do |hdp|
52+
hdp.start_time_unix_nano = start_time
53+
hdp.time_unix_nano = end_time
54+
hdp
55+
end
56+
data_points.clear
57+
hdps
58+
else
59+
# Update timestamps and take a snapshot.
60+
data_points.values.map! do |hdp|
61+
hdp.start_time_unix_nano ||= start_time # Start time of a data point is from the first observation.
62+
hdp.time_unix_nano = end_time
63+
hdp = hdp.dup
64+
hdp.positive = hdp.positive.dup
65+
hdp.negative = hdp.negative.dup
66+
hdp
67+
end
68+
end
69+
end
70+
71+
# rubocop:disable Metrics/MethodLength
72+
def update(amount, attributes, data_points)
73+
# fetch or initialize the ExponentialHistogramDataPoint
74+
hdp = data_points.fetch(attributes) do
75+
if @record_min_max
76+
min = Float::INFINITY
77+
max = -Float::INFINITY
78+
end
79+
80+
data_points[attributes] = ExponentialHistogramDataPoint.new(
81+
attributes,
82+
nil, # :start_time_unix_nano
83+
0, # :time_unix_nano
84+
0, # :count
85+
0, # :sum
86+
@scale, # :scale
87+
@zero_count, # :zero_count
88+
ExponentialHistogram::Buckets.new, # :positive
89+
ExponentialHistogram::Buckets.new, # :negative
90+
0, # :flags
91+
nil, # :exemplars
92+
min, # :min
93+
max, # :max
94+
@zero_threshold # :zero_threshold)
95+
)
96+
end
97+
98+
# Start to populate the data point (esp. the buckets)
99+
if @record_min_max
100+
hdp.max = amount if amount > hdp.max
101+
hdp.min = amount if amount < hdp.min
102+
end
103+
104+
hdp.sum += amount
105+
hdp.count += 1
106+
107+
if amount.abs <= @zero_threshold
108+
hdp.zero_count += 1
109+
hdp.scale = 0 if hdp.count == hdp.zero_count # if always getting zero, then there is no point to keep doing the update
110+
return
111+
end
112+
113+
# rescale, map to index, update the buckets here
114+
buckets = amount.positive? ? hdp.positive : hdp.negative
115+
amount = -amount if amount.negative?
116+
117+
bucket_index = @mapping.map_to_index(amount)
118+
119+
rescaling_needed = false
120+
low = high = 0
121+
122+
if buckets.counts == [0] # special case of empty
123+
buckets.index_start = bucket_index
124+
buckets.index_end = bucket_index
125+
buckets.index_base = bucket_index
126+
127+
elsif bucket_index < buckets.index_start && (buckets.index_end - bucket_index) >= @size
128+
rescaling_needed = true
129+
low = bucket_index
130+
high = buckets.index_end
131+
132+
elsif bucket_index > buckets.index_end && (bucket_index - buckets.index_start) >= @size
133+
rescaling_needed = true
134+
low = buckets.index_start
135+
high = bucket_index
136+
end
137+
138+
if rescaling_needed
139+
scale_change = get_scale_change(low, high)
140+
downscale(scale_change, hdp.positive, hdp.negative)
141+
new_scale = @mapping.scale - scale_change
142+
hdp.scale = new_scale
143+
@mapping = new_mapping(new_scale)
144+
bucket_index = @mapping.map_to_index(amount)
145+
146+
OpenTelemetry.logger.debug "Rescaled with new scale #{new_scale} from #{low} and #{high}; bucket_index is updated to #{bucket_index}"
147+
end
148+
149+
# adjust buckets based on the bucket_index
150+
if bucket_index < buckets.index_start
151+
span = buckets.index_end - bucket_index
152+
grow_buckets(span, buckets)
153+
buckets.index_start = bucket_index
154+
elsif bucket_index > buckets.index_end
155+
span = bucket_index - buckets.index_start
156+
grow_buckets(span, buckets)
157+
buckets.index_end = bucket_index
158+
end
159+
160+
bucket_index -= buckets.index_base
161+
bucket_index += buckets.counts.size if bucket_index.negative?
162+
163+
buckets.increment_bucket(bucket_index)
164+
nil
165+
end
166+
# rubocop:enable Metrics/MethodLength
167+
168+
private
169+
170+
def grow_buckets(span, buckets)
171+
return if span < buckets.counts.size
172+
173+
OpenTelemetry.logger.debug "buckets need to grow to #{span + 1} from #{buckets.counts.size} (max bucket size #{@size})"
174+
buckets.grow(span + 1, @size)
175+
end
176+
177+
def new_mapping(scale)
178+
scale <= 0 ? ExponentialHistogram::ExponentMapping.new(scale) : ExponentialHistogram::LogarithmMapping.new(scale)
179+
end
180+
181+
def empty_counts
182+
@boundaries ? Array.new(@boundaries.size + 1, 0) : nil
183+
end
184+
185+
def get_scale_change(low, high)
186+
# puts "get_scale_change: low: #{low}, high: #{high}, @size: #{@size}"
187+
# python code also produce 18 with 0,1048575, the high is little bit off
188+
# just checked, the mapping is also ok, produce the 1048575
189+
change = 0
190+
while high - low >= @size
191+
high >>= 1
192+
low >>= 1
193+
change += 1
194+
end
195+
change
196+
end
197+
198+
def downscale(change, positive, negative)
199+
return if change <= 0
200+
201+
positive.downscale(change)
202+
negative.downscale(change)
203+
end
204+
205+
def validate_scale(scale)
206+
return scale unless scale > MAX_SCALE || scale < MIN_SCALE
207+
208+
OpenTelemetry.logger.warn "Scale #{scale} is invalid, using default max scale #{MAX_SCALE}"
209+
MAX_SCALE
210+
end
211+
212+
def validate_size(size)
213+
return size unless size > MAX_SIZE || size < 0
214+
215+
OpenTelemetry.logger.warn "Size #{size} is invalid, using default max size #{MAX_SIZE}"
216+
MAX_SIZE
217+
end
218+
end
219+
end
220+
end
221+
end
222+
end
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
module OpenTelemetry
8+
module SDK
9+
module Metrics
10+
module Aggregation
11+
module ExponentialHistogram
12+
# Buckets is the fundamental building block of exponential histogram that store bucket/boundary value
13+
class Buckets
14+
attr_accessor :index_start, :index_end, :index_base
15+
16+
def initialize
17+
@counts = [0]
18+
@index_base = 0
19+
@index_start = 0
20+
@index_end = 0
21+
end
22+
23+
# grow simply expand the @counts size
24+
def grow(needed, max_size)
25+
size = @counts.size
26+
bias = @index_base - @index_start
27+
old_positive_limit = size - bias
28+
29+
new_size = [2**Math.log2(needed).ceil, max_size].min
30+
31+
new_positive_limit = new_size - bias
32+
33+
tmp = Array.new(new_size, 0)
34+
tmp[new_positive_limit..-1] = @counts[old_positive_limit..]
35+
tmp[0...old_positive_limit] = @counts[0...old_positive_limit]
36+
@counts = tmp
37+
end
38+
39+
def offset
40+
@index_start
41+
end
42+
43+
def offset_counts
44+
bias = @index_base - @index_start
45+
@counts[-bias..] + @counts[0...-bias]
46+
end
47+
alias counts offset_counts
48+
49+
def length
50+
return 0 if @counts.empty?
51+
return 0 if @index_end == @index_start && counts[0] == 0
52+
53+
@index_end - @index_start + 1
54+
end
55+
56+
def get_bucket(key)
57+
bias = @index_base - @index_start
58+
59+
key += @counts.size if key < bias
60+
key -= bias
61+
62+
@counts[key]
63+
end
64+
65+
def downscale(amount)
66+
bias = @index_base - @index_start
67+
68+
if bias != 0
69+
@index_base = @index_start
70+
@counts.reverse!
71+
@counts = @counts[0...bias].reverse + @counts[bias..].reverse
72+
end
73+
74+
size = 1 + @index_end - @index_start
75+
each = 1 << amount
76+
inpos = 0
77+
outpos = 0
78+
pos = @index_start
79+
80+
while pos <= @index_end
81+
mod = pos % each
82+
mod += each if mod < 0
83+
84+
inds = mod
85+
86+
while inds < each && inpos < size
87+
if outpos != inpos
88+
@counts[outpos] += @counts[inpos]
89+
@counts[inpos] = 0
90+
end
91+
92+
inpos += 1
93+
pos += 1
94+
inds += 1
95+
end
96+
97+
outpos += 1
98+
end
99+
100+
@index_start >>= amount
101+
@index_end >>= amount
102+
@index_base = @index_start
103+
end
104+
105+
def increment_bucket(bucket_index, increment = 1)
106+
@counts[bucket_index] += increment
107+
end
108+
end
109+
end
110+
end
111+
end
112+
end
113+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
module OpenTelemetry
8+
module SDK
9+
module Metrics
10+
module Aggregation
11+
module ExponentialHistogram
12+
# LogarithmMapping for mapping when scale < 0
13+
class ExponentMapping
14+
attr_reader :scale
15+
16+
def initialize(scale)
17+
@scale = scale
18+
@min_normal_lower_boundary_index = calculate_min_normal_lower_boundary_index(scale)
19+
@max_normal_lower_boundary_index = IEEE754::MAX_NORMAL_EXPONENT >> -@scale
20+
end
21+
22+
def map_to_index(value)
23+
return @min_normal_lower_boundary_index if value < IEEE754::MIN_NORMAL_VALUE
24+
25+
exponent = IEEE754.get_ieee_754_exponent(value)
26+
correction = (IEEE754.get_ieee_754_mantissa(value) - 1) >> IEEE754::MANTISSA_WIDTH
27+
(exponent + correction) >> -@scale
28+
end
29+
30+
def calculate_min_normal_lower_boundary_index(scale)
31+
inds = IEEE754::MIN_NORMAL_EXPONENT >> -scale
32+
inds -= 1 if -scale < 2
33+
inds
34+
end
35+
36+
# for testing
37+
def get_lower_boundary(inds)
38+
raise StandardError, 'mapping underflow' if inds < @min_normal_lower_boundary_index || inds > @max_normal_lower_boundary_index
39+
40+
Math.ldexp(1, inds << -@scale)
41+
end
42+
end
43+
end
44+
end
45+
end
46+
end
47+
end

0 commit comments

Comments
 (0)