debugbundle 1.5.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f8040734d7a652566f0c06328713b3b7fa5bc559b1e30f1432c45b71f8decab
4
- data.tar.gz: b53fba03cb4013cb375610e1e9acef0264f79cc87ad98c987b72ae1968e9a411
3
+ metadata.gz: de914935eb26ac5a4a4c7a21e91e13f92fcdef0949c8a19cc9566adf70fa7b5d
4
+ data.tar.gz: ff2d61415de5d618556d00f4918e4d3a29af38ae7be2bddafc088304a7b11e2b
5
5
  SHA512:
6
- metadata.gz: e76d4d31fb538e7a9261b3a544e51440f9406c4ba3fc61508dd34477052e84d4036955568188f1baf988c6a30c30fa8fdbe08d4f286a26131adac3f9ef0f26aa
7
- data.tar.gz: 64d7f18d44af78e8bd267fde9b967996d90b308efc0df021fa3d1cc2205f0cad69929a9b896ad5819df0ee4618ca399f9f6229dd372ce956e50397e770cd3db0
6
+ metadata.gz: 7655a43d83ce82e3d7cd6febebc560a2c6c31dde18df9b3a579897fa236c1094c36bc170aa203f1a412caca0ff86c1d6bf529745e9b69e48d55c88799ba84531
7
+ data.tar.gz: 06b7e321fe0bae17dfe91bd4ffecd4288f84c0151a9909def3d18c02fc1e5f4e262171b7318a2bed337876a0ed7740c9d1d31d0ab518b187a05f72e0db0fcf49
data/README.md CHANGED
@@ -4,7 +4,11 @@ Ruby SDK for DebugBundle.
4
4
 
5
5
  Use this gem to capture Ruby backend exceptions, request metadata, structured logs, runtime context, probe data, and browser relay traffic. It supports a singleton facade plus instance clients for Rack, Rails, Sidekiq, and explicit Ruby instrumentation.
6
6
 
7
- The source test matrix covers Ruby 3.1 through 4.0. Rails 7.x remains an installed-base lane; the current Rails 8.1 lane exercises the relay and Rack middleware on Ruby 4.0.
7
+ The source test matrix covers Ruby 3.1 through 4.0. Rails 7.x remains an installed-base lane; the current Rails 8.1 lane exercises the relay and Rack middleware on Ruby 4.0. `make compat` runs the Rack, Rails, and Sidekiq Docker lanes, including Rails 8.1.
8
+
9
+ Version 2.0 changes capture and hook timing. Existing installed 1.5.x applications continue to use their pinned gem until upgraded. Review [the migration guide](MIGRATION-2.0.md) before upgrading.
10
+
11
+ `make sdk-safety-perf` runs a Docker-backed host-safety budget with 10,000 filtered INFO calls, 1,000 accepted ERROR calls with and without custom privacy fields, and 10,000 concurrent ERROR calls against a full queue while transport is held. CI and the release workflow run this gate before publication. These bounded synthetic checks supplement, but do not replace, installed runtime and workload qualification.
8
12
 
9
13
  ## Automatic capture and application filtering
10
14
 
@@ -40,7 +44,7 @@ DebugBundle.capture_message("worker started", level: :info)
40
44
  DebugBundle.flush
41
45
  ```
42
46
 
43
- `DebugBundle.init(...)` arms best-effort process exception capture automatically through `at_exit` and thread exception hooks.
47
+ `DebugBundle.init(...)` arms best-effort process exception capture automatically through `at_exit` and thread exception hooks. These hooks wake the sender without waiting for delivery; shutdown exceptions can be lost if the process exits before the sender finishes. Automatic delivery runs on one background sender thread when the configured batch size or flush interval is reached. Remote configuration uses a separate bounded poller so a slow fetch cannot hold delivery. An explicit `flush` waits for the sender for at most five seconds and reports whether delivery completed. Call it during a controlled shutdown window when delivery is required. A forked child starts fresh workers on its first SDK use and drops inherited parent events, context, and probes while retaining the parent's capture restrictions until remote configuration refreshes.
44
48
 
45
49
  ## Framework Integrations
46
50
 
@@ -75,7 +79,7 @@ Capture-policy fields are server-owned and must not be supplied in local SDK con
75
79
  | `local_events_dir` | `.debugbundle/local/events` | Local event file destination. |
76
80
  | `spool_dir` | `.debugbundle/local/browser-relay-spool` | Relay durable spool destination. |
77
81
  | `redact_fields` | `[]` | Additional sensitive field names merged with built-in redaction defaults. Rails `filter_parameters` are added automatically. |
78
- | `batch_size` | `25` | Max events per flush batch. |
82
+ | `batch_size` | `25` | Wake the background sender when this many events are buffered. |
79
83
  | `flush_interval` | `5` | Flush interval in seconds. |
80
84
  | `sample_rate` | `1.0` | Fraction of events kept before transport. |
81
85
  | `log_level` | `warning` | Minimum captured log severity. |
@@ -323,7 +327,7 @@ make smoke
323
327
  make smoke-published VERSION=1.5.0
324
328
  ```
325
329
 
326
- `make smoke` builds the gem, installs it into a fresh RubyGems home, drives a Rack request plus a browser relay batch through the public SDK surface, validates event envelope shape, and confirms the mock ingestion endpoint receives the expected service, environment, SDK metadata, and correlation fields.
330
+ `make smoke` builds the gem, installs it into a fresh RubyGems home, drives a Rack request plus a browser relay batch through the public SDK surface, validates event envelope shape, and confirms the mock ingestion endpoint receives the expected service, environment, SDK metadata, and correlation fields. For version 2.0, it also forks the installed client and verifies exactly one child event without replaying the parent's buffered event.
327
331
 
328
332
  ## Examples
329
333
 
@@ -352,7 +356,7 @@ The repository ships a GitHub Actions release workflow at `.github/workflows/rel
352
356
 
353
357
  - Push a `v*` tag or run the workflow manually with a `version` input.
354
358
  - Configure the `RUBYGEMS_API_KEY` repository secret before enabling publish.
355
- - The workflow runs lint, tests, gem build, `make smoke`, RubyGems publish, and `make smoke-published VERSION=<tag>` before creating the GitHub release.
359
+ - The workflow runs lint, tests, the full `make compat` framework/runtime matrix, gem build, `make smoke`, RubyGems publish, and `make smoke-published VERSION=<tag>` before creating the GitHub release.
356
360
 
357
361
  ## Documentation
358
362
 
@@ -364,3 +368,9 @@ The repository ships a GitHub Actions release workflow at `.github/workflows/rel
364
368
  ## License
365
369
 
366
370
  Apache-2.0. See LICENSE.
371
+
372
+ ## Delivery acknowledgement and retry limits
373
+
374
+ The built-in HTTP transport requires the canonical ingestion acknowledgement (`accepted`, `rejected`, and `errors`). Empty responses, unrelated JSON, malformed counts, or invalid rejection indices retain the full batch with backoff. Valid acknowledgements remove accepted and terminally rejected events and retry only the indexed retryable rejections; an all-rejected batch does not advance `lastEventAt`.
375
+
376
+ File transports and explicitly supplied custom/legacy transports retain their documented bodyless success fallback. A custom transport that returns acknowledgement fields must return the complete canonical shape. Retry hints (delay seconds or HTTP dates) are bounded to five minutes and measured from response receipt, including partial/protocol acknowledgements and retryable server failures. Without a server hint, existing retry timing is preserved. Failures remain contained within the SDK's existing delivery path.
@@ -11,8 +11,8 @@ module DebugBundle
11
11
 
12
12
  module_function
13
13
 
14
- def decide(body, batch_length)
15
- return { kind: :legacy } unless body.is_a?(Hash) && FIELDS.any? { |field| body.key?(field) }
14
+ def decide(body, batch_length, required: false)
15
+ return missing_acknowledgement(required) unless body.is_a?(Hash) && FIELDS.any? { |field| body.key?(field) }
16
16
  return protocol_failure unless FIELDS.all? { |field| body.key?(field) }
17
17
 
18
18
  accepted = body['accepted']
@@ -55,5 +55,9 @@ module DebugBundle
55
55
  def protocol_failure
56
56
  { kind: :protocol_failure }
57
57
  end
58
+
59
+ def missing_acknowledgement(required)
60
+ required ? protocol_failure : { kind: :legacy }
61
+ end
58
62
  end
59
63
  end
@@ -27,7 +27,7 @@ module DebugBundle
27
27
  rejection_reason dom_context probe_data
28
28
  ],
29
29
  'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
30
- 'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen device],
30
+ 'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen reason level device],
31
31
  'probe_event' => %w[label data activation_id probe_label_pattern device]
32
32
  }.freeze
33
33
  ROOT_FIELDS = %w[
@@ -1,19 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'json'
5
+ require 'set'
4
6
  require 'time'
5
- require 'uri'
6
7
 
7
8
  require 'debugbundle/client_event_support'
9
+ require 'debugbundle/client_process_support'
10
+ require 'debugbundle/client_queue_support'
11
+ require 'debugbundle/config_worker'
12
+ require 'debugbundle/delivery_worker'
8
13
  require 'debugbundle/runtime'
9
14
 
10
15
  module DebugBundle
11
16
  class Client
17
+ include QueueSupport
18
+ include ProcessSupport
19
+
12
20
  SCHEMA_VERSION = '2026-03-01'
13
21
  SDK_NAME = '@debugbundle/sdk-ruby'
14
22
  DEFAULT_SERVICE_NAME = 'ruby-service'
15
23
  DEFAULT_ENVIRONMENT = 'development'
16
24
  MAX_BUFFER_SIZE = 1_000
25
+ MAX_BUFFER_BYTES = 8 * 1_024 * 1_024
17
26
  RETRY_AFTER_CAP_SECONDS = 300
18
27
  DEFAULT_HEADER_ALLOWLIST = %w[
19
28
  user-agent
@@ -80,6 +89,7 @@ module DebugBundle
80
89
  end
81
90
 
82
91
  def initialize(transport: nil, time_provider: nil, random_provider: nil, config_fetcher: nil, **options)
92
+ @owner_pid = Process.pid
83
93
  @config = Config.new(**options)
84
94
  @time_provider = time_provider || -> { Time.now.utc }
85
95
  @random_provider = random_provider || -> { rand }
@@ -90,6 +100,14 @@ module DebugBundle
90
100
  @config_fetcher = config_fetcher || build_default_config_fetcher(custom_transport: !transport.nil?)
91
101
  @context = {}
92
102
  @buffer = []
103
+ @buffer_bytes = 0
104
+ @buffer_priority_counts = [0, 0, 0, 0]
105
+ @inflight_event_ids = Set.new
106
+ @inflight_priority_counts = [0, 0, 0, 0]
107
+ @event_sizes = {}
108
+ @pressure_drops = {}
109
+ @hook_bypass = Set.new
110
+ @finalized_events = {}
93
111
  @buffer_mutex = Mutex.new
94
112
  @flush_mutex = Mutex.new
95
113
  @probe_buffers = {}
@@ -105,10 +123,22 @@ module DebugBundle
105
123
  @next_remote_config_poll_at = nil
106
124
  @remote_config_etag = nil
107
125
  @remote_config = RemoteConfig::Snapshot.default
108
- @capture_policy = @remote_config.capture_policy
126
+ @capture_policy = @config_fetcher ? RemoteConfig.minimal_capture_policy : @remote_config.capture_policy
127
+ @initial_remote_config_pending = !@config_fetcher.nil?
128
+ @config_worker = nil
129
+ return unless capture_enabled?
109
130
 
110
- refresh_remote_config!
111
- @capture_policy = RemoteConfig.minimal_capture_policy if @config_fetcher && @remote_config_etag.nil?
131
+ @delivery_worker = DeliveryWorker.new(
132
+ interval: config.flush_interval,
133
+ before_work: -> {},
134
+ &method(:flush_now)
135
+ )
136
+ return unless @config_fetcher
137
+
138
+ @config_worker = ConfigWorker.new(
139
+ next_wait_seconds: method(:next_remote_config_wait_seconds),
140
+ &method(:refresh_remote_config_on_worker)
141
+ )
112
142
  end
113
143
 
114
144
  def capture_exception(error, context: nil, handled: true)
@@ -117,14 +147,15 @@ module DebugBundle
117
147
 
118
148
  def capture_exception_internal(error, context:, handled:, run_before_send:)
119
149
  return unless capture_enabled?
150
+ return unless preflight_capacity?(3, 'exception')
120
151
 
121
- poll_remote_config_if_due!
152
+ request_remote_config_poll_if_due
122
153
 
123
154
  merged_context = merge_context(context)
124
155
  payload = {
125
- 'name' => error.class.name,
126
- 'message' => error.message.to_s,
127
- 'stack' => Array(error.backtrace).join("\n"),
156
+ 'name' => safe_exception_name(error),
157
+ 'message' => safe_exception_message(error),
158
+ 'stack' => safe_exception_stack(error),
128
159
  'handled' => handled,
129
160
  'request' => request_payload(merged_context['request']),
130
161
  'response' => response_payload(merged_context['response']),
@@ -140,8 +171,6 @@ module DebugBundle
140
171
  extra_context['causes'] = causes unless causes.empty?
141
172
 
142
173
  event = base_event('backend_exception', payload, extra_context)
143
- event = apply_before_send(event) if run_before_send
144
- return if event.nil?
145
174
 
146
175
  event_payload = event.fetch('payload')
147
176
  suppression_key = [
@@ -152,7 +181,7 @@ module DebugBundle
152
181
  ].join(':')
153
182
  return unless @suppression.should_capture(suppression_key, now: monotonic_now)
154
183
 
155
- enqueue_event(event)
184
+ enqueue_event(event, skip_before_send: !run_before_send)
156
185
  end
157
186
  private :capture_exception_internal
158
187
 
@@ -161,26 +190,39 @@ module DebugBundle
161
190
  def capture_log(message, level: :warning, context: nil)
162
191
  return unless capture_enabled?
163
192
 
164
- poll_remote_config_if_due!
165
-
166
193
  normalized_level = normalize_level(level || :warning)
194
+ return unless level_enabled?(normalized_level)
195
+
196
+ priority = LOG_LEVEL_RANKS.fetch(normalized_level) >= LOG_LEVEL_RANKS.fetch(:error) ? 2 : 0
197
+ return unless preflight_capacity?(priority, normalized_level.to_s)
198
+
199
+ request_remote_config_poll_if_due
167
200
 
168
201
  merged_context = merge_context(context)
169
202
  payload = {
170
203
  'level' => normalized_level.to_s,
171
- 'message' => message.to_s,
204
+ 'message' => safe_log_message(message),
172
205
  'attributes' => merged_context
173
206
  }
174
- event = apply_before_send(base_event('log_event', payload, merged_context))
175
- return if event.nil? || !level_enabled?(normalized_level)
176
-
177
- enqueue_event(event)
207
+ enqueue_event(base_event('log_event', payload, merged_context))
178
208
  end
179
209
 
180
210
  def capture_request(request, response, context: nil)
181
211
  return unless capture_enabled?
182
212
 
183
- poll_remote_config_if_due!
213
+ status = if response.is_a?(Hash)
214
+ response[:status_code] || response['status_code'] || response[:status] || response['status']
215
+ end
216
+ priority = if status.is_a?(Integer)
217
+ status >= 400 ? 2 : 1
218
+ elsif response.nil?
219
+ 1
220
+ else
221
+ 3
222
+ end
223
+ return unless preflight_capacity?(priority, 'request')
224
+
225
+ request_remote_config_poll_if_due
184
226
 
185
227
  merged_context = merge_context(context)
186
228
  sanitized_request = request_payload(request)
@@ -199,12 +241,9 @@ module DebugBundle
199
241
  'response_headers' => sanitized_response['headers'],
200
242
  'response_body' => sanitized_response['body']
201
243
  }
202
- event = apply_before_send(
203
- base_event('request_event', payload, merged_context.merge('request' => sanitized_request))
204
- )
205
- return if event.nil? || !capture_request_event?(response_status, sanitized_request)
244
+ return unless capture_request_event?(response_status, sanitized_request)
206
245
 
207
- enqueue_event(event)
246
+ enqueue_event(base_event('request_event', payload, merged_context.merge('request' => sanitized_request)))
208
247
  end
209
248
 
210
249
  def capture_message(message, level: nil, context: nil)
@@ -212,10 +251,14 @@ module DebugBundle
212
251
  end
213
252
 
214
253
  def set_context(key, value)
254
+ ensure_current_process!
255
+ safe_key = SafeInput.key(key)
256
+ return value unless safe_key
257
+
215
258
  safe = TelemetryPrivacy.protect(
216
- { key.to_s => @redactor.redact_value(value) }, additional_fields: config.redact_fields
259
+ { safe_key => @redactor.redact_value(value) }, additional_fields: config.redact_fields
217
260
  )
218
- @context[key.to_s] = safe[key.to_s] if safe.key?(key.to_s)
261
+ @context[safe_key] = safe[safe_key] if safe.key?(safe_key)
219
262
  value
220
263
  rescue StandardError
221
264
  value
@@ -224,7 +267,7 @@ module DebugBundle
224
267
  def probe(label, data = nil, heavy: false, &block)
225
268
  return unless capture_enabled?
226
269
 
227
- poll_remote_config_if_due!
270
+ request_remote_config_poll_if_due
228
271
  return unless @remote_config.probes_enabled
229
272
 
230
273
  matching_directives = matching_probe_directives(label)
@@ -282,7 +325,9 @@ module DebugBundle
282
325
  handled: false,
283
326
  run_before_send: false
284
327
  )
285
- client.flush
328
+ client.__send__(:wake_sender)
329
+ rescue StandardError
330
+ nil
286
331
  end
287
332
  true
288
333
  end
@@ -300,7 +345,7 @@ module DebugBundle
300
345
  end
301
346
 
302
347
  def with_request_trigger(request)
303
- poll_remote_config_if_due! if capture_enabled?
348
+ request_remote_config_poll_if_due if capture_enabled?
304
349
 
305
350
  directives = TriggerToken.resolve_request_directives(
306
351
  request: request,
@@ -352,29 +397,56 @@ module DebugBundle
352
397
  end
353
398
 
354
399
  def flush
400
+ ensure_current_process!
401
+ @delivery_worker&.flush || false
402
+ end
403
+
404
+ def close
405
+ ensure_current_process!
406
+ @delivery_worker&.close
407
+ @config_worker&.close
408
+ end
409
+
410
+ def flush_now
355
411
  # rubocop:disable Metrics/BlockLength
356
412
  @flush_mutex.synchronize do
357
413
  append_suppression_aggregates
358
- batch = buffered_batch
359
- return true if batch.empty?
414
+ append_pressure_aggregates
415
+ candidates = reserve_buffered_batch
416
+ return true if candidates.empty?
360
417
  return false if @transport.nil?
361
418
  return false if rate_limited?
362
419
 
420
+ # Replace consumed snapshot slots so dropped events do not remain owned
421
+ # by this batch while later callbacks run or transport waits.
422
+ prepared = candidates.map! do |event|
423
+ finalized = finalized_event_for(event)
424
+ if finalized.nil?
425
+ remove_buffered_events([event])
426
+ next
427
+ end
428
+ [event, finalized]
429
+ end.compact
430
+ return true if prepared.empty?
431
+
432
+ batch = prepared.map(&:first)
433
+ wire_events = prepared.map(&:last)
434
+
363
435
  result = Transport.coerce_result(
364
436
  @transport.call(
365
437
  project_token: config.project_token,
366
438
  service_name: service_name,
367
- events: batch.map(&:dup)
439
+ events: wire_events.map(&:dup)
368
440
  )
369
441
  )
370
442
 
371
443
  case result.status_code
372
444
  when 200..299
373
445
  handle_successful_result(result, batch)
374
- when 429
446
+ when 429, 500..599
375
447
  @consecutive_failures += 1
376
- retry_after_seconds = (result.retry_after_seconds || 1).clamp(1, RETRY_AFTER_CAP_SECONDS)
377
- @retry_at = now + retry_after_seconds
448
+ retry_after_seconds = bounded_retry_hint(result.retry_after_seconds)
449
+ @retry_at = now + retry_after_seconds if result.status_code == 429 || !result.retry_after_seconds.nil?
378
450
  false
379
451
  when 400..499
380
452
  remove_buffered_events(batch)
@@ -390,9 +462,43 @@ module DebugBundle
390
462
  rescue StandardError
391
463
  @consecutive_failures += 1
392
464
  false
465
+ ensure
466
+ release_buffered_batch
467
+ end
468
+ private :flush_now
469
+
470
+ def finalized_event_for(event)
471
+ event_id = event['event_id']
472
+ cached, bypass = @buffer_mutex.synchronize do
473
+ [@finalized_events[event_id], @hook_bypass.include?(event_id)]
474
+ end
475
+ return cached if cached
476
+
477
+ finalized = bypass || !config.before_send ? event : apply_before_send(event)
478
+ return nil if finalized.nil?
479
+ return nil unless post_hook_event_allowed?(finalized)
480
+
481
+ cache_finalized_event(event, finalized)
393
482
  end
483
+ private :finalized_event_for
484
+
485
+ def post_hook_event_allowed?(event)
486
+ case event['event_type']
487
+ when 'log_event'
488
+ return false if @capture_policy.capture_logs == 'off'
489
+
490
+ level_enabled?(normalize_level(event.dig('payload', 'level')))
491
+ when 'request_event'
492
+ payload = event['payload']
493
+ capture_request_event?(payload['response_status'].to_i, payload)
494
+ else
495
+ true
496
+ end
497
+ end
498
+ private :post_hook_event_allowed?
394
499
 
395
500
  def status
501
+ ensure_current_process!
396
502
  return :disconnected unless config.enabled?
397
503
  return :degraded unless config.configured?
398
504
  return @acknowledgement_state if @acknowledgement_state
@@ -402,13 +508,22 @@ module DebugBundle
402
508
  :healthy
403
509
  end
404
510
 
405
- def buffered_event_count = @buffer_mutex.synchronize { @buffer.length }
511
+ def buffered_event_count
512
+ ensure_current_process!
513
+ @buffer_mutex.synchronize { @buffer.length }
514
+ end
406
515
 
407
516
  private
408
517
 
518
+ def bounded_retry_hint(value)
519
+ return 1 unless value.is_a?(Numeric) && value.real? && value.finite?
520
+
521
+ value.clamp(1, RETRY_AFTER_CAP_SECONDS)
522
+ end
523
+
409
524
  def handle_successful_result(result, batch)
410
- decision = Acknowledgement.decide(result.body, batch.length)
411
- return handle_protocol_failure if decision[:kind] == :protocol_failure
525
+ decision = Acknowledgement.decide(result.body, batch.length, required: @transport.is_a?(Transport::HttpTransport))
526
+ return handle_protocol_failure(result.retry_after_seconds) if decision[:kind] == :protocol_failure
412
527
 
413
528
  if decision[:kind] == :legacy
414
529
  remove_buffered_events(batch)
@@ -423,7 +538,7 @@ module DebugBundle
423
538
 
424
539
  if retryable_events.any?
425
540
  @consecutive_failures += 1
426
- @retry_at = now + 1
541
+ @retry_at = now + bounded_retry_hint(result.retry_after_seconds)
427
542
  @acknowledgement_state = :degraded
428
543
  false
429
544
  else
@@ -434,9 +549,9 @@ module DebugBundle
434
549
  end
435
550
  end
436
551
 
437
- def handle_protocol_failure
552
+ def handle_protocol_failure(retry_hint)
438
553
  @consecutive_failures += 1
439
- @retry_at = now + 1
554
+ @retry_at = now + bounded_retry_hint(retry_hint)
440
555
  @acknowledgement_state = :degraded
441
556
  false
442
557
  end
@@ -505,8 +620,8 @@ module DebugBundle
505
620
  def matching_immediate_client_error_path_rule?(status_code, request)
506
621
  return false unless (400..499).cover?(status_code)
507
622
 
508
- path = normalize_request_path(request['path'] || request['url'])
509
- method = request['method'].to_s.upcase
623
+ path = SafeInput.request_path(request['path'] || request['url'])
624
+ method = SafeInput.key(request['method'])&.upcase || ''
510
625
  Array(@capture_policy.immediate_client_error_path_rules).any? do |rule|
511
626
  next false unless rule.status_code == status_code
512
627
  next false if !rule.http_methods.empty? && !rule.http_methods.include?(method)
@@ -519,19 +634,6 @@ module DebugBundle
519
634
  end
520
635
  end
521
636
 
522
- def normalize_request_path(value)
523
- begin
524
- uri = URI.parse(value.to_s)
525
- return uri.path if uri.path && !uri.path.empty?
526
- rescue URI::InvalidURIError
527
- # Fall through to the lightweight path-only fallback.
528
- end
529
- fallback = value.to_s.split('?', 2).first.to_s.split('#', 2).first
530
- return fallback if fallback.start_with?('/') && !fallback.empty?
531
-
532
- '/'
533
- end
534
-
535
637
  def matching_probe_directives(label)
536
638
  active_directives = @remote_config.directives + current_request_trigger_directives
537
639
 
@@ -554,21 +656,16 @@ module DebugBundle
554
656
  return if candidate_directives.empty?
555
657
 
556
658
  candidate_directives.each do |directive|
557
- event = apply_before_send(
558
- base_event(
559
- 'probe_event',
560
- {
561
- 'label' => label,
562
- 'data' => data,
563
- 'activation_id' => directive.id,
564
- 'probe_label_pattern' => directive.label_pattern
565
- },
566
- {}
567
- )
568
- )
569
659
  allowed = request_directives.include?(directive) ||
570
660
  @capture_policy.capture_probe_events == 'standalone_when_activated'
571
- enqueue_event(event) if event && allowed
661
+ next unless allowed
662
+
663
+ enqueue_event(base_event('probe_event', {
664
+ 'label' => label,
665
+ 'data' => data,
666
+ 'activation_id' => directive.id,
667
+ 'probe_label_pattern' => directive.label_pattern
668
+ }, {}))
572
669
  end
573
670
  end
574
671
 
@@ -583,7 +680,22 @@ module DebugBundle
583
680
 
584
681
  def local_environment? = LOCAL_ENVIRONMENTS.include?(environment_name.to_s)
585
682
 
586
- def poll_remote_config_if_due!
683
+ def request_remote_config_poll_if_due
684
+ return unless @next_remote_config_poll_at && @next_remote_config_poll_at <= now
685
+
686
+ @config_worker&.wake
687
+ end
688
+
689
+ def refresh_remote_config_on_worker
690
+ if @initial_remote_config_pending
691
+ @initial_remote_config_pending = false
692
+ refresh_remote_config!
693
+ else
694
+ refresh_remote_config_if_due!
695
+ end
696
+ end
697
+
698
+ def refresh_remote_config_if_due!
587
699
  return unless @config_fetcher
588
700
  return unless @next_remote_config_poll_at && @next_remote_config_poll_at <= now
589
701
 
@@ -600,6 +712,12 @@ module DebugBundle
600
712
  @next_remote_config_poll_at = interval_seconds ? now + interval_seconds : nil
601
713
  end
602
714
 
715
+ def next_remote_config_wait_seconds
716
+ return nil unless @next_remote_config_poll_at
717
+
718
+ [@next_remote_config_poll_at - now, 0].max
719
+ end
720
+
603
721
  def capture_thread_exceptions
604
722
  return false if @thread_exception_registered
605
723
 
@@ -612,11 +730,15 @@ module DebugBundle
612
730
 
613
731
  def capture_thread_exception(error)
614
732
  capture_exception_internal(error, context: nil, handled: false, run_before_send: false)
615
- flush
733
+ wake_sender
616
734
  rescue StandardError
617
735
  nil
618
736
  end
619
737
 
738
+ def wake_sender
739
+ @delivery_worker&.wake
740
+ end
741
+
620
742
  def now = @time_provider.call
621
743
 
622
744
  def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)