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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +25 -12
- 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 +22 -1
- 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 +19 -2
- 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
- data/lib/generators/chronos/install/templates/chronos.rb +152 -6
- 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"
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
14
|
-
config.
|
|
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
|
-
#
|
|
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.
|
|
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-
|
|
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
|