chronos-ruby 0.9.0.pre.4 → 1.1.0
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 +36 -0
- data/README.md +108 -197
- data/contracts/apm-batch-v1.schema.json +51 -1
- data/docs/adr/ADR-007-feature-detection.md +25 -0
- data/docs/adr/ADR-008-context-store.md +25 -0
- data/docs/adr/ADR-009-sampling.md +25 -0
- data/docs/adr/ADR-010-opentelemetry-interoperability.md +25 -0
- data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
- data/docs/adr/ADR-018-pre-1.0-hardening.md +6 -2
- data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
- data/docs/architecture.md +5 -2
- data/docs/compatibility.md +30 -23
- data/docs/configuration.md +21 -0
- data/docs/data-collected.md +9 -3
- data/docs/deprecation-policy.md +1 -1
- data/docs/examples/plain-ruby.md +6 -0
- data/docs/migration-from-airbrake.md +1 -1
- data/docs/modules/apm-aggregation.md +18 -5
- data/docs/modules/breadcrumbs.md +23 -0
- data/docs/modules/context.md +21 -0
- data/docs/modules/deploys.md +23 -0
- data/docs/modules/job-monitoring.md +22 -0
- data/docs/modules/request-monitoring.md +20 -0
- data/docs/modules/runtime-metrics.md +22 -0
- data/docs/modules/sampling.md +22 -0
- data/docs/modules/sidekiq-legacy.md +1 -1
- data/docs/modules/sql-monitoring.md +76 -0
- data/docs/modules/telemetry-events.md +1 -1
- data/docs/performance.md +30 -3
- data/docs/privacy-lgpd.md +6 -3
- data/docs/protocol-v1.md +2 -2
- data/docs/release-1.0-readiness.md +17 -15
- data/docs/release-1.1-readiness.md +51 -0
- data/docs/security-review.md +7 -3
- data/docs/troubleshooting.md +6 -0
- data/lib/chronos/agent.rb +12 -2
- data/lib/chronos/application/apm_aggregator.rb +179 -29
- data/lib/chronos/configuration/apm_validation.rb +51 -1
- data/lib/chronos/configuration.rb +17 -1
- data/lib/chronos/core/metric_aggregate.rb +69 -7
- data/lib/chronos/core/sql_query_analyzer.rb +309 -0
- data/lib/chronos/ports/query_inspector.rb +23 -0
- data/lib/chronos/rails/active_record_query_inspector.rb +235 -0
- data/lib/chronos/rails/notifications_subscriber.rb +163 -2
- data/lib/chronos/rails.rb +1 -0
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +2 -0
- metadata +21 -4
|
@@ -12,7 +12,7 @@ module Chronos
|
|
|
12
12
|
# Chronos::Rails::NotificationsSubscriber.new.install
|
|
13
13
|
# @errors Subscriber failures are contained and never escape into Rails.
|
|
14
14
|
# @performance Each notification builds a small allowlisted hash and queues asynchronously.
|
|
15
|
-
class NotificationsSubscriber
|
|
15
|
+
class NotificationsSubscriber # rubocop:disable Metrics/ClassLength
|
|
16
16
|
EVENTS = %w(
|
|
17
17
|
process_action.action_controller render_template.action_view sql.active_record
|
|
18
18
|
deliver.action_mailer perform.active_job cache_read.active_support
|
|
@@ -26,10 +26,18 @@ module Chronos
|
|
|
26
26
|
attr_reader :mutex, :installed_buses
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(notifier = Chronos, notifications = nil)
|
|
29
|
+
def initialize(notifier = Chronos, notifications = nil, options = {})
|
|
30
30
|
@notifier = notifier
|
|
31
31
|
@notifications = notifications || active_support_notifications
|
|
32
32
|
@sql_normalizer = Core::SqlNormalizer.new
|
|
33
|
+
@query_analyzer = options[:query_analyzer] || Core::SqlQueryAnalyzer.new
|
|
34
|
+
@query_inspector = options[:query_inspector] || default_query_inspector
|
|
35
|
+
@query_inspection_mutex = Mutex.new
|
|
36
|
+
@query_inspections = {}
|
|
37
|
+
@query_analyses = {}
|
|
38
|
+
@clock = options[:clock] || proc { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
39
|
+
@transaction_mutex = Mutex.new
|
|
40
|
+
@transactions = {}
|
|
33
41
|
cache_options = notifier.respond_to?(:cache_integration_options) ? notifier.cache_integration_options : {}
|
|
34
42
|
@cache_normalizer = Core::CacheNormalizer.new(
|
|
35
43
|
cache_options[:project_id].to_s, cache_options[:key_mode] || :none
|
|
@@ -122,6 +130,8 @@ module Chronos
|
|
|
122
130
|
end
|
|
123
131
|
|
|
124
132
|
def sql(payload, duration)
|
|
133
|
+
return false if query_inspection_suppressed?
|
|
134
|
+
|
|
125
135
|
metadata = {
|
|
126
136
|
:name => value(payload, :name), :cached => value(payload, :cached),
|
|
127
137
|
:adapter => value(payload, :adapter), :connection => value(payload, :connection),
|
|
@@ -131,9 +141,160 @@ module Chronos
|
|
|
131
141
|
}
|
|
132
142
|
metadata[:source] = sampled_query_source if duration >= slow_query_threshold
|
|
133
143
|
data = @sql_normalizer.call(value(payload, :sql), metadata).merge("duration_ms" => duration)
|
|
144
|
+
track_transaction(data, metadata[:connection])
|
|
145
|
+
if query_analysis_options[:analysis_enabled]
|
|
146
|
+
inspection = query_inspection(value(payload, :sql), data, metadata[:connection], duration)
|
|
147
|
+
analysis = cached_query_analysis(data, inspection)
|
|
148
|
+
data["analysis"] = analysis unless analysis.empty?
|
|
149
|
+
end
|
|
134
150
|
@notifier.record_event("query", data)
|
|
135
151
|
end
|
|
136
152
|
|
|
153
|
+
def cached_query_analysis(data, inspection)
|
|
154
|
+
options = query_analysis_options
|
|
155
|
+
fingerprint = data["fingerprint"].to_s
|
|
156
|
+
@query_inspection_mutex.synchronize do
|
|
157
|
+
existing = @query_analyses[fingerprint]
|
|
158
|
+
return existing if existing && inspection.empty?
|
|
159
|
+
return existing if existing && !hash(existing["inspection"]).empty?
|
|
160
|
+
return {} unless existing || @query_analyses.length < options[:max_analyses]
|
|
161
|
+
|
|
162
|
+
@query_analyses[fingerprint] = @query_analyzer.call(data, inspection)
|
|
163
|
+
end
|
|
164
|
+
rescue StandardError => error
|
|
165
|
+
{"diagnostics" => [{"code" => "query_analysis_failed", "severity" => "error",
|
|
166
|
+
"category" => "analysis", "message" => "Normalized query analysis failed",
|
|
167
|
+
"evidence" => {"error_class" => safe_error_class(error)}}]}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def query_inspection(raw_sql, data, connection, duration)
|
|
171
|
+
options = query_analysis_options
|
|
172
|
+
return {} unless options[:inspection_enabled]
|
|
173
|
+
return {} unless duration >= options[:min_duration_ms]
|
|
174
|
+
return {} unless @query_inspector && Ports::QueryInspector.compatible?(@query_inspector)
|
|
175
|
+
|
|
176
|
+
fingerprint = data["fingerprint"].to_s
|
|
177
|
+
@query_inspection_mutex.synchronize do
|
|
178
|
+
return @query_inspections[fingerprint] if @query_inspections.key?(fingerprint)
|
|
179
|
+
return {} if @query_inspections.length >= options[:max_queries]
|
|
180
|
+
|
|
181
|
+
@query_inspections[fingerprint] = @query_inspector.call(
|
|
182
|
+
raw_sql, data, :connection => connection,
|
|
183
|
+
:statistics => options[:statistics_enabled], :plan => options[:plan_enabled]
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
rescue StandardError => error
|
|
187
|
+
{"errors" => [safe_error_class(error)]}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def query_analysis_options
|
|
191
|
+
values = @notifier.respond_to?(:apm_integration_options) ? @notifier.apm_integration_options : {}
|
|
192
|
+
{
|
|
193
|
+
:analysis_enabled => values.fetch(:query_analysis_enabled, true) == true,
|
|
194
|
+
:max_analyses => (values[:query_analysis_max_queries] || 100).to_i,
|
|
195
|
+
:inspection_enabled => values[:query_inspection_enabled] == true,
|
|
196
|
+
:statistics_enabled => values[:query_statistics_enabled] == true,
|
|
197
|
+
:plan_enabled => values[:query_plan_enabled] == true,
|
|
198
|
+
:min_duration_ms => (values[:query_inspection_min_duration_ms] || 500.0).to_f,
|
|
199
|
+
:max_queries => (values[:query_inspection_max_queries] || 20).to_i,
|
|
200
|
+
:transaction_tracking_enabled => values.fetch(:transaction_tracking_enabled, true) == true,
|
|
201
|
+
:transaction_max_connections => (values[:transaction_max_connections] || 100).to_i,
|
|
202
|
+
:transaction_ttl_seconds => (values[:transaction_ttl_seconds] || 60.0).to_f
|
|
203
|
+
}
|
|
204
|
+
rescue StandardError
|
|
205
|
+
{:analysis_enabled => true, :max_analyses => 100,
|
|
206
|
+
:inspection_enabled => false, :statistics_enabled => false,
|
|
207
|
+
:plan_enabled => false, :min_duration_ms => 500.0, :max_queries => 20,
|
|
208
|
+
:transaction_tracking_enabled => true, :transaction_max_connections => 100,
|
|
209
|
+
:transaction_ttl_seconds => 60.0}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def track_transaction(data, connection)
|
|
213
|
+
options = query_analysis_options
|
|
214
|
+
return unless options[:transaction_tracking_enabled] && connection
|
|
215
|
+
|
|
216
|
+
operation = data["operation"].to_s
|
|
217
|
+
normalized = data["normalized_query"].to_s
|
|
218
|
+
key = connection.object_id.to_s
|
|
219
|
+
current_time = monotonic_now
|
|
220
|
+
@transaction_mutex.synchronize do
|
|
221
|
+
expire_tracked_transactions(current_time, options[:transaction_ttl_seconds])
|
|
222
|
+
details = {
|
|
223
|
+
:operation => operation, :normalized => normalized, :current_time => current_time,
|
|
224
|
+
:max_connections => options[:transaction_max_connections]
|
|
225
|
+
}
|
|
226
|
+
update_transaction_state(data, key, details)
|
|
227
|
+
end
|
|
228
|
+
rescue StandardError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def update_transaction_state(data, key, details)
|
|
233
|
+
operation = details[:operation]
|
|
234
|
+
normalized = details[:normalized]
|
|
235
|
+
current_time = details[:current_time]
|
|
236
|
+
state = @transactions[key]
|
|
237
|
+
return start_tracked_transaction(key, state, current_time, details) if transaction_start?(operation, normalized)
|
|
238
|
+
return unless state
|
|
239
|
+
|
|
240
|
+
if operation == "SAVEPOINT"
|
|
241
|
+
state["depth"] += 1
|
|
242
|
+
elsif operation == "RELEASE" || normalized =~ /\AROLLBACK\s+TO\b/i
|
|
243
|
+
state["depth"] = [state["depth"] - 1, 1].max
|
|
244
|
+
elsif ["COMMIT", "ROLLBACK"].include?(operation)
|
|
245
|
+
finish_tracked_transaction(data, key, state, current_time)
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
state["last_seen_at"] = current_time
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def transaction_start?(operation, normalized)
|
|
252
|
+
operation == "BEGIN" || normalized =~ /\ASTART\s+TRANSACTION\b/i
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def start_tracked_transaction(key, state, current_time, details)
|
|
256
|
+
if state
|
|
257
|
+
state["depth"] += 1
|
|
258
|
+
state["last_seen_at"] = current_time
|
|
259
|
+
elsif @transactions.length < details[:max_connections]
|
|
260
|
+
@transactions[key] = {"started_at" => current_time, "last_seen_at" => current_time, "depth" => 1}
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def finish_tracked_transaction(data, key, state, current_time)
|
|
265
|
+
@transactions.delete(key)
|
|
266
|
+
elapsed = [(current_time - state["started_at"]) * 1000.0, 0.0].max
|
|
267
|
+
data["transaction_duration_ms"] = elapsed.round(3)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def expire_tracked_transactions(current_time, ttl)
|
|
271
|
+
@transactions.delete_if { |_key, state| current_time - state["last_seen_at"] > ttl }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def monotonic_now
|
|
275
|
+
@clock.call.to_f
|
|
276
|
+
rescue StandardError
|
|
277
|
+
Time.now.to_f
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def default_query_inspector
|
|
281
|
+
defined?(ActiveRecordQueryInspector) ? ActiveRecordQueryInspector.new : nil
|
|
282
|
+
rescue StandardError
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def query_inspection_suppressed?
|
|
287
|
+
defined?(ActiveRecordQueryInspector) && ActiveRecordQueryInspector.suppressed?
|
|
288
|
+
rescue StandardError
|
|
289
|
+
false
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def safe_error_class(error)
|
|
293
|
+
error.class.name.to_s[0, 128]
|
|
294
|
+
rescue StandardError
|
|
295
|
+
"StandardError"
|
|
296
|
+
end
|
|
297
|
+
|
|
137
298
|
def mailer(payload, duration)
|
|
138
299
|
data = {
|
|
139
300
|
"kind" => "mailer", "mailer" => value(payload, :mailer).to_s,
|
data/lib/chronos/rails.rb
CHANGED
data/lib/chronos/version.rb
CHANGED
data/lib/chronos.rb
CHANGED
|
@@ -20,10 +20,12 @@ require "chronos/core/deploy_normalizer"
|
|
|
20
20
|
require "chronos/core/payload_serializer"
|
|
21
21
|
require "chronos/core/telemetry_event"
|
|
22
22
|
require "chronos/core/sql_normalizer"
|
|
23
|
+
require "chronos/core/sql_query_analyzer"
|
|
23
24
|
require "chronos/core/metric_aggregate"
|
|
24
25
|
require "chronos/core/cache_normalizer"
|
|
25
26
|
require "chronos/ports/transport"
|
|
26
27
|
require "chronos/ports/context_store"
|
|
28
|
+
require "chronos/ports/query_inspector"
|
|
27
29
|
require "chronos/internal/safe_logger"
|
|
28
30
|
require "chronos/internal/bounded_queue"
|
|
29
31
|
require "chronos/internal/memory_backlog"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chronos-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Antonio Jefferson
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
@@ -113,6 +113,10 @@ files:
|
|
|
113
113
|
- docs/adr/ADR-004-bounded-queue.md
|
|
114
114
|
- docs/adr/ADR-005-sanitize-before-backlog.md
|
|
115
115
|
- docs/adr/ADR-006-versioned-event-contract.md
|
|
116
|
+
- docs/adr/ADR-007-feature-detection.md
|
|
117
|
+
- docs/adr/ADR-008-context-store.md
|
|
118
|
+
- docs/adr/ADR-009-sampling.md
|
|
119
|
+
- docs/adr/ADR-010-opentelemetry-interoperability.md
|
|
116
120
|
- docs/adr/ADR-011-bounded-resilience-and-remote-control.md
|
|
117
121
|
- docs/adr/ADR-012-rack-context-isolation.md
|
|
118
122
|
- docs/adr/ADR-013-legacy-rails-notifications.md
|
|
@@ -121,6 +125,7 @@ files:
|
|
|
121
125
|
- docs/adr/ADR-016-explicit-observability-integrations.md
|
|
122
126
|
- docs/adr/ADR-017-deploy-events-and-correlation.md
|
|
123
127
|
- docs/adr/ADR-018-pre-1.0-hardening.md
|
|
128
|
+
- docs/adr/ADR-019-bounded-query-diagnostics.md
|
|
124
129
|
- docs/architecture.md
|
|
125
130
|
- docs/compatibility.md
|
|
126
131
|
- docs/configuration.md
|
|
@@ -131,27 +136,36 @@ files:
|
|
|
131
136
|
- docs/modules/active-job.md
|
|
132
137
|
- docs/modules/apm-aggregation.md
|
|
133
138
|
- docs/modules/async-queue.md
|
|
139
|
+
- docs/modules/breadcrumbs.md
|
|
134
140
|
- docs/modules/cache-observability.md
|
|
135
141
|
- docs/modules/configuration.md
|
|
142
|
+
- docs/modules/context.md
|
|
136
143
|
- docs/modules/dependencies.md
|
|
137
144
|
- docs/modules/deploy-tracking.md
|
|
145
|
+
- docs/modules/deploys.md
|
|
138
146
|
- docs/modules/external-http.md
|
|
139
147
|
- docs/modules/ignore-rules.md
|
|
140
148
|
- docs/modules/integration-verification.md
|
|
149
|
+
- docs/modules/job-monitoring.md
|
|
141
150
|
- docs/modules/notice-pipeline.md
|
|
142
151
|
- docs/modules/rack-context.md
|
|
143
152
|
- docs/modules/rails-legacy.md
|
|
144
153
|
- docs/modules/remote-configuration.md
|
|
154
|
+
- docs/modules/request-monitoring.md
|
|
145
155
|
- docs/modules/retry-backlog.md
|
|
156
|
+
- docs/modules/runtime-metrics.md
|
|
157
|
+
- docs/modules/sampling.md
|
|
146
158
|
- docs/modules/sanitization.md
|
|
147
159
|
- docs/modules/serialization.md
|
|
148
160
|
- docs/modules/sidekiq-legacy.md
|
|
161
|
+
- docs/modules/sql-monitoring.md
|
|
149
162
|
- docs/modules/telemetry-events.md
|
|
150
163
|
- docs/modules/transport.md
|
|
151
164
|
- docs/performance.md
|
|
152
165
|
- docs/privacy-lgpd.md
|
|
153
166
|
- docs/protocol-v1.md
|
|
154
167
|
- docs/release-1.0-readiness.md
|
|
168
|
+
- docs/release-1.1-readiness.md
|
|
155
169
|
- docs/security-review.md
|
|
156
170
|
- docs/semver.md
|
|
157
171
|
- docs/troubleshooting.md
|
|
@@ -194,6 +208,7 @@ files:
|
|
|
194
208
|
- lib/chronos/core/sanitizer.rb
|
|
195
209
|
- lib/chronos/core/sensitive_value_filter.rb
|
|
196
210
|
- lib/chronos/core/sql_normalizer.rb
|
|
211
|
+
- lib/chronos/core/sql_query_analyzer.rb
|
|
197
212
|
- lib/chronos/core/telemetry_event.rb
|
|
198
213
|
- lib/chronos/errors.rb
|
|
199
214
|
- lib/chronos/integrations.rb
|
|
@@ -213,8 +228,10 @@ files:
|
|
|
213
228
|
- lib/chronos/observability_facade.rb
|
|
214
229
|
- lib/chronos/ports.rb
|
|
215
230
|
- lib/chronos/ports/context_store.rb
|
|
231
|
+
- lib/chronos/ports/query_inspector.rb
|
|
216
232
|
- lib/chronos/ports/transport.rb
|
|
217
233
|
- lib/chronos/rails.rb
|
|
234
|
+
- lib/chronos/rails/active_record_query_inspector.rb
|
|
218
235
|
- lib/chronos/rails/installer.rb
|
|
219
236
|
- lib/chronos/rails/notifications_subscriber.rb
|
|
220
237
|
- lib/chronos/rails/railtie.rb
|
|
@@ -247,9 +264,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
247
264
|
version: '2.7'
|
|
248
265
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
249
266
|
requirements:
|
|
250
|
-
- - "
|
|
267
|
+
- - ">="
|
|
251
268
|
- !ruby/object:Gem::Version
|
|
252
|
-
version:
|
|
269
|
+
version: '0'
|
|
253
270
|
requirements: []
|
|
254
271
|
rubygems_version: 3.4.22
|
|
255
272
|
signing_key:
|