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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +27 -8
- data/contracts/apm-batch-v1.schema.json +35 -0
- data/contracts/event-v1.schema.json +1 -1
- data/docs/adr/ADR-015-bounded-apm-aggregation.md +27 -0
- data/docs/architecture.md +6 -2
- data/docs/compatibility.md +2 -0
- data/docs/configuration.md +18 -1
- data/docs/data-collected.md +10 -1
- data/docs/modules/apm-aggregation.md +57 -0
- data/docs/modules/configuration.md +2 -0
- data/docs/modules/rails-legacy.md +1 -1
- data/docs/modules/sidekiq-legacy.md +2 -2
- data/docs/modules/telemetry-events.md +1 -1
- data/docs/performance.md +14 -1
- data/docs/privacy-lgpd.md +7 -2
- data/docs/troubleshooting.md +12 -0
- data/lib/chronos/agent.rb +25 -0
- data/lib/chronos/application/apm_aggregator.rb +273 -0
- data/lib/chronos/application/capture_telemetry.rb +45 -5
- data/lib/chronos/application/delivery_pipeline.rb +7 -0
- data/lib/chronos/application/remote_configuration.rb +6 -1
- data/lib/chronos/configuration/apm_validation.rb +60 -0
- data/lib/chronos/configuration.rb +22 -2
- data/lib/chronos/core/metric_aggregate.rb +113 -0
- data/lib/chronos/core/sql_normalizer.rb +114 -0
- data/lib/chronos/core/telemetry_event.rb +1 -1
- data/lib/chronos/integrations/rack/middleware.rb +19 -1
- data/lib/chronos/integrations/sidekiq.rb +2 -0
- data/lib/chronos/rails/notifications_subscriber.rb +36 -4
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +17 -0
- metadata +8 -1
|
@@ -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
|
|
6
|
-
# @motivation Keep
|
|
7
|
-
# @limits It handles request, query, job, and
|
|
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
|