chronos-ruby 0.5.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +37 -8
  4. data/contracts/apm-batch-v1.schema.json +35 -0
  5. data/contracts/event-v1.schema.json +1 -1
  6. data/contracts/sidekiq-job-v1.schema.json +21 -0
  7. data/docs/adr/ADR-014-sidekiq-envelope-context.md +27 -0
  8. data/docs/adr/ADR-015-bounded-apm-aggregation.md +27 -0
  9. data/docs/architecture.md +10 -1
  10. data/docs/compatibility.md +8 -0
  11. data/docs/configuration.md +18 -1
  12. data/docs/data-collected.md +16 -2
  13. data/docs/modules/apm-aggregation.md +57 -0
  14. data/docs/modules/configuration.md +2 -0
  15. data/docs/modules/rails-legacy.md +1 -1
  16. data/docs/modules/sidekiq-legacy.md +23 -0
  17. data/docs/modules/telemetry-events.md +1 -1
  18. data/docs/performance.md +25 -1
  19. data/docs/privacy-lgpd.md +13 -3
  20. data/docs/troubleshooting.md +20 -0
  21. data/lib/chronos/agent.rb +40 -0
  22. data/lib/chronos/application/apm_aggregator.rb +273 -0
  23. data/lib/chronos/application/capture_telemetry.rb +45 -5
  24. data/lib/chronos/application/delivery_pipeline.rb +7 -0
  25. data/lib/chronos/application/remote_configuration.rb +6 -1
  26. data/lib/chronos/configuration/apm_validation.rb +60 -0
  27. data/lib/chronos/configuration.rb +22 -2
  28. data/lib/chronos/core/metric_aggregate.rb +113 -0
  29. data/lib/chronos/core/sql_normalizer.rb +114 -0
  30. data/lib/chronos/core/telemetry_event.rb +1 -1
  31. data/lib/chronos/integrations/job_payload.rb +72 -0
  32. data/lib/chronos/integrations/rack/middleware.rb +19 -1
  33. data/lib/chronos/integrations/sidekiq.rb +260 -0
  34. data/lib/chronos/internal/worker_pool.rb +19 -2
  35. data/lib/chronos/rails/notifications_subscriber.rb +36 -4
  36. data/lib/chronos/sidekiq.rb +12 -0
  37. data/lib/chronos/version.rb +1 -1
  38. data/lib/chronos.rb +25 -0
  39. metadata +15 -2
@@ -24,6 +24,26 @@ Require `chronos/rails` from the generated initializer and confirm it runs befor
24
24
 
25
25
  This is intentional. Version 0.5 records SQL operation name/duration and cache operation/store/hit status without raw statements, binds, keys, or values. These omissions are privacy and cardinality boundaries, not capture failures.
26
26
 
27
+ ## Sidekiq telemetry is missing
28
+
29
+ Require `chronos/sidekiq` after Sidekiq is available and configure Chronos before jobs run. The entry point uses Sidekiq's public client/server middleware configuration and remains optional. Requiring only `chronos` or `chronos/rails` does not load Sidekiq. A job already enqueued before client middleware installation may lack propagated trace context, but server timing and failure capture can still run.
30
+
31
+ ## A failed Sidekiq job appears twice
32
+
33
+ The bundled middleware does not install a global error handler and uses `notify_once` inside a shared job scope. Check for application-installed Chronos calls or third-party global handlers outside that scope. Keep a single bundled server middleware entry in the Sidekiq chain.
34
+
35
+ ## Request, query, or job events are not sent immediately
36
+
37
+ This is expected with version 0.7 APM aggregation. Groups drain after `apm_flush_count` observations or during `Chronos.flush`/`Chronos.close`. Inspect `agent.diagnostics[:apm]` when using an explicit agent. Set `apm_enabled = false` only for temporary individual-event diagnostics.
38
+
39
+ ## Query metrics contain no literal values or binds
40
+
41
+ This is intentional. The normalizer removes common literal forms and never reads binds. Queries differing only by values should share a fingerprint. If an unsupported database dialect leaves a sensitive literal form, stop delivery, add a synthetic privacy fixture, and report the dialect through the security/support process.
42
+
43
+ ## Possible N+1 or deadlock signal is inaccurate
44
+
45
+ Local detectors emit bounded heuristics, not confirmed diagnoses. Repetition can be legitimate and exception class names can be adapter-specific. Confirm the trace in the SaaS and application logs before changing application behavior.
46
+
27
47
  ## Context appears missing
28
48
 
29
49
  The legacy context store is thread-local. A new application-created thread does not inherit context. Establish a new `Chronos.with_context` scope inside that thread, and issue manual notification before the scope exits. For Rack capture, supply user and explicit parameters through the documented environment keys.
data/lib/chronos/agent.rb CHANGED
@@ -67,6 +67,41 @@ module Chronos
67
67
  @telemetry.call(event_type, payload, telemetry_context(context))
68
68
  end
69
69
 
70
+ def record_event_once(key, event_type, payload = {}, context = {})
71
+ execution = @context_store.get
72
+ captured = execution[:__chronos_captured_events] || {}
73
+ return false if captured[key.to_s]
74
+
75
+ captured[key.to_s] = true
76
+ @context_store.set(execution.merge(:__chronos_captured_events => captured))
77
+ record_event(event_type, payload, context)
78
+ rescue StandardError
79
+ false
80
+ end
81
+
82
+ def apm_integration_options
83
+ {
84
+ :enabled => @config.apm_enabled,
85
+ :slow_query_threshold_ms => @config.apm_slow_query_threshold_ms,
86
+ :root_directory => @config.root_directory
87
+ }
88
+ end
89
+
90
+ # Returns the correlation subset safe for an integration-owned process boundary.
91
+ def propagation_context
92
+ current = context_hash(@context_store.get)
93
+ nested = context_hash(current[:context] || current["context"])
94
+ request = context_hash(nested["request"] || nested[:request])
95
+ values = {
96
+ "trace_id" => nested["trace_id"] || nested[:trace_id],
97
+ "request_id" => nested["request_id"] || nested[:request_id] ||
98
+ request["request_id"] || request[:request_id]
99
+ }
100
+ values.delete_if { |_key, value| value.to_s.empty? }
101
+ rescue StandardError
102
+ {}
103
+ end
104
+
70
105
  def notify_once(exception, context = {})
71
106
  execution = @context_store.get
72
107
  captured = execution[:__chronos_captured_exceptions] || {}
@@ -89,6 +124,7 @@ module Chronos
89
124
  end
90
125
 
91
126
  def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
127
+ @telemetry.flush
92
128
  @delivery_pipeline.flush(timeout)
93
129
  rescue StandardError => error
94
130
  @logger.warn("Chronos flush failed: #{error.class}")
@@ -96,6 +132,7 @@ module Chronos
96
132
  end
97
133
 
98
134
  def close(timeout = DEFAULT_FLUSH_TIMEOUT)
135
+ @telemetry.flush
99
136
  @delivery_pipeline.close(timeout)
100
137
  rescue StandardError => error
101
138
  @logger.warn("Chronos close failed: #{error.class}")
@@ -104,6 +141,7 @@ module Chronos
104
141
 
105
142
  def diagnostics
106
143
  details = @delivery_pipeline.diagnostics
144
+ details[:apm] = @telemetry.diagnostics
107
145
  details[:queue].merge(details)
108
146
  end
109
147
 
@@ -124,6 +162,8 @@ module Chronos
124
162
  merged = deep_merge(context_hash(@context_store.get), context_hash(additional))
125
163
  merged.delete(:__chronos_captured_exceptions)
126
164
  merged.delete("__chronos_captured_exceptions")
165
+ merged.delete(:__chronos_captured_events)
166
+ merged.delete("__chronos_captured_events")
127
167
  buffer = merged.delete(:__chronos_breadcrumbs) || merged.delete("__chronos_breadcrumbs")
128
168
  if buffer.respond_to?(:to_a)
129
169
  merged[:context] = context_hash(merged[:context]).merge("breadcrumbs" => buffer.to_a)
@@ -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