chronos-ruby 1.0.0 → 1.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +27 -13
  4. data/contracts/apm-batch-v1.schema.json +51 -1
  5. data/docs/adr/ADR-010-opentelemetry-interoperability.md +3 -3
  6. data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
  7. data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
  8. data/docs/architecture.md +5 -2
  9. data/docs/compatibility.md +12 -18
  10. data/docs/configuration.md +27 -2
  11. data/docs/data-collected.md +9 -3
  12. data/docs/examples/plain-ruby.md +6 -0
  13. data/docs/modules/apm-aggregation.md +18 -5
  14. data/docs/modules/context.md +1 -1
  15. data/docs/modules/external-http.md +3 -2
  16. data/docs/modules/sidekiq-legacy.md +1 -1
  17. data/docs/modules/sql-monitoring.md +60 -6
  18. data/docs/modules/telemetry-events.md +1 -1
  19. data/docs/performance.md +19 -3
  20. data/docs/privacy-lgpd.md +6 -3
  21. data/docs/protocol-v1.md +1 -1
  22. data/docs/release-1.1-readiness.md +51 -0
  23. data/docs/security-review.md +7 -3
  24. data/docs/troubleshooting.md +6 -0
  25. data/lib/chronos/adapters/fiber_local_context_store.rb +57 -0
  26. data/lib/chronos/agent.rb +19 -3
  27. data/lib/chronos/application/apm_aggregator.rb +179 -29
  28. data/lib/chronos/configuration/apm_validation.rb +53 -1
  29. data/lib/chronos/configuration/validation.rb +2 -2
  30. data/lib/chronos/configuration.rb +21 -2
  31. data/lib/chronos/core/metric_aggregate.rb +69 -7
  32. data/lib/chronos/core/sql_query_analyzer.rb +309 -0
  33. data/lib/chronos/core/trace_context.rb +38 -0
  34. data/lib/chronos/integrations/active_job.rb +2 -2
  35. data/lib/chronos/integrations/faraday.rb +76 -0
  36. data/lib/chronos/integrations/net_http.rb +5 -1
  37. data/lib/chronos/integrations/opentelemetry.rb +44 -0
  38. data/lib/chronos/integrations/rack/middleware.rb +8 -2
  39. data/lib/chronos/integrations/sidekiq.rb +1 -1
  40. data/lib/chronos/ports/query_inspector.rb +23 -0
  41. data/lib/chronos/rails/active_record_query_inspector.rb +235 -0
  42. data/lib/chronos/rails/error_reporter_subscriber.rb +39 -0
  43. data/lib/chronos/rails/installer.rb +13 -0
  44. data/lib/chronos/rails/notifications_subscriber.rb +175 -2
  45. data/lib/chronos/rails.rb +2 -0
  46. data/lib/chronos/version.rb +2 -2
  47. data/lib/chronos.rb +6 -0
  48. metadata +46 -38
@@ -0,0 +1,235 @@
1
+ require "json"
2
+
3
+ module Chronos
4
+ module Rails
5
+ # Performs bounded, read-only ActiveRecord index, statistics, and query-plan inspection.
6
+ #
7
+ # @responsibility Read public schema metadata and optional non-executing SELECT plans.
8
+ # @motivation Add database evidence to query diagnostics without exposing raw SQL or bind values.
9
+ # @limits It never runs EXPLAIN ANALYZE or DDL; support is best-effort and explicitly opt-in.
10
+ # @collaborators An ActiveRecord connection supplied by sql.active_record notifications.
11
+ # @thread_safety Calls use the notification connection and a thread-local recursion guard.
12
+ # @compatibility Feature-detected Rails 4.2/5.2 connections for PostgreSQL and MySQL-family adapters.
13
+ # @example
14
+ # inspector.call(sql, query_metadata, :plan => false)
15
+ # @errors Adapter failures become bounded error-class evidence and never escape.
16
+ # @performance Each uncached inspection adds schema/statistics queries and optionally EXPLAIN.
17
+ class ActiveRecordQueryInspector
18
+ GUARD_KEY = :__chronos_query_inspection
19
+ MAX_PLAN_NODES = 20
20
+
21
+ def self.suppressed?
22
+ Thread.current[GUARD_KEY] == true
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ def call(raw_sql, query, options = {})
28
+ connection = options[:connection]
29
+ return {} unless eligible?(raw_sql, query, connection)
30
+
31
+ previous = Thread.current[GUARD_KEY]
32
+ Thread.current[GUARD_KEY] = true
33
+ guarded = true
34
+ result = {"errors" => []}
35
+ capture(result) { result["indexes"] = indexes(connection, table(query)) }
36
+ if options[:statistics] == true
37
+ capture(result) { result["statistics"] = statistics(connection, table(query)) }
38
+ end
39
+ if options[:plan] == true
40
+ capture(result) do
41
+ plan_result = plan(connection, raw_sql, adapter_name(connection))
42
+ result["errors"].concat(Array(plan_result.delete("errors")))
43
+ result["plan"] = plan_result
44
+ end
45
+ end
46
+ result.delete("errors") if result["errors"].empty?
47
+ result
48
+ rescue StandardError => error
49
+ {"errors" => [safe_error_class(error)]}
50
+ ensure
51
+ Thread.current[GUARD_KEY] = previous if guarded
52
+ end
53
+
54
+ private
55
+
56
+ def capture(result)
57
+ yield
58
+ rescue StandardError => error
59
+ result["errors"] << safe_error_class(error) if result["errors"].length < 5
60
+ end
61
+
62
+ def eligible?(raw_sql, query, connection)
63
+ return false unless connection
64
+ return false unless (query["operation"] || query[:operation]).to_s == "SELECT"
65
+ return false if table(query).empty?
66
+ return false unless raw_sql.to_s.lstrip =~ /\ASELECT\b/i
67
+
68
+ normalized = (query["normalized_query"] || query[:normalized_query]).to_s
69
+ normalized.sub(/;\s*\z/, "") !~ /;/
70
+ end
71
+
72
+ def indexes(connection, table_name)
73
+ return [] unless connection.respond_to?(:indexes)
74
+
75
+ Array(connection.indexes(table_name)).first(20).map do |index|
76
+ {
77
+ "table" => bounded_identifier(table_name), "name" => bounded(value(index, :name), 128),
78
+ "columns" => Array(value(index, :columns)).first(12).map { |column| bounded_identifier(column) },
79
+ "unique" => value(index, :unique) == true
80
+ }
81
+ end
82
+ end
83
+
84
+ def statistics(connection, table_name)
85
+ adapter = adapter_name(connection)
86
+ if adapter =~ /postgres/i
87
+ row = first_row(connection, "SELECT reltuples AS estimated_rows FROM pg_class WHERE oid = " \
88
+ "#{quote(connection, table_name)}::regclass")
89
+ return {"estimated_rows" => numeric(row, "estimated_rows"), "source" => "pg_class"}
90
+ end
91
+ if adapter =~ /mysql|trilogy/i
92
+ sql = "SELECT table_rows AS estimated_rows FROM information_schema.tables " \
93
+ "WHERE table_schema = DATABASE() AND table_name = #{quote(connection, table_name)}"
94
+ row = first_row(connection, sql)
95
+ return {"estimated_rows" => numeric(row, "estimated_rows"), "source" => "information_schema"}
96
+ end
97
+
98
+ {}
99
+ end
100
+
101
+ def plan(connection, raw_sql, adapter)
102
+ return {} unless connection.respond_to?(:select_all)
103
+
104
+ if adapter =~ /postgres/i
105
+ rows = rows(connection.select_all("EXPLAIN (FORMAT JSON) #{raw_sql}"))
106
+ return postgres_plan(rows)
107
+ end
108
+ if adapter =~ /mysql|trilogy/i
109
+ return mysql_plan(rows(connection.select_all("EXPLAIN #{raw_sql}")))
110
+ end
111
+
112
+ {}
113
+ end
114
+
115
+ def postgres_plan(rows_value)
116
+ raw = rows_value.first || {}
117
+ document = raw["QUERY PLAN"] || raw["query_plan"] || raw.values.first
118
+ parsed = document.is_a?(String) ? JSON.parse(document) : document
119
+ root = Array(parsed).first
120
+ root = root["Plan"] if root.is_a?(Hash) && root["Plan"]
121
+ nodes = []
122
+ collect_postgres_nodes(root, nodes)
123
+ summarize_plan(nodes)
124
+ rescue StandardError => error
125
+ {"nodes" => [], "uses_index" => false, "sequential_scan" => false,
126
+ "errors" => [safe_error_class(error)]}
127
+ end
128
+
129
+ def collect_postgres_nodes(node, result)
130
+ return unless node.is_a?(Hash) && result.length < MAX_PLAN_NODES
131
+
132
+ result << {
133
+ "type" => bounded(node["Node Type"], 64),
134
+ "table" => bounded_identifier(node["Relation Name"]),
135
+ "index" => bounded(node["Index Name"], 128),
136
+ "estimated_rows" => integer(node["Plan Rows"]),
137
+ "total_cost" => number(node["Total Cost"])
138
+ }.delete_if { |_key, child| child.nil? || child == "" }
139
+ Array(node["Plans"]).each { |child| collect_postgres_nodes(child, result) }
140
+ end
141
+
142
+ def mysql_plan(rows_value)
143
+ nodes = rows_value.first(MAX_PLAN_NODES).map do |row|
144
+ {
145
+ "type" => bounded(row["type"] || row[:type] || row["select_type"], 64),
146
+ "table" => bounded_identifier(row["table"] || row[:table]),
147
+ "index" => bounded(row["key"] || row[:key], 128),
148
+ "estimated_rows" => integer(row["rows"] || row[:rows])
149
+ }.delete_if { |_key, child| child.nil? || child == "" }
150
+ end
151
+ summarize_plan(nodes)
152
+ end
153
+
154
+ def summarize_plan(nodes)
155
+ types = nodes.map { |node| node["type"].to_s }
156
+ {
157
+ "nodes" => nodes,
158
+ "uses_index" => nodes.any? { |node| !node["index"].to_s.empty? || node["type"].to_s =~ /Index/i },
159
+ "sequential_scan" => types.any? { |type| type =~ /Seq Scan|ALL/i }
160
+ }
161
+ end
162
+
163
+ def first_row(connection, sql)
164
+ return {} unless connection.respond_to?(:select_all)
165
+
166
+ rows(connection.select_all(sql)).first || {}
167
+ end
168
+
169
+ def rows(value)
170
+ return value.to_a if value.respond_to?(:to_a)
171
+
172
+ Array(value)
173
+ rescue StandardError
174
+ []
175
+ end
176
+
177
+ def quote(connection, value)
178
+ return connection.quote(value.to_s) if connection.respond_to?(:quote)
179
+
180
+ "'#{value.to_s.gsub("'", "''")}'"
181
+ end
182
+
183
+ def adapter_name(connection)
184
+ connection.respond_to?(:adapter_name) ? connection.adapter_name.to_s : ""
185
+ rescue StandardError
186
+ ""
187
+ end
188
+
189
+ def table(query)
190
+ bounded_identifier(query["table"] || query[:table])
191
+ end
192
+
193
+ def value(object, method_name)
194
+ object.respond_to?(method_name) ? object.public_send(method_name) : nil
195
+ rescue StandardError
196
+ nil
197
+ end
198
+
199
+ def numeric(row, key)
200
+ value = row[key] || row[key.to_sym]
201
+ integer(value)
202
+ end
203
+
204
+ def integer(value)
205
+ value.nil? ? nil : [value.to_i, 0].max
206
+ rescue StandardError
207
+ nil
208
+ end
209
+
210
+ def number(value)
211
+ value.nil? ? nil : [value.to_f, 0.0].max
212
+ rescue StandardError
213
+ nil
214
+ end
215
+
216
+ def safe_error_class(error)
217
+ bounded(error.class.name.to_s, 128)
218
+ rescue StandardError
219
+ "StandardError"
220
+ end
221
+
222
+ def bounded_identifier(value)
223
+ bounded(value, 128).gsub(/[^A-Za-z0-9_.$-]/, "")
224
+ end
225
+
226
+ def bounded(value, limit)
227
+ text = value.to_s
228
+ text = text.scrub("?") if text.respond_to?(:scrub)
229
+ text.bytesize > limit ? text.byteslice(0, limit).to_s : text
230
+ rescue StandardError
231
+ ""
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,39 @@
1
+ module Chronos
2
+ module Rails
3
+ # Sends exceptions observed by the Rails 7 error reporter to Chronos once.
4
+ # @responsibility Translate the public Rails reporter callback into one notice.
5
+ # @motivation Rails can report handled errors that never reach Rack middleware.
6
+ # @limits Only handled, severity, source, and one bounded component are retained.
7
+ # @collaborators ActiveSupport::ErrorReporter and Chronos facade.
8
+ # @thread_safety Instances contain only an immutable notifier reference.
9
+ # @compatibility Rails 7 public error reporter subscriber API.
10
+ # @example Rails.error.subscribe(ErrorReporterSubscriber.new)
11
+ # @errors Agent and context failures are contained and return false.
12
+ # @performance Allocates one small allowlisted metadata hash per report.
13
+ class ErrorReporterSubscriber
14
+ def initialize(notifier = Chronos)
15
+ @notifier = notifier
16
+ end
17
+
18
+ def report(error, handled:, severity:, context:, source: nil)
19
+ details = {
20
+ :context => {"rails_error_reporter" => {
21
+ "handled" => handled == true, "severity" => severity.to_s, "source" => source.to_s
22
+ }}
23
+ }
24
+ details[:context]["rails_error_reporter"]["component"] = component(context)
25
+ @notifier.notify_once(error, details)
26
+ rescue StandardError
27
+ false
28
+ end
29
+
30
+ private
31
+
32
+ def component(context)
33
+ return "" unless context.is_a?(Hash)
34
+
35
+ (context[:controller] || context["controller"] || context[:job] || context["job"]).to_s[0, 128]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -34,6 +34,7 @@ module Chronos
34
34
 
35
35
  install_middleware(application, options)
36
36
  install_active_job
37
+ install_error_reporter
37
38
  @subscriber.install
38
39
  self.class.applications[application.object_id] = true
39
40
  end
@@ -65,6 +66,18 @@ module Chronos
65
66
  Chronos::Integrations::ActiveJob.install(::ActiveJob::Base, @notifier)
66
67
  end
67
68
 
69
+ def install_error_reporter
70
+ return false unless defined?(::Rails) && ::Rails.respond_to?(:error)
71
+
72
+ reporter = ::Rails.error
73
+ return false unless reporter && reporter.respond_to?(:subscribe)
74
+
75
+ reporter.subscribe(ErrorReporterSubscriber.new(@notifier))
76
+ true
77
+ rescue StandardError
78
+ false
79
+ end
80
+
68
81
  def environment
69
82
  defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : nil
70
83
  end
@@ -12,11 +12,12 @@ 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
19
19
  cache_write.active_support cache_fetch_hit.active_support
20
+ perform_action.action_cable transmit.action_cable broadcast.action_cable
20
21
  ).freeze
21
22
 
22
23
  @mutex = Mutex.new
@@ -26,10 +27,18 @@ module Chronos
26
27
  attr_reader :mutex, :installed_buses
27
28
  end
28
29
 
29
- def initialize(notifier = Chronos, notifications = nil)
30
+ def initialize(notifier = Chronos, notifications = nil, options = {})
30
31
  @notifier = notifier
31
32
  @notifications = notifications || active_support_notifications
32
33
  @sql_normalizer = Core::SqlNormalizer.new
34
+ @query_analyzer = options[:query_analyzer] || Core::SqlQueryAnalyzer.new
35
+ @query_inspector = options[:query_inspector] || default_query_inspector
36
+ @query_inspection_mutex = Mutex.new
37
+ @query_inspections = {}
38
+ @query_analyses = {}
39
+ @clock = options[:clock] || proc { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
40
+ @transaction_mutex = Mutex.new
41
+ @transactions = {}
33
42
  cache_options = notifier.respond_to?(:cache_integration_options) ? notifier.cache_integration_options : {}
34
43
  @cache_normalizer = Core::CacheNormalizer.new(
35
44
  cache_options[:project_id].to_s, cache_options[:key_mode] || :none
@@ -97,6 +106,8 @@ module Chronos
97
106
  when "sql.active_record" then sql(payload, duration)
98
107
  when "deliver.action_mailer" then mailer(payload, duration)
99
108
  when "perform.active_job" then active_job(payload, duration)
109
+ when "perform_action.action_cable", "transmit.action_cable", "broadcast.action_cable"
110
+ action_cable(name, payload, duration)
100
111
  else cache(name, payload, duration)
101
112
  end
102
113
  end
@@ -122,6 +133,8 @@ module Chronos
122
133
  end
123
134
 
124
135
  def sql(payload, duration)
136
+ return false if query_inspection_suppressed?
137
+
125
138
  metadata = {
126
139
  :name => value(payload, :name), :cached => value(payload, :cached),
127
140
  :adapter => value(payload, :adapter), :connection => value(payload, :connection),
@@ -131,9 +144,160 @@ module Chronos
131
144
  }
132
145
  metadata[:source] = sampled_query_source if duration >= slow_query_threshold
133
146
  data = @sql_normalizer.call(value(payload, :sql), metadata).merge("duration_ms" => duration)
147
+ track_transaction(data, metadata[:connection])
148
+ if query_analysis_options[:analysis_enabled]
149
+ inspection = query_inspection(value(payload, :sql), data, metadata[:connection], duration)
150
+ analysis = cached_query_analysis(data, inspection)
151
+ data["analysis"] = analysis unless analysis.empty?
152
+ end
134
153
  @notifier.record_event("query", data)
135
154
  end
136
155
 
156
+ def cached_query_analysis(data, inspection)
157
+ options = query_analysis_options
158
+ fingerprint = data["fingerprint"].to_s
159
+ @query_inspection_mutex.synchronize do
160
+ existing = @query_analyses[fingerprint]
161
+ return existing if existing && inspection.empty?
162
+ return existing if existing && !hash(existing["inspection"]).empty?
163
+ return {} unless existing || @query_analyses.length < options[:max_analyses]
164
+
165
+ @query_analyses[fingerprint] = @query_analyzer.call(data, inspection)
166
+ end
167
+ rescue StandardError => error
168
+ {"diagnostics" => [{"code" => "query_analysis_failed", "severity" => "error",
169
+ "category" => "analysis", "message" => "Normalized query analysis failed",
170
+ "evidence" => {"error_class" => safe_error_class(error)}}]}
171
+ end
172
+
173
+ def query_inspection(raw_sql, data, connection, duration)
174
+ options = query_analysis_options
175
+ return {} unless options[:inspection_enabled]
176
+ return {} unless duration >= options[:min_duration_ms]
177
+ return {} unless @query_inspector && Ports::QueryInspector.compatible?(@query_inspector)
178
+
179
+ fingerprint = data["fingerprint"].to_s
180
+ @query_inspection_mutex.synchronize do
181
+ return @query_inspections[fingerprint] if @query_inspections.key?(fingerprint)
182
+ return {} if @query_inspections.length >= options[:max_queries]
183
+
184
+ @query_inspections[fingerprint] = @query_inspector.call(
185
+ raw_sql, data, :connection => connection,
186
+ :statistics => options[:statistics_enabled], :plan => options[:plan_enabled]
187
+ )
188
+ end
189
+ rescue StandardError => error
190
+ {"errors" => [safe_error_class(error)]}
191
+ end
192
+
193
+ def query_analysis_options
194
+ values = @notifier.respond_to?(:apm_integration_options) ? @notifier.apm_integration_options : {}
195
+ {
196
+ :analysis_enabled => values.fetch(:query_analysis_enabled, true) == true,
197
+ :max_analyses => (values[:query_analysis_max_queries] || 100).to_i,
198
+ :inspection_enabled => values[:query_inspection_enabled] == true,
199
+ :statistics_enabled => values[:query_statistics_enabled] == true,
200
+ :plan_enabled => values[:query_plan_enabled] == true,
201
+ :min_duration_ms => (values[:query_inspection_min_duration_ms] || 500.0).to_f,
202
+ :max_queries => (values[:query_inspection_max_queries] || 20).to_i,
203
+ :transaction_tracking_enabled => values.fetch(:transaction_tracking_enabled, true) == true,
204
+ :transaction_max_connections => (values[:transaction_max_connections] || 100).to_i,
205
+ :transaction_ttl_seconds => (values[:transaction_ttl_seconds] || 60.0).to_f
206
+ }
207
+ rescue StandardError
208
+ {:analysis_enabled => true, :max_analyses => 100,
209
+ :inspection_enabled => false, :statistics_enabled => false,
210
+ :plan_enabled => false, :min_duration_ms => 500.0, :max_queries => 20,
211
+ :transaction_tracking_enabled => true, :transaction_max_connections => 100,
212
+ :transaction_ttl_seconds => 60.0}
213
+ end
214
+
215
+ def track_transaction(data, connection)
216
+ options = query_analysis_options
217
+ return unless options[:transaction_tracking_enabled] && connection
218
+
219
+ operation = data["operation"].to_s
220
+ normalized = data["normalized_query"].to_s
221
+ key = connection.object_id.to_s
222
+ current_time = monotonic_now
223
+ @transaction_mutex.synchronize do
224
+ expire_tracked_transactions(current_time, options[:transaction_ttl_seconds])
225
+ details = {
226
+ :operation => operation, :normalized => normalized, :current_time => current_time,
227
+ :max_connections => options[:transaction_max_connections]
228
+ }
229
+ update_transaction_state(data, key, details)
230
+ end
231
+ rescue StandardError
232
+ nil
233
+ end
234
+
235
+ def update_transaction_state(data, key, details)
236
+ operation = details[:operation]
237
+ normalized = details[:normalized]
238
+ current_time = details[:current_time]
239
+ state = @transactions[key]
240
+ return start_tracked_transaction(key, state, current_time, details) if transaction_start?(operation, normalized)
241
+ return unless state
242
+
243
+ if operation == "SAVEPOINT"
244
+ state["depth"] += 1
245
+ elsif operation == "RELEASE" || normalized =~ /\AROLLBACK\s+TO\b/i
246
+ state["depth"] = [state["depth"] - 1, 1].max
247
+ elsif ["COMMIT", "ROLLBACK"].include?(operation)
248
+ finish_tracked_transaction(data, key, state, current_time)
249
+ return
250
+ end
251
+ state["last_seen_at"] = current_time
252
+ end
253
+
254
+ def transaction_start?(operation, normalized)
255
+ operation == "BEGIN" || normalized =~ /\ASTART\s+TRANSACTION\b/i
256
+ end
257
+
258
+ def start_tracked_transaction(key, state, current_time, details)
259
+ if state
260
+ state["depth"] += 1
261
+ state["last_seen_at"] = current_time
262
+ elsif @transactions.length < details[:max_connections]
263
+ @transactions[key] = {"started_at" => current_time, "last_seen_at" => current_time, "depth" => 1}
264
+ end
265
+ end
266
+
267
+ def finish_tracked_transaction(data, key, state, current_time)
268
+ @transactions.delete(key)
269
+ elapsed = [(current_time - state["started_at"]) * 1000.0, 0.0].max
270
+ data["transaction_duration_ms"] = elapsed.round(3)
271
+ end
272
+
273
+ def expire_tracked_transactions(current_time, ttl)
274
+ @transactions.delete_if { |_key, state| current_time - state["last_seen_at"] > ttl }
275
+ end
276
+
277
+ def monotonic_now
278
+ @clock.call.to_f
279
+ rescue StandardError
280
+ Time.now.to_f
281
+ end
282
+
283
+ def default_query_inspector
284
+ defined?(ActiveRecordQueryInspector) ? ActiveRecordQueryInspector.new : nil
285
+ rescue StandardError
286
+ nil
287
+ end
288
+
289
+ def query_inspection_suppressed?
290
+ defined?(ActiveRecordQueryInspector) && ActiveRecordQueryInspector.suppressed?
291
+ rescue StandardError
292
+ false
293
+ end
294
+
295
+ def safe_error_class(error)
296
+ error.class.name.to_s[0, 128]
297
+ rescue StandardError
298
+ "StandardError"
299
+ end
300
+
137
301
  def mailer(payload, duration)
138
302
  data = {
139
303
  "kind" => "mailer", "mailer" => value(payload, :mailer).to_s,
@@ -184,6 +348,15 @@ module Chronos
184
348
  @notifier.record_event("cache", data)
185
349
  end
186
350
 
351
+ def action_cable(name, payload, duration)
352
+ data = {
353
+ "kind" => "action_cable", "operation" => name.split(".").first,
354
+ "channel" => safe_class_name(value(payload, :channel)),
355
+ "action" => value(payload, :action).to_s[0, 128], "duration_ms" => duration
356
+ }
357
+ @notifier.record_event("request", data)
358
+ end
359
+
187
360
  def capture_controller_exception(payload)
188
361
  exception = value(payload, :exception_object)
189
362
  details = value(payload, :exception)
data/lib/chronos/rails.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require "chronos"
2
+ require "chronos/rails/active_record_query_inspector"
2
3
  require "chronos/rails/notifications_subscriber"
4
+ require "chronos/rails/error_reporter_subscriber"
3
5
  require "chronos/integrations/active_job"
4
6
  require "chronos/rails/installer"
5
7
 
@@ -1,4 +1,4 @@
1
1
  module Chronos
2
- # Current version of the legacy Chronos Ruby agent.
3
- VERSION = "1.0.0".freeze
2
+ # Current version of the transitional Chronos Ruby agent.
3
+ VERSION = "1.2.0.pre.1".freeze
4
4
  end
data/lib/chronos.rb CHANGED
@@ -16,20 +16,24 @@ require "chronos/core/sensitive_value_filter"
16
16
  require "chronos/core/sanitizer"
17
17
  require "chronos/core/safe_serializer"
18
18
  require "chronos/core/correlation_context"
19
+ require "chronos/core/trace_context"
19
20
  require "chronos/core/deploy_normalizer"
20
21
  require "chronos/core/payload_serializer"
21
22
  require "chronos/core/telemetry_event"
22
23
  require "chronos/core/sql_normalizer"
24
+ require "chronos/core/sql_query_analyzer"
23
25
  require "chronos/core/metric_aggregate"
24
26
  require "chronos/core/cache_normalizer"
25
27
  require "chronos/ports/transport"
26
28
  require "chronos/ports/context_store"
29
+ require "chronos/ports/query_inspector"
27
30
  require "chronos/internal/safe_logger"
28
31
  require "chronos/internal/bounded_queue"
29
32
  require "chronos/internal/memory_backlog"
30
33
  require "chronos/internal/worker_pool"
31
34
  require "chronos/adapters/net_http_transport"
32
35
  require "chronos/adapters/thread_local_context_store"
36
+ require "chronos/adapters/fiber_local_context_store"
33
37
  require "chronos/core/breadcrumb"
34
38
  require "chronos/application/retry_policy"
35
39
  require "chronos/application/circuit_breaker"
@@ -47,6 +51,8 @@ require "chronos/observability_facade"
47
51
  require "chronos/integrations"
48
52
  require "chronos/integrations/rack"
49
53
  require "chronos/integrations/rack/middleware"
54
+ require "chronos/integrations/opentelemetry"
55
+ require "chronos/integrations/faraday"
50
56
 
51
57
  # Framework-independent public facade for the Chronos Ruby agent.
52
58
  #