chronos-ruby 1.0.0 → 1.1.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.
@@ -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
@@ -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 = "1.0.0".freeze
3
+ VERSION = "1.1.1".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"
@@ -1,20 +1,166 @@
1
1
  require "chronos/rails"
2
2
 
3
+ # This initializer lists every public Chronos option so the application's
4
+ # monitoring behavior is explicit and can be reviewed in one place.
3
5
  Chronos.configure do |config|
4
- # Chronos reads only the environment variables explicitly selected here.
6
+ # Identifies the project that will receive monitoring events.
5
7
  config.project_id = ENV["CHRONOS_PROJECT_ID"]
8
+ # Authenticates event delivery. Keep this value in a secret manager.
6
9
  config.project_key = ENV["CHRONOS_PROJECT_KEY"]
7
- config.host = ENV["CHRONOS_HOST"]
10
+ # Defines the Chronos Monitor endpoint used to deliver events.
11
+ config.host = "https://chronosmonitor.com.br"
12
+ # Labels events with the current Rails environment.
8
13
  config.environment = Rails.env.to_s
9
- config.service_name = ENV["CHRONOS_SERVICE_NAME"]
14
+ # Identifies the deployed application version.
10
15
  config.app_version = ENV["CHRONOS_APP_VERSION"]
16
+ # Distinguishes this application from other monitored services.
17
+ config.service_name = ENV["CHRONOS_SERVICE_NAME"]
18
+ # Records the source revision, such as a Git commit SHA.
19
+ config.revision = ENV["CHRONOS_REVISION"]
20
+ # Associates events with a specific deployment.
21
+ config.deploy_id = ENV["CHRONOS_DEPLOY_ID"]
22
+ # Identifies the infrastructure region running the application.
23
+ config.region = ENV["CHRONOS_REGION"]
24
+ # Identifies the process, container, or host producing an event.
25
+ config.instance_id = ENV["CHRONOS_INSTANCE_ID"]
26
+ # Removes the application root from reported file paths.
27
+ config.root_directory = Rails.root.to_s
28
+ # Sends internal Chronos diagnostics through the Rails logger.
11
29
  config.logger = Rails.logger if Rails.respond_to?(:logger)
12
30
 
13
- # Safe legacy defaults: test and console integrations remain disabled.
14
- config.rails_capture_in_test = false
31
+ # Limits how long an established request waits for the server.
32
+ config.timeout = 5
33
+ # Limits how long Chronos waits while opening a connection.
34
+ config.open_timeout = 2
35
+ # Sets the maximum number of events in the in-memory queue.
36
+ config.queue_size = 100
37
+ # Sets the number of background delivery workers.
38
+ config.workers = 1
39
+ # Enables or disables all event collection and delivery.
40
+ config.enabled = true
41
+ # Controls whether captured exceptions are sent to Chronos.
42
+ config.error_notifications = true
43
+ # Prevents delivery from the listed application environments.
44
+ config.ignored_environments = []
45
+ # Routes outbound requests through a proxy URL when required.
46
+ config.proxy = nil
47
+ # Verifies the TLS certificate presented by the endpoint.
48
+ config.ssl_verify = true
49
+ # Identifies this SDK in outbound HTTP requests.
50
+ config.user_agent = "chronos-ruby/#{Chronos::VERSION}"
51
+ # Rejects serialized events larger than this number of bytes.
52
+ config.max_payload_size = 1_048_576
53
+ # Compresses request payloads with gzip when enabled.
54
+ config.gzip = false
55
+
56
+ # Removes values whose keys commonly contain private data.
57
+ config.blocklist_keys = Chronos::Configuration::DEFAULT_BLOCKLIST_KEYS.dup
58
+ # Keeps only the listed keys when this list is not empty.
59
+ config.allowlist_keys = []
60
+ # Applies custom callables that transform or discard events.
61
+ config.filters = []
62
+ # Replaces values for these keys with stable hashes.
63
+ config.hash_keys = []
64
+ # Removes the final segment of captured IP addresses.
65
+ config.anonymize_ip = true
66
+ # Discards events matching any configured ignore rule.
67
+ config.ignore_rules = []
68
+ # Limits the ignore rules evaluated for each event.
69
+ config.max_ignore_rules = 20
70
+
71
+ # Sets the number of retries after a recoverable failure.
72
+ config.max_retries = 3
73
+ # Sets the initial exponential-backoff delay in seconds.
74
+ config.retry_base_interval = 0.5
75
+ # Caps the retry delay in seconds.
76
+ config.retry_max_interval = 30
77
+ # Randomizes retry delays to prevent synchronized traffic.
78
+ config.retry_jitter = 0.25
79
+ # Keeps this many failed events for later delivery attempts.
80
+ config.backlog_size = 100
81
+ # Opens the circuit breaker after consecutive failures.
82
+ config.circuit_failure_threshold = 5
83
+ # Waits this many seconds before testing an open circuit.
84
+ config.circuit_reset_timeout = 30
85
+ # Allows bounded runtime configuration from the server.
86
+ config.remote_configuration = true
87
+ # Limits an accepted remote configuration response in bytes.
88
+ config.remote_config_max_bytes = 4_096
89
+ # Captures this fraction of eligible events, from 0.0 to 1.0.
90
+ config.sampling_rate = 1.0
91
+ # Restricts remote collection to these event categories.
92
+ config.enabled_event_types = %w(
93
+ exception request query job cache external_http dependencies deploy metric_batch
94
+ )
95
+ # Caps the server-requested delivery interval in seconds.
96
+ config.max_remote_send_interval = 60
97
+
98
+ # Stores request and trace context independently in each thread.
99
+ config.context_store = :thread_local
100
+ # Keeps this many recent breadcrumbs per execution context.
101
+ config.breadcrumb_capacity = 20
102
+ # Limits each serialized breadcrumb in bytes.
103
+ config.breadcrumb_max_bytes = 2_048
104
+
105
+ # Installs Rails request, job, cache, SQL, and error integrations.
106
+ config.rails_enabled = true
107
+ # Captures events from Rails console sessions when enabled.
15
108
  config.rails_capture_in_console = false
109
+ # Captures events from the Rails test environment when enabled.
110
+ config.rails_capture_in_test = false
111
+ # Includes the request User-Agent in metadata when enabled.
16
112
  config.rails_capture_user_agent = false
113
+
114
+ # Enables transaction, span, query, and performance aggregation.
115
+ config.apm_enabled = true
116
+ # Limits the metric groups retained between flushes.
117
+ config.apm_max_groups = 200
118
+ # Flushes APM aggregates after this many observations.
119
+ config.apm_flush_count = 100
120
+ # Sends at most this many APM items in one request.
121
+ config.apm_batch_size = 50
122
+ # Limits SQL queries recorded for one request or transaction.
123
+ config.apm_max_queries_per_request = 100
124
+ # Marks queries slower than this duration in milliseconds.
125
+ config.apm_slow_query_threshold_ms = 500
126
+ # Marks transactions longer than this duration in milliseconds.
127
+ config.apm_long_transaction_threshold_ms = 1_000
128
+ # Reports repeated equivalent queries after this count.
129
+ config.apm_n_plus_one_threshold = 5
130
+ # Defines duration buckets in milliseconds for APM histograms.
131
+ config.apm_histogram_buckets = [5, 10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000]
132
+ # Retains active trace state for this many seconds.
133
+ config.apm_trace_ttl_seconds = 60
134
+ # Normalizes SQL and records bounded analysis metadata.
135
+ config.apm_query_analysis_enabled = true
136
+ # Limits analyzed queries retained per transaction.
137
+ config.apm_query_analysis_max_queries = 100
138
+ # Enables database-specific inspection for slow queries.
139
+ config.apm_query_inspection_enabled = false
140
+ # Collects database query statistics when supported.
141
+ config.apm_query_statistics_enabled = false
142
+ # Collects query execution plans when supported.
143
+ config.apm_query_plan_enabled = false
144
+ # Inspects only queries at least this slow in milliseconds.
145
+ config.apm_query_inspection_min_duration_ms = 500
146
+ # Limits inspected queries retained per transaction.
147
+ config.apm_query_inspection_max_queries = 20
148
+ # Tracks database transaction lifecycles and durations.
149
+ config.apm_transaction_tracking_enabled = true
150
+ # Limits database connections tracked for transaction state.
151
+ config.apm_transaction_max_connections = 100
152
+
153
+ # Instruments supported outbound HTTP clients when enabled.
154
+ config.external_http_enabled = false
155
+ # Propagates Chronos trace headers on outbound requests.
156
+ config.external_http_trace_headers = true
157
+ # Controls cache key reporting; :none collects no key material.
158
+ config.cache_key_mode = :none
159
+ # Reports a bounded inventory of application dependencies.
160
+ config.dependency_reporting = true
161
+ # Limits dependencies included in an inventory event.
162
+ config.dependency_max_items = 100
17
163
  end
18
164
 
19
- # Safe when the Railtie already ran or will run later; installation is idempotent.
165
+ # Installation is idempotent, regardless of when the Railtie was evaluated.
20
166
  Chronos::Rails::Installer.new.install(Rails.application)
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: 1.0.0
4
+ version: 1.1.1
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-29 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -125,6 +125,7 @@ files:
125
125
  - docs/adr/ADR-016-explicit-observability-integrations.md
126
126
  - docs/adr/ADR-017-deploy-events-and-correlation.md
127
127
  - docs/adr/ADR-018-pre-1.0-hardening.md
128
+ - docs/adr/ADR-019-bounded-query-diagnostics.md
128
129
  - docs/architecture.md
129
130
  - docs/compatibility.md
130
131
  - docs/configuration.md
@@ -164,6 +165,7 @@ files:
164
165
  - docs/privacy-lgpd.md
165
166
  - docs/protocol-v1.md
166
167
  - docs/release-1.0-readiness.md
168
+ - docs/release-1.1-readiness.md
167
169
  - docs/security-review.md
168
170
  - docs/semver.md
169
171
  - docs/troubleshooting.md
@@ -206,6 +208,7 @@ files:
206
208
  - lib/chronos/core/sanitizer.rb
207
209
  - lib/chronos/core/sensitive_value_filter.rb
208
210
  - lib/chronos/core/sql_normalizer.rb
211
+ - lib/chronos/core/sql_query_analyzer.rb
209
212
  - lib/chronos/core/telemetry_event.rb
210
213
  - lib/chronos/errors.rb
211
214
  - lib/chronos/integrations.rb
@@ -225,8 +228,10 @@ files:
225
228
  - lib/chronos/observability_facade.rb
226
229
  - lib/chronos/ports.rb
227
230
  - lib/chronos/ports/context_store.rb
231
+ - lib/chronos/ports/query_inspector.rb
228
232
  - lib/chronos/ports/transport.rb
229
233
  - lib/chronos/rails.rb
234
+ - lib/chronos/rails/active_record_query_inspector.rb
230
235
  - lib/chronos/rails/installer.rb
231
236
  - lib/chronos/rails/notifications_subscriber.rb
232
237
  - lib/chronos/rails/railtie.rb