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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +27 -13
  4. data/contracts/apm-batch-v1.schema.json +51 -1
  5. data/docs/adr/ADR-010-opentelemetry-interoperability.md +3 -3
  6. data/docs/adr/ADR-015-bounded-apm-aggregation.md +3 -3
  7. data/docs/adr/ADR-019-bounded-query-diagnostics.md +44 -0
  8. data/docs/architecture.md +5 -2
  9. data/docs/compatibility.md +12 -18
  10. data/docs/configuration.md +27 -2
  11. data/docs/data-collected.md +9 -3
  12. data/docs/examples/plain-ruby.md +6 -0
  13. data/docs/modules/apm-aggregation.md +18 -5
  14. data/docs/modules/context.md +1 -1
  15. data/docs/modules/external-http.md +3 -2
  16. data/docs/modules/sidekiq-legacy.md +1 -1
  17. data/docs/modules/sql-monitoring.md +60 -6
  18. data/docs/modules/telemetry-events.md +1 -1
  19. data/docs/performance.md +19 -3
  20. data/docs/privacy-lgpd.md +6 -3
  21. data/docs/protocol-v1.md +1 -1
  22. data/docs/release-1.1-readiness.md +51 -0
  23. data/docs/security-review.md +7 -3
  24. data/docs/troubleshooting.md +6 -0
  25. data/lib/chronos/adapters/fiber_local_context_store.rb +57 -0
  26. data/lib/chronos/agent.rb +19 -3
  27. data/lib/chronos/application/apm_aggregator.rb +179 -29
  28. data/lib/chronos/configuration/apm_validation.rb +53 -1
  29. data/lib/chronos/configuration/validation.rb +2 -2
  30. data/lib/chronos/configuration.rb +21 -2
  31. data/lib/chronos/core/metric_aggregate.rb +69 -7
  32. data/lib/chronos/core/sql_query_analyzer.rb +309 -0
  33. data/lib/chronos/core/trace_context.rb +38 -0
  34. data/lib/chronos/integrations/active_job.rb +2 -2
  35. data/lib/chronos/integrations/faraday.rb +76 -0
  36. data/lib/chronos/integrations/net_http.rb +5 -1
  37. data/lib/chronos/integrations/opentelemetry.rb +44 -0
  38. data/lib/chronos/integrations/rack/middleware.rb +8 -2
  39. data/lib/chronos/integrations/sidekiq.rb +1 -1
  40. data/lib/chronos/ports/query_inspector.rb +23 -0
  41. data/lib/chronos/rails/active_record_query_inspector.rb +235 -0
  42. data/lib/chronos/rails/error_reporter_subscriber.rb +39 -0
  43. data/lib/chronos/rails/installer.rb +13 -0
  44. data/lib/chronos/rails/notifications_subscriber.rb +175 -2
  45. data/lib/chronos/rails.rb +2 -0
  46. data/lib/chronos/version.rb +2 -2
  47. data/lib/chronos.rb +6 -0
  48. 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 => {"request" => request, "trace_id" => trace_id(env)},
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["chronos.trace_id"] || SecureRandom.uuid
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