debugbundle 1.5.0 → 2.0.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 +9 -5
- data/lib/debugbundle/before_send.rb +1 -1
- data/lib/debugbundle/client.rb +179 -63
- data/lib/debugbundle/client_event_support.rb +76 -43
- data/lib/debugbundle/client_process_support.rb +64 -0
- data/lib/debugbundle/client_queue_support.rb +183 -0
- data/lib/debugbundle/config_worker.rb +60 -0
- data/lib/debugbundle/delivery_worker.rb +103 -0
- data/lib/debugbundle/redaction.rb +7 -8
- data/lib/debugbundle/safe_input.rb +40 -0
- data/lib/debugbundle/suppression.rb +62 -20
- data/lib/debugbundle/telemetry_privacy.rb +15 -5
- data/lib/debugbundle/version.rb +1 -1
- metadata +7 -2
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'debugbundle/safe_input'
|
|
4
|
+
|
|
3
5
|
module DebugBundle
|
|
4
6
|
class Client
|
|
5
7
|
module EventSupport
|
|
6
8
|
private
|
|
7
9
|
|
|
8
|
-
def capture_enabled?
|
|
10
|
+
def capture_enabled?
|
|
11
|
+
ensure_current_process!
|
|
12
|
+
config.enabled? && config.configured?
|
|
13
|
+
end
|
|
9
14
|
|
|
10
15
|
def merge_context(context)
|
|
11
16
|
merged = @context.merge(stringify_hash(context || {}))
|
|
@@ -16,7 +21,8 @@ module DebugBundle
|
|
|
16
21
|
return {} unless value.is_a?(Hash)
|
|
17
22
|
|
|
18
23
|
value.each_with_object({}) do |(key, nested_value), result|
|
|
19
|
-
|
|
24
|
+
safe_key = SafeInput.key(key)
|
|
25
|
+
result[safe_key] = nested_value if safe_key
|
|
20
26
|
end
|
|
21
27
|
end
|
|
22
28
|
|
|
@@ -44,20 +50,78 @@ module DebugBundle
|
|
|
44
50
|
|
|
45
51
|
def exception_causes(error)
|
|
46
52
|
causes = []
|
|
47
|
-
current = error
|
|
53
|
+
current = safe_exception_cause(error)
|
|
54
|
+
seen = {}.compare_by_identity
|
|
55
|
+
seen[error] = true
|
|
48
56
|
|
|
49
|
-
while current
|
|
57
|
+
while current && causes.length < 8 && !seen[current]
|
|
58
|
+
seen[current] = true
|
|
50
59
|
causes << {
|
|
51
|
-
'name' => current
|
|
52
|
-
'message' => current
|
|
53
|
-
'stack' =>
|
|
60
|
+
'name' => safe_exception_name(current),
|
|
61
|
+
'message' => safe_exception_message(current),
|
|
62
|
+
'stack' => safe_exception_stack(current)
|
|
54
63
|
}
|
|
55
|
-
current = current
|
|
64
|
+
current = safe_exception_cause(current)
|
|
56
65
|
end
|
|
57
66
|
|
|
58
67
|
causes
|
|
59
68
|
end
|
|
60
69
|
|
|
70
|
+
def safe_exception_message(error)
|
|
71
|
+
value = Exception.instance_method(:to_s).bind_call(error)
|
|
72
|
+
value.is_a?(String) ? value[0, 4096] : '[exception message unavailable]'
|
|
73
|
+
rescue StandardError
|
|
74
|
+
'[exception message unavailable]'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def safe_exception_name(error)
|
|
78
|
+
type = Object.instance_method(:class).bind_call(error)
|
|
79
|
+
value = Module.instance_method(:name).bind_call(type)
|
|
80
|
+
value.is_a?(String) && !value.empty? ? value[0, 256] : 'Exception'
|
|
81
|
+
rescue StandardError
|
|
82
|
+
'Exception'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def safe_exception_stack(error)
|
|
86
|
+
frames = Exception.instance_method(:backtrace).bind_call(error)
|
|
87
|
+
return '' unless frames.is_a?(Array)
|
|
88
|
+
|
|
89
|
+
frames.first(64).filter_map { |frame| frame[0, 512] if frame.is_a?(String) }.join("\n")[0, 16_384]
|
|
90
|
+
rescue StandardError
|
|
91
|
+
''
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def safe_exception_cause(error)
|
|
95
|
+
Exception.instance_method(:cause).bind_call(error)
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def safe_log_message(value)
|
|
101
|
+
is_a = Object.instance_method(:is_a?)
|
|
102
|
+
return String.instance_method(:[]).bind_call(value, 0, 16_384) if is_a.bind_call(value, String)
|
|
103
|
+
|
|
104
|
+
if is_a.bind_call(value, Integer)
|
|
105
|
+
return '[unsupported log message]' if Integer.instance_method(:bit_length).bind_call(value) > 4096
|
|
106
|
+
|
|
107
|
+
return Integer.instance_method(:to_s).bind_call(value)
|
|
108
|
+
end
|
|
109
|
+
return Float.instance_method(:to_s).bind_call(value) if is_a.bind_call(value, Float)
|
|
110
|
+
|
|
111
|
+
if is_a.bind_call(value, Symbol)
|
|
112
|
+
return '[unsupported log message]' if Symbol.instance_method(:length).bind_call(value) > 16_384
|
|
113
|
+
|
|
114
|
+
return Symbol.instance_method(:to_s).bind_call(value)
|
|
115
|
+
end
|
|
116
|
+
return TrueClass.instance_method(:to_s).bind_call(value) if is_a.bind_call(value, TrueClass)
|
|
117
|
+
return FalseClass.instance_method(:to_s).bind_call(value) if is_a.bind_call(value, FalseClass)
|
|
118
|
+
return '' if is_a.bind_call(value, NilClass)
|
|
119
|
+
|
|
120
|
+
'[unsupported log message]'
|
|
121
|
+
rescue StandardError
|
|
122
|
+
'[unsupported log message]'
|
|
123
|
+
end
|
|
124
|
+
|
|
61
125
|
def probe_snapshot
|
|
62
126
|
items = @probe_buffers.values.flatten.map do |entry|
|
|
63
127
|
entry.merge('activation_id' => nil)
|
|
@@ -88,17 +152,6 @@ module DebugBundle
|
|
|
88
152
|
prepared && protect_event(prepared)
|
|
89
153
|
end
|
|
90
154
|
|
|
91
|
-
def enqueue_event(event)
|
|
92
|
-
event = protect_event(event)
|
|
93
|
-
return unless event
|
|
94
|
-
return unless sampled_in?
|
|
95
|
-
|
|
96
|
-
@buffer_mutex.synchronize do
|
|
97
|
-
@buffer << event
|
|
98
|
-
@buffer.shift while @buffer.length > MAX_BUFFER_SIZE
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
155
|
def protect_event(event)
|
|
103
156
|
return nil unless TelemetryPrivacy.safe_event_identity?(event, additional_fields: config.redact_fields)
|
|
104
157
|
|
|
@@ -116,17 +169,6 @@ module DebugBundle
|
|
|
116
169
|
nil
|
|
117
170
|
end
|
|
118
171
|
|
|
119
|
-
def buffered_batch
|
|
120
|
-
@buffer_mutex.synchronize { @buffer.dup }
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def remove_buffered_events(events)
|
|
124
|
-
event_ids = events.map { |event| event['event_id'] }
|
|
125
|
-
@buffer_mutex.synchronize do
|
|
126
|
-
@buffer.reject! { |event| event_ids.include?(event['event_id']) }
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
172
|
def sampled_in?
|
|
131
173
|
return false if config.sample_rate <= 0.0
|
|
132
174
|
return true if config.sample_rate >= 1.0
|
|
@@ -138,8 +180,7 @@ module DebugBundle
|
|
|
138
180
|
|
|
139
181
|
def append_suppression_aggregates
|
|
140
182
|
@suppression.drain_aggregates(now: monotonic_now).each do |aggregate|
|
|
141
|
-
|
|
142
|
-
enqueue_event(event) if event
|
|
183
|
+
enqueue_event(base_event('error_suppressed', aggregate, {}))
|
|
143
184
|
end
|
|
144
185
|
end
|
|
145
186
|
|
|
@@ -217,17 +258,7 @@ module DebugBundle
|
|
|
217
258
|
end
|
|
218
259
|
|
|
219
260
|
def object_to_hash(value)
|
|
220
|
-
|
|
221
|
-
when Hash
|
|
222
|
-
stringify_hash(value)
|
|
223
|
-
else
|
|
224
|
-
return stringify_hash(value.to_h) if value.respond_to?(:to_h)
|
|
225
|
-
return stringify_hash(value.to_hash) if value.respond_to?(:to_hash)
|
|
226
|
-
|
|
227
|
-
{}
|
|
228
|
-
end
|
|
229
|
-
rescue StandardError
|
|
230
|
-
{}
|
|
261
|
+
value.is_a?(Hash) ? stringify_hash(value) : {}
|
|
231
262
|
end
|
|
232
263
|
|
|
233
264
|
def sanitized_headers(headers)
|
|
@@ -247,6 +278,8 @@ module DebugBundle
|
|
|
247
278
|
end
|
|
248
279
|
|
|
249
280
|
def level_enabled?(level)
|
|
281
|
+
return false if @capture_policy.capture_logs == 'off'
|
|
282
|
+
|
|
250
283
|
threshold = [normalize_level(config.log_level), policy_log_level].max_by do |entry|
|
|
251
284
|
LOG_LEVEL_RANKS.fetch(entry)
|
|
252
285
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
class Client
|
|
5
|
+
module ProcessSupport
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def ensure_current_process!
|
|
9
|
+
current_pid = Process.pid
|
|
10
|
+
return if @owner_pid == current_pid
|
|
11
|
+
|
|
12
|
+
inherited_policy = @capture_policy
|
|
13
|
+
inherited_probes_enabled = @remote_config.probes_enabled
|
|
14
|
+
# A fork inherits buffers, locks, and an unusable sender thread. Reset them
|
|
15
|
+
# before touching any inherited synchronization object or captured event.
|
|
16
|
+
@buffer = []
|
|
17
|
+
@buffer_bytes = 0
|
|
18
|
+
@buffer_priority_counts = [0, 0, 0, 0]
|
|
19
|
+
@inflight_event_ids = Set.new
|
|
20
|
+
@inflight_priority_counts = [0, 0, 0, 0]
|
|
21
|
+
@event_sizes = {}
|
|
22
|
+
@pressure_drops = {}
|
|
23
|
+
@hook_bypass = Set.new
|
|
24
|
+
@finalized_events = {}
|
|
25
|
+
@buffer_mutex = Mutex.new
|
|
26
|
+
@flush_mutex = Mutex.new
|
|
27
|
+
@probe_buffers = {}
|
|
28
|
+
@context = {}
|
|
29
|
+
@suppression = Suppression::Tracker.new
|
|
30
|
+
@last_event_at = nil
|
|
31
|
+
@retry_at = nil
|
|
32
|
+
@consecutive_failures = 0
|
|
33
|
+
@acknowledgement_state = nil
|
|
34
|
+
@next_remote_config_poll_at = nil
|
|
35
|
+
@remote_config_etag = nil
|
|
36
|
+
@remote_config = RemoteConfig::Snapshot.default
|
|
37
|
+
@remote_config.probes_enabled = inherited_probes_enabled
|
|
38
|
+
@capture_policy = @config_fetcher ? inherited_policy : @remote_config.capture_policy
|
|
39
|
+
@remote_config.capture_policy = @capture_policy
|
|
40
|
+
@initial_remote_config_pending = !@config_fetcher.nil?
|
|
41
|
+
@delivery_worker = nil
|
|
42
|
+
@config_worker = nil
|
|
43
|
+
@owner_pid = current_pid
|
|
44
|
+
return unless config.enabled? && config.configured?
|
|
45
|
+
|
|
46
|
+
@delivery_worker = DeliveryWorker.new(
|
|
47
|
+
interval: config.flush_interval,
|
|
48
|
+
before_work: -> {},
|
|
49
|
+
&method(:flush_now)
|
|
50
|
+
)
|
|
51
|
+
if @config_fetcher
|
|
52
|
+
@config_worker = ConfigWorker.new(
|
|
53
|
+
next_wait_seconds: method(:next_remote_config_wait_seconds),
|
|
54
|
+
&method(:refresh_remote_config_on_worker)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError
|
|
58
|
+
# Capture must remain best-effort if a child cannot start a sender.
|
|
59
|
+
@delivery_worker = nil
|
|
60
|
+
@config_worker = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
class Client
|
|
5
|
+
module QueueSupport
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def preflight_capacity?(priority, kind)
|
|
9
|
+
@buffer_mutex.synchronize do
|
|
10
|
+
full = @buffer.length >= MAX_BUFFER_SIZE || @buffer_bytes >= MAX_BUFFER_BYTES
|
|
11
|
+
evictable = @buffer_priority_counts.take(priority).sum - @inflight_priority_counts.take(priority).sum
|
|
12
|
+
return true unless full && evictable.zero?
|
|
13
|
+
|
|
14
|
+
record_pressure_kind_locked(kind, Time.now.utc.iso8601)
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enqueue_event(event, skip_before_send: false, record_pressure: true)
|
|
20
|
+
event = protect_event(event)
|
|
21
|
+
return unless event
|
|
22
|
+
return unless sampled_in?
|
|
23
|
+
|
|
24
|
+
event_size = JSON.generate(event).bytesize
|
|
25
|
+
admitted, buffered = @buffer_mutex.synchronize do
|
|
26
|
+
unless admit_event_locked?(event, event_size)
|
|
27
|
+
record_pressure_drop_locked(event) if record_pressure
|
|
28
|
+
next [false, @buffer.length]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@buffer << event
|
|
32
|
+
@buffer_bytes += event_size
|
|
33
|
+
@buffer_priority_counts[event_priority(event)] += 1
|
|
34
|
+
@event_sizes[event['event_id']] = event_size
|
|
35
|
+
@hook_bypass.add(event['event_id']) if skip_before_send
|
|
36
|
+
[true, @buffer.length]
|
|
37
|
+
end
|
|
38
|
+
@delivery_worker&.wake if admitted && buffered >= config.batch_size
|
|
39
|
+
admitted
|
|
40
|
+
rescue JSON::GeneratorError, Encoding::InvalidByteSequenceError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def admit_event_locked?(event, event_size)
|
|
45
|
+
return false if event_size > MAX_BUFFER_BYTES
|
|
46
|
+
|
|
47
|
+
while @buffer.length >= MAX_BUFFER_SIZE || @buffer_bytes + event_size > MAX_BUFFER_BYTES
|
|
48
|
+
index = evictable_event_index(event_priority(event))
|
|
49
|
+
return false unless index
|
|
50
|
+
|
|
51
|
+
removed = @buffer.delete_at(index)
|
|
52
|
+
remove_event_ownership_locked(removed)
|
|
53
|
+
record_pressure_drop_locked(removed)
|
|
54
|
+
end
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def event_priority(event)
|
|
59
|
+
return 3 if event['event_type'] == 'backend_exception'
|
|
60
|
+
return 2 if event['event_type'] == 'error_suppressed'
|
|
61
|
+
|
|
62
|
+
if event['event_type'] == 'request_event'
|
|
63
|
+
status = event.dig('payload', 'response_status')
|
|
64
|
+
return status.is_a?(Integer) && status >= 400 ? 2 : 1
|
|
65
|
+
end
|
|
66
|
+
return 1 unless event['event_type'] == 'log_event'
|
|
67
|
+
|
|
68
|
+
level = normalize_level(event.dig('payload', 'level'))
|
|
69
|
+
LOG_LEVEL_RANKS.fetch(level) >= LOG_LEVEL_RANKS.fetch(:error) ? 2 : 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pressure_class(event)
|
|
73
|
+
return 'exception' if event['event_type'] == 'backend_exception'
|
|
74
|
+
return 'request' if event['event_type'] == 'request_event'
|
|
75
|
+
return 'other' unless event['event_type'] == 'log_event'
|
|
76
|
+
|
|
77
|
+
normalize_level(event.dig('payload', 'level')).to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def record_pressure_drop_locked(event)
|
|
81
|
+
record_pressure_kind_locked(pressure_class(event), event['occurred_at'])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def record_pressure_kind_locked(kind, occurred_at)
|
|
85
|
+
state = (@pressure_drops[kind] ||= { count: 0, first_seen: occurred_at, last_seen: nil })
|
|
86
|
+
state[:count] += 1
|
|
87
|
+
state[:last_seen] = occurred_at
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def append_pressure_aggregates
|
|
91
|
+
pending = @buffer_mutex.synchronize { @pressure_drops.transform_values(&:dup) }
|
|
92
|
+
pending.each do |kind, state|
|
|
93
|
+
next unless state[:count].positive?
|
|
94
|
+
next if @buffer_mutex.synchronize { @buffer.length >= MAX_BUFFER_SIZE }
|
|
95
|
+
|
|
96
|
+
payload = {
|
|
97
|
+
'fingerprint' => Digest::SHA256.hexdigest("ruby-queue-pressure:#{kind}"),
|
|
98
|
+
'suppressed_count' => state[:count],
|
|
99
|
+
'window_seconds' => 60,
|
|
100
|
+
'first_seen' => state[:first_seen],
|
|
101
|
+
'last_seen' => state[:last_seen],
|
|
102
|
+
'reason' => 'queue_pressure',
|
|
103
|
+
'level' => kind
|
|
104
|
+
}
|
|
105
|
+
next unless enqueue_event(base_event('error_suppressed', payload, {}), record_pressure: false)
|
|
106
|
+
|
|
107
|
+
@buffer_mutex.synchronize do
|
|
108
|
+
current = @pressure_drops[kind]
|
|
109
|
+
current[:count] -= state[:count]
|
|
110
|
+
current[:first_seen] = current[:last_seen] if current[:count].positive?
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def remove_event_ownership_locked(event)
|
|
116
|
+
event_id = event['event_id']
|
|
117
|
+
@buffer_bytes -= @event_sizes.delete(event_id).to_i
|
|
118
|
+
@buffer_priority_counts[event_priority(event)] -= 1
|
|
119
|
+
@inflight_priority_counts[event_priority(event)] -= 1 if @inflight_event_ids.delete?(event_id)
|
|
120
|
+
@hook_bypass.delete(event_id)
|
|
121
|
+
@finalized_events.delete(event_id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def evictable_event_index(priority)
|
|
125
|
+
@buffer.index do |queued|
|
|
126
|
+
!@inflight_event_ids.include?(queued['event_id']) && event_priority(queued) < priority
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cache_finalized_event(event, finalized)
|
|
131
|
+
# The original stays available for acknowledgement identity and retries, so
|
|
132
|
+
# a distinct hook replacement owns additional bytes until removal.
|
|
133
|
+
extra_bytes = finalized.equal?(event) ? 0 : JSON.generate(finalized).bytesize
|
|
134
|
+
@buffer_mutex.synchronize do
|
|
135
|
+
while @buffer_bytes + extra_bytes > MAX_BUFFER_BYTES
|
|
136
|
+
index = evictable_event_index(event_priority(event))
|
|
137
|
+
unless index
|
|
138
|
+
record_pressure_drop_locked(event)
|
|
139
|
+
return nil
|
|
140
|
+
end
|
|
141
|
+
removed = @buffer.delete_at(index)
|
|
142
|
+
remove_event_ownership_locked(removed)
|
|
143
|
+
record_pressure_drop_locked(removed)
|
|
144
|
+
end
|
|
145
|
+
@buffer_bytes += extra_bytes
|
|
146
|
+
@event_sizes[event['event_id']] += extra_bytes
|
|
147
|
+
@finalized_events[event['event_id']] = finalized
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def reserve_buffered_batch
|
|
152
|
+
@buffer_mutex.synchronize do
|
|
153
|
+
@inflight_event_ids = @buffer.to_set { |event| event['event_id'] }
|
|
154
|
+
@inflight_priority_counts = @buffer_priority_counts.dup
|
|
155
|
+
@buffer.dup
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def release_buffered_batch
|
|
160
|
+
@buffer_mutex.synchronize do
|
|
161
|
+
@inflight_event_ids.clear
|
|
162
|
+
@inflight_priority_counts = [0, 0, 0, 0]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def buffered_batch
|
|
167
|
+
@buffer_mutex.synchronize { @buffer.dup }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def remove_buffered_events(events)
|
|
171
|
+
event_ids = events.to_set { |event| event['event_id'] }
|
|
172
|
+
@buffer_mutex.synchronize do
|
|
173
|
+
@buffer.reject! do |event|
|
|
174
|
+
next false unless event_ids.include?(event['event_id'])
|
|
175
|
+
|
|
176
|
+
remove_event_ownership_locked(event)
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
# One owned config poller; a stalled fetch cannot hold the event sender.
|
|
5
|
+
class ConfigWorker
|
|
6
|
+
def initialize(next_wait_seconds:, &refresh)
|
|
7
|
+
@next_wait_seconds = next_wait_seconds
|
|
8
|
+
@refresh = refresh
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@condition = ConditionVariable.new
|
|
11
|
+
@requested = false
|
|
12
|
+
@closed = false
|
|
13
|
+
@thread = Thread.new { run }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def wake
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
return if @closed
|
|
19
|
+
|
|
20
|
+
@requested = true
|
|
21
|
+
@condition.signal
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def close
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
@closed = true
|
|
28
|
+
@condition.broadcast
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def run
|
|
35
|
+
loop do
|
|
36
|
+
begin
|
|
37
|
+
@refresh.call
|
|
38
|
+
rescue StandardError
|
|
39
|
+
# A custom fetcher must not terminate the bounded poller.
|
|
40
|
+
end
|
|
41
|
+
break unless wait_for_next
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def wait_for_next
|
|
46
|
+
delay = @next_wait_seconds.call
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return false if @closed
|
|
49
|
+
|
|
50
|
+
unless @requested
|
|
51
|
+
delay.nil? ? @condition.wait(@mutex) : @condition.wait(@mutex, [delay, 0].max)
|
|
52
|
+
end
|
|
53
|
+
@requested = false
|
|
54
|
+
!@closed
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
# One owned sender and one coalesced wakeup, including interval delivery.
|
|
5
|
+
class DeliveryWorker
|
|
6
|
+
MAX_WAITERS = 64
|
|
7
|
+
EXPLICIT_WAIT_SECONDS = 5
|
|
8
|
+
|
|
9
|
+
def initialize(interval:, before_work:, &deliver)
|
|
10
|
+
@interval = interval
|
|
11
|
+
@before_work = before_work
|
|
12
|
+
@deliver = deliver
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@condition = ConditionVariable.new
|
|
15
|
+
@waiters = []
|
|
16
|
+
@requested = false
|
|
17
|
+
@closed = false
|
|
18
|
+
@thread = Thread.new { run }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def wake
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
return if @closed
|
|
24
|
+
|
|
25
|
+
@requested = true
|
|
26
|
+
@condition.signal
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def flush
|
|
31
|
+
ticket = { done: false, result: false }
|
|
32
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + EXPLICIT_WAIT_SECONDS
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
return false if @closed || @waiters.length >= MAX_WAITERS
|
|
35
|
+
|
|
36
|
+
@waiters << ticket
|
|
37
|
+
@requested = true
|
|
38
|
+
@condition.signal
|
|
39
|
+
until ticket[:done]
|
|
40
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
|
+
break if remaining <= 0
|
|
42
|
+
|
|
43
|
+
@condition.wait(@mutex, remaining)
|
|
44
|
+
end
|
|
45
|
+
ticket[:result]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def close
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@closed = true
|
|
52
|
+
@waiters.each { |ticket| ticket[:done] = true }
|
|
53
|
+
@waiters.clear
|
|
54
|
+
@condition.broadcast
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def run
|
|
61
|
+
@before_work.call
|
|
62
|
+
rescue StandardError
|
|
63
|
+
# A failed config fetch must not terminate the sender.
|
|
64
|
+
ensure
|
|
65
|
+
loop do
|
|
66
|
+
tickets = wait_for_work
|
|
67
|
+
break if tickets.nil?
|
|
68
|
+
|
|
69
|
+
result = perform_work
|
|
70
|
+
finish_waiters(tickets, result)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wait_for_work
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@condition.wait(@mutex, @interval) unless @requested || @closed
|
|
77
|
+
return nil if @closed
|
|
78
|
+
|
|
79
|
+
@requested = false
|
|
80
|
+
tickets = @waiters
|
|
81
|
+
@waiters = []
|
|
82
|
+
tickets
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def perform_work
|
|
87
|
+
@before_work.call
|
|
88
|
+
@deliver.call
|
|
89
|
+
rescue StandardError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def finish_waiters(tickets, result)
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
tickets.each do |ticket|
|
|
96
|
+
ticket[:done] = true
|
|
97
|
+
ticket[:result] = result
|
|
98
|
+
end
|
|
99
|
+
@condition.broadcast
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'date'
|
|
4
|
+
require 'debugbundle/safe_input'
|
|
4
5
|
|
|
5
6
|
module DebugBundle
|
|
6
7
|
module Redaction
|
|
@@ -8,6 +9,7 @@ module DebugBundle
|
|
|
8
9
|
CIRCULAR_VALUE = '[Circular]'
|
|
9
10
|
TRUNCATED_DEPTH_VALUE = '[Truncated:depth]'
|
|
10
11
|
TRUNCATED_COLLECTION_VALUE = '[Truncated:collection]'
|
|
12
|
+
UNSUPPORTED_VALUE = '[unsupported value]'
|
|
11
13
|
DEFAULT_MAX_DEPTH = 5
|
|
12
14
|
DEFAULT_MAX_STRING_LENGTH = 1_024
|
|
13
15
|
DEFAULT_MAX_ARRAY_LENGTH = 50
|
|
@@ -84,13 +86,8 @@ module DebugBundle
|
|
|
84
86
|
mark_seen(value, seen)
|
|
85
87
|
sanitize_hash(value, depth: depth + 1, seen: seen)
|
|
86
88
|
else
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
elsif value.respond_to?(:to_hash)
|
|
90
|
-
sanitize(value.to_hash, depth: depth + 1, seen: seen)
|
|
91
|
-
else
|
|
92
|
-
truncate_string(value.to_s)
|
|
93
|
-
end
|
|
89
|
+
# Application conversion methods can block or raise on the capture caller.
|
|
90
|
+
UNSUPPORTED_VALUE
|
|
94
91
|
end
|
|
95
92
|
end
|
|
96
93
|
|
|
@@ -98,7 +95,9 @@ module DebugBundle
|
|
|
98
95
|
value.each_with_index.with_object({}) do |((key, nested_value), index), result|
|
|
99
96
|
break result if index >= @max_hash_keys
|
|
100
97
|
|
|
101
|
-
key_string = key
|
|
98
|
+
key_string = SafeInput.key(key)
|
|
99
|
+
next unless key_string
|
|
100
|
+
|
|
102
101
|
result[key_string] =
|
|
103
102
|
sensitive_key?(key_string) ? REDACTED_VALUE : sanitize(nested_value, depth: depth, seen: seen)
|
|
104
103
|
end.tap do |result|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module DebugBundle
|
|
6
|
+
# Built-in scalar extraction for capture paths that must not run application renderers.
|
|
7
|
+
module SafeInput
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def key(value)
|
|
11
|
+
is_a = Object.instance_method(:is_a?)
|
|
12
|
+
return String.instance_method(:[]).bind_call(value, 0, 128) if is_a.bind_call(value, String)
|
|
13
|
+
return Symbol.instance_method(:to_s).bind_call(value)[0, 128] if is_a.bind_call(value, Symbol)
|
|
14
|
+
|
|
15
|
+
if is_a.bind_call(value, Integer) && Integer.instance_method(:bit_length).bind_call(value) <= 512
|
|
16
|
+
return Integer.instance_method(:to_s).bind_call(value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
nil
|
|
20
|
+
rescue StandardError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def request_path(value)
|
|
25
|
+
return '/' unless Object.instance_method(:is_a?).bind_call(value, String)
|
|
26
|
+
|
|
27
|
+
raw = String.instance_method(:[]).bind_call(value, 0, 2_048)
|
|
28
|
+
begin
|
|
29
|
+
uri = URI.parse(raw)
|
|
30
|
+
return uri.path if uri.path && !uri.path.empty?
|
|
31
|
+
rescue URI::InvalidURIError
|
|
32
|
+
# Fall through to the bounded path-only fallback.
|
|
33
|
+
end
|
|
34
|
+
fallback = raw.split('?', 2).first.to_s.split('#', 2).first
|
|
35
|
+
fallback.start_with?('/') && !fallback.empty? ? fallback : '/'
|
|
36
|
+
rescue StandardError
|
|
37
|
+
'/'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|