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,309 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# Derives bounded query-shape evidence and index recommendations from normalized SQL.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Extract tables and access columns, compare index metadata, and emit diagnostics.
|
|
6
|
+
# @motivation Give the Chronos consumer actionable SQL evidence without collecting binds or raw SQL.
|
|
7
|
+
# @limits It is a defensive heuristic for SELECT statements, not a dialect-complete parser or optimizer.
|
|
8
|
+
# @collaborators SqlNormalizer and an optional query-inspection adapter.
|
|
9
|
+
# @thread_safety Instances are stateless and safe to share.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails and database drivers.
|
|
11
|
+
# @example
|
|
12
|
+
# SqlQueryAnalyzer.new.call("normalized_query" => "SELECT * FROM users WHERE email = ?")
|
|
13
|
+
# @errors Malformed input returns an empty bounded analysis and never escapes.
|
|
14
|
+
# @performance Input, tables, columns, candidates, diagnostics, and inspection evidence are capped.
|
|
15
|
+
class SqlQueryAnalyzer # rubocop:disable Metrics/ClassLength
|
|
16
|
+
MAX_TABLES = 8
|
|
17
|
+
MAX_COLUMNS = 12
|
|
18
|
+
MAX_INDEXES = 20
|
|
19
|
+
MAX_DIAGNOSTICS = 20
|
|
20
|
+
IDENTIFIER = "[A-Za-z_][A-Za-z0-9_$]*".freeze
|
|
21
|
+
TABLE_PATTERN = /\b(?:FROM|JOIN)\s+["`\[]?(#{IDENTIFIER}(?:\.#{IDENTIFIER})?)["`\]]?
|
|
22
|
+
(?:\s+(?:AS\s+)?(#{IDENTIFIER}))?/ix
|
|
23
|
+
CLAUSE_END = /\b(?:GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION|RETURNING)\b/i
|
|
24
|
+
RESERVED = %w(WHERE JOIN LEFT RIGHT INNER OUTER FULL CROSS ON GROUP ORDER HAVING LIMIT OFFSET).freeze
|
|
25
|
+
|
|
26
|
+
def call(query, inspection = {})
|
|
27
|
+
data = hash(query)
|
|
28
|
+
normalized = bounded(data["normalized_query"] || data[:normalized_query], 512)
|
|
29
|
+
operation = (data["operation"] || data[:operation]).to_s.upcase
|
|
30
|
+
return empty_analysis(operation) unless operation == "SELECT" && !normalized.empty?
|
|
31
|
+
|
|
32
|
+
aliases = table_aliases(normalized, data["table"] || data[:table])
|
|
33
|
+
access = access_columns(normalized, aliases)
|
|
34
|
+
candidates = index_candidates(access, hash(inspection))
|
|
35
|
+
evidence = inspection_evidence(inspection)
|
|
36
|
+
{
|
|
37
|
+
"operation" => operation,
|
|
38
|
+
"tables" => aliases.values.uniq.first(MAX_TABLES),
|
|
39
|
+
"access_columns" => access,
|
|
40
|
+
"index_candidates" => candidates,
|
|
41
|
+
"inspection" => evidence,
|
|
42
|
+
"diagnostics" => diagnostics(candidates, evidence)
|
|
43
|
+
}
|
|
44
|
+
rescue StandardError
|
|
45
|
+
empty_analysis("UNKNOWN")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def empty_analysis(operation)
|
|
51
|
+
{
|
|
52
|
+
"operation" => operation.to_s.empty? ? "UNKNOWN" : operation.to_s,
|
|
53
|
+
"tables" => [], "access_columns" => {}, "index_candidates" => [],
|
|
54
|
+
"inspection" => {}, "diagnostics" => []
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def table_aliases(sql, fallback)
|
|
59
|
+
result = {}
|
|
60
|
+
sql.scan(TABLE_PATTERN) do |table, name|
|
|
61
|
+
alias_name = name.to_s
|
|
62
|
+
alias_name = table.to_s.split(".").last if alias_name.empty? || RESERVED.include?(alias_name.upcase)
|
|
63
|
+
result[alias_name] = bounded_identifier(table)
|
|
64
|
+
break if result.length >= MAX_TABLES
|
|
65
|
+
end
|
|
66
|
+
if result.empty? && !fallback.to_s.empty?
|
|
67
|
+
table = bounded_identifier(fallback)
|
|
68
|
+
result[table.split(".").last] = table unless table.empty?
|
|
69
|
+
end
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def access_columns(sql, aliases)
|
|
74
|
+
result = {}
|
|
75
|
+
aliases.values.uniq.each do |table|
|
|
76
|
+
result[table] = {"equality" => [], "range" => [], "join" => [], "order" => []}
|
|
77
|
+
end
|
|
78
|
+
base_table = aliases.values.first
|
|
79
|
+
where = clause(sql, /\bWHERE\b/i, CLAUSE_END)
|
|
80
|
+
collect_predicates(where, aliases, base_table, result)
|
|
81
|
+
collect_join_predicates(sql, aliases, result)
|
|
82
|
+
collect_order_columns(clause(sql, /\bORDER\s+BY\b/i, /\b(?:LIMIT|OFFSET|UNION|RETURNING)\b/i), aliases,
|
|
83
|
+
base_table, result)
|
|
84
|
+
result.delete_if { |_table, groups| groups.values.all?(&:empty?) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collect_predicates(section, aliases, base_table, result)
|
|
88
|
+
return if section.empty?
|
|
89
|
+
|
|
90
|
+
pattern = /(?:(#{IDENTIFIER})\.)?(#{IDENTIFIER})\s*(=|<>|!=|<=|>=|<|>|\bIN\b|\bIS\b|\bLIKE\b)/i
|
|
91
|
+
section.scan(pattern).each do |alias_name, column, operator|
|
|
92
|
+
table = alias_name.to_s.empty? ? base_table : aliases[alias_name]
|
|
93
|
+
next unless table && result[table]
|
|
94
|
+
|
|
95
|
+
group = ["=", "IN", "IS"].include?(operator.to_s.upcase) ? "equality" : "range"
|
|
96
|
+
append_column(result[table][group], column)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def collect_join_predicates(sql, aliases, result)
|
|
101
|
+
sql.scan(/\bON\b(.*?)(?=\b(?:JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET)\b|\z)/im) do |match|
|
|
102
|
+
match.first.to_s.scan(/(#{IDENTIFIER})\.(#{IDENTIFIER})/i).each do |alias_name, column|
|
|
103
|
+
table = aliases[alias_name]
|
|
104
|
+
append_column(result[table]["join"], column) if table && result[table]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def collect_order_columns(section, aliases, base_table, result)
|
|
110
|
+
section.split(",").first(MAX_COLUMNS).each do |item|
|
|
111
|
+
match = item.match(/(?:(#{IDENTIFIER})\.)?(#{IDENTIFIER})(?:\s+(?:ASC|DESC))?/i)
|
|
112
|
+
next unless match
|
|
113
|
+
|
|
114
|
+
table = match[1].to_s.empty? ? base_table : aliases[match[1]]
|
|
115
|
+
append_column(result[table]["order"], match[2]) if table && result[table]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def index_candidates(access, inspection)
|
|
120
|
+
indexes = Array(inspection["indexes"] || inspection[:indexes]).first(MAX_INDEXES)
|
|
121
|
+
access.map do |table, groups|
|
|
122
|
+
columns = (groups["equality"] + groups["join"] + groups["range"] + groups["order"]).uniq.first(MAX_COLUMNS)
|
|
123
|
+
next if columns.empty?
|
|
124
|
+
|
|
125
|
+
covering = indexes.find { |index| index_covers?(index, table, columns) }
|
|
126
|
+
{
|
|
127
|
+
"table" => table, "columns" => columns,
|
|
128
|
+
"status" => candidate_status(indexes, covering),
|
|
129
|
+
"covered_by" => covering ? bounded(hash(covering)["name"] || hash(covering)[:name], 128) : nil,
|
|
130
|
+
"confidence" => candidate_confidence(inspection, covering)
|
|
131
|
+
}.delete_if { |_key, value| value.nil? || value == "" }
|
|
132
|
+
end.compact.first(MAX_TABLES)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def candidate_status(indexes, covering)
|
|
136
|
+
return "unverified" if indexes.empty?
|
|
137
|
+
|
|
138
|
+
covering ? "covered" : "missing"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def index_covers?(index, table, columns)
|
|
142
|
+
data = hash(index)
|
|
143
|
+
return false unless (data["table"] || data[:table]).to_s == table.to_s
|
|
144
|
+
|
|
145
|
+
existing = Array(data["columns"] || data[:columns]).map(&:to_s)
|
|
146
|
+
existing.first(columns.length) == columns
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def candidate_confidence(inspection, covering)
|
|
150
|
+
return "high" if covering
|
|
151
|
+
|
|
152
|
+
plan = hash(hash(inspection)["plan"] || hash(inspection)[:plan])
|
|
153
|
+
return "high" if plan["sequential_scan"] == true || plan[:sequential_scan] == true
|
|
154
|
+
return "medium" unless Array(hash(inspection)["indexes"] || hash(inspection)[:indexes]).empty?
|
|
155
|
+
|
|
156
|
+
"low"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def inspection_evidence(value)
|
|
160
|
+
data = hash(value)
|
|
161
|
+
result = {"indexes" => bounded_indexes(data["indexes"] || data[:indexes])}
|
|
162
|
+
statistics = hash(data["statistics"] || data[:statistics])
|
|
163
|
+
result["statistics"] = bounded_statistics(statistics) unless statistics.empty?
|
|
164
|
+
plan = hash(data["plan"] || data[:plan])
|
|
165
|
+
result["plan"] = bounded_plan(plan) unless plan.empty?
|
|
166
|
+
errors = bounded_errors(data["errors"] || data[:errors])
|
|
167
|
+
result["errors"] = errors unless errors.empty?
|
|
168
|
+
result
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def bounded_indexes(values)
|
|
172
|
+
Array(values).first(MAX_INDEXES).map do |index|
|
|
173
|
+
item = hash(index)
|
|
174
|
+
columns = Array(item["columns"] || item[:columns]).first(MAX_COLUMNS)
|
|
175
|
+
{
|
|
176
|
+
"table" => bounded_identifier(item["table"] || item[:table]),
|
|
177
|
+
"name" => bounded(item["name"] || item[:name], 128),
|
|
178
|
+
"columns" => columns.map { |column| bounded_identifier(column) },
|
|
179
|
+
"unique" => item["unique"] == true || item[:unique] == true
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def bounded_errors(values)
|
|
185
|
+
Array(values).first(5).map { |error| bounded(error, 128) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def bounded_statistics(statistics)
|
|
189
|
+
{
|
|
190
|
+
"estimated_rows" => non_negative_integer(statistics["estimated_rows"] || statistics[:estimated_rows]),
|
|
191
|
+
"source" => bounded(statistics["source"] || statistics[:source], 64)
|
|
192
|
+
}.delete_if { |_key, value| value.nil? || value == "" }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def bounded_plan(plan)
|
|
196
|
+
{
|
|
197
|
+
"nodes" => Array(plan["nodes"] || plan[:nodes]).first(20).map do |node|
|
|
198
|
+
bounded_plan_node(node)
|
|
199
|
+
end,
|
|
200
|
+
"uses_index" => plan["uses_index"] == true || plan[:uses_index] == true,
|
|
201
|
+
"sequential_scan" => plan["sequential_scan"] == true || plan[:sequential_scan] == true
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def bounded_plan_node(node)
|
|
206
|
+
data = hash(node)
|
|
207
|
+
{
|
|
208
|
+
"type" => bounded(data["type"] || data[:type], 64),
|
|
209
|
+
"table" => bounded_identifier(data["table"] || data[:table]),
|
|
210
|
+
"index" => bounded(data["index"] || data[:index], 128),
|
|
211
|
+
"estimated_rows" => non_negative_integer(data["estimated_rows"] || data[:estimated_rows]),
|
|
212
|
+
"total_cost" => non_negative_number(data["total_cost"] || data[:total_cost])
|
|
213
|
+
}.delete_if { |_key, child| child.nil? || child == "" }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def diagnostics(candidates, inspection)
|
|
217
|
+
result = [diagnostic("query_pattern_analyzed", "info", "query_pattern", {},
|
|
218
|
+
"Normalized SELECT access pattern was analyzed")]
|
|
219
|
+
candidates.each do |candidate|
|
|
220
|
+
status = candidate["status"]
|
|
221
|
+
code = index_diagnostic_code(status)
|
|
222
|
+
severity = status == "covered" ? "info" : "suggestion"
|
|
223
|
+
result << diagnostic(code, severity, "index", candidate, index_diagnostic_message(status))
|
|
224
|
+
end
|
|
225
|
+
plan = hash(inspection["plan"])
|
|
226
|
+
if plan["sequential_scan"] == true
|
|
227
|
+
result << diagnostic("sequential_scan", "warning", "plan", {},
|
|
228
|
+
"The bounded query plan contains a sequential scan")
|
|
229
|
+
end
|
|
230
|
+
Array(inspection["errors"]).each do |error|
|
|
231
|
+
result << diagnostic("query_inspection_failed", "error", "inspection", {"error_class" => error},
|
|
232
|
+
"Read-only query inspection failed")
|
|
233
|
+
end
|
|
234
|
+
result.first(MAX_DIAGNOSTICS)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def index_diagnostic_code(status)
|
|
238
|
+
return "missing_index_candidate" if status == "missing"
|
|
239
|
+
return "index_covered" if status == "covered"
|
|
240
|
+
|
|
241
|
+
"index_candidate"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def index_diagnostic_message(status)
|
|
245
|
+
return "Existing index covers the observed access pattern" if status == "covered"
|
|
246
|
+
|
|
247
|
+
"Review a candidate index for the observed access pattern"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def diagnostic(code, severity, category, evidence, message)
|
|
251
|
+
result = {
|
|
252
|
+
"code" => code, "severity" => severity, "category" => category,
|
|
253
|
+
"message" => message, "evidence" => evidence
|
|
254
|
+
}
|
|
255
|
+
if severity == "suggestion"
|
|
256
|
+
result["recommendation"] = "Validate selectivity and write cost before creating the candidate index"
|
|
257
|
+
end
|
|
258
|
+
result
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def clause(sql, start_pattern, finish_pattern)
|
|
262
|
+
start = sql.index(start_pattern)
|
|
263
|
+
return "" unless start
|
|
264
|
+
|
|
265
|
+
tail = sql[start..-1].to_s.sub(start_pattern, "")
|
|
266
|
+
finish = tail.index(finish_pattern)
|
|
267
|
+
finish ? tail[0...finish] : tail
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def append_column(collection, value)
|
|
271
|
+
column = bounded_identifier(value)
|
|
272
|
+
collection << column if !column.empty? && !collection.include?(column) && collection.length < MAX_COLUMNS
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def bounded_identifier(value)
|
|
276
|
+
bounded(value, 128).gsub(/[^A-Za-z0-9_.$-]/, "")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def bounded(value, limit)
|
|
280
|
+
text = value.to_s
|
|
281
|
+
text = text.scrub("?") if text.respond_to?(:scrub)
|
|
282
|
+
text.bytesize > limit ? text.byteslice(0, limit).to_s : text
|
|
283
|
+
rescue StandardError
|
|
284
|
+
""
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def non_negative_integer(value)
|
|
288
|
+
return nil if value.nil?
|
|
289
|
+
|
|
290
|
+
[value.to_i, 0].max
|
|
291
|
+
rescue StandardError
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def non_negative_number(value)
|
|
296
|
+
return nil if value.nil?
|
|
297
|
+
|
|
298
|
+
number = value.to_f
|
|
299
|
+
number < 0.0 ? 0.0 : number
|
|
300
|
+
rescue StandardError
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def hash(value)
|
|
305
|
+
value.is_a?(Hash) ? value : {}
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# Parses and formats the W3C Trace Context headers without an OpenTelemetry dependency.
|
|
4
|
+
module TraceContext
|
|
5
|
+
TRACEPARENT = /\A([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})\z/.freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def parse(value)
|
|
10
|
+
match = TRACEPARENT.match(value.to_s.downcase)
|
|
11
|
+
return {} unless match && match[1] == "00"
|
|
12
|
+
return {} if match[2] == ("0" * 32) || match[3] == ("0" * 16)
|
|
13
|
+
|
|
14
|
+
{"trace_id" => match[2], "span_id" => match[3], "trace_flags" => match[4]}
|
|
15
|
+
rescue StandardError
|
|
16
|
+
{}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def format(context)
|
|
20
|
+
trace_id = value(context, "trace_id")
|
|
21
|
+
span_id = value(context, "span_id")
|
|
22
|
+
flags = value(context, "trace_flags")
|
|
23
|
+
return nil unless trace_id =~ /\A[\da-f]{32}\z/ && span_id =~ /\A[\da-f]{16}\z/
|
|
24
|
+
return nil if trace_id == ("0" * 32) || span_id == ("0" * 16)
|
|
25
|
+
|
|
26
|
+
"00-#{trace_id}-#{span_id}-#{flags =~ /\A[\da-f]{2}\z/ ? flags : '01'}"
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def value(context, key)
|
|
32
|
+
return "" unless context.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
(context[key] || context[key.to_sym]).to_s.downcase
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -40,7 +40,7 @@ module Chronos
|
|
|
40
40
|
def envelope(notifier)
|
|
41
41
|
source = notifier.respond_to?(:propagation_context) ? notifier.propagation_context : {}
|
|
42
42
|
source = {} unless source.is_a?(Hash)
|
|
43
|
-
context = %w(trace_id request_id).each_with_object({}) do |key, result|
|
|
43
|
+
context = %w(trace_id span_id trace_flags request_id).each_with_object({}) do |key, result|
|
|
44
44
|
value = source[key] || source[key.to_sym]
|
|
45
45
|
result[key] = bounded(value) unless value.to_s.empty?
|
|
46
46
|
end
|
|
@@ -57,7 +57,7 @@ module Chronos
|
|
|
57
57
|
source = value["context"] || value[:context]
|
|
58
58
|
return {} unless source.is_a?(Hash)
|
|
59
59
|
|
|
60
|
-
%w(trace_id request_id).each_with_object({}) do |key, result|
|
|
60
|
+
%w(trace_id span_id trace_flags request_id).each_with_object({}) do |key, result|
|
|
61
61
|
candidate = source[key] || source[key.to_sym]
|
|
62
62
|
result[key] = bounded(candidate) unless candidate.to_s.empty?
|
|
63
63
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Integrations
|
|
3
|
+
# Optional Faraday middleware for outbound timing and W3C propagation.
|
|
4
|
+
# @responsibility Instrument one explicit Faraday connection without global patches.
|
|
5
|
+
# @motivation Modern Rails applications commonly use Faraday for outbound HTTP.
|
|
6
|
+
# @limits It never reads paths, queries, headers other than trace fields, or bodies.
|
|
7
|
+
# @collaborators Faraday middleware stack, TraceContext, and Chronos facade.
|
|
8
|
+
# @thread_safety Calls retain request state in local variables.
|
|
9
|
+
# @compatibility Faraday 1.x and 2.x middleware contracts.
|
|
10
|
+
# @example builder.use Chronos::Integrations::FaradayMiddleware
|
|
11
|
+
# @errors Original client exceptions are recorded by class and re-raised.
|
|
12
|
+
# @performance Two clock reads and one bounded asynchronous event per request.
|
|
13
|
+
class FaradayMiddleware
|
|
14
|
+
def initialize(app, options = {})
|
|
15
|
+
@app = app
|
|
16
|
+
@notifier = options[:notifier] || Chronos
|
|
17
|
+
@clock = options[:clock] || proc { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(environment)
|
|
21
|
+
started_at = @clock.call
|
|
22
|
+
inject_headers(environment)
|
|
23
|
+
response = @app.call(environment)
|
|
24
|
+
if response.respond_to?(:on_complete)
|
|
25
|
+
response.on_complete { |completed| record(completed, started_at, nil) }
|
|
26
|
+
else
|
|
27
|
+
record(environment, started_at, nil)
|
|
28
|
+
end
|
|
29
|
+
response
|
|
30
|
+
rescue StandardError => error
|
|
31
|
+
record(environment, started_at, error) if started_at
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def inject_headers(environment)
|
|
38
|
+
options = integration_options
|
|
39
|
+
return unless options[:trace_headers] && environment.respond_to?(:request_headers)
|
|
40
|
+
|
|
41
|
+
context = @notifier.respond_to?(:propagation_context) ? @notifier.propagation_context : {}
|
|
42
|
+
headers = environment.request_headers
|
|
43
|
+
traceparent = Core::TraceContext.format(context) if options[:w3c_trace_context]
|
|
44
|
+
headers["traceparent"] ||= traceparent if traceparent
|
|
45
|
+
headers["X-Chronos-Trace-ID"] ||= context["trace_id"] if context["trace_id"]
|
|
46
|
+
headers["X-Chronos-Request-ID"] ||= context["request_id"] if context["request_id"]
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def record(environment, started_at, error)
|
|
52
|
+
url = environment.url if environment.respond_to?(:url)
|
|
53
|
+
payload = {
|
|
54
|
+
"host" => (url.host.to_s if url && url.respond_to?(:host)),
|
|
55
|
+
"method" => (environment.method.to_s.upcase if environment.respond_to?(:method)),
|
|
56
|
+
"status" => (environment.status.to_i if environment.respond_to?(:status) && environment.status),
|
|
57
|
+
"duration_ms" => ((@clock.call - started_at) * 1000.0).round(3),
|
|
58
|
+
"error_class" => (error.class.name.to_s if error)
|
|
59
|
+
}
|
|
60
|
+
payload.delete_if { |_key, value| value.nil? || value == "" }
|
|
61
|
+
@notifier.record_event("external_http", payload)
|
|
62
|
+
rescue StandardError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def integration_options
|
|
67
|
+
return {:enabled => true, :trace_headers => true, :w3c_trace_context => false} unless
|
|
68
|
+
@notifier.respond_to?(:external_http_integration_options)
|
|
69
|
+
|
|
70
|
+
@notifier.external_http_integration_options
|
|
71
|
+
rescue StandardError
|
|
72
|
+
{:enabled => false, :trace_headers => false, :w3c_trace_context => false}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -53,7 +53,8 @@ module Chronos
|
|
|
53
53
|
{
|
|
54
54
|
:notifier => notifier,
|
|
55
55
|
:clock => options[:clock] || proc { monotonic_time },
|
|
56
|
-
:trace_headers => configured.fetch(:trace_headers, true)
|
|
56
|
+
:trace_headers => configured.fetch(:trace_headers, true),
|
|
57
|
+
:w3c_trace_context => configured.fetch(:w3c_trace_context, false)
|
|
57
58
|
}
|
|
58
59
|
end
|
|
59
60
|
|
|
@@ -106,6 +107,9 @@ module Chronos
|
|
|
106
107
|
end
|
|
107
108
|
set_header(request, "X-Chronos-Trace-ID", context["trace_id"] || context[:trace_id])
|
|
108
109
|
set_header(request, "X-Chronos-Request-ID", context["request_id"] || context[:request_id])
|
|
110
|
+
if options[:w3c_trace_context]
|
|
111
|
+
set_header(request, "traceparent", Core::TraceContext.format(context))
|
|
112
|
+
end
|
|
109
113
|
rescue StandardError
|
|
110
114
|
nil
|
|
111
115
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Integrations
|
|
3
|
+
# Optional, dependency-free bridge to an already configured OpenTelemetry SDK.
|
|
4
|
+
module OpenTelemetry
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def current_context
|
|
8
|
+
return {} unless defined?(::OpenTelemetry::Trace) && ::OpenTelemetry::Trace.respond_to?(:current_span)
|
|
9
|
+
|
|
10
|
+
span = ::OpenTelemetry::Trace.current_span
|
|
11
|
+
context = span.context if span && span.respond_to?(:context)
|
|
12
|
+
return {} unless context && (!context.respond_to?(:valid?) || context.valid?)
|
|
13
|
+
|
|
14
|
+
trace_id = hex_identifier(context, :hex_trace_id, :trace_id, 32)
|
|
15
|
+
span_id = hex_identifier(context, :hex_span_id, :span_id, 16)
|
|
16
|
+
return {} if trace_id.empty? || span_id.empty?
|
|
17
|
+
|
|
18
|
+
{"trace_id" => trace_id, "span_id" => span_id,
|
|
19
|
+
"trace_flags" => sampled?(context) ? "01" : "00", "source" => "opentelemetry"}
|
|
20
|
+
rescue StandardError
|
|
21
|
+
{}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def active?
|
|
25
|
+
!current_context.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def hex_identifier(context, hex_method, numeric_method, width)
|
|
29
|
+
value = context.public_send(hex_method) if context.respond_to?(hex_method)
|
|
30
|
+
value ||= context.public_send(numeric_method).to_i.to_s(16) if context.respond_to?(numeric_method)
|
|
31
|
+
value.to_s.downcase.rjust(width, "0")[-width, width]
|
|
32
|
+
rescue StandardError
|
|
33
|
+
""
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def sampled?(context)
|
|
37
|
+
flags = context.trace_flags if context.respond_to?(:trace_flags)
|
|
38
|
+
flags.respond_to?(:sampled?) ? flags.sampled? : flags.to_i.odd?
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -62,7 +62,7 @@ module Chronos
|
|
|
62
62
|
def request_capture_context(env)
|
|
63
63
|
request = request_values(env)
|
|
64
64
|
{
|
|
65
|
-
:context =>
|
|
65
|
+
:context => trace_context(env).merge("request" => request),
|
|
66
66
|
:parameters => parameters(env),
|
|
67
67
|
:user => hash_value(env["chronos.user"])
|
|
68
68
|
}
|
|
@@ -124,7 +124,13 @@ module Chronos
|
|
|
124
124
|
end
|
|
125
125
|
|
|
126
126
|
def trace_id(env)
|
|
127
|
-
env["
|
|
127
|
+
trace_context(env)["trace_id"]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def trace_context(env)
|
|
131
|
+
parsed = Core::TraceContext.parse(env["HTTP_TRACEPARENT"])
|
|
132
|
+
parsed["trace_id"] = env["chronos.trace_id"] || SecureRandom.hex(16) if parsed.empty?
|
|
133
|
+
parsed
|
|
128
134
|
end
|
|
129
135
|
|
|
130
136
|
def response_size(headers)
|
|
@@ -110,7 +110,7 @@ module Chronos
|
|
|
110
110
|
source = @notifier.propagation_context
|
|
111
111
|
return {} unless source.is_a?(Hash)
|
|
112
112
|
|
|
113
|
-
%w(trace_id request_id).each_with_object({}) do |key, result|
|
|
113
|
+
%w(trace_id span_id trace_flags request_id).each_with_object({}) do |key, result|
|
|
114
114
|
value = source[key] || source[key.to_sym]
|
|
115
115
|
result[key] = value.to_s unless value.to_s.empty?
|
|
116
116
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Ports
|
|
3
|
+
# Documents the optional read-only database-inspection boundary used by SQL monitoring.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Define compatibility for index, statistics, and plan inspection adapters.
|
|
6
|
+
# @motivation Keep database and ActiveRecord behavior outside the framework-independent core.
|
|
7
|
+
# @limits It does not require inspection, execute DDL, or prescribe a database driver.
|
|
8
|
+
# @thread_safety Implementations define their own connection and concurrency guarantees.
|
|
9
|
+
# @compatibility The compatibility check uses APIs available in Ruby 2.2.10.
|
|
10
|
+
# @example
|
|
11
|
+
# QueryInspector.compatible?(adapter) #=> true
|
|
12
|
+
# @errors Compatibility checks contain objects with failing reflection methods.
|
|
13
|
+
module QueryInspector
|
|
14
|
+
REQUIRED_METHODS = [:call].freeze
|
|
15
|
+
|
|
16
|
+
def self.compatible?(object)
|
|
17
|
+
REQUIRED_METHODS.all? { |method_name| object.respond_to?(method_name) }
|
|
18
|
+
rescue StandardError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|