opentrace 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9eff1e2688a1239f8ebafd7cd59487b4ffab4619107c7fda9db580f9de04bed
4
- data.tar.gz: 6a4151670c6f6b803e7fff47fe2e7621ab17c52a7576d2d43c0fd416e337ccc6
3
+ metadata.gz: a52fdd745f23752d86ae494048fb8b6395374ec08f7ba6b9e3edccd0bea80036
4
+ data.tar.gz: b9b3aaab811c9997681a535a1b50d7c9ad0e567c872cb0075c509abd7567f339
5
5
  SHA512:
6
- metadata.gz: 1931c39a5b24f9aea0eeb81aacb77945a4c0f2386f13c3862172119e59c90eb13fab43e7af42981c6c8626f660a6a548bd040d6f1d087262716045e971d81ae0
7
- data.tar.gz: ef27898fabf3fe17bc1aaf98cd4003b4008740685124398c5eeb80ddb47c737284ee36e99c516103d9306d00f2c18ea016d2346b93380452dfa56b15f6c6b01c
6
+ metadata.gz: 7dc954766e198906d4c16448b49be2e0dc26aca08e5f8fa3031d3a6f6c6d9c98f6278e2febbc3f717680522b06807c756a80f3b84dbf69c1c8c20e8ca1c99b6b
7
+ data.tar.gz: 0f0a49079c6bef6317208325361dfa4edd07cc509043a7e10dbceadd67ecda35311e306c2e5fb64115bf4e6fab111e23fac05ccbe5bcddb5324aad7325d4a06c
data/README.md CHANGED
@@ -118,7 +118,8 @@ OpenTrace.configure do |c|
118
118
  c.service = "billing-api"
119
119
 
120
120
  # Optional
121
- c.environment = "production" # default: nil
121
+ c.environment = "production" # default: auto-detected from
122
+ # OPENTRACE_ENV → Rails.env → RACK_ENV → RAILS_ENV
122
123
  c.timeout = 1.0 # HTTP timeout in seconds (default: 1.0)
123
124
  c.enabled = true # default: true
124
125
  c.min_level = :info # minimum level to forward (default: :info)
@@ -405,10 +406,11 @@ In a Rails app, add an initializer:
405
406
  ```ruby
406
407
  # config/initializers/opentrace.rb
407
408
  OpenTrace.configure do |c|
408
- c.endpoint = ENV["OPENTRACE_ENDPOINT"]
409
- c.api_key = ENV["OPENTRACE_API_KEY"]
410
- c.service = "my-rails-app"
411
- c.environment = Rails.env
409
+ c.endpoint = ENV["OPENTRACE_ENDPOINT"]
410
+ c.api_key = ENV["OPENTRACE_API_KEY"]
411
+ c.service = "my-rails-app"
412
+ # c.environment is auto-resolved from OPENTRACE_ENV (preferred) or
413
+ # Rails.env, so you only need to set it explicitly for unusual cases.
412
414
  end
413
415
  ```
414
416
 
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTrace
4
+ # Auto-included concern on ActiveRecord::Base when audit_tracking is enabled.
5
+ # Captures before/after diffs on create, update, and destroy using saved_changes.
6
+ module AuditTracker
7
+ def self.included(base)
8
+ base.after_create { |record| OpenTrace::AuditTracker.track(record, :create) }
9
+ base.after_update { |record| OpenTrace::AuditTracker.track(record, :update) }
10
+ base.after_destroy { |record| OpenTrace::AuditTracker.track(record, :destroy) }
11
+ end
12
+
13
+ class << self
14
+ def track(record, action)
15
+ buffer = Fiber[:opentrace_buffer]
16
+ return unless buffer
17
+ return unless OpenTrace.config.audit_tracking
18
+
19
+ model_name = record.class.name
20
+ return if excluded_model?(model_name)
21
+
22
+ # Resolve actor
23
+ actor_id = nil
24
+ actor_type = nil
25
+ actor_proc = OpenTrace.config.audit_actor
26
+ if actor_proc.is_a?(Proc)
27
+ actor = actor_proc.call rescue nil
28
+ if actor
29
+ actor_id = actor.respond_to?(:id) ? actor.id.to_s : actor.to_s
30
+ actor_type = actor.class.name
31
+ end
32
+ end
33
+
34
+ case action
35
+ when :create
36
+ after_values = filter_fields(record.attributes)
37
+ buffer.record_audit(
38
+ action: "create",
39
+ record_type: model_name,
40
+ record_id: record.id.to_s,
41
+ actor_id: actor_id,
42
+ actor_type: actor_type,
43
+ changed_fields: nil,
44
+ full_before: nil,
45
+ full_after: after_values
46
+ )
47
+
48
+ when :update
49
+ changes = record.saved_changes
50
+ return if changes.empty?
51
+
52
+ filtered = filter_changes(changes)
53
+ return if filtered.empty?
54
+
55
+ changed_fields = {}
56
+ filtered.each do |field, (old_val, new_val)|
57
+ changed_fields[field] = { "from" => old_val, "to" => new_val }
58
+ end
59
+
60
+ buffer.record_audit(
61
+ action: "update",
62
+ record_type: model_name,
63
+ record_id: record.id.to_s,
64
+ actor_id: actor_id,
65
+ actor_type: actor_type,
66
+ changed_fields: changed_fields,
67
+ full_before: nil,
68
+ full_after: nil
69
+ )
70
+
71
+ when :destroy
72
+ before_values = filter_fields(record.attributes)
73
+ buffer.record_audit(
74
+ action: "destroy",
75
+ record_type: model_name,
76
+ record_id: record.id.to_s,
77
+ actor_id: actor_id,
78
+ actor_type: actor_type,
79
+ changed_fields: nil,
80
+ full_before: before_values,
81
+ full_after: nil
82
+ )
83
+ end
84
+
85
+ buffer.record_timeline(type: :audit, name: "#{model_name}##{action}")
86
+ rescue StandardError
87
+ # Never affect the host app
88
+ end
89
+
90
+ private
91
+
92
+ def excluded_model?(model_name)
93
+ OpenTrace.config.audit_exclude_models.any? { |m| model_name == m }
94
+ rescue StandardError
95
+ false
96
+ end
97
+
98
+ def filter_changes(changes)
99
+ exclude = OpenTrace.config.audit_exclude_fields
100
+ changes.reject { |field, _| exclude.include?(field.to_s) }
101
+ rescue StandardError
102
+ changes
103
+ end
104
+
105
+ def filter_fields(attributes)
106
+ exclude = OpenTrace.config.audit_exclude_fields
107
+ attributes.reject { |field, _| exclude.include?(field.to_s) }
108
+ rescue StandardError
109
+ attributes
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request_buffer"
4
+
5
+ module OpenTrace
6
+ class BufferPool
7
+ def initialize(size: 32, max_buffer_bytes: 1_048_576, max_audit_events: 50)
8
+ @max_buffer_bytes = max_buffer_bytes
9
+ @max_audit_events = max_audit_events
10
+ @max_size = size
11
+
12
+ @mutex = Mutex.new
13
+ @pool = Array.new(size) { RequestBuffer.new(max_buffer_bytes: max_buffer_bytes, max_audit_events: max_audit_events) }
14
+ @checked_out = 0
15
+ end
16
+
17
+ # Returns a RequestBuffer ready for use. Pulls from pool if available,
18
+ # allocates a new one if pool is empty. Sets id and started_at.
19
+ def checkout
20
+ buffer = @mutex.synchronize do
21
+ buf = @pool.pop
22
+ @checked_out += 1
23
+ buf
24
+ end
25
+
26
+ # Pool was empty — allocate fresh (outside the lock)
27
+ buffer ||= RequestBuffer.new(max_buffer_bytes: @max_buffer_bytes, max_audit_events: @max_audit_events)
28
+
29
+ buffer.id = ULID.generate
30
+ buffer.started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+
32
+ buffer
33
+ end
34
+
35
+ # Resets the buffer and returns it to the pool. If the pool is already
36
+ # at max capacity, the buffer is discarded (GC will collect it).
37
+ def checkin(buffer)
38
+ buffer.reset!
39
+
40
+ @mutex.synchronize do
41
+ @checked_out -= 1
42
+ @checked_out = 0 if @checked_out < 0 # safety clamp
43
+
44
+ if @pool.size < @max_size
45
+ @pool.push(buffer)
46
+ end
47
+ # else: discard — pool is full
48
+ end
49
+ end
50
+
51
+ # Returns a snapshot of pool statistics.
52
+ def stats
53
+ @mutex.synchronize do
54
+ {
55
+ pool_size: @max_size,
56
+ available: @pool.size,
57
+ checked_out: @checked_out
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTrace
4
+ class CaptureRules
5
+ LEVELS = %i[none minimal standard full].freeze
6
+ LEVEL_ORDER = LEVELS.each_with_index.to_h.freeze # { none: 0, minimal: 1, standard: 2, full: 3 }
7
+
8
+ def initialize(&block)
9
+ @path_rules = []
10
+ @error_rule = nil
11
+ @slow_rule = nil
12
+
13
+ block&.call(self)
14
+ end
15
+
16
+ # Register a path-matching rule. Pattern supports:
17
+ # ** — matches any number of path segments (including zero)
18
+ # * — matches exactly one path segment
19
+ def on_path(pattern, &block)
20
+ regex = path_pattern_to_regex(pattern)
21
+ @path_rules << { regex: regex, block: block }
22
+ end
23
+
24
+ # Register a rule that fires when the request resulted in an error.
25
+ def on_error(&block)
26
+ @error_rule = block
27
+ end
28
+
29
+ # Register a rule that fires when the request duration exceeds a threshold.
30
+ # threshold is in milliseconds.
31
+ def on_slow(threshold:, &block)
32
+ @slow_rule = { threshold: threshold, block: block }
33
+ end
34
+
35
+ # Evaluate all rules for a given request and return the final capture level.
36
+ #
37
+ # env — Rack env hash (must contain "PATH_INFO")
38
+ # base_level — fallback level when no path rule matches (default :standard)
39
+ # status — HTTP response status (not used directly, reserved)
40
+ # duration_ms — request duration in milliseconds (for on_slow)
41
+ # error — whether the request raised an error (for on_error)
42
+ def resolve(env, base_level: :standard, status: nil, duration_ms: nil, error: false)
43
+ level = resolve_path(env) || base_level
44
+
45
+ # Post-request upgrades: only go up, never down
46
+ if error && @error_rule
47
+ error_level = @error_rule.call
48
+ level = max_level(level, error_level)
49
+ end
50
+
51
+ if duration_ms && @slow_rule && duration_ms > @slow_rule[:threshold]
52
+ slow_level = @slow_rule[:block].call
53
+ level = max_level(level, slow_level)
54
+ end
55
+
56
+ level
57
+ end
58
+
59
+ # Apply per-domain overrides. If the domain has an explicit override, use it.
60
+ # Otherwise return the resolved_level as-is.
61
+ def resolve_domain(domain, resolved_level, overrides)
62
+ override = overrides[domain]
63
+ override.nil? ? resolved_level : override
64
+ end
65
+
66
+ private
67
+
68
+ # Find the first path rule that matches and return its level.
69
+ # Returns nil if no rule matches.
70
+ def resolve_path(env)
71
+ path = env["PATH_INFO"].to_s
72
+
73
+ @path_rules.each do |rule|
74
+ return rule[:block].call if rule[:regex].match?(path)
75
+ end
76
+
77
+ nil
78
+ end
79
+
80
+ # Return whichever level is higher in the ordering.
81
+ def max_level(a, b)
82
+ (LEVEL_ORDER[a] || 0) >= (LEVEL_ORDER[b] || 0) ? a : b
83
+ end
84
+
85
+ # Convert a path pattern to a regex.
86
+ # ** → matches any characters (including /)
87
+ # * → matches any characters except /
88
+ #
89
+ # Processing order matters: replace ** first (as a distinct token),
90
+ # then replace remaining single *.
91
+ def path_pattern_to_regex(pattern)
92
+ # Escape everything except our wildcards
93
+ # Step 1: Replace ** with a unique placeholder
94
+ escaped = pattern.gsub("**", "\x00DOUBLE\x00")
95
+ # Step 2: Replace remaining single * with a placeholder
96
+ escaped = escaped.gsub("*", "\x00SINGLE\x00")
97
+ # Step 3: Escape regex metacharacters in the rest
98
+ escaped = Regexp.escape(escaped)
99
+ # Step 4: Restore placeholders with regex patterns
100
+ escaped = escaped.gsub("\x00DOUBLE\x00", ".*")
101
+ escaped = escaped.gsub("\x00SINGLE\x00", "[^/]+")
102
+
103
+ Regexp.new("\\A#{escaped}\\z")
104
+ end
105
+ end
106
+ end
@@ -12,7 +12,7 @@ module OpenTrace
12
12
  MAX_QUEUE_SIZE = 1000
13
13
  PAYLOAD_MAX_BYTES = 262_144 # 256 KB (default; use config.max_payload_bytes to override)
14
14
  MAX_RATE_LIMIT_BACKOFF = 60 # Cap Retry-After at 60 seconds
15
- API_VERSION = 1
15
+ API_VERSION = 2
16
16
 
17
17
  attr_reader :stats
18
18
 
@@ -249,9 +249,9 @@ module OpenTrace
249
249
  end
250
250
  end
251
251
  # PII scrubbing (runs on background thread)
252
- if @config.pii_scrubbing && payload[:metadata]
252
+ if @config.pii_scrubbing && payload[:body]
253
253
  active_patterns = build_pii_patterns
254
- PiiScrubber.scrub!(payload[:metadata], patterns: active_patterns)
254
+ PiiScrubber.scrub!(payload[:body], patterns: active_patterns)
255
255
  end
256
256
 
257
257
  fit_payload(payload)
@@ -583,24 +583,31 @@ module OpenTrace
583
583
  end
584
584
 
585
585
  def truncate_payload(payload)
586
- meta = payload[:metadata]&.dup || {}
586
+ body = payload[:body]&.dup || {}
587
587
 
588
588
  # Truncation priority: remove largest optional fields first
589
- meta.delete(:backtrace)
590
- meta.delete(:params)
591
- meta.delete(:job_arguments)
592
- meta[:sql] = meta[:sql][0, 200] + "..." if meta[:sql].is_a?(String) && meta[:sql].length > 200
593
- meta[:exception_message] = meta[:exception_message][0, 200] + "..." if meta[:exception_message].is_a?(String) && meta[:exception_message].length > 200
594
-
595
- result = payload.merge(metadata: meta)
596
-
597
- # Remove timeline from request_summary (can be very large)
598
- if result[:request_summary]
599
- result[:request_summary] = result[:request_summary].dup
600
- result[:request_summary].delete(:timeline)
589
+ body.delete(:timeline)
590
+ body.delete(:queries)
591
+
592
+ if body[:exception].is_a?(Hash)
593
+ exc = body[:exception] = body[:exception].dup
594
+ exc.delete(:backtrace)
595
+ exc[:message] = exc[:message][0, 200] + "..." if exc[:message].is_a?(String) && exc[:message].length > 200
601
596
  end
602
597
 
603
- result
598
+ if body[:context].is_a?(Hash)
599
+ ctx = body[:context] = body[:context].dup
600
+ ctx.delete(:params)
601
+ ctx.delete(:job_arguments)
602
+ ctx[:sql] = ctx[:sql][0, 200] + "..." if ctx[:sql].is_a?(String) && ctx[:sql].length > 200
603
+ end
604
+
605
+ if body[:request].is_a?(Hash)
606
+ body[:request] = body[:request].dup
607
+ body[:request].delete(:params)
608
+ end
609
+
610
+ payload.merge(body: body)
604
611
  end
605
612
  end
606
613
  end
@@ -29,10 +29,24 @@ module OpenTrace
29
29
  :pii_scrubbing, :pii_patterns, :pii_disabled_patterns,
30
30
  :session_tracking,
31
31
  :on_error, :after_send,
32
- :transport, :socket_path,
33
- :local_vars_capture,
34
- :explain_slow_queries, :explain_threshold_ms,
35
- :runtime_metrics, :runtime_metrics_interval
32
+ :transport, :socket_path,
33
+ :local_vars_capture,
34
+ :explain_slow_queries, :explain_threshold_ms,
35
+ :runtime_metrics, :runtime_metrics_interval,
36
+ # Deep capture
37
+ :capture_depth, :capture_rules_block,
38
+ # Per-domain overrides (nil = follow capture_depth)
39
+ :email_capture, :sql_capture, :http_capture,
40
+ :audit_capture, :request_capture,
41
+ # Buffering & safety
42
+ :max_buffer_bytes, :max_total_buffer_bytes, :max_queue_bytes,
43
+ # Audit trail
44
+ :audit_tracking, :audit_max_events_per_request,
45
+ :audit_exclude_models, :audit_exclude_fields, :audit_actor,
46
+ # Body capture
47
+ :max_request_body_bytes,
48
+ # Serialization format
49
+ :serialization_format
36
50
 
37
51
  # Custom writers that invalidate caches
38
52
  attr_reader :enabled, :min_level, :allowed_levels, :ignore_paths, :sample_rate
@@ -127,6 +141,29 @@ module OpenTrace
127
141
  @explain_threshold_ms = 100.0 # Threshold for EXPLAIN capture
128
142
  @runtime_metrics = false # Collect GC/runtime metrics
129
143
  @runtime_metrics_interval = 30 # Interval in seconds
144
+ # Deep capture
145
+ @capture_depth = :standard
146
+ @capture_rules_block = nil
147
+ # Per-domain overrides (nil = follow capture_depth)
148
+ @email_capture = nil
149
+ @sql_capture = nil
150
+ @http_capture = nil
151
+ @audit_capture = nil
152
+ @request_capture = nil
153
+ # Buffering & safety
154
+ @max_buffer_bytes = 1_048_576 # 1MB per request
155
+ @max_total_buffer_bytes = 52_428_800 # 50MB global
156
+ @max_queue_bytes = 10_485_760 # 10MB
157
+ # Audit trail
158
+ @audit_tracking = false
159
+ @audit_max_events_per_request = 50
160
+ @audit_exclude_models = []
161
+ @audit_exclude_fields = %w[updated_at created_at password_digest]
162
+ @audit_actor = nil
163
+ # Body capture
164
+ @max_request_body_bytes = 262_144 # 256KB
165
+ # Serialization format
166
+ @serialization_format = :json
130
167
  @level_cache = nil
131
168
  @enabled_cache = nil
132
169
  end
@@ -172,9 +209,39 @@ module OpenTrace
172
209
  # and lazily when settings change afterward.
173
210
  def finalize!
174
211
  @enabled_cache = nil
212
+ resolve_environment!
175
213
  build_level_cache!
176
214
  end
177
215
 
216
+ # Resolve environment from env vars if the caller didn't set it explicitly.
217
+ # Fallback order:
218
+ # 1. explicit config (c.environment = "...")
219
+ # 2. ENV["OPENTRACE_ENV"] — canonical opentrace variable
220
+ # 3. Rails.env — Rails apps, no extra config needed
221
+ # 4. ENV["RACK_ENV"] — Rack apps without Rails
222
+ # 5. ENV["RAILS_ENV"] — edge case: RAILS_ENV set but Rails not loaded
223
+ # The first non-empty value wins. Callers who want "no env" can explicitly
224
+ # clear it after finalize!, but there's rarely a reason to.
225
+ def resolve_environment!
226
+ return if @environment && !@environment.to_s.empty?
227
+
228
+ if (v = ENV["OPENTRACE_ENV"]) && !v.empty?
229
+ @environment = v
230
+ return
231
+ end
232
+ if defined?(Rails) && Rails.respond_to?(:env) && (v = Rails.env.to_s) && !v.empty?
233
+ @environment = v
234
+ return
235
+ end
236
+ if (v = ENV["RACK_ENV"]) && !v.empty?
237
+ @environment = v
238
+ return
239
+ end
240
+ if (v = ENV["RAILS_ENV"]) && !v.empty?
241
+ @environment = v
242
+ end
243
+ end
244
+
178
245
  # Maps OpenTrace min_level to Ruby Logger severity constant.
179
246
  # Used by LogForwarder to set its level so BroadcastLogger
180
247
  # doesn't downgrade the effective log level for the entire app.
@@ -190,6 +257,10 @@ module OpenTrace
190
257
  LEVEL_TO_LOGGER_SEVERITY[min_level.to_s.downcase.to_sym] || 0
191
258
  end
192
259
 
260
+ def capture_rules(&block)
261
+ @capture_rules_block = block
262
+ end
263
+
193
264
  private
194
265
 
195
266
  def build_level_cache!
@@ -34,7 +34,6 @@ module OpenTrace
34
34
  if error.backtrace
35
35
  cleaned = clean_backtrace(error.backtrace)
36
36
  meta[:backtrace] = cleaned.first(15)
37
- meta[:error_fingerprint] = OpenTrace.send(:compute_error_fingerprint, error.class.name, cleaned)
38
37
  end
39
38
 
40
39
  # Capture exception cause chain
@@ -4,6 +4,38 @@ require "net/http"
4
4
 
5
5
  module OpenTrace
6
6
  module HttpTracker
7
+ # Max body size to capture (64KB) — avoids bloating memory with large payloads
8
+ MAX_BODY_CAPTURE_BYTES = 65_536
9
+
10
+ VENDOR_PATTERNS = {
11
+ "stripe" => /api\.stripe\.com/i,
12
+ "sendgrid" => /api\.sendgrid\.com/i,
13
+ "twilio" => /api\.twilio\.com/i,
14
+ "slack" => /slack\.com/i,
15
+ "github" => /api\.github\.com/i,
16
+ "aws" => /\.amazonaws\.com/i,
17
+ "google" => /googleapis\.com/i,
18
+ "mailgun" => /api\.mailgun\.net/i,
19
+ "postmark" => /api\.postmarkapp\.com/i,
20
+ "braintree" => /api\.braintreegateway\.com/i,
21
+ "paypal" => /api\.paypal\.com/i,
22
+ "shopify" => /\.myshopify\.com|\.shopify\.com/i,
23
+ "intercom" => /api\.intercom\.io/i,
24
+ "segment" => /api\.segment\.io/i,
25
+ "sentry" => /sentry\.io/i,
26
+ "datadog" => /api\.datadoghq\.com/i,
27
+ "plaid" => /\.plaid\.com/i,
28
+ }.freeze
29
+
30
+ def self.infer_vendor(host)
31
+ return nil unless host
32
+
33
+ VENDOR_PATTERNS.each do |vendor, pattern|
34
+ return vendor if pattern.match?(host)
35
+ end
36
+ nil
37
+ end
38
+
7
39
  def request(req, body = nil, &block)
8
40
  # Guard 1: skip if disabled
9
41
  return super unless OpenTrace.enabled?
@@ -11,14 +43,34 @@ module OpenTrace
11
43
  # Guard 2: skip if this IS an OpenTrace dispatch call (prevent infinite recursion)
12
44
  return super if Fiber[:opentrace_http_tracking_disabled]
13
45
 
14
- # Inject trace context into outgoing request headers
15
- inject_trace_context(req) if OpenTrace.config.trace_propagation
46
+ # Trace context injection is bookkeeping — must never raise to the host.
47
+ begin
48
+ inject_trace_context(req) if OpenTrace.config.trace_propagation
49
+ rescue StandardError
50
+ end
16
51
 
17
- collector = Fiber[:opentrace_collector]
18
52
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
53
 
20
- response = super
54
+ begin
55
+ response = super
56
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError, Timeout::Error, Net::ProtocolError => e
57
+ # Real network error from the host's HTTP call. Record it, then re-raise
58
+ # so the host app sees the error it's expecting to handle.
59
+ record_http_failure(req, e, start_time) rescue nil
60
+ raise
61
+ end
62
+
63
+ # Bookkeeping for a successful response. Any bug here (NoMethodError on
64
+ # a streaming body, a payload builder regression, etc.) must NOT bubble
65
+ # into host code — swallow and move on.
66
+ record_http_success(req, response, start_time) rescue nil
67
+
68
+ response
69
+ end
70
+
71
+ private
21
72
 
73
+ def record_http_success(req, response, start_time)
22
74
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
23
75
  host = address
24
76
  port_str = (port == 443 || port == 80) ? "" : ":#{port}"
@@ -26,6 +78,11 @@ module OpenTrace
26
78
  safe_path = req.path.to_s.split("?").first
27
79
  url = "#{scheme}://#{host}#{port_str}#{safe_path}"
28
80
 
81
+ req_body = capturable_body(req.body)
82
+ resp_body = capturable_body(response.body)
83
+ resp_size = body_size(response)
84
+
85
+ collector = Fiber[:opentrace_collector]
29
86
  if collector
30
87
  collector.record_http(
31
88
  method: req.method,
@@ -36,11 +93,33 @@ module OpenTrace
36
93
  )
37
94
  end
38
95
 
39
- response
40
- rescue IOError, SystemCallError, OpenSSL::SSL::SSLError, Timeout::Error, Net::ProtocolError => e
41
- # Record the failed HTTP call, then re-raise
96
+ buffer = Fiber[:opentrace_buffer]
97
+ if buffer
98
+ vendor = OpenTrace::HttpTracker.infer_vendor(host)
99
+ buffer.record_http(
100
+ method: req.method,
101
+ url: url,
102
+ host: host,
103
+ vendor: vendor,
104
+ status: response.code.to_i,
105
+ duration_ms: duration_ms,
106
+ request_headers: nil,
107
+ request_body: req_body,
108
+ response_headers: nil,
109
+ response_body: resp_body,
110
+ response_size: resp_size,
111
+ retry_attempt: 0,
112
+ error_class: nil
113
+ )
114
+ buffer.record_timeline(type: :http, name: "#{req.method} #{host}", duration_ms: duration_ms)
115
+ end
116
+ end
117
+
118
+ def record_http_failure(req, error, start_time)
42
119
  duration_ms = start_time ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000 : 0
120
+ req_body = req ? capturable_body(req.body) : nil
43
121
 
122
+ collector = Fiber[:opentrace_collector]
44
123
  if collector
45
124
  collector.record_http(
46
125
  method: req&.method,
@@ -48,14 +127,42 @@ module OpenTrace
48
127
  host: address,
49
128
  status: 0,
50
129
  duration_ms: duration_ms,
51
- error: e.class.name
130
+ error: error.class.name
131
+ )
132
+ end
133
+
134
+ buffer = Fiber[:opentrace_buffer]
135
+ if buffer
136
+ vendor = OpenTrace::HttpTracker.infer_vendor(address)
137
+ buffer.record_http(
138
+ method: req&.method,
139
+ url: "#{address}#{req&.path}",
140
+ host: address,
141
+ vendor: vendor,
142
+ status: 0,
143
+ duration_ms: duration_ms,
144
+ request_body: req_body,
145
+ response_body: nil,
146
+ response_size: nil,
147
+ error_class: error.class.name
52
148
  )
53
149
  end
150
+ end
54
151
 
55
- raise # ALWAYS re-raise — never swallow app errors
152
+ def capturable_body(body)
153
+ return nil unless body.is_a?(String)
154
+ return nil if body.bytesize >= MAX_BODY_CAPTURE_BYTES
155
+ body
56
156
  end
57
157
 
58
- private
158
+ # Response body size when it can be determined without re-reading the stream.
159
+ # Streaming responses (Net::ReadAdapter) can't be re-read, so we fall back
160
+ # to Content-Length when present, otherwise nil.
161
+ def body_size(response)
162
+ return response.body.bytesize if response.body.is_a?(String)
163
+ len = response["Content-Length"]
164
+ len ? len.to_i : nil
165
+ end
59
166
 
60
167
  def inject_trace_context(req)
61
168
  trace_id = Fiber[:opentrace_trace_id]