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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +27 -13
- data/contracts/apm-batch-v1.schema.json +51 -1
- data/docs/adr/ADR-010-opentelemetry-interoperability.md +3 -3
- 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 +12 -18
- data/docs/configuration.md +27 -2
- 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/context.md +1 -1
- data/docs/modules/external-http.md +3 -2
- 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/adapters/fiber_local_context_store.rb +57 -0
- data/lib/chronos/agent.rb +19 -3
- data/lib/chronos/application/apm_aggregator.rb +179 -29
- data/lib/chronos/configuration/apm_validation.rb +53 -1
- data/lib/chronos/configuration/validation.rb +2 -2
- data/lib/chronos/configuration.rb +21 -2
- data/lib/chronos/core/metric_aggregate.rb +69 -7
- data/lib/chronos/core/sql_query_analyzer.rb +309 -0
- data/lib/chronos/core/trace_context.rb +38 -0
- data/lib/chronos/integrations/active_job.rb +2 -2
- data/lib/chronos/integrations/faraday.rb +76 -0
- data/lib/chronos/integrations/net_http.rb +5 -1
- data/lib/chronos/integrations/opentelemetry.rb +44 -0
- data/lib/chronos/integrations/rack/middleware.rb +8 -2
- data/lib/chronos/integrations/sidekiq.rb +1 -1
- data/lib/chronos/ports/query_inspector.rb +23 -0
- data/lib/chronos/rails/active_record_query_inspector.rb +235 -0
- data/lib/chronos/rails/error_reporter_subscriber.rb +39 -0
- data/lib/chronos/rails/installer.rb +13 -0
- data/lib/chronos/rails/notifications_subscriber.rb +175 -2
- data/lib/chronos/rails.rb +2 -0
- data/lib/chronos/version.rb +2 -2
- data/lib/chronos.rb +6 -0
- 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
data/lib/chronos/version.rb
CHANGED
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
|
#
|