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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +108 -197
  4. data/contracts/apm-batch-v1.schema.json +51 -1
  5. data/docs/adr/ADR-007-feature-detection.md +25 -0
  6. data/docs/adr/ADR-008-context-store.md +25 -0
  7. data/docs/adr/ADR-009-sampling.md +25 -0
  8. data/docs/adr/ADR-010-opentelemetry-interoperability.md +25 -0
  9. data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
  10. data/docs/adr/ADR-018-pre-1.0-hardening.md +6 -2
  11. data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
  12. data/docs/architecture.md +5 -2
  13. data/docs/compatibility.md +30 -23
  14. data/docs/configuration.md +21 -0
  15. data/docs/data-collected.md +9 -3
  16. data/docs/deprecation-policy.md +1 -1
  17. data/docs/examples/plain-ruby.md +6 -0
  18. data/docs/migration-from-airbrake.md +1 -1
  19. data/docs/modules/apm-aggregation.md +18 -5
  20. data/docs/modules/breadcrumbs.md +23 -0
  21. data/docs/modules/context.md +21 -0
  22. data/docs/modules/deploys.md +23 -0
  23. data/docs/modules/job-monitoring.md +22 -0
  24. data/docs/modules/request-monitoring.md +20 -0
  25. data/docs/modules/runtime-metrics.md +22 -0
  26. data/docs/modules/sampling.md +22 -0
  27. data/docs/modules/sidekiq-legacy.md +1 -1
  28. data/docs/modules/sql-monitoring.md +76 -0
  29. data/docs/modules/telemetry-events.md +1 -1
  30. data/docs/performance.md +30 -3
  31. data/docs/privacy-lgpd.md +6 -3
  32. data/docs/protocol-v1.md +2 -2
  33. data/docs/release-1.0-readiness.md +17 -15
  34. data/docs/release-1.1-readiness.md +51 -0
  35. data/docs/security-review.md +7 -3
  36. data/docs/troubleshooting.md +6 -0
  37. data/lib/chronos/agent.rb +12 -2
  38. data/lib/chronos/application/apm_aggregator.rb +179 -29
  39. data/lib/chronos/configuration/apm_validation.rb +51 -1
  40. data/lib/chronos/configuration.rb +17 -1
  41. data/lib/chronos/core/metric_aggregate.rb +69 -7
  42. data/lib/chronos/core/sql_query_analyzer.rb +309 -0
  43. data/lib/chronos/ports/query_inspector.rb +23 -0
  44. data/lib/chronos/rails/active_record_query_inspector.rb +235 -0
  45. data/lib/chronos/rails/notifications_subscriber.rb +163 -2
  46. data/lib/chronos/rails.rb +1 -0
  47. data/lib/chronos/version.rb +1 -1
  48. data/lib/chronos.rb +2 -0
  49. 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
@@ -1,4 +1,5 @@
1
1
  require "chronos"
2
+ require "chronos/rails/active_record_query_inspector"
2
3
  require "chronos/rails/notifications_subscriber"
3
4
  require "chronos/integrations/active_job"
4
5
  require "chronos/rails/installer"
@@ -1,4 +1,4 @@
1
1
  module Chronos
2
2
  # Current version of the legacy Chronos Ruby agent.
3
- VERSION = "0.9.0.pre.4".freeze
3
+ VERSION = "1.1.0".freeze
4
4
  end
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: 0.9.0.pre.4
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-07-22 00:00:00.000000000 Z
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: 1.3.1
269
+ version: '0'
253
270
  requirements: []
254
271
  rubygems_version: 3.4.22
255
272
  signing_key: