chronos-ruby 0.6.0.pre.1 → 0.7.0.pre.1

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.
@@ -0,0 +1,273 @@
1
+ module Chronos
2
+ module Application
3
+ # Aggregates bounded request, query, and job observations into metric batches.
4
+ #
5
+ # @responsibility Maintain statistics, histograms, breakdown, and local query signals.
6
+ # @motivation Avoid one network event per observation while retaining diagnostic value.
7
+ # @limits Percentiles and heavy correlation remain server-side; state is process-local.
8
+ # @collaborators CaptureTelemetry and immutable Chronos configuration.
9
+ # @thread_safety A mutex protects groups, request trackers, counters, and drains.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
11
+ # @example
12
+ # aggregator.record("request", payload, context)
13
+ # batches = aggregator.flush
14
+ # @errors Invalid observations are ignored and never escape to the application.
15
+ # @performance Group, transaction, query, bucket, and batch counts are strictly bounded.
16
+ class ApmAggregator
17
+ METRIC_TYPES = %w(request query job).freeze
18
+ def initialize(config)
19
+ @config = config
20
+ @mutex = Mutex.new
21
+ @groups = {}
22
+ @transactions = {}
23
+ @observations = 0
24
+ @dropped_groups = 0
25
+ end
26
+
27
+ def record(event_type, payload = {}, context = {})
28
+ return [] unless @config.apm_enabled
29
+
30
+ @mutex.synchronize do
31
+ type = event_type.to_s
32
+ data = hash(payload)
33
+ execution = hash(context)
34
+ observe_component(type, data, execution)
35
+ add_metric(type, data, execution) if aggregate_metric?(type, data)
36
+ @observations += 1 if aggregate_metric?(type, data)
37
+ @observations >= @config.apm_flush_count ? drain_locked : []
38
+ end
39
+ rescue StandardError
40
+ []
41
+ end
42
+
43
+ def flush
44
+ @mutex.synchronize { drain_locked }
45
+ rescue StandardError
46
+ []
47
+ end
48
+
49
+ def diagnostics
50
+ @mutex.synchronize do
51
+ {
52
+ "groups" => @groups.length, "dropped_groups" => @dropped_groups,
53
+ "transactions" => @transactions.length,
54
+ "tracked_queries" => @transactions.values.inject(0) do |total, transaction|
55
+ total + transaction["queries"].length
56
+ end
57
+ }
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def aggregate_metric?(type, payload)
64
+ METRIC_TYPES.include?(type) && !(type == "request" && payload["kind"].to_s == "view")
65
+ end
66
+
67
+ def add_metric(type, payload, context)
68
+ dimensions = dimensions_for(type, payload)
69
+ key = metric_key(type, dimensions)
70
+ aggregate = @groups[key]
71
+ unless aggregate
72
+ if @groups.length >= @config.apm_max_groups
73
+ @dropped_groups += 1
74
+ return
75
+ end
76
+ aggregate = Core::MetricAggregate.new(type, dimensions, @config.apm_histogram_buckets)
77
+ @groups[key] = aggregate
78
+ end
79
+ duration = non_negative(payload["duration_ms"] || payload[:duration_ms])
80
+ status = type == "request" ? payload["status"] || payload[:status] : nil
81
+ signals = signals_for(type, payload, context, duration)
82
+ breakdown = breakdown_for(type, payload, context, duration)
83
+ aggregate.observe(
84
+ duration, error?(type, payload), breakdown, signals, status
85
+ )
86
+ end
87
+
88
+ def observe_component(type, payload, context)
89
+ return if type == "job"
90
+
91
+ trace_id = trace_id(context)
92
+ return if trace_id.empty?
93
+
94
+ transaction = transaction_for(trace_id)
95
+ return unless transaction
96
+
97
+ category = component_category(type, payload)
98
+ return observe_query(transaction, payload) if type == "query" && category.nil?
99
+ return unless category
100
+
101
+ transaction["breakdown_ms"][category] ||= 0.0
102
+ transaction["breakdown_ms"][category] += non_negative(payload["duration_ms"] || payload[:duration_ms])
103
+ observe_query(transaction, payload) if type == "query"
104
+ end
105
+
106
+ def transaction_for(trace_id)
107
+ existing = @transactions[trace_id]
108
+ return existing if existing
109
+ return nil if @transactions.length >= @config.apm_max_groups
110
+
111
+ @transactions[trace_id] = {"breakdown_ms" => {}, "signals" => {}, "queries" => {}}
112
+ end
113
+
114
+ def observe_query(transaction, payload)
115
+ fingerprint = (payload["fingerprint"] || payload[:fingerprint]).to_s
116
+ return if fingerprint.empty?
117
+
118
+ queries = transaction["queries"]
119
+ return unless queries.key?(fingerprint) || queries.length < @config.apm_max_queries_per_request
120
+
121
+ queries[fingerprint] ||= 0
122
+ queries[fingerprint] += 1
123
+ count = queries[fingerprint]
124
+ slow = non_negative(payload["duration_ms"]) >= @config.apm_slow_query_threshold_ms
125
+ increment_signal(transaction, "slow_query") if slow
126
+ increment_signal(transaction, "repeated_query") if count > 1
127
+ increment_signal(transaction, "possible_n_plus_one") if count == @config.apm_n_plus_one_threshold
128
+ end
129
+
130
+ def breakdown_for(type, payload, context, duration)
131
+ explicit = hash(payload["breakdown_ms"] || payload[:breakdown_ms]).dup
132
+ if type == "request"
133
+ transaction = @transactions.delete(trace_id(context))
134
+ explicit = merge_numeric(explicit, transaction && transaction["breakdown_ms"])
135
+ accounted = explicit.values.inject(0.0) { |total, value| total + non_negative(value) }
136
+ explicit["application"] = [duration - accounted, 0.0].max
137
+ elsif type == "query"
138
+ explicit["database"] = duration
139
+ elsif type == "job"
140
+ explicit["queue"] = non_negative(payload["queue_latency_ms"] || payload[:queue_latency_ms])
141
+ explicit["application"] = duration
142
+ end
143
+ explicit
144
+ end
145
+
146
+ def signals_for(type, payload, context, duration)
147
+ return request_signals(context) if type == "request"
148
+ return query_signals(payload, duration) if type == "query"
149
+
150
+ {}
151
+ end
152
+
153
+ def request_signals(context)
154
+ transaction = @transactions[trace_id(context)]
155
+ transaction ? transaction["signals"].dup : {}
156
+ end
157
+
158
+ def query_signals(payload, duration)
159
+ signals = {}
160
+ signals["slow_query"] = 1 if duration >= @config.apm_slow_query_threshold_ms
161
+ signals["long_transaction"] = 1 if long_transaction?(payload, duration)
162
+ error_class = (payload["error_class"] || payload[:error_class]).to_s
163
+ signals["connection_error"] = 1 if error_class =~ /Connection|NoDatabase|Adapter/i
164
+ signals["deadlock"] = 1 if error_class =~ /Deadlock/i
165
+ signals
166
+ end
167
+
168
+ def long_transaction?(payload, duration)
169
+ operation = (payload["operation"] || payload[:operation]).to_s
170
+ name = (payload["name"] || payload[:name]).to_s
171
+ transaction = operation == "BEGIN" || operation == "COMMIT" || name =~ /TRANSACTION/i
172
+ transaction && duration >= @config.apm_long_transaction_threshold_ms
173
+ end
174
+
175
+ def dimensions_for(type, payload)
176
+ names = case type
177
+ when "request" then %w(route method)
178
+ when "query" then %w(adapter operation table fingerprint normalized_query name cached role shard source)
179
+ when "job" then %w(kind class queue status)
180
+ else []
181
+ end
182
+ names.each_with_object({}) do |name, result|
183
+ value = payload[name] || payload[name.to_sym]
184
+ next if value.nil? || value.to_s.empty?
185
+
186
+ result[name] = dimension_value(type, name, value)
187
+ end
188
+ end
189
+
190
+ def dimension_value(type, name, value)
191
+ return value == true if name == "cached"
192
+ return value.to_i if type == "request" && name == "status"
193
+
194
+ text = value.to_s
195
+ text = normalize_route(text) if name == "route"
196
+ text.bytesize > 512 ? text.byteslice(0, 512) : text
197
+ end
198
+
199
+ def normalize_route(route)
200
+ route.split("/").map do |segment|
201
+ segment =~ /\A\d+\z/ || segment =~ /\A[0-9a-f]{8}-[0-9a-f-]{27,}\z/i ? ":id" : segment
202
+ end.join("/")
203
+ end
204
+
205
+ def metric_key(type, dimensions)
206
+ type + "|" + dimensions.keys.sort.map { |key| "#{key}=#{dimensions[key]}" }.join("|")
207
+ end
208
+
209
+ def component_category(type, payload)
210
+ return "database" if type == "query"
211
+ return "view" if type == "request" && payload["kind"].to_s == "view"
212
+ return "cache" if type == "cache"
213
+ return "queue" if type == "job"
214
+
215
+ nil
216
+ end
217
+
218
+ def error?(type, payload)
219
+ return (payload["status"] || payload[:status]).to_i >= 500 if type == "request"
220
+ return (payload["status"] || payload[:status]).to_s == "failed" if type == "job"
221
+
222
+ !(payload["error_class"] || payload[:error_class]).to_s.empty?
223
+ end
224
+
225
+ def trace_id(context)
226
+ (context["trace_id"] || context[:trace_id]).to_s
227
+ end
228
+
229
+ def increment_signal(transaction, name)
230
+ transaction["signals"][name] ||= 0
231
+ transaction["signals"][name] += 1
232
+ end
233
+
234
+ def merge_numeric(left, right)
235
+ hash(right).each do |key, value|
236
+ left[key.to_s] ||= 0.0
237
+ left[key.to_s] += non_negative(value)
238
+ end
239
+ left
240
+ end
241
+
242
+ def drain_locked
243
+ metrics = @groups.values.map(&:to_h)
244
+ if metrics.empty?
245
+ @transactions = {}
246
+ return []
247
+ end
248
+
249
+ dropped = @dropped_groups
250
+ @groups = {}
251
+ @transactions = {}
252
+ @observations = 0
253
+ @dropped_groups = 0
254
+ batches = []
255
+ metrics.each_slice(@config.apm_batch_size) do |slice|
256
+ batches << {"metrics" => slice, "dropped_groups" => batches.empty? ? dropped : 0}
257
+ end
258
+ batches
259
+ end
260
+
261
+ def non_negative(value)
262
+ number = value.to_f
263
+ number < 0.0 ? 0.0 : number
264
+ rescue StandardError
265
+ 0.0
266
+ end
267
+
268
+ def hash(value)
269
+ value.is_a?(Hash) ? value : {}
270
+ end
271
+ end
272
+ end
273
+ end
@@ -2,10 +2,10 @@ module Chronos
2
2
  module Application
3
3
  # Coordinates bounded non-exception telemetry capture.
4
4
  #
5
- # @responsibility Build, sanitize, serialize, and enqueue integration events.
6
- # @motivation Keep Rails notification policy outside transport and domain objects.
7
- # @limits It handles request, query, job, and cache events only.
8
- # @collaborators TelemetryEvent, TelemetrySerializer, and DeliveryPipeline.
5
+ # @responsibility Aggregate APM observations or serialize and enqueue integration events.
6
+ # @motivation Keep framework policy and local batching outside transport and domain objects.
7
+ # @limits It handles request, query, job, cache, and metric-batch events only.
8
+ # @collaborators ApmAggregator, TelemetryEvent, TelemetrySerializer, and DeliveryPipeline.
9
9
  # @thread_safety Calls own event state and may execute concurrently.
10
10
  # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
11
11
  # @example
@@ -20,18 +20,58 @@ module Chronos
20
20
  @serializer = options[:serializer] || Core::TelemetrySerializer.new(
21
21
  config, nil, :max_payload_size => proc { @delivery_pipeline.max_payload_size }
22
22
  )
23
+ @aggregator = options[:aggregator] || ApmAggregator.new(config)
23
24
  end
24
25
 
25
26
  def call(event_type, payload = {}, context = {})
26
27
  return false unless @config.enabled_for_environment?
27
28
  return false unless @delivery_pipeline.capture_allowed?(event_type)
28
29
 
30
+ aggregate = aggregation_enabled?
31
+ batches = aggregate ? @aggregator.record(event_type, payload, context) : []
32
+ return enqueue_batches(batches) if aggregate && aggregate_type?(event_type)
33
+
29
34
  event = Core::TelemetryEvent.new(event_type, payload, context)
30
- @delivery_pipeline.enqueue(@serializer.call(event))
35
+ delivered = @delivery_pipeline.enqueue(@serializer.call(event))
36
+ enqueue_batches(batches)
37
+ delivered
31
38
  rescue StandardError => error
32
39
  @logger.warn("Chronos telemetry capture failed: #{error.class}")
33
40
  false
34
41
  end
42
+
43
+ def flush
44
+ enqueue_batches(@aggregator.flush)
45
+ rescue StandardError => error
46
+ @logger.warn("Chronos APM flush failed: #{error.class}")
47
+ false
48
+ end
49
+
50
+ def diagnostics
51
+ @aggregator.diagnostics
52
+ rescue StandardError
53
+ {}
54
+ end
55
+
56
+ private
57
+
58
+ def aggregate_type?(event_type)
59
+ ApmAggregator::METRIC_TYPES.include?(event_type.to_s)
60
+ end
61
+
62
+ def aggregation_enabled?
63
+ @config.apm_enabled && @delivery_pipeline.event_enabled?("metric_batch")
64
+ end
65
+
66
+ def enqueue_batches(batches)
67
+ results = Array(batches).map do |batch|
68
+ next false unless @delivery_pipeline.event_enabled?("metric_batch")
69
+
70
+ event = Core::TelemetryEvent.new("metric_batch", batch)
71
+ @delivery_pipeline.enqueue(@serializer.call(event))
72
+ end
73
+ results.empty? || results.all?
74
+ end
35
75
  end
36
76
  end
37
77
  end
@@ -67,6 +67,13 @@ module Chronos
67
67
  false
68
68
  end
69
69
 
70
+ def event_enabled?(event_type)
71
+ @remote_configuration.delivery_enabled?(event_type)
72
+ rescue StandardError => error
73
+ diagnose(error)
74
+ false
75
+ end
76
+
70
77
  def max_payload_size
71
78
  @remote_configuration.max_payload_size
72
79
  end
@@ -13,7 +13,7 @@ module Chronos
13
13
  # @errors Invalid documents are ignored and return false.
14
14
  # @performance Validation is bounded by configured document and list limits.
15
15
  class RemoteConfiguration
16
- SUPPORTED_EVENT_TYPES = ["exception", "request", "query", "job", "cache"].freeze
16
+ SUPPORTED_EVENT_TYPES = ["exception", "request", "query", "job", "cache", "metric_batch"].freeze
17
17
  MAX_IGNORED_FINGERPRINTS = 100
18
18
  MAX_FINGERPRINT_BYTES = 256
19
19
  MIN_PAYLOAD_SIZE = 256
@@ -63,6 +63,11 @@ module Chronos
63
63
  snapshot["enabled_event_types"].include?(event_type.to_s)
64
64
  end
65
65
 
66
+ def delivery_enabled?(event_type)
67
+ values = snapshot
68
+ !values["kill_switch"] && values["enabled_event_types"].include?(event_type.to_s)
69
+ end
70
+
66
71
  def ignored_fingerprint?(fingerprint)
67
72
  snapshot["ignored_fingerprints"].include?(fingerprint.to_s)
68
73
  end
@@ -0,0 +1,60 @@
1
+ module Chronos
2
+ module Internal
3
+ # Validates bounded APM aggregation, histogram, and detector settings.
4
+ #
5
+ # @responsibility Return configuration errors for version 0.7 APM options.
6
+ # @motivation Keep the general configuration validator focused and maintainable.
7
+ # @limits It validates shape and bounds but does not allocate aggregator state.
8
+ # @collaborators Chronos::Configuration predicates and attributes.
9
+ # @thread_safety Reads one mutable configuration instance without shared state.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @errors Invalid values become messages and never raise from validation.
12
+ module ApmConfigurationValidation
13
+ private
14
+
15
+ def apm_errors
16
+ errors = apm_capacity_errors
17
+ errors.concat(apm_threshold_errors)
18
+ errors << "apm_enabled must be true or false" unless [true, false].include?(apm_enabled)
19
+ unless increasing_positive_numbers?(apm_histogram_buckets)
20
+ errors << "apm_histogram_buckets must contain increasing positive numbers"
21
+ end
22
+ errors
23
+ end
24
+
25
+ def apm_capacity_errors
26
+ errors = []
27
+ errors << "apm_max_groups must be a positive integer" unless positive_integer?(apm_max_groups)
28
+ errors << "apm_flush_count must be a positive integer" unless positive_integer?(apm_flush_count)
29
+ unless apm_batch_size.is_a?(Integer) && apm_batch_size >= 1 && apm_batch_size <= 50
30
+ errors << "apm_batch_size must be between 1 and 50"
31
+ end
32
+ unless positive_integer?(apm_max_queries_per_request)
33
+ errors << "apm_max_queries_per_request must be a positive integer"
34
+ end
35
+ errors
36
+ end
37
+
38
+ def apm_threshold_errors
39
+ errors = []
40
+ unless positive_number?(apm_slow_query_threshold_ms)
41
+ errors << "apm_slow_query_threshold_ms must be greater than zero"
42
+ end
43
+ unless positive_number?(apm_long_transaction_threshold_ms)
44
+ errors << "apm_long_transaction_threshold_ms must be greater than zero"
45
+ end
46
+ unless apm_n_plus_one_threshold.is_a?(Integer) && apm_n_plus_one_threshold >= 2
47
+ errors << "apm_n_plus_one_threshold must be an integer greater than or equal to 2"
48
+ end
49
+ errors
50
+ end
51
+
52
+ def increasing_positive_numbers?(values)
53
+ return false unless values.is_a?(Array) && !values.empty? && values.length <= 19
54
+ return false unless values.all? { |value| positive_number?(value) }
55
+
56
+ values.each_cons(2).all? { |left, right| right > left }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,6 @@
1
1
  require "uri"
2
2
  require "chronos/configuration/validation"
3
+ require "chronos/configuration/apm_validation"
3
4
 
4
5
  module Chronos
5
6
  # Mutable configuration used only while the Chronos agent is being set up.
@@ -21,6 +22,7 @@ module Chronos
21
22
  # snapshot = config.snapshot
22
23
  class Configuration
23
24
  include Internal::ConfigurationValidation
25
+ include Internal::ApmConfigurationValidation
24
26
  DEFAULT_BLOCKLIST_KEYS = %w(
25
27
  password password_confirmation passwd secret api_key apikey authorization
26
28
  token access_token refresh_token private_key client_secret cookie set-cookie
@@ -40,7 +42,11 @@ module Chronos
40
42
  :remote_configuration, :remote_config_max_bytes, :sampling_rate,
41
43
  :enabled_event_types, :max_remote_send_interval, :context_store,
42
44
  :breadcrumb_capacity, :breadcrumb_max_bytes, :rails_enabled,
43
- :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent
45
+ :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent,
46
+ :apm_enabled, :apm_max_groups, :apm_flush_count, :apm_batch_size,
47
+ :apm_max_queries_per_request, :apm_slow_query_threshold_ms,
48
+ :apm_long_transaction_threshold_ms, :apm_n_plus_one_threshold,
49
+ :apm_histogram_buckets
44
50
  ].freeze
45
51
 
46
52
  attr_accessor(*ATTRIBUTES)
@@ -50,6 +56,7 @@ module Chronos
50
56
  initialize_privacy_defaults
51
57
  initialize_resilience_defaults
52
58
  initialize_rails_defaults
59
+ initialize_apm_defaults
53
60
  end
54
61
 
55
62
  def snapshot
@@ -78,6 +85,7 @@ module Chronos
78
85
  errors.concat(resilience_errors)
79
86
  errors.concat(privacy_errors)
80
87
  errors.concat(context_errors)
88
+ errors.concat(apm_errors)
81
89
  errors
82
90
  end
83
91
 
@@ -116,6 +124,18 @@ module Chronos
116
124
  @rails_capture_user_agent = false
117
125
  end
118
126
 
127
+ def initialize_apm_defaults
128
+ @apm_enabled = true
129
+ @apm_max_groups = 200
130
+ @apm_flush_count = 100
131
+ @apm_batch_size = 50
132
+ @apm_max_queries_per_request = 100
133
+ @apm_slow_query_threshold_ms = 500.0
134
+ @apm_long_transaction_threshold_ms = 1000.0
135
+ @apm_n_plus_one_threshold = 5
136
+ @apm_histogram_buckets = [5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0]
137
+ end
138
+
119
139
  def initialize_privacy_defaults
120
140
  @blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
121
141
  @allowlist_keys = []
@@ -135,7 +155,7 @@ module Chronos
135
155
  @remote_configuration = true
136
156
  @remote_config_max_bytes = 4096
137
157
  @sampling_rate = 1.0
138
- @enabled_event_types = ["exception", "request", "query", "job", "cache"]
158
+ @enabled_event_types = ["exception", "request", "query", "job", "cache", "metric_batch"]
139
159
  @max_remote_send_interval = 60.0
140
160
  end
141
161
 
@@ -0,0 +1,113 @@
1
+ module Chronos
2
+ module Core
3
+ # Accumulates one bounded APM metric group and serializes aggregate statistics.
4
+ #
5
+ # @responsibility Track counts, errors, durations, histogram, breakdown, and signals.
6
+ # @motivation Keep numerical accumulation separate from grouping and request correlation.
7
+ # @limits It does not choose dimensions, retain observations, or calculate percentiles.
8
+ # @collaborators ApmAggregator and immutable histogram boundaries.
9
+ # @thread_safety Mutable by design; callers must synchronize access.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @example
12
+ # metric.observe(12.0, false, {"database" => 3.0}, {})
13
+ # @errors Non-numeric durations become zero and never escape.
14
+ # @performance Memory is fixed by the configured histogram boundary count.
15
+ class MetricAggregate
16
+ BREAKDOWN_CATEGORIES = %w(database view external_http cache queue application unknown).freeze
17
+
18
+ def initialize(metric_type, dimensions, boundaries)
19
+ @metric_type = metric_type
20
+ @dimensions = dimensions
21
+ @boundaries = boundaries
22
+ @count = 0
23
+ @error_count = 0
24
+ @total = 0.0
25
+ @min = nil
26
+ @max = nil
27
+ @buckets = Array.new(boundaries.length + 1, 0)
28
+ @breakdown = {}
29
+ @signals = {}
30
+ @status_codes = {}
31
+ end
32
+
33
+ def observe(duration, error, breakdown, signals, status = nil)
34
+ value = non_negative(duration)
35
+ @count += 1
36
+ @error_count += 1 if error
37
+ @total += value
38
+ @min = value if @min.nil? || value < @min
39
+ @max = value if @max.nil? || value > @max
40
+ bucket = @boundaries.index { |boundary| value <= boundary }
41
+ @buckets[bucket || @boundaries.length] += 1
42
+ add_breakdown(breakdown)
43
+ add_signals(signals)
44
+ add_status(status)
45
+ self
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ "metric_type" => @metric_type, "dimensions" => @dimensions,
51
+ "count" => @count, "error_count" => @error_count,
52
+ "error_rate" => (@error_count.to_f / @count).round(6),
53
+ "duration_ms" => duration_summary, "histogram" => histogram,
54
+ "breakdown_ms" => rounded_hash(@breakdown), "signals" => @signals,
55
+ "status_codes" => @status_codes
56
+ }
57
+ end
58
+
59
+ private
60
+
61
+ def add_breakdown(values)
62
+ hash(values).each do |category, duration|
63
+ name = BREAKDOWN_CATEGORIES.include?(category.to_s) ? category.to_s : "unknown"
64
+ @breakdown[name] ||= 0.0
65
+ @breakdown[name] += non_negative(duration)
66
+ end
67
+ end
68
+
69
+ def add_signals(values)
70
+ hash(values).each do |name, count|
71
+ @signals[name.to_s] ||= 0
72
+ @signals[name.to_s] += count.to_i
73
+ end
74
+ end
75
+
76
+ def add_status(status)
77
+ return if status.nil?
78
+
79
+ key = status.to_i.to_s
80
+ @status_codes[key] ||= 0
81
+ @status_codes[key] += 1
82
+ end
83
+
84
+ def duration_summary
85
+ {
86
+ "total" => @total.round(3), "min" => @min.round(3), "max" => @max.round(3),
87
+ "average" => (@total / @count).round(3)
88
+ }
89
+ end
90
+
91
+ def histogram
92
+ @buckets.each_with_index.map do |count, index|
93
+ {"le" => @boundaries[index] || "+Inf", "count" => count}
94
+ end
95
+ end
96
+
97
+ def rounded_hash(values)
98
+ values.each_with_object({}) { |(key, value), result| result[key] = value.round(3) }
99
+ end
100
+
101
+ def non_negative(value)
102
+ number = value.to_f
103
+ number < 0.0 ? 0.0 : number
104
+ rescue StandardError
105
+ 0.0
106
+ end
107
+
108
+ def hash(value)
109
+ value.is_a?(Hash) ? value : {}
110
+ end
111
+ end
112
+ end
113
+ end