opentrace 0.15.1 → 0.17.0

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: e5c67d0aecd2be7ec0624bd339bda99a46d8c2de8d0a04009d6778f94cefee3d
4
- data.tar.gz: 0b54c97978becc24a2339e1f615de4f715388fc9247dc88099880dc7e1532a43
3
+ metadata.gz: '0785fe05f95bd64567435b2bb20ed18d4882a14ac765351a741cf322dc8d6ade'
4
+ data.tar.gz: b656ded61cf12de7b5657d69234d11fe52ffb7b9eb9aee2cc6568b33baeaa16a
5
5
  SHA512:
6
- metadata.gz: 60bc99ef6e4e950851358a9dbf3b2b8342209f81ff24d58eb248c07362657d4c040c1f60e6e8fdb3cb56d625cac4f8b9a4641e953241a1af52195f27232790f6
7
- data.tar.gz: d655b2237f513c8b3fe8a12e936086937fc80b1f50d5bb3766b6e5ef3ed1a137d90bf9798546aa99413633dba9e9588c87f2b99d4e54e226c0842fea720f309e
6
+ metadata.gz: ce7a3567bb634e3859218b7be7b57ede064ebdad47943855fc4758c764eea74a13608e2c5983e965e793f0f36786043146c00dfa65c8b5bcb85ebf4e499567d4
7
+ data.tar.gz: 9c01f33f3b5416e9fea909b2c3ac841742fdb6ed4b85feb156e8166585790a76d373e2e2714ea67afc9bb5d8ec2d24f040b4c53e6c7646389d55b3165f759427
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!
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenTrace
4
+ # Rails 7.0+ Error Reporter subscriber.
5
+ #
6
+ # Captures ALL exceptions reported via Rails.error (including those
7
+ # rescued by rescue_from in controllers). This fills the gap where
8
+ # process_action.action_controller only includes exception data for
9
+ # unhandled exceptions — rescued exceptions show status 500 but no
10
+ # exception_class, backtrace, or error_fingerprint.
11
+ #
12
+ # Registered automatically in the Railtie when Rails.error is available.
13
+ class ErrorSubscriber
14
+ SEVERITY_MAP = {
15
+ error: "ERROR",
16
+ warning: "WARN",
17
+ info: "INFO"
18
+ }.freeze
19
+
20
+ def report(error, handled:, severity:, context: {}, source: nil)
21
+ return unless OpenTrace.enabled?
22
+
23
+ # Skip if already inside an OpenTrace logging call (re-entrance guard)
24
+ return if Fiber[:opentrace_logging]
25
+
26
+ level = SEVERITY_MAP[severity] || "ERROR"
27
+
28
+ meta = {}
29
+ meta[:exception_class] = error.class.name
30
+ meta[:exception_message] = error.message&.slice(0, 500)
31
+ meta[:handled] = handled
32
+ meta[:error_source] = source if source
33
+
34
+ if error.backtrace
35
+ cleaned = clean_backtrace(error.backtrace)
36
+ meta[:backtrace] = cleaned.first(15)
37
+ end
38
+
39
+ # Capture exception cause chain
40
+ if error.cause
41
+ meta[:exception_causes] = OpenTrace.send(:build_cause_chain, error.cause, depth: 0)
42
+ end
43
+
44
+ # Include request params from context if available
45
+ if context[:params].is_a?(Hash)
46
+ params = context[:params].except("controller", "action")
47
+ meta[:params] = truncate_hash(params, 2048) unless params.empty?
48
+ end
49
+
50
+ # Include controller/action context
51
+ meta[:controller] = context[:controller] if context[:controller]
52
+ meta[:action] = context[:action] if context[:action]
53
+
54
+ # Merge any additional context from the reporter
55
+ context.each do |k, v|
56
+ next if %i[params controller action].include?(k)
57
+ meta[k] = v
58
+ end
59
+
60
+ OpenTrace.log(level, "#{error.class}: #{error.message&.slice(0, 200)}", meta)
61
+ rescue StandardError
62
+ # Never raise to the host app
63
+ end
64
+
65
+ private
66
+
67
+ def clean_backtrace(backtrace)
68
+ if defined?(::Rails) && ::Rails.respond_to?(:backtrace_cleaner)
69
+ ::Rails.backtrace_cleaner.clean(backtrace)
70
+ else
71
+ backtrace.reject { |line| line.include?("/gems/") }
72
+ end
73
+ end
74
+
75
+ def truncate_hash(hash, max_bytes)
76
+ json = JSON.generate(hash)
77
+ return hash if json.bytesize <= max_bytes
78
+ { _truncated: true, _size: json.bytesize }
79
+ rescue StandardError
80
+ { _truncated: true }
81
+ end
82
+ end
83
+ end
@@ -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?
@@ -26,6 +58,18 @@ module OpenTrace
26
58
  safe_path = req.path.to_s.split("?").first
27
59
  url = "#{scheme}://#{host}#{port_str}#{safe_path}"
28
60
 
61
+ # Capture request body (if present and under size limit)
62
+ req_body = nil
63
+ if req.body && req.body.is_a?(String) && req.body.bytesize < MAX_BODY_CAPTURE_BYTES
64
+ req_body = req.body
65
+ end
66
+
67
+ # Capture response body (if under size limit)
68
+ resp_body = nil
69
+ if response.body && response.body.is_a?(String) && response.body.bytesize < MAX_BODY_CAPTURE_BYTES
70
+ resp_body = response.body
71
+ end
72
+
29
73
  if collector
30
74
  collector.record_http(
31
75
  method: req.method,
@@ -36,6 +80,27 @@ module OpenTrace
36
80
  )
37
81
  end
38
82
 
83
+ buffer = Fiber[:opentrace_buffer]
84
+ if buffer
85
+ vendor = OpenTrace::HttpTracker.infer_vendor(host)
86
+ buffer.record_http(
87
+ method: req.method,
88
+ url: url,
89
+ host: host,
90
+ vendor: vendor,
91
+ status: response.code.to_i,
92
+ duration_ms: duration_ms,
93
+ request_headers: nil, # skip headers for now to save memory
94
+ request_body: req_body,
95
+ response_headers: nil,
96
+ response_body: resp_body,
97
+ response_size: response.body&.bytesize,
98
+ retry_attempt: 0,
99
+ error_class: nil
100
+ )
101
+ buffer.record_timeline(type: :http, name: "#{req.method} #{host}", duration_ms: duration_ms)
102
+ end
103
+
39
104
  response
40
105
  rescue IOError, SystemCallError, OpenSSL::SSL::SSLError, Timeout::Error, Net::ProtocolError => e
41
106
  # Record the failed HTTP call, then re-raise
@@ -52,6 +117,23 @@ module OpenTrace
52
117
  )
53
118
  end
54
119
 
120
+ buffer = Fiber[:opentrace_buffer]
121
+ if buffer
122
+ vendor = OpenTrace::HttpTracker.infer_vendor(address)
123
+ buffer.record_http(
124
+ method: req&.method,
125
+ url: "#{address}#{req&.path}",
126
+ host: address,
127
+ vendor: vendor,
128
+ status: 0,
129
+ duration_ms: duration_ms,
130
+ request_body: req_body,
131
+ response_body: nil,
132
+ response_size: nil,
133
+ error_class: e.class.name
134
+ )
135
+ end
136
+
55
137
  raise # ALWAYS re-raise — never swallow app errors
56
138
  end
57
139