opentrace 0.16.0 → 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 +4 -4
- data/README.md +7 -5
- data/lib/opentrace/audit_tracker.rb +113 -0
- data/lib/opentrace/buffer_pool.rb +62 -0
- data/lib/opentrace/capture_rules.rb +106 -0
- data/lib/opentrace/client.rb +24 -17
- data/lib/opentrace/config.rb +75 -4
- data/lib/opentrace/error_subscriber.rb +0 -1
- data/lib/opentrace/http_tracker.rb +82 -0
- data/lib/opentrace/instrumentation_context.rb +133 -0
- data/lib/opentrace/memory_guard.rb +107 -0
- data/lib/opentrace/middleware.rb +108 -1
- data/lib/opentrace/payload_builder.rb +167 -108
- data/lib/opentrace/pipeline.rb +239 -0
- data/lib/opentrace/rails.rb +181 -154
- data/lib/opentrace/request_buffer.rb +450 -0
- data/lib/opentrace/ring_buffer.rb +133 -0
- data/lib/opentrace/serializer.rb +63 -0
- data/lib/opentrace/ulid.rb +54 -0
- data/lib/opentrace/version.rb +1 -1
- data/lib/opentrace.rb +168 -147
- metadata +26 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0785fe05f95bd64567435b2bb20ed18d4882a14ac765351a741cf322dc8d6ade'
|
|
4
|
+
data.tar.gz: b656ded61cf12de7b5657d69234d11fe52ffb7b9eb9aee2cc6568b33baeaa16a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
|
409
|
-
c.api_key
|
|
410
|
-
c.service
|
|
411
|
-
c.environment
|
|
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
|
data/lib/opentrace/client.rb
CHANGED
|
@@ -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 =
|
|
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[:
|
|
252
|
+
if @config.pii_scrubbing && payload[:body]
|
|
253
253
|
active_patterns = build_pii_patterns
|
|
254
|
-
PiiScrubber.scrub!(payload[:
|
|
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
|
-
|
|
586
|
+
body = payload[:body]&.dup || {}
|
|
587
587
|
|
|
588
588
|
# Truncation priority: remove largest optional fields first
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
data/lib/opentrace/config.rb
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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?
|
|
@@ -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
|
|