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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f8040734d7a652566f0c06328713b3b7fa5bc559b1e30f1432c45b71f8decab
4
- data.tar.gz: b53fba03cb4013cb375610e1e9acef0264f79cc87ad98c987b72ae1968e9a411
3
+ metadata.gz: 154069d58e3649c8b90ab0b5567749fef0b0ae27756354467d2f54e347c20bdb
4
+ data.tar.gz: 10364db06a596a352b0b96cdfda8b54fe6ec228ab2a4682b89740d38ca0f2a09
5
5
  SHA512:
6
- metadata.gz: e76d4d31fb538e7a9261b3a544e51440f9406c4ba3fc61508dd34477052e84d4036955568188f1baf988c6a30c30fa8fdbe08d4f286a26131adac3f9ef0f26aa
7
- data.tar.gz: 64d7f18d44af78e8bd267fde9b967996d90b308efc0df021fa3d1cc2205f0cad69929a9b896ad5819df0ee4618ca399f9f6229dd372ce956e50397e770cd3db0
6
+ metadata.gz: 529aa156d0361a75d06a6b9c1f1dd9950269c1c0c82a97367436a6987a8546c0f9274631c1027e0e5585147b18e34ce5a441eca453891a442e105617370f373f
7
+ data.tar.gz: 3d768b54c0ca11c5ed227bbe02ece3ad61a40a77597028366e900c1cc42a4e4777bb93fc837dfc2eeee030e610c0756993fb363f0d2eab5e17457b34b0c66e02
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
 
@@ -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,19 +397,46 @@ 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
 
@@ -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)
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
393
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,7 +508,10 @@ 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
 
@@ -505,8 +614,8 @@ module DebugBundle
505
614
  def matching_immediate_client_error_path_rule?(status_code, request)
506
615
  return false unless (400..499).cover?(status_code)
507
616
 
508
- path = normalize_request_path(request['path'] || request['url'])
509
- method = request['method'].to_s.upcase
617
+ path = SafeInput.request_path(request['path'] || request['url'])
618
+ method = SafeInput.key(request['method'])&.upcase || ''
510
619
  Array(@capture_policy.immediate_client_error_path_rules).any? do |rule|
511
620
  next false unless rule.status_code == status_code
512
621
  next false if !rule.http_methods.empty? && !rule.http_methods.include?(method)
@@ -519,19 +628,6 @@ module DebugBundle
519
628
  end
520
629
  end
521
630
 
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
631
  def matching_probe_directives(label)
536
632
  active_directives = @remote_config.directives + current_request_trigger_directives
537
633
 
@@ -554,21 +650,16 @@ module DebugBundle
554
650
  return if candidate_directives.empty?
555
651
 
556
652
  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
653
  allowed = request_directives.include?(directive) ||
570
654
  @capture_policy.capture_probe_events == 'standalone_when_activated'
571
- enqueue_event(event) if event && allowed
655
+ next unless allowed
656
+
657
+ enqueue_event(base_event('probe_event', {
658
+ 'label' => label,
659
+ 'data' => data,
660
+ 'activation_id' => directive.id,
661
+ 'probe_label_pattern' => directive.label_pattern
662
+ }, {}))
572
663
  end
573
664
  end
574
665
 
@@ -583,7 +674,22 @@ module DebugBundle
583
674
 
584
675
  def local_environment? = LOCAL_ENVIRONMENTS.include?(environment_name.to_s)
585
676
 
586
- def poll_remote_config_if_due!
677
+ def request_remote_config_poll_if_due
678
+ return unless @next_remote_config_poll_at && @next_remote_config_poll_at <= now
679
+
680
+ @config_worker&.wake
681
+ end
682
+
683
+ def refresh_remote_config_on_worker
684
+ if @initial_remote_config_pending
685
+ @initial_remote_config_pending = false
686
+ refresh_remote_config!
687
+ else
688
+ refresh_remote_config_if_due!
689
+ end
690
+ end
691
+
692
+ def refresh_remote_config_if_due!
587
693
  return unless @config_fetcher
588
694
  return unless @next_remote_config_poll_at && @next_remote_config_poll_at <= now
589
695
 
@@ -600,6 +706,12 @@ module DebugBundle
600
706
  @next_remote_config_poll_at = interval_seconds ? now + interval_seconds : nil
601
707
  end
602
708
 
709
+ def next_remote_config_wait_seconds
710
+ return nil unless @next_remote_config_poll_at
711
+
712
+ [@next_remote_config_poll_at - now, 0].max
713
+ end
714
+
603
715
  def capture_thread_exceptions
604
716
  return false if @thread_exception_registered
605
717
 
@@ -612,11 +724,15 @@ module DebugBundle
612
724
 
613
725
  def capture_thread_exception(error)
614
726
  capture_exception_internal(error, context: nil, handled: false, run_before_send: false)
615
- flush
727
+ wake_sender
616
728
  rescue StandardError
617
729
  nil
618
730
  end
619
731
 
732
+ def wake_sender
733
+ @delivery_worker&.wake
734
+ end
735
+
620
736
  def now = @time_provider.call
621
737
 
622
738
  def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)