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.
@@ -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? = config.enabled? && config.configured?
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
- result[key.to_s] = nested_value
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.cause
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.class.name,
52
- 'message' => current.message.to_s,
53
- 'stack' => Array(current.backtrace).join("\n")
60
+ 'name' => safe_exception_name(current),
61
+ 'message' => safe_exception_message(current),
62
+ 'stack' => safe_exception_stack(current)
54
63
  }
55
- current = current.cause
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
- event = apply_before_send(base_event('error_suppressed', aggregate, {}))
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
- case value
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
- if value.respond_to?(:to_h)
88
- sanitize(value.to_h, depth: depth + 1, seen: seen)
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.to_s
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