chronos-ruby 0.9.0.pre.4 → 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 +36 -0
- data/README.md +108 -197
- data/contracts/apm-batch-v1.schema.json +51 -1
- data/docs/adr/ADR-007-feature-detection.md +25 -0
- data/docs/adr/ADR-008-context-store.md +25 -0
- data/docs/adr/ADR-009-sampling.md +25 -0
- data/docs/adr/ADR-010-opentelemetry-interoperability.md +25 -0
- data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
- data/docs/adr/ADR-018-pre-1.0-hardening.md +6 -2
- data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
- data/docs/architecture.md +5 -2
- data/docs/compatibility.md +30 -23
- data/docs/configuration.md +21 -0
- data/docs/data-collected.md +9 -3
- data/docs/deprecation-policy.md +1 -1
- data/docs/examples/plain-ruby.md +6 -0
- data/docs/migration-from-airbrake.md +1 -1
- data/docs/modules/apm-aggregation.md +18 -5
- data/docs/modules/breadcrumbs.md +23 -0
- data/docs/modules/context.md +21 -0
- data/docs/modules/deploys.md +23 -0
- data/docs/modules/job-monitoring.md +22 -0
- data/docs/modules/request-monitoring.md +20 -0
- data/docs/modules/runtime-metrics.md +22 -0
- data/docs/modules/sampling.md +22 -0
- data/docs/modules/sidekiq-legacy.md +1 -1
- data/docs/modules/sql-monitoring.md +76 -0
- data/docs/modules/telemetry-events.md +1 -1
- data/docs/performance.md +30 -3
- data/docs/privacy-lgpd.md +6 -3
- data/docs/protocol-v1.md +2 -2
- data/docs/release-1.0-readiness.md +17 -15
- 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 +21 -4
|
@@ -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,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
|
|
@@ -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
|