Class: Philiprehberger::RateWindow::Tracker

Inherits:
Object
  • Object
show all
Defined in:
lib/philiprehberger/rate_window/tracker.rb

Overview

Thread-safe time-windowed rate tracker with configurable resolution.

Instance Method Summary collapse

Constructor Details

#initialize(window: 60, resolution: 1) ⇒ Tracker

Returns a new instance of Tracker.

Parameters:

  • window (Numeric) (defaults to: 60)

    window duration in seconds

  • resolution (Numeric) (defaults to: 1)

    bucket size in seconds

Raises:



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# File 'lib/philiprehberger/rate_window/tracker.rb', line 9

def initialize(window: 60, resolution: 1)
  raise Error, 'window must be positive' unless window.positive?
  raise Error, 'resolution must be positive' unless resolution.positive?
  raise Error, 'resolution must be <= window' unless resolution <= window

  @window = window.to_f
  @resolution = resolution.to_f
  @bucket_count = (@window / @resolution).ceil
  @mutex = Mutex.new
  @buckets = Array.new(@bucket_count, 0.0)
  @counts = Array.new(@bucket_count, 0)
  @mins = Array.new(@bucket_count, Float::INFINITY)
  @maxs = Array.new(@bucket_count, -Float::INFINITY)
  @last_bucket_index = current_bucket_index
  @last_time = now
end

Instance Method Details

#averageFloat

Average value per recording in the window.

Returns:

  • (Float)

    average, or 0.0 if no recordings



81
82
83
84
85
86
87
88
89
# File 'lib/philiprehberger/rate_window/tracker.rb', line 81

def average
  @mutex.synchronize do
    cleanup
    total_count = @counts.sum
    return 0.0 if total_count.zero?

    @buckets.sum / total_count
  end
end

#countInteger

Number of recordings in the window.

Returns:

  • (Integer)


71
72
73
74
75
76
# File 'lib/philiprehberger/rate_window/tracker.rb', line 71

def count
  @mutex.synchronize do
    cleanup
    @counts.sum
  end
end

#histogram(buckets: 10) ⇒ Array<Hash>

Returns a histogram of value distribution across equal-width buckets.

Parameters:

  • buckets (Integer) (defaults to: 10)

    number of histogram buckets (default: 10)

Returns:

  • (Array<Hash>)

    array of { range:, count: } hashes

Raises:



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/philiprehberger/rate_window/tracker.rb', line 199

def histogram(buckets: 10)
  raise Error, 'buckets must be positive' unless buckets.positive?

  @mutex.synchronize do
    cleanup
    values = collect_values
    return [] if values.empty?

    min_val = values.min
    max_val = values.max

    if min_val == max_val
      return [{ range: (min_val..max_val), count: values.length }]
    end

    width = (max_val - min_val).to_f / buckets
    result = Array.new(buckets) do |i|
      range_start = min_val + (i * width)
      range_end = min_val + ((i + 1) * width)
      { range: (range_start..range_end), count: 0 }
    end

    values.each do |v|
      idx = ((v - min_val) / width).floor
      idx = buckets - 1 if idx >= buckets
      result[idx][:count] += 1
    end

    result
  end
end

#maxFloat

Maximum recorded value in the current window.

Returns:

  • (Float)

    maximum value, or 0.0 if no recordings



164
165
166
167
168
169
170
171
172
173
# File 'lib/philiprehberger/rate_window/tracker.rb', line 164

def max
  @mutex.synchronize do
    cleanup
    result = -Float::INFINITY
    @bucket_count.times do |i|
      result = @maxs[i] if @counts[i].positive? && @maxs[i] > result
    end
    result == -Float::INFINITY ? 0.0 : result
  end
end

#medianFloat

Median value across active buckets (shortcut for percentile(50)).

Returns:

  • (Float)

    the median value



110
111
112
# File 'lib/philiprehberger/rate_window/tracker.rb', line 110

def median
  percentile(50)
end

#minFloat

Minimum recorded value in the current window.

Returns:

  • (Float)

    minimum value, or 0.0 if no recordings



150
151
152
153
154
155
156
157
158
159
# File 'lib/philiprehberger/rate_window/tracker.rb', line 150

def min
  @mutex.synchronize do
    cleanup
    result = Float::INFINITY
    @bucket_count.times do |i|
      result = @mins[i] if @counts[i].positive? && @mins[i] < result
    end
    result == Float::INFINITY ? 0.0 : result
  end
end

#p95Float

95th percentile value across active buckets (shortcut for percentile(95)).

Returns:

  • (Float)

    the 95th percentile value



117
118
119
# File 'lib/philiprehberger/rate_window/tracker.rb', line 117

def p95
  percentile(95)
end

#percentile(p) ⇒ Float

Calculate a percentile of recorded values using linear interpolation.

Parameters:

  • p (Numeric)

    percentile (0-100)

Returns:

  • (Float)

    the percentile value

Raises:



95
96
97
98
99
100
101
102
103
104
105
# File 'lib/philiprehberger/rate_window/tracker.rb', line 95

def percentile(p)
  raise Error, 'percentile must be between 0 and 100' unless p.between?(0, 100)

  @mutex.synchronize do
    cleanup
    values = collect_values
    return 0.0 if values.empty?

    interpolate(values.sort, p / 100.0)
  end
end

#quantiles(*fractions) ⇒ Hash{Float => Float}

Compute multiple quantiles in a single pass (sorted once).

Parameters:

  • fractions (Array<Float>)

    quantile fractions in [0.0, 1.0]

Returns:

  • (Hash{Float => Float})

    mapping of fraction to interpolated value

Raises:

  • (ArgumentError)

    if any fraction is outside [0.0, 1.0]



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/philiprehberger/rate_window/tracker.rb', line 126

def quantiles(*fractions)
  fractions.each do |fraction|
    unless fraction.is_a?(Numeric) && fraction.between?(0.0, 1.0)
      raise ArgumentError, 'fractions must be between 0.0 and 1.0 inclusive'
    end
  end

  @mutex.synchronize do
    cleanup
    values = collect_values
    if values.empty?
      return fractions.to_h { |fraction| [fraction, 0.0] }
    end

    sorted = values.sort
    fractions.to_h do |fraction|
      [fraction, interpolate(sorted, fraction)]
    end
  end
end

#rateFloat

Calculate the rate per second over the window.

Returns:

  • (Float)

    rate per second



50
51
52
53
54
55
56
# File 'lib/philiprehberger/rate_window/tracker.rb', line 50

def rate
  @mutex.synchronize do
    cleanup
    total = @buckets.sum
    total / @window
  end
end

#record(value = 1) ⇒ self

Record a value in the current time bucket.

Parameters:

  • value (Numeric) (defaults to: 1)

    the value to record (default: 1)

Returns:

  • (self)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/philiprehberger/rate_window/tracker.rb', line 30

def record(value = 1)
  @mutex.synchronize do
    cleanup
    # Use @last_bucket_index (set by cleanup) rather than re-reading
    # the clock: the next cleanup zeros (@last_bucket_index, current],
    # so a fresh time read here can land in a bucket that the next
    # cleanup wipes if the thread was paused between the two reads.
    idx = @last_bucket_index % @bucket_count
    val = value.to_f
    @buckets[idx] += val
    @counts[idx] += 1
    @mins[idx] = val if val < @mins[idx]
    @maxs[idx] = val if val > @maxs[idx]
  end
  self
end

#resetself

Reset all buckets.

Returns:

  • (self)


278
279
280
281
282
283
284
285
286
287
288
# File 'lib/philiprehberger/rate_window/tracker.rb', line 278

def reset
  @mutex.synchronize do
    @buckets.fill(0.0)
    @counts.fill(0)
    @mins.fill(Float::INFINITY)
    @maxs.fill(-Float::INFINITY)
    @last_bucket_index = current_bucket_index
    @last_time = now
  end
  self
end

#snapshotHash

Take an atomic snapshot of all stats in a single mutex acquisition.

Runs cleanup once, then computes sum, count, rate, average, min, max, median, p95, variance, and stddev in a single pass under the same lock.

Returns:

  • (Hash)

    snapshot with keys :sum, :count, :rate, :average, :min, :max, :median, :p95, :variance, :stddev. Returns zero/nil-safe values for an empty tracker.



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/philiprehberger/rate_window/tracker.rb', line 240

def snapshot
  @mutex.synchronize do
    cleanup

    total_sum = @buckets.sum
    total_count = @counts.sum

    min_val = Float::INFINITY
    max_val = -Float::INFINITY
    @bucket_count.times do |i|
      if @counts[i].positive?
        min_val = @mins[i] if @mins[i] < min_val
        max_val = @maxs[i] if @maxs[i] > max_val
      end
    end

    values = collect_values
    sorted = values.sort
    var = values.empty? ? 0.0 : population_variance(values)

    {
      sum: total_sum,
      count: total_count,
      rate: total_sum / @window,
      average: total_count.zero? ? 0.0 : total_sum / total_count,
      min: min_val == Float::INFINITY ? 0.0 : min_val,
      max: max_val == -Float::INFINITY ? 0.0 : max_val,
      median: sorted.empty? ? 0.0 : interpolate(sorted, 0.5),
      p95: sorted.empty? ? 0.0 : interpolate(sorted, 0.95),
      variance: var,
      stddev: Math.sqrt(var)
    }
  end
end

#stddevFloat

Population standard deviation of values recorded in the current window.

Returns:

  • (Float)

    standard deviation, or 0.0 if no recordings



188
189
190
191
192
193
# File 'lib/philiprehberger/rate_window/tracker.rb', line 188

def stddev
  @mutex.synchronize do
    cleanup
    Math.sqrt(compute_variance)
  end
end

#sumFloat

Sum of all values in the window.

Returns:

  • (Float)


61
62
63
64
65
66
# File 'lib/philiprehberger/rate_window/tracker.rb', line 61

def sum
  @mutex.synchronize do
    cleanup
    @buckets.sum
  end
end

#varianceFloat

Population variance of values recorded in the current window.

Returns:

  • (Float)

    variance, or 0.0 if no recordings



178
179
180
181
182
183
# File 'lib/philiprehberger/rate_window/tracker.rb', line 178

def variance
  @mutex.synchronize do
    cleanup
    compute_variance
  end
end