chronos-ruby 1.0.0 → 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 +18 -0
- data/README.md +22 -10
- data/contracts/apm-batch-v1.schema.json +51 -1
- data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
- data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
- data/docs/architecture.md +5 -2
- data/docs/compatibility.md +4 -4
- data/docs/configuration.md +21 -0
- data/docs/data-collected.md +9 -3
- data/docs/examples/plain-ruby.md +6 -0
- data/docs/modules/apm-aggregation.md +18 -5
- data/docs/modules/sidekiq-legacy.md +1 -1
- data/docs/modules/sql-monitoring.md +60 -6
- data/docs/modules/telemetry-events.md +1 -1
- data/docs/performance.md +19 -3
- data/docs/privacy-lgpd.md +6 -3
- data/docs/protocol-v1.md +1 -1
- 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 +7 -2
|
@@ -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
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: 1.
|
|
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
|
|
@@ -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
|