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
|
@@ -23,6 +23,7 @@ module Chronos
|
|
|
23
23
|
class Configuration
|
|
24
24
|
include Internal::ConfigurationValidation
|
|
25
25
|
include Internal::ApmConfigurationValidation
|
|
26
|
+
DEFAULT_HOST = "https://chronosmonitor.com.br".freeze
|
|
26
27
|
DEFAULT_BLOCKLIST_KEYS = %w(
|
|
27
28
|
password password_confirmation passwd secret api_key apikey authorization
|
|
28
29
|
token access_token refresh_token private_key client_secret cookie set-cookie
|
|
@@ -47,7 +48,13 @@ module Chronos
|
|
|
47
48
|
:apm_enabled, :apm_max_groups, :apm_flush_count, :apm_batch_size,
|
|
48
49
|
:apm_max_queries_per_request, :apm_slow_query_threshold_ms,
|
|
49
50
|
:apm_long_transaction_threshold_ms, :apm_n_plus_one_threshold,
|
|
50
|
-
:apm_histogram_buckets, :
|
|
51
|
+
:apm_histogram_buckets, :apm_trace_ttl_seconds,
|
|
52
|
+
:apm_query_analysis_enabled, :apm_query_inspection_enabled,
|
|
53
|
+
:apm_query_analysis_max_queries,
|
|
54
|
+
:apm_query_statistics_enabled, :apm_query_plan_enabled,
|
|
55
|
+
:apm_query_inspection_min_duration_ms, :apm_query_inspection_max_queries,
|
|
56
|
+
:apm_transaction_tracking_enabled, :apm_transaction_max_connections,
|
|
57
|
+
:external_http_enabled, :external_http_trace_headers,
|
|
51
58
|
:cache_key_mode, :dependency_reporting, :dependency_max_items
|
|
52
59
|
].freeze
|
|
53
60
|
|
|
@@ -99,7 +106,7 @@ module Chronos
|
|
|
99
106
|
def initialize_core_defaults
|
|
100
107
|
@project_id = nil
|
|
101
108
|
@project_key = nil
|
|
102
|
-
@host =
|
|
109
|
+
@host = DEFAULT_HOST
|
|
103
110
|
@environment = "production"
|
|
104
111
|
@app_version = nil
|
|
105
112
|
@service_name = nil
|
|
@@ -146,6 +153,16 @@ module Chronos
|
|
|
146
153
|
@apm_long_transaction_threshold_ms = 1000.0
|
|
147
154
|
@apm_n_plus_one_threshold = 5
|
|
148
155
|
@apm_histogram_buckets = [5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0]
|
|
156
|
+
@apm_trace_ttl_seconds = 60.0
|
|
157
|
+
@apm_query_analysis_enabled = true
|
|
158
|
+
@apm_query_analysis_max_queries = 100
|
|
159
|
+
@apm_query_inspection_enabled = false
|
|
160
|
+
@apm_query_statistics_enabled = false
|
|
161
|
+
@apm_query_plan_enabled = false
|
|
162
|
+
@apm_query_inspection_min_duration_ms = 500.0
|
|
163
|
+
@apm_query_inspection_max_queries = 20
|
|
164
|
+
@apm_transaction_tracking_enabled = true
|
|
165
|
+
@apm_transaction_max_connections = 100
|
|
149
166
|
end
|
|
150
167
|
|
|
151
168
|
def initialize_observability_defaults
|
|
@@ -2,18 +2,20 @@ module Chronos
|
|
|
2
2
|
module Core
|
|
3
3
|
# Accumulates one bounded APM metric group and serializes aggregate statistics.
|
|
4
4
|
#
|
|
5
|
-
# @responsibility Track counts, errors, durations, histogram, breakdown, and
|
|
5
|
+
# @responsibility Track counts, errors, durations, histogram, breakdown, signals, and diagnostics.
|
|
6
6
|
# @motivation Keep numerical accumulation separate from grouping and request correlation.
|
|
7
|
-
# @limits It does not choose dimensions, retain observations, or calculate percentiles.
|
|
7
|
+
# @limits It does not choose dimensions, retain observations, or calculate exact percentiles.
|
|
8
8
|
# @collaborators ApmAggregator and immutable histogram boundaries.
|
|
9
9
|
# @thread_safety Mutable by design; callers must synchronize access.
|
|
10
10
|
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
11
|
# @example
|
|
12
|
-
# metric.observe(12.0, false, {"database" => 3.0}, {})
|
|
12
|
+
# metric.observe(12.0, false, {"database" => 3.0}, {}, :diagnostics => [])
|
|
13
13
|
# @errors Non-numeric durations become zero and never escape.
|
|
14
14
|
# @performance Memory is fixed by the configured histogram boundary count.
|
|
15
15
|
class MetricAggregate
|
|
16
16
|
BREAKDOWN_CATEGORIES = %w(database view external_http cache queue application unknown).freeze
|
|
17
|
+
DIAGNOSTIC_SEVERITIES = %w(error warning info suggestion).freeze
|
|
18
|
+
MAX_DIAGNOSTICS = 20
|
|
17
19
|
|
|
18
20
|
def initialize(metric_type, dimensions, boundaries)
|
|
19
21
|
@metric_type = metric_type
|
|
@@ -28,9 +30,12 @@ module Chronos
|
|
|
28
30
|
@breakdown = {}
|
|
29
31
|
@signals = {}
|
|
30
32
|
@status_codes = {}
|
|
33
|
+
@diagnostics = {}
|
|
34
|
+
@severity_counts = {}
|
|
35
|
+
@query_analysis = nil
|
|
31
36
|
end
|
|
32
37
|
|
|
33
|
-
def observe(duration, error, breakdown, signals,
|
|
38
|
+
def observe(duration, error, breakdown, signals, options = {})
|
|
34
39
|
value = non_negative(duration)
|
|
35
40
|
@count += 1
|
|
36
41
|
@error_count += 1 if error
|
|
@@ -41,7 +46,9 @@ module Chronos
|
|
|
41
46
|
@buckets[bucket || @boundaries.length] += 1
|
|
42
47
|
add_breakdown(breakdown)
|
|
43
48
|
add_signals(signals)
|
|
44
|
-
add_status(status)
|
|
49
|
+
add_status(options[:status])
|
|
50
|
+
add_diagnostics(options[:diagnostics])
|
|
51
|
+
retain_query_analysis(options[:query_analysis])
|
|
45
52
|
self
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -50,9 +57,10 @@ module Chronos
|
|
|
50
57
|
"metric_type" => @metric_type, "dimensions" => @dimensions,
|
|
51
58
|
"count" => @count, "error_count" => @error_count,
|
|
52
59
|
"error_rate" => (@error_count.to_f / @count).round(6),
|
|
53
|
-
"duration_ms" => duration_summary, "histogram" => histogram,
|
|
60
|
+
"duration_ms" => duration_summary, "percentiles_ms" => percentiles, "histogram" => histogram,
|
|
54
61
|
"breakdown_ms" => rounded_hash(@breakdown), "signals" => @signals,
|
|
55
|
-
"status_codes" => @status_codes
|
|
62
|
+
"status_codes" => @status_codes, "severity_counts" => @severity_counts,
|
|
63
|
+
"diagnostics" => @diagnostics.values, "query_analysis" => @query_analysis || {}
|
|
56
64
|
}
|
|
57
65
|
end
|
|
58
66
|
|
|
@@ -81,6 +89,46 @@ module Chronos
|
|
|
81
89
|
@status_codes[key] += 1
|
|
82
90
|
end
|
|
83
91
|
|
|
92
|
+
def add_diagnostics(values)
|
|
93
|
+
Array(values).first(MAX_DIAGNOSTICS).each do |diagnostic|
|
|
94
|
+
data = hash(diagnostic)
|
|
95
|
+
severity = data["severity"].to_s
|
|
96
|
+
next unless DIAGNOSTIC_SEVERITIES.include?(severity)
|
|
97
|
+
|
|
98
|
+
@severity_counts[severity] ||= 0
|
|
99
|
+
@severity_counts[severity] += 1
|
|
100
|
+
key = diagnostic_key(data)
|
|
101
|
+
existing = @diagnostics[key]
|
|
102
|
+
if existing
|
|
103
|
+
existing["count"] += 1
|
|
104
|
+
elsif @diagnostics.length < MAX_DIAGNOSTICS
|
|
105
|
+
@diagnostics[key] = data.merge("count" => 1)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def diagnostic_key(data)
|
|
111
|
+
evidence = hash(data["evidence"])
|
|
112
|
+
[data["code"], data["severity"], evidence["table"], Array(evidence["columns"]).join(",")].join("|")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def retain_query_analysis(value)
|
|
116
|
+
return unless value.is_a?(Hash) && !value.empty?
|
|
117
|
+
return if @query_analysis && analysis_score(@query_analysis) >= analysis_score(value)
|
|
118
|
+
|
|
119
|
+
@query_analysis = value.reject { |key, _child| key.to_s == "diagnostics" }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def analysis_score(value)
|
|
123
|
+
data = hash(value)
|
|
124
|
+
inspection = hash(data["inspection"] || data[:inspection])
|
|
125
|
+
score = inspection.empty? ? 0 : 1
|
|
126
|
+
score += 1 unless Array(inspection["indexes"] || inspection[:indexes]).empty?
|
|
127
|
+
score += 1 unless hash(inspection["statistics"] || inspection[:statistics]).empty?
|
|
128
|
+
score += 1 unless hash(inspection["plan"] || inspection[:plan]).empty?
|
|
129
|
+
score
|
|
130
|
+
end
|
|
131
|
+
|
|
84
132
|
def duration_summary
|
|
85
133
|
{
|
|
86
134
|
"total" => @total.round(3), "min" => @min.round(3), "max" => @max.round(3),
|
|
@@ -94,6 +142,20 @@ module Chronos
|
|
|
94
142
|
end
|
|
95
143
|
end
|
|
96
144
|
|
|
145
|
+
def percentiles
|
|
146
|
+
{"p50" => percentile(0.50), "p95" => percentile(0.95), "p99" => percentile(0.99)}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def percentile(ratio)
|
|
150
|
+
target = (@count * ratio).ceil
|
|
151
|
+
accumulated = 0
|
|
152
|
+
@buckets.each_with_index do |count, index|
|
|
153
|
+
accumulated += count
|
|
154
|
+
return (@boundaries[index] || @max).to_f.round(3) if accumulated >= target
|
|
155
|
+
end
|
|
156
|
+
@max.to_f.round(3)
|
|
157
|
+
end
|
|
158
|
+
|
|
97
159
|
def rounded_hash(values)
|
|
98
160
|
values.each_with_object({}) { |(key, value), result| result[key] = value.round(3) }
|
|
99
161
|
end
|
|
@@ -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
|