chronos-ruby 0.6.0.pre.1 → 0.8.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +53 -10
  4. data/contracts/apm-batch-v1.schema.json +35 -0
  5. data/contracts/dependencies-v1.schema.json +38 -0
  6. data/contracts/event-v1.schema.json +1 -1
  7. data/docs/adr/ADR-015-bounded-apm-aggregation.md +27 -0
  8. data/docs/adr/ADR-016-explicit-observability-integrations.md +25 -0
  9. data/docs/architecture.md +11 -2
  10. data/docs/compatibility.md +4 -0
  11. data/docs/configuration.md +28 -1
  12. data/docs/data-collected.md +17 -3
  13. data/docs/examples/plain-ruby.md +7 -1
  14. data/docs/modules/apm-aggregation.md +57 -0
  15. data/docs/modules/cache-observability.md +16 -0
  16. data/docs/modules/configuration.md +4 -0
  17. data/docs/modules/dependencies.md +18 -0
  18. data/docs/modules/external-http.md +27 -0
  19. data/docs/modules/rails-legacy.md +2 -2
  20. data/docs/modules/sidekiq-legacy.md +2 -2
  21. data/docs/modules/telemetry-events.md +2 -2
  22. data/docs/performance.md +29 -1
  23. data/docs/privacy-lgpd.md +16 -2
  24. data/docs/troubleshooting.md +25 -1
  25. data/lib/chronos/agent.rb +49 -0
  26. data/lib/chronos/application/apm_aggregator.rb +270 -0
  27. data/lib/chronos/application/apm_error_classifier.rb +27 -0
  28. data/lib/chronos/application/capture_telemetry.rb +45 -5
  29. data/lib/chronos/application/delivery_pipeline.rb +7 -0
  30. data/lib/chronos/application/dependency_reporter.rb +129 -0
  31. data/lib/chronos/application/remote_configuration.rb +8 -1
  32. data/lib/chronos/configuration/apm_validation.rb +76 -0
  33. data/lib/chronos/configuration.rb +35 -2
  34. data/lib/chronos/core/cache_normalizer.rb +99 -0
  35. data/lib/chronos/core/metric_aggregate.rb +113 -0
  36. data/lib/chronos/core/sql_normalizer.rb +114 -0
  37. data/lib/chronos/core/telemetry_event.rb +1 -1
  38. data/lib/chronos/integrations/net_http.rb +165 -0
  39. data/lib/chronos/integrations/rack/middleware.rb +19 -1
  40. data/lib/chronos/integrations/sidekiq.rb +2 -0
  41. data/lib/chronos/net_http.rb +4 -0
  42. data/lib/chronos/observability_facade.rb +43 -0
  43. data/lib/chronos/rails/notifications_subscriber.rb +42 -9
  44. data/lib/chronos/version.rb +1 -1
  45. data/lib/chronos.rb +23 -0
  46. metadata +19 -1
@@ -0,0 +1,129 @@
1
+ module Chronos
2
+ module Application
3
+ # Builds one bounded inventory from already loaded runtime components.
4
+ #
5
+ # @responsibility Report loaded gems and runtime/framework versions once per agent.
6
+ # @motivation Dependency context aids diagnosis without attaching the bundle to every error.
7
+ # @limits It does not read lockfiles, environment variables, gem paths, or open DB connections.
8
+ # @collaborators RubyGems loaded specs and immutable Chronos configuration.
9
+ # @thread_safety A mutex guarantees at-most-once collection across concurrent first events.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; all framework detection is optional.
11
+ # @example
12
+ # payload = DependencyReporter.new(config).call
13
+ # @errors Detection failures yield omitted fields and never escape.
14
+ # @performance Runs once and visits at most the configured number of loaded specs.
15
+ class DependencyReporter
16
+ def initialize(config, options = {})
17
+ @config = config
18
+ @loaded_specs = options[:loaded_specs] || proc { Gem.loaded_specs }
19
+ @constants = options[:constants] || {}
20
+ @mutex = Mutex.new
21
+ @reported = false
22
+ end
23
+
24
+ def call
25
+ @mutex.synchronize do
26
+ return nil if @reported || !@config.dependency_reporting
27
+
28
+ @reported = true
29
+ build_payload
30
+ end
31
+ rescue StandardError
32
+ nil
33
+ end
34
+
35
+ private
36
+
37
+ def build_payload
38
+ payload = {
39
+ "dependencies" => dependencies,
40
+ "ruby" => {
41
+ "version" => bounded(RUBY_VERSION, 64), "engine" => bounded(ruby_engine, 64),
42
+ "platform" => bounded(RUBY_PLATFORM, 128)
43
+ },
44
+ "rails" => detected("rails") { rails_version },
45
+ "web_server" => detected("web_server") { web_server },
46
+ "database_adapter" => detected("database_adapter") { database_adapter },
47
+ "sidekiq" => detected("sidekiq") { sidekiq_version },
48
+ "release" => bounded(@config.app_version.to_s, 128)
49
+ }
50
+ payload.delete_if do |key, value|
51
+ !["dependencies", "ruby"].include?(key) && value.to_s.empty?
52
+ end
53
+ payload
54
+ end
55
+
56
+ def dependencies
57
+ specs = @loaded_specs.call
58
+ values = specs.respond_to?(:values) ? specs.values : []
59
+ values.first(@config.dependency_max_items).sort_by { |spec| spec.name.to_s }.map do |spec|
60
+ {"name" => bounded(spec.name.to_s, 128), "version" => bounded(spec.version.to_s, 64)}
61
+ end
62
+ rescue StandardError
63
+ []
64
+ end
65
+
66
+ def detected(name)
67
+ explicit = @constants[name]
68
+ return bounded(explicit.to_s, 128) unless explicit.nil?
69
+
70
+ bounded(yield.to_s, 128)
71
+ rescue StandardError
72
+ ""
73
+ end
74
+
75
+ def rails_version
76
+ defined?(::Rails) && ::Rails.respond_to?(:version) ? ::Rails.version.to_s : loaded_version("rails")
77
+ end
78
+
79
+ def sidekiq_version
80
+ defined?(::Sidekiq::VERSION) ? ::Sidekiq::VERSION.to_s : loaded_version("sidekiq")
81
+ end
82
+
83
+ def web_server
84
+ return "Puma" if defined?(::Puma)
85
+ return "Unicorn" if defined?(::Unicorn)
86
+ return "Passenger" if defined?(::PhusionPassenger)
87
+ return "WEBrick" if defined?(::WEBrick)
88
+
89
+ ""
90
+ end
91
+
92
+ def database_adapter
93
+ names = loaded_spec_names
94
+ return "PostgreSQL" if names.include?("pg")
95
+ return "MySQL" if names.include?("mysql2")
96
+ return "SQLite" if names.include?("sqlite3")
97
+
98
+ ""
99
+ end
100
+
101
+ def loaded_version(name)
102
+ specs = @loaded_specs.call
103
+ spec = specs[name] || specs[name.to_sym] if specs.respond_to?(:[])
104
+ spec ? spec.version.to_s : ""
105
+ rescue StandardError
106
+ ""
107
+ end
108
+
109
+ def loaded_spec_names
110
+ specs = @loaded_specs.call
111
+ specs.respond_to?(:keys) ? specs.keys.map(&:to_s) : []
112
+ rescue StandardError
113
+ []
114
+ end
115
+
116
+ def ruby_engine
117
+ defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
118
+ end
119
+
120
+ def bounded(value, limit)
121
+ text = value.to_s
122
+ text = text.scrub("?") if text.respond_to?(:scrub)
123
+ text.bytesize > limit ? text.byteslice(0, limit) : text
124
+ rescue StandardError
125
+ ""
126
+ end
127
+ end
128
+ end
129
+ end
@@ -13,7 +13,9 @@ module Chronos
13
13
  # @errors Invalid documents are ignored and return false.
14
14
  # @performance Validation is bounded by configured document and list limits.
15
15
  class RemoteConfiguration
16
- SUPPORTED_EVENT_TYPES = ["exception", "request", "query", "job", "cache"].freeze
16
+ SUPPORTED_EVENT_TYPES = %w(
17
+ exception request query job cache external_http dependencies metric_batch
18
+ ).freeze
17
19
  MAX_IGNORED_FINGERPRINTS = 100
18
20
  MAX_FINGERPRINT_BYTES = 256
19
21
  MIN_PAYLOAD_SIZE = 256
@@ -63,6 +65,11 @@ module Chronos
63
65
  snapshot["enabled_event_types"].include?(event_type.to_s)
64
66
  end
65
67
 
68
+ def delivery_enabled?(event_type)
69
+ values = snapshot
70
+ !values["kill_switch"] && values["enabled_event_types"].include?(event_type.to_s)
71
+ end
72
+
66
73
  def ignored_fingerprint?(fingerprint)
67
74
  snapshot["ignored_fingerprints"].include?(fingerprint.to_s)
68
75
  end
@@ -0,0 +1,76 @@
1
+ module Chronos
2
+ module Internal
3
+ # Validates bounded APM aggregation, histogram, and detector settings.
4
+ #
5
+ # @responsibility Return configuration errors for bounded APM and observability options.
6
+ # @motivation Keep the general configuration validator focused and maintainable.
7
+ # @limits It validates shape and bounds but does not allocate aggregator state.
8
+ # @collaborators Chronos::Configuration predicates and attributes.
9
+ # @thread_safety Reads one mutable configuration instance without shared state.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @errors Invalid values become messages and never raise from validation.
12
+ module ApmConfigurationValidation
13
+ private
14
+
15
+ def apm_errors
16
+ errors = apm_capacity_errors
17
+ errors.concat(apm_threshold_errors)
18
+ errors << "apm_enabled must be true or false" unless [true, false].include?(apm_enabled)
19
+ unless increasing_positive_numbers?(apm_histogram_buckets)
20
+ errors << "apm_histogram_buckets must contain increasing positive numbers"
21
+ end
22
+ errors
23
+ end
24
+
25
+ def apm_capacity_errors
26
+ errors = []
27
+ errors << "apm_max_groups must be a positive integer" unless positive_integer?(apm_max_groups)
28
+ errors << "apm_flush_count must be a positive integer" unless positive_integer?(apm_flush_count)
29
+ unless apm_batch_size.is_a?(Integer) && apm_batch_size >= 1 && apm_batch_size <= 50
30
+ errors << "apm_batch_size must be between 1 and 50"
31
+ end
32
+ unless positive_integer?(apm_max_queries_per_request)
33
+ errors << "apm_max_queries_per_request must be a positive integer"
34
+ end
35
+ errors
36
+ end
37
+
38
+ def apm_threshold_errors
39
+ errors = []
40
+ unless positive_number?(apm_slow_query_threshold_ms)
41
+ errors << "apm_slow_query_threshold_ms must be greater than zero"
42
+ end
43
+ unless positive_number?(apm_long_transaction_threshold_ms)
44
+ errors << "apm_long_transaction_threshold_ms must be greater than zero"
45
+ end
46
+ unless apm_n_plus_one_threshold.is_a?(Integer) && apm_n_plus_one_threshold >= 2
47
+ errors << "apm_n_plus_one_threshold must be an integer greater than or equal to 2"
48
+ end
49
+ errors
50
+ end
51
+
52
+ def increasing_positive_numbers?(values)
53
+ return false unless values.is_a?(Array) && !values.empty? && values.length <= 19
54
+ return false unless values.all? { |value| positive_number?(value) }
55
+
56
+ values.each_cons(2).all? { |left, right| right > left }
57
+ end
58
+
59
+ def observability_errors
60
+ errors = []
61
+ errors << "external_http_enabled must be true or false" unless boolean?(external_http_enabled)
62
+ errors << "external_http_trace_headers must be true or false" unless boolean?(external_http_trace_headers)
63
+ errors << "cache_key_mode must be :none or :sha256" unless [:none, :sha256].include?(cache_key_mode)
64
+ errors << "dependency_reporting must be true or false" unless boolean?(dependency_reporting)
65
+ unless dependency_max_items.is_a?(Integer) && dependency_max_items >= 1 && dependency_max_items <= 200
66
+ errors << "dependency_max_items must be between 1 and 200"
67
+ end
68
+ errors
69
+ end
70
+
71
+ def boolean?(value)
72
+ [true, false].include?(value)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,6 @@
1
1
  require "uri"
2
2
  require "chronos/configuration/validation"
3
+ require "chronos/configuration/apm_validation"
3
4
 
4
5
  module Chronos
5
6
  # Mutable configuration used only while the Chronos agent is being set up.
@@ -21,6 +22,7 @@ module Chronos
21
22
  # snapshot = config.snapshot
22
23
  class Configuration
23
24
  include Internal::ConfigurationValidation
25
+ include Internal::ApmConfigurationValidation
24
26
  DEFAULT_BLOCKLIST_KEYS = %w(
25
27
  password password_confirmation passwd secret api_key apikey authorization
26
28
  token access_token refresh_token private_key client_secret cookie set-cookie
@@ -40,7 +42,12 @@ module Chronos
40
42
  :remote_configuration, :remote_config_max_bytes, :sampling_rate,
41
43
  :enabled_event_types, :max_remote_send_interval, :context_store,
42
44
  :breadcrumb_capacity, :breadcrumb_max_bytes, :rails_enabled,
43
- :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent
45
+ :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent,
46
+ :apm_enabled, :apm_max_groups, :apm_flush_count, :apm_batch_size,
47
+ :apm_max_queries_per_request, :apm_slow_query_threshold_ms,
48
+ :apm_long_transaction_threshold_ms, :apm_n_plus_one_threshold,
49
+ :apm_histogram_buckets, :external_http_enabled, :external_http_trace_headers,
50
+ :cache_key_mode, :dependency_reporting, :dependency_max_items
44
51
  ].freeze
45
52
 
46
53
  attr_accessor(*ATTRIBUTES)
@@ -50,6 +57,8 @@ module Chronos
50
57
  initialize_privacy_defaults
51
58
  initialize_resilience_defaults
52
59
  initialize_rails_defaults
60
+ initialize_apm_defaults
61
+ initialize_observability_defaults
53
62
  end
54
63
 
55
64
  def snapshot
@@ -78,6 +87,8 @@ module Chronos
78
87
  errors.concat(resilience_errors)
79
88
  errors.concat(privacy_errors)
80
89
  errors.concat(context_errors)
90
+ errors.concat(apm_errors)
91
+ errors.concat(observability_errors)
81
92
  errors
82
93
  end
83
94
 
@@ -116,6 +127,26 @@ module Chronos
116
127
  @rails_capture_user_agent = false
117
128
  end
118
129
 
130
+ def initialize_apm_defaults
131
+ @apm_enabled = true
132
+ @apm_max_groups = 200
133
+ @apm_flush_count = 100
134
+ @apm_batch_size = 50
135
+ @apm_max_queries_per_request = 100
136
+ @apm_slow_query_threshold_ms = 500.0
137
+ @apm_long_transaction_threshold_ms = 1000.0
138
+ @apm_n_plus_one_threshold = 5
139
+ @apm_histogram_buckets = [5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0]
140
+ end
141
+
142
+ def initialize_observability_defaults
143
+ @external_http_enabled = false
144
+ @external_http_trace_headers = true
145
+ @cache_key_mode = :none
146
+ @dependency_reporting = true
147
+ @dependency_max_items = 100
148
+ end
149
+
119
150
  def initialize_privacy_defaults
120
151
  @blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
121
152
  @allowlist_keys = []
@@ -135,7 +166,9 @@ module Chronos
135
166
  @remote_configuration = true
136
167
  @remote_config_max_bytes = 4096
137
168
  @sampling_rate = 1.0
138
- @enabled_event_types = ["exception", "request", "query", "job", "cache"]
169
+ @enabled_event_types = [
170
+ "exception", "request", "query", "job", "cache", "external_http", "dependencies", "metric_batch"
171
+ ]
139
172
  @max_remote_send_interval = 60.0
140
173
  end
141
174
 
@@ -0,0 +1,99 @@
1
+ require "digest"
2
+
3
+ module Chronos
4
+ module Core
5
+ # Produces bounded cache metadata without exposing raw cache keys or values.
6
+ #
7
+ # @responsibility Normalize operation, backend, namespace, outcome, and optional key hash.
8
+ # @motivation Cache telemetry needs diagnostic identity without leaking application keys.
9
+ # @limits It never reads cache values and hashes keys only after explicit opt-in.
10
+ # @collaborators Rails notification subscriber and Chronos configuration.
11
+ # @thread_safety Instances hold only immutable scalar configuration.
12
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
13
+ # @example
14
+ # CacheNormalizer.new("project", :none).call(name, payload)
15
+ # @errors Unreadable fields become empty bounded strings.
16
+ # @performance Constant work plus SHA-256 only when key hashing is enabled.
17
+ class CacheNormalizer
18
+ def initialize(project_id, key_mode)
19
+ @project_id = project_id.to_s
20
+ @key_mode = key_mode
21
+ end
22
+
23
+ def call(name, payload = {})
24
+ hit = hit_value(name, payload)
25
+ result = {
26
+ "operation" => bounded(name.to_s.split(".").first, 64),
27
+ "backend" => backend(payload),
28
+ "namespace" => bounded(namespace(payload).to_s, 128),
29
+ "hit" => hit,
30
+ "outcome" => outcome(hit)
31
+ }
32
+ result["key_hash"] = key_hash(value(payload, :key)) if @key_mode == :sha256
33
+ result.delete_if { |key, child| child.to_s.empty? && key != "hit" }
34
+ rescue StandardError
35
+ {"operation" => "cache", "outcome" => "unknown"}
36
+ end
37
+
38
+ private
39
+
40
+ def outcome(hit)
41
+ return "unknown" if hit.nil?
42
+
43
+ hit ? "hit" : "miss"
44
+ end
45
+
46
+ def backend(payload)
47
+ store = value(payload, :store)
48
+ return "" if store.nil?
49
+
50
+ name = store.is_a?(String) || store.is_a?(Symbol) ? store.to_s : store.class.name.to_s
51
+ bounded(name, 128)
52
+ rescue StandardError
53
+ ""
54
+ end
55
+
56
+ def namespace(payload)
57
+ direct = value(payload, :namespace)
58
+ return direct unless direct.nil?
59
+
60
+ value(value(payload, :options), :namespace)
61
+ end
62
+
63
+ def hit_value(name, payload)
64
+ return true if name.to_s.start_with?("cache_fetch_hit")
65
+
66
+ hit = value(payload, :hit)
67
+ [true, false].include?(hit) ? hit : nil
68
+ end
69
+
70
+ def key_hash(key)
71
+ return nil if key.nil?
72
+
73
+ Digest::SHA256.hexdigest("#{@project_id}:cache:#{safe_key(key)}")
74
+ end
75
+
76
+ def safe_key(key)
77
+ text = key.to_s
78
+ text = text.scrub("?") if text.respond_to?(:scrub)
79
+ text.bytesize > 2048 ? text.byteslice(0, 2048) : text
80
+ rescue StandardError
81
+ "[UNREADABLE]"
82
+ end
83
+
84
+ def value(hash, key)
85
+ return nil unless hash.is_a?(Hash)
86
+
87
+ hash.key?(key) ? hash[key] : hash[key.to_s]
88
+ end
89
+
90
+ def bounded(value, limit)
91
+ text = value.to_s
92
+ text = text.scrub("?") if text.respond_to?(:scrub)
93
+ text.bytesize > limit ? text.byteslice(0, limit) : text
94
+ rescue StandardError
95
+ ""
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,113 @@
1
+ module Chronos
2
+ module Core
3
+ # Accumulates one bounded APM metric group and serializes aggregate statistics.
4
+ #
5
+ # @responsibility Track counts, errors, durations, histogram, breakdown, and signals.
6
+ # @motivation Keep numerical accumulation separate from grouping and request correlation.
7
+ # @limits It does not choose dimensions, retain observations, or calculate percentiles.
8
+ # @collaborators ApmAggregator and immutable histogram boundaries.
9
+ # @thread_safety Mutable by design; callers must synchronize access.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @example
12
+ # metric.observe(12.0, false, {"database" => 3.0}, {})
13
+ # @errors Non-numeric durations become zero and never escape.
14
+ # @performance Memory is fixed by the configured histogram boundary count.
15
+ class MetricAggregate
16
+ BREAKDOWN_CATEGORIES = %w(database view external_http cache queue application unknown).freeze
17
+
18
+ def initialize(metric_type, dimensions, boundaries)
19
+ @metric_type = metric_type
20
+ @dimensions = dimensions
21
+ @boundaries = boundaries
22
+ @count = 0
23
+ @error_count = 0
24
+ @total = 0.0
25
+ @min = nil
26
+ @max = nil
27
+ @buckets = Array.new(boundaries.length + 1, 0)
28
+ @breakdown = {}
29
+ @signals = {}
30
+ @status_codes = {}
31
+ end
32
+
33
+ def observe(duration, error, breakdown, signals, status = nil)
34
+ value = non_negative(duration)
35
+ @count += 1
36
+ @error_count += 1 if error
37
+ @total += value
38
+ @min = value if @min.nil? || value < @min
39
+ @max = value if @max.nil? || value > @max
40
+ bucket = @boundaries.index { |boundary| value <= boundary }
41
+ @buckets[bucket || @boundaries.length] += 1
42
+ add_breakdown(breakdown)
43
+ add_signals(signals)
44
+ add_status(status)
45
+ self
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ "metric_type" => @metric_type, "dimensions" => @dimensions,
51
+ "count" => @count, "error_count" => @error_count,
52
+ "error_rate" => (@error_count.to_f / @count).round(6),
53
+ "duration_ms" => duration_summary, "histogram" => histogram,
54
+ "breakdown_ms" => rounded_hash(@breakdown), "signals" => @signals,
55
+ "status_codes" => @status_codes
56
+ }
57
+ end
58
+
59
+ private
60
+
61
+ def add_breakdown(values)
62
+ hash(values).each do |category, duration|
63
+ name = BREAKDOWN_CATEGORIES.include?(category.to_s) ? category.to_s : "unknown"
64
+ @breakdown[name] ||= 0.0
65
+ @breakdown[name] += non_negative(duration)
66
+ end
67
+ end
68
+
69
+ def add_signals(values)
70
+ hash(values).each do |name, count|
71
+ @signals[name.to_s] ||= 0
72
+ @signals[name.to_s] += count.to_i
73
+ end
74
+ end
75
+
76
+ def add_status(status)
77
+ return if status.nil?
78
+
79
+ key = status.to_i.to_s
80
+ @status_codes[key] ||= 0
81
+ @status_codes[key] += 1
82
+ end
83
+
84
+ def duration_summary
85
+ {
86
+ "total" => @total.round(3), "min" => @min.round(3), "max" => @max.round(3),
87
+ "average" => (@total / @count).round(3)
88
+ }
89
+ end
90
+
91
+ def histogram
92
+ @buckets.each_with_index.map do |count, index|
93
+ {"le" => @boundaries[index] || "+Inf", "count" => count}
94
+ end
95
+ end
96
+
97
+ def rounded_hash(values)
98
+ values.each_with_object({}) { |(key, value), result| result[key] = value.round(3) }
99
+ end
100
+
101
+ def non_negative(value)
102
+ number = value.to_f
103
+ number < 0.0 ? 0.0 : number
104
+ rescue StandardError
105
+ 0.0
106
+ end
107
+
108
+ def hash(value)
109
+ value.is_a?(Hash) ? value : {}
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,114 @@
1
+ require "digest"
2
+
3
+ module Chronos
4
+ module Core
5
+ # Produces bounded, value-free SQL metadata for aggregation and local signals.
6
+ #
7
+ # @responsibility Remove comments/literals and derive operation, table, and fingerprint.
8
+ # @motivation SQL metrics need stable low-cardinality identity without bind values.
9
+ # @limits It is a defensive lexer, not a complete dialect-specific SQL parser.
10
+ # @collaborators Rails notification adapter and APM aggregator.
11
+ # @thread_safety Instances are stateless and safe to share.
12
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; no ActiveRecord dependency.
13
+ # @example
14
+ # SqlNormalizer.new.call("SELECT * FROM users WHERE id = 42")
15
+ # @errors Malformed or unreadable input returns bounded unknown metadata.
16
+ # @performance Input and normalized output are capped before fingerprinting.
17
+ class SqlNormalizer
18
+ MAX_SQL_BYTES = 4096
19
+ MAX_NORMALIZED_BYTES = 512
20
+ KEYWORDS = %w(
21
+ select insert update delete from into where join left right inner outer on set values
22
+ and or order group by having limit offset as distinct begin commit rollback savepoint release
23
+ ).freeze
24
+
25
+ def call(sql, metadata = {})
26
+ normalized = normalize(sql)
27
+ {
28
+ "adapter" => adapter(metadata), "operation" => operation(normalized),
29
+ "table" => table(normalized), "normalized_query" => normalized,
30
+ "fingerprint" => Digest::SHA256.hexdigest(normalized),
31
+ "name" => value(metadata, :name).to_s,
32
+ "cached" => value(metadata, :cached) == true,
33
+ "role" => optional_string(metadata, :role, :connection_role),
34
+ "shard" => optional_string(metadata, :shard, :connection_shard),
35
+ "source" => bounded(value(metadata, :source).to_s, 256),
36
+ "error_class" => error_class(metadata)
37
+ }.delete_if { |_key, child| child.nil? || child == "" }
38
+ rescue StandardError
39
+ {"operation" => "UNKNOWN", "normalized_query" => "", "fingerprint" => Digest::SHA256.hexdigest("")}
40
+ end
41
+
42
+ private
43
+
44
+ def normalize(sql)
45
+ text = bounded(sql.to_s, MAX_SQL_BYTES)
46
+ text = text.gsub(%r{/\*.*?\*/}m, " ").gsub(/--[^\r\n]*/, " ")
47
+ text = text.gsub(/'(?:''|[^'])*'/, "?")
48
+ text = text.gsub(/\$([A-Za-z_][A-Za-z0-9_]*)?\$.*?\$\1\$/m, "?")
49
+ text = text.gsub(/\b(?:0x[0-9a-f]+|\d+(?:\.\d+)?)\b/i, "?")
50
+ text = text.gsub(/\b(?:true|false|null)\b/i, "?")
51
+ text = text.gsub(/\(\s*\?(?:\s*,\s*\?)+\s*\)/, "(?)")
52
+ text = text.gsub(/\s+/, " ").strip
53
+ KEYWORDS.each { |keyword| text.gsub!(/\b#{keyword}\b/i, keyword.upcase) }
54
+ bounded(text, MAX_NORMALIZED_BYTES)
55
+ end
56
+
57
+ def operation(normalized)
58
+ candidate = normalized.to_s.split(/\s+/, 2).first.to_s.upcase
59
+ candidate =~ /\A[A-Z]+\z/ ? candidate : "UNKNOWN"
60
+ end
61
+
62
+ def table(normalized)
63
+ match = normalized.match(/\b(?:FROM|INTO|UPDATE|JOIN)\s+["`\[]?([A-Za-z0-9_.-]+)/i)
64
+ bounded(match && match[1].to_s, 128)
65
+ end
66
+
67
+ def adapter(metadata)
68
+ direct = value(metadata, :adapter)
69
+ return bounded(direct.to_s, 64) unless direct.to_s.empty?
70
+
71
+ connection = value(metadata, :connection)
72
+ return "" unless connection && connection.respond_to?(:adapter_name)
73
+
74
+ bounded(connection.adapter_name.to_s, 64)
75
+ rescue StandardError
76
+ ""
77
+ end
78
+
79
+ def error_class(metadata)
80
+ error = value(metadata, :exception_object) || value(metadata, :exception)
81
+ return error.class.name.to_s if error.is_a?(Exception)
82
+ return Array(error).first.to_s unless error.nil?
83
+
84
+ ""
85
+ rescue StandardError
86
+ ""
87
+ end
88
+
89
+ def optional_string(metadata, *names)
90
+ names.each do |name|
91
+ candidate = value(metadata, name)
92
+ return bounded(candidate.to_s, 64) unless candidate.to_s.empty?
93
+ end
94
+ ""
95
+ end
96
+
97
+ def value(hash, key)
98
+ return nil unless hash.is_a?(Hash)
99
+
100
+ hash.key?(key) ? hash[key] : hash[key.to_s]
101
+ end
102
+
103
+ def bounded(value, limit)
104
+ text = value.to_s
105
+ text = text.scrub("?") if text.respond_to?(:scrub)
106
+ return text if text.bytesize <= limit
107
+
108
+ text.byteslice(0, limit)
109
+ rescue StandardError
110
+ ""
111
+ end
112
+ end
113
+ end
114
+ end
@@ -16,7 +16,7 @@ module Chronos
16
16
  # @errors Unsupported types raise ArgumentError during construction.
17
17
  # @performance Construction is linear in supplied context and payload size.
18
18
  class TelemetryEvent
19
- TYPES = %w(request query job cache).freeze
19
+ TYPES = %w(request query job cache external_http dependencies metric_batch).freeze
20
20
 
21
21
  attr_reader :event_id, :event_type, :timestamp, :context, :payload
22
22