chronos-ruby 0.7.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +33 -9
  4. data/contracts/apm-batch-v1.schema.json +1 -1
  5. data/contracts/dependencies-v1.schema.json +38 -0
  6. data/contracts/event-v1.schema.json +1 -1
  7. data/docs/adr/ADR-016-explicit-observability-integrations.md +25 -0
  8. data/docs/architecture.md +6 -1
  9. data/docs/compatibility.md +2 -0
  10. data/docs/configuration.md +12 -2
  11. data/docs/data-collected.md +9 -4
  12. data/docs/examples/plain-ruby.md +7 -1
  13. data/docs/modules/apm-aggregation.md +2 -2
  14. data/docs/modules/cache-observability.md +16 -0
  15. data/docs/modules/configuration.md +2 -0
  16. data/docs/modules/dependencies.md +18 -0
  17. data/docs/modules/external-http.md +27 -0
  18. data/docs/modules/rails-legacy.md +1 -1
  19. data/docs/modules/sidekiq-legacy.md +1 -1
  20. data/docs/modules/telemetry-events.md +2 -2
  21. data/docs/performance.md +16 -1
  22. data/docs/privacy-lgpd.md +11 -2
  23. data/docs/troubleshooting.md +13 -1
  24. data/lib/chronos/agent.rb +24 -0
  25. data/lib/chronos/application/apm_aggregator.rb +7 -10
  26. data/lib/chronos/application/apm_error_classifier.rb +27 -0
  27. data/lib/chronos/application/capture_telemetry.rb +1 -1
  28. data/lib/chronos/application/dependency_reporter.rb +129 -0
  29. data/lib/chronos/application/remote_configuration.rb +3 -1
  30. data/lib/chronos/configuration/apm_validation.rb +17 -1
  31. data/lib/chronos/configuration.rb +15 -2
  32. data/lib/chronos/core/cache_normalizer.rb +99 -0
  33. data/lib/chronos/core/telemetry_event.rb +1 -1
  34. data/lib/chronos/integrations/net_http.rb +165 -0
  35. data/lib/chronos/net_http.rb +4 -0
  36. data/lib/chronos/observability_facade.rb +43 -0
  37. data/lib/chronos/rails/notifications_subscriber.rb +6 -5
  38. data/lib/chronos/version.rb +1 -1
  39. data/lib/chronos.rb +6 -0
  40. metadata +12 -1
data/lib/chronos/agent.rb CHANGED
@@ -37,13 +37,16 @@ module Chronos
37
37
  pipeline_options
38
38
  )
39
39
  initialize_capture(options)
40
+ @dependency_reporter = options[:dependency_reporter] || Application::DependencyReporter.new(config)
40
41
  end
41
42
 
42
43
  def notify(exception, context = {})
44
+ report_dependencies
43
45
  @capture.call(exception, context_for_capture(context))
44
46
  end
45
47
 
46
48
  def notify_sync(exception, context = {})
49
+ report_dependencies
47
50
  @capture.call_sync(exception, context_for_capture(context))
48
51
  end
49
52
 
@@ -64,9 +67,20 @@ module Chronos
64
67
  end
65
68
 
66
69
  def record_event(event_type, payload = {}, context = {})
70
+ report_dependencies unless event_type.to_s == "dependencies"
67
71
  @telemetry.call(event_type, payload, telemetry_context(context))
68
72
  end
69
73
 
74
+ def report_dependencies
75
+ payload = @dependency_reporter.call
76
+ return false unless payload
77
+
78
+ @telemetry.call("dependencies", payload, {})
79
+ rescue StandardError => error
80
+ @logger.warn("Chronos dependency reporting failed: #{error.class}")
81
+ false
82
+ end
83
+
70
84
  def record_event_once(key, event_type, payload = {}, context = {})
71
85
  execution = @context_store.get
72
86
  captured = execution[:__chronos_captured_events] || {}
@@ -87,6 +101,14 @@ module Chronos
87
101
  }
88
102
  end
89
103
 
104
+ def external_http_integration_options
105
+ {:enabled => @config.external_http_enabled, :trace_headers => @config.external_http_trace_headers}
106
+ end
107
+
108
+ def cache_integration_options
109
+ {:project_id => @config.project_id, :key_mode => @config.cache_key_mode}
110
+ end
111
+
90
112
  # Returns the correlation subset safe for an integration-owned process boundary.
91
113
  def propagation_context
92
114
  current = context_hash(@context_store.get)
@@ -124,6 +146,7 @@ module Chronos
124
146
  end
125
147
 
126
148
  def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
149
+ report_dependencies
127
150
  @telemetry.flush
128
151
  @delivery_pipeline.flush(timeout)
129
152
  rescue StandardError => error
@@ -132,6 +155,7 @@ module Chronos
132
155
  end
133
156
 
134
157
  def close(timeout = DEFAULT_FLUSH_TIMEOUT)
158
+ report_dependencies
135
159
  @telemetry.flush
136
160
  @delivery_pipeline.close(timeout)
137
161
  rescue StandardError => error
@@ -1,6 +1,6 @@
1
1
  module Chronos
2
2
  module Application
3
- # Aggregates bounded request, query, and job observations into metric batches.
3
+ # Aggregates bounded request, query, job, and external HTTP observations into metric batches.
4
4
  #
5
5
  # @responsibility Maintain statistics, histograms, breakdown, and local query signals.
6
6
  # @motivation Avoid one network event per observation while retaining diagnostic value.
@@ -14,7 +14,9 @@ module Chronos
14
14
  # @errors Invalid observations are ignored and never escape to the application.
15
15
  # @performance Group, transaction, query, bucket, and batch counts are strictly bounded.
16
16
  class ApmAggregator
17
- METRIC_TYPES = %w(request query job).freeze
17
+ include ApmErrorClassifier
18
+
19
+ METRIC_TYPES = %w(request query job external_http).freeze
18
20
  def initialize(config)
19
21
  @config = config
20
22
  @mutex = Mutex.new
@@ -77,7 +79,7 @@ module Chronos
77
79
  @groups[key] = aggregate
78
80
  end
79
81
  duration = non_negative(payload["duration_ms"] || payload[:duration_ms])
80
- status = type == "request" ? payload["status"] || payload[:status] : nil
82
+ status = ["request", "external_http"].include?(type) ? payload["status"] || payload[:status] : nil
81
83
  signals = signals_for(type, payload, context, duration)
82
84
  breakdown = breakdown_for(type, payload, context, duration)
83
85
  aggregate.observe(
@@ -177,6 +179,7 @@ module Chronos
177
179
  when "request" then %w(route method)
178
180
  when "query" then %w(adapter operation table fingerprint normalized_query name cached role shard source)
179
181
  when "job" then %w(kind class queue status)
182
+ when "external_http" then %w(host method)
180
183
  else []
181
184
  end
182
185
  names.each_with_object({}) do |name, result|
@@ -211,17 +214,11 @@ module Chronos
211
214
  return "view" if type == "request" && payload["kind"].to_s == "view"
212
215
  return "cache" if type == "cache"
213
216
  return "queue" if type == "job"
217
+ return "external_http" if type == "external_http"
214
218
 
215
219
  nil
216
220
  end
217
221
 
218
- def error?(type, payload)
219
- return (payload["status"] || payload[:status]).to_i >= 500 if type == "request"
220
- return (payload["status"] || payload[:status]).to_s == "failed" if type == "job"
221
-
222
- !(payload["error_class"] || payload[:error_class]).to_s.empty?
223
- end
224
-
225
222
  def trace_id(context)
226
223
  (context["trace_id"] || context[:trace_id]).to_s
227
224
  end
@@ -0,0 +1,27 @@
1
+ module Chronos
2
+ module Application
3
+ # Classifies bounded APM observations without inspecting exception messages.
4
+ module ApmErrorClassifier
5
+ private
6
+
7
+ def error?(type, payload)
8
+ return status_error?(payload) if type == "request"
9
+ return (payload["status"] || payload[:status]).to_s == "failed" if type == "job"
10
+ return external_http_error?(payload) if type == "external_http"
11
+
12
+ !(payload["error_class"] || payload[:error_class]).to_s.empty?
13
+ end
14
+
15
+ def external_http_error?(payload)
16
+ return true unless (payload["error_class"] || payload[:error_class]).to_s.empty?
17
+ return true if payload["timeout"] == true || payload[:timeout] == true
18
+
19
+ status_error?(payload)
20
+ end
21
+
22
+ def status_error?(payload)
23
+ (payload["status"] || payload[:status]).to_i >= 500
24
+ end
25
+ end
26
+ end
27
+ end
@@ -4,7 +4,7 @@ module Chronos
4
4
  #
5
5
  # @responsibility Aggregate APM observations or serialize and enqueue integration events.
6
6
  # @motivation Keep framework policy and local batching outside transport and domain objects.
7
- # @limits It handles request, query, job, cache, and metric-batch events only.
7
+ # @limits It handles only the event types declared by TelemetryEvent.
8
8
  # @collaborators ApmAggregator, TelemetryEvent, TelemetrySerializer, and DeliveryPipeline.
9
9
  # @thread_safety Calls own event state and may execute concurrently.
10
10
  # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
@@ -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", "metric_batch"].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
@@ -2,7 +2,7 @@ module Chronos
2
2
  module Internal
3
3
  # Validates bounded APM aggregation, histogram, and detector settings.
4
4
  #
5
- # @responsibility Return configuration errors for version 0.7 APM options.
5
+ # @responsibility Return configuration errors for bounded APM and observability options.
6
6
  # @motivation Keep the general configuration validator focused and maintainable.
7
7
  # @limits It validates shape and bounds but does not allocate aggregator state.
8
8
  # @collaborators Chronos::Configuration predicates and attributes.
@@ -55,6 +55,22 @@ module Chronos
55
55
 
56
56
  values.each_cons(2).all? { |left, right| right > left }
57
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
58
74
  end
59
75
  end
60
76
  end
@@ -46,7 +46,8 @@ module Chronos
46
46
  :apm_enabled, :apm_max_groups, :apm_flush_count, :apm_batch_size,
47
47
  :apm_max_queries_per_request, :apm_slow_query_threshold_ms,
48
48
  :apm_long_transaction_threshold_ms, :apm_n_plus_one_threshold,
49
- :apm_histogram_buckets
49
+ :apm_histogram_buckets, :external_http_enabled, :external_http_trace_headers,
50
+ :cache_key_mode, :dependency_reporting, :dependency_max_items
50
51
  ].freeze
51
52
 
52
53
  attr_accessor(*ATTRIBUTES)
@@ -57,6 +58,7 @@ module Chronos
57
58
  initialize_resilience_defaults
58
59
  initialize_rails_defaults
59
60
  initialize_apm_defaults
61
+ initialize_observability_defaults
60
62
  end
61
63
 
62
64
  def snapshot
@@ -86,6 +88,7 @@ module Chronos
86
88
  errors.concat(privacy_errors)
87
89
  errors.concat(context_errors)
88
90
  errors.concat(apm_errors)
91
+ errors.concat(observability_errors)
89
92
  errors
90
93
  end
91
94
 
@@ -136,6 +139,14 @@ module Chronos
136
139
  @apm_histogram_buckets = [5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0]
137
140
  end
138
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
+
139
150
  def initialize_privacy_defaults
140
151
  @blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
141
152
  @allowlist_keys = []
@@ -155,7 +166,9 @@ module Chronos
155
166
  @remote_configuration = true
156
167
  @remote_config_max_bytes = 4096
157
168
  @sampling_rate = 1.0
158
- @enabled_event_types = ["exception", "request", "query", "job", "cache", "metric_batch"]
169
+ @enabled_event_types = [
170
+ "exception", "request", "query", "job", "cache", "external_http", "dependencies", "metric_batch"
171
+ ]
159
172
  @max_remote_send_interval = 60.0
160
173
  end
161
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
@@ -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 metric_batch).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
 
@@ -0,0 +1,165 @@
1
+ module Chronos
2
+ module Integrations
3
+ # Optional per-instance Net::HTTP instrumentation without a global monkey patch.
4
+ #
5
+ # @responsibility Install one idempotent request wrapper on an explicit HTTP connection.
6
+ # @motivation Legacy Net::HTTP has no middleware callback but must remain safe and optional.
7
+ # @limits Class convenience methods and uninstrumented instances are deliberately untouched.
8
+ # @collaborators Net::HTTP-compatible connection, request objects, and Chronos notifier.
9
+ # @thread_safety Installation is synchronized per object; request state is call-local.
10
+ # @compatibility Net::HTTP on Ruby 2.2.10 through Ruby 2.6.
11
+ # @example
12
+ # Chronos::Integrations::NetHttp.install(Net::HTTP.new("api.example.com"))
13
+ # @errors Instrumentation errors are contained; original HTTP errors are re-raised unchanged.
14
+ # @performance Adds bounded hashes and clock reads; never reads request/response bodies.
15
+ module NetHttp
16
+ INSTALLED_KEY = :@__chronos_net_http_installed
17
+ OPTIONS_KEY = :@__chronos_net_http_options
18
+
19
+ class << self
20
+ def install(connection, options = {})
21
+ notifier = options[:notifier] || Chronos
22
+ return false unless enabled?(notifier)
23
+ return false unless connection.respond_to?(:request) && connection.respond_to?(:address)
24
+
25
+ mutex_for(connection).synchronize do
26
+ return false if connection.instance_variable_get(INSTALLED_KEY)
27
+
28
+ connection.instance_variable_set(OPTIONS_KEY, instrumentation_options(notifier, options))
29
+ connection.singleton_class.send(:prepend, InstrumentedRequest)
30
+ connection.instance_variable_set(INSTALLED_KEY, true)
31
+ end
32
+ true
33
+ rescue StandardError
34
+ false
35
+ end
36
+
37
+ private
38
+
39
+ def enabled?(notifier)
40
+ return true unless notifier.respond_to?(:external_http_integration_options)
41
+
42
+ notifier.external_http_integration_options[:enabled] == true
43
+ rescue StandardError
44
+ false
45
+ end
46
+
47
+ def instrumentation_options(notifier, options)
48
+ configured = if notifier.respond_to?(:external_http_integration_options)
49
+ notifier.external_http_integration_options
50
+ else
51
+ {}
52
+ end
53
+ {
54
+ :notifier => notifier,
55
+ :clock => options[:clock] || proc { monotonic_time },
56
+ :trace_headers => configured.fetch(:trace_headers, true)
57
+ }
58
+ end
59
+
60
+ def mutex_for(connection)
61
+ key = :@__chronos_net_http_mutex
62
+ connection.instance_variable_get(key) || connection.instance_variable_set(key, Mutex.new)
63
+ end
64
+
65
+ def monotonic_time
66
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ rescue StandardError
68
+ Time.now.to_f
69
+ end
70
+ end
71
+
72
+ # Request wrapper prepended only to an explicitly selected HTTP object.
73
+ #
74
+ # @responsibility Inject bounded trace headers and record outcome/timing around `request`.
75
+ # @motivation Preserve the native API, streaming block, and exception semantics.
76
+ # @limits It never reads URL path/query, Authorization, request body, headers, or response body.
77
+ # @collaborators NetHttp installation options and Chronos record_event.
78
+ # @thread_safety All request observations use local variables.
79
+ # @compatibility Net::HTTP#request signature on Ruby 2.2.10 through Ruby 2.6.
80
+ # @example
81
+ # connection.request(Net::HTTP::Get.new("/health"))
82
+ # @errors Original StandardError values are recorded by class and re-raised unchanged.
83
+ # @performance Two clock reads and one asynchronous telemetry observation per call.
84
+ module InstrumentedRequest
85
+ def request(request, body = nil, &block)
86
+ options = instance_variable_get(NetHttp::OPTIONS_KEY)
87
+ started_at = options[:clock].call
88
+ inject_trace_headers(request, options) if options[:trace_headers]
89
+ response = super(request, body, &block)
90
+ record_external_http(options, request, started_at, response, nil)
91
+ response
92
+ rescue StandardError => error
93
+ record_external_http(options, request, started_at, nil, error) if options && started_at
94
+ raise
95
+ end
96
+
97
+ private
98
+
99
+ def inject_trace_headers(request, options)
100
+ return unless request.respond_to?(:[]) && request.respond_to?(:[]=)
101
+
102
+ context = if options[:notifier].respond_to?(:propagation_context)
103
+ options[:notifier].propagation_context
104
+ else
105
+ {}
106
+ end
107
+ set_header(request, "X-Chronos-Trace-ID", context["trace_id"] || context[:trace_id])
108
+ set_header(request, "X-Chronos-Request-ID", context["request_id"] || context[:request_id])
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ def set_header(request, name, value)
114
+ request[name] = value.to_s if request[name].to_s.empty? && !value.to_s.empty?
115
+ end
116
+
117
+ def record_external_http(options, request, started_at, response, error)
118
+ payload = {
119
+ "host" => sanitized_external_host,
120
+ "method" => request.respond_to?(:method) ? request.method.to_s.upcase : "",
121
+ "status" => response_status(response),
122
+ "duration_ms" => ((options[:clock].call - started_at) * 1000.0).round(3),
123
+ "timeout" => timeout_error?(error),
124
+ "connection_error" => connection_error?(error),
125
+ "error_class" => error ? error.class.name.to_s : ""
126
+ }
127
+ payload.delete_if { |_key, value| value.nil? || value == "" }
128
+ options[:notifier].record_event("external_http", payload)
129
+ rescue StandardError
130
+ false
131
+ end
132
+
133
+ def response_status(response)
134
+ response.code.to_i if response && response.respond_to?(:code)
135
+ end
136
+
137
+ def sanitized_external_host
138
+ host = respond_to?(:address) ? address.to_s.downcase.sub(/\.\z/, "") : ""
139
+ host = host.scrub("?") if host.respond_to?(:scrub)
140
+ host.bytesize > 253 ? host.byteslice(0, 253) : host
141
+ rescue StandardError
142
+ ""
143
+ end
144
+
145
+ def timeout_error?(error)
146
+ return false unless error
147
+
148
+ error.is_a?(Timeout::Error) || !error.class.name.to_s.match(/Timeout/).nil?
149
+ rescue StandardError
150
+ false
151
+ end
152
+
153
+ def connection_error?(error)
154
+ return false unless error
155
+ return false if timeout_error?(error)
156
+
157
+ error.is_a?(SystemCallError) || error.is_a?(IOError) ||
158
+ !error.class.name.to_s.match(/SocketError|OpenSSL::SSL::SSLError/).nil?
159
+ rescue StandardError
160
+ false
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,4 @@
1
+ require "net/http"
2
+ require "timeout"
3
+ require "chronos"
4
+ require "chronos/integrations/net_http"