debugbundle 1.4.1 → 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 +11 -5
- data/lib/debugbundle/before_send.rb +1 -1
- data/lib/debugbundle/client.rb +189 -65
- data/lib/debugbundle/client_event_support.rb +96 -38
- 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/rack/middleware.rb +13 -3
- data/lib/debugbundle/redaction.rb +7 -8
- data/lib/debugbundle/relay/handler.rb +9 -1
- data/lib/debugbundle/safe_input.rb +40 -0
- data/lib/debugbundle/suppression.rb +62 -20
- data/lib/debugbundle/telemetry_privacy.rb +204 -0
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +1 -0
- metadata +8 -23
- data/Gemfile +0 -17
- data/Makefile +0 -58
- data/debugbundle.gemspec +0 -30
- data/spec/before_send_spec.rb +0 -74
- data/spec/client_spec.rb +0 -432
- data/spec/debugbundle_spec.rb +0 -54
- data/spec/file_transport_spec.rb +0 -54
- data/spec/fixtures/event-envelope.schema.json +0 -142
- data/spec/logger_integration_spec.rb +0 -202
- data/spec/rack_integration_spec.rb +0 -44
- data/spec/rack_middleware_spec.rb +0 -210
- data/spec/rails_railtie_spec.rb +0 -96
- data/spec/rails_relay_spec.rb +0 -121
- data/spec/redaction_spec.rb +0 -42
- data/spec/relay_spec.rb +0 -274
- data/spec/remote_config_spec.rb +0 -80
- data/spec/repository_metadata_spec.rb +0 -65
- data/spec/sidekiq_integration_spec.rb +0 -66
- data/spec/sidekiq_middleware_spec.rb +0 -50
- data/spec/spec_helper.rb +0 -23
- data/spec/suppression_spec.rb +0 -16
|
@@ -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)
|
|
@@ -74,32 +138,35 @@ module DebugBundle
|
|
|
74
138
|
end
|
|
75
139
|
|
|
76
140
|
def normalize_probe_data(value)
|
|
77
|
-
redacted = @redactor.redact_value(value)
|
|
141
|
+
redacted = TelemetryPrivacy.protect(@redactor.redact_value(value), additional_fields: config.redact_fields)
|
|
78
142
|
redacted.is_a?(Hash) ? redacted : { 'value' => redacted }
|
|
143
|
+
rescue StandardError
|
|
144
|
+
{ 'value' => '[REDACTED]' }
|
|
79
145
|
end
|
|
80
146
|
|
|
81
147
|
def apply_before_send(event)
|
|
82
|
-
|
|
83
|
-
|
|
148
|
+
protected = protect_event(event)
|
|
149
|
+
return nil unless protected
|
|
84
150
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@buffer_mutex.synchronize do
|
|
89
|
-
@buffer << event
|
|
90
|
-
@buffer.shift while @buffer.length > MAX_BUFFER_SIZE
|
|
91
|
-
end
|
|
151
|
+
prepared = BeforeSend.apply(protected, config.before_send)
|
|
152
|
+
prepared && protect_event(prepared)
|
|
92
153
|
end
|
|
93
154
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
def protect_event(event)
|
|
156
|
+
return nil unless TelemetryPrivacy.safe_event_identity?(event, additional_fields: config.redact_fields)
|
|
157
|
+
|
|
158
|
+
fields = TelemetryPrivacy.protect(
|
|
159
|
+
{ 'service' => event['service'], 'payload' => event['payload'], 'context' => event['context'] },
|
|
160
|
+
additional_fields: config.redact_fields
|
|
161
|
+
)
|
|
162
|
+
return nil unless fields['service'].is_a?(Hash) && fields['payload'].is_a?(Hash)
|
|
163
|
+
return nil unless fields['service']['name'].is_a?(String) && fields['service']['environment'].is_a?(String)
|
|
97
164
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
@buffer_mutex.synchronize do
|
|
101
|
-
@buffer.reject! { |event| event_ids.include?(event['event_id']) }
|
|
165
|
+
event.merge('service' => fields['service'], 'payload' => fields['payload']).tap do |protected|
|
|
166
|
+
protected['context'] = fields['context'] if event.key?('context')
|
|
102
167
|
end
|
|
168
|
+
rescue StandardError
|
|
169
|
+
nil
|
|
103
170
|
end
|
|
104
171
|
|
|
105
172
|
def sampled_in?
|
|
@@ -113,8 +180,7 @@ module DebugBundle
|
|
|
113
180
|
|
|
114
181
|
def append_suppression_aggregates
|
|
115
182
|
@suppression.drain_aggregates(now: monotonic_now).each do |aggregate|
|
|
116
|
-
|
|
117
|
-
enqueue_event(event) if event
|
|
183
|
+
enqueue_event(base_event('error_suppressed', aggregate, {}))
|
|
118
184
|
end
|
|
119
185
|
end
|
|
120
186
|
|
|
@@ -192,17 +258,7 @@ module DebugBundle
|
|
|
192
258
|
end
|
|
193
259
|
|
|
194
260
|
def object_to_hash(value)
|
|
195
|
-
|
|
196
|
-
when Hash
|
|
197
|
-
stringify_hash(value)
|
|
198
|
-
else
|
|
199
|
-
return stringify_hash(value.to_h) if value.respond_to?(:to_h)
|
|
200
|
-
return stringify_hash(value.to_hash) if value.respond_to?(:to_hash)
|
|
201
|
-
|
|
202
|
-
{}
|
|
203
|
-
end
|
|
204
|
-
rescue StandardError
|
|
205
|
-
{}
|
|
261
|
+
value.is_a?(Hash) ? stringify_hash(value) : {}
|
|
206
262
|
end
|
|
207
263
|
|
|
208
264
|
def sanitized_headers(headers)
|
|
@@ -222,6 +278,8 @@ module DebugBundle
|
|
|
222
278
|
end
|
|
223
279
|
|
|
224
280
|
def level_enabled?(level)
|
|
281
|
+
return false if @capture_policy.capture_logs == 'off'
|
|
282
|
+
|
|
225
283
|
threshold = [normalize_level(config.log_level), policy_log_level].max_by do |entry|
|
|
226
284
|
LOG_LEVEL_RANKS.fetch(entry)
|
|
227
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,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
3
|
+
require 'uri'
|
|
4
4
|
|
|
5
5
|
module DebugBundle
|
|
6
6
|
module Rack
|
|
@@ -65,9 +65,19 @@ module DebugBundle
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def parse_query(query_string)
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
raw_query = query_string.to_s
|
|
69
|
+
return {} if raw_query.bytesize > 16_384
|
|
70
|
+
|
|
71
|
+
URI.decode_www_form(raw_query.tr(';', '&')).each_with_object({}) do |(key, value), result|
|
|
72
|
+
result[key] = if result.key?(key)
|
|
73
|
+
Array(result[key]) << value
|
|
74
|
+
else
|
|
75
|
+
value
|
|
76
|
+
end
|
|
70
77
|
end
|
|
78
|
+
rescue StandardError
|
|
79
|
+
# Broken request metadata must never interrupt the host application.
|
|
80
|
+
{}
|
|
71
81
|
end
|
|
72
82
|
|
|
73
83
|
def route_template(env)
|
|
@@ -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|
|