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.
@@ -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, :external_http_enabled, :external_http_trace_headers,
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 = nil
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 signals.
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, status = nil)
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