debugbundle 2.0.0 → 2.1.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +133 -0
  3. data/lib/debugbundle/acknowledgement.rb +6 -2
  4. data/lib/debugbundle/before_send.rb +58 -30
  5. data/lib/debugbundle/bounded_http_response.rb +24 -0
  6. data/lib/debugbundle/canonical_event_schema.rb +137 -0
  7. data/lib/debugbundle/client.rb +55 -75
  8. data/lib/debugbundle/client_event_support.rb +11 -12
  9. data/lib/debugbundle/client_evidence_support.rb +169 -0
  10. data/lib/debugbundle/client_exception_support.rb +54 -0
  11. data/lib/debugbundle/client_process_support.rb +5 -12
  12. data/lib/debugbundle/client_queue_support.rb +59 -4
  13. data/lib/debugbundle/config.rb +15 -2
  14. data/lib/debugbundle/diagnostic_budget.rb +155 -0
  15. data/lib/debugbundle/event-envelope.schema.json +4904 -0
  16. data/lib/debugbundle/event_batch.rb +48 -0
  17. data/lib/debugbundle/evidence_delivery.rb +50 -0
  18. data/lib/debugbundle/evidence_graph.rb +73 -0
  19. data/lib/debugbundle/evidence_json.rb +55 -0
  20. data/lib/debugbundle/evidence_resources.rb +60 -0
  21. data/lib/debugbundle/evidence_value.rb +107 -0
  22. data/lib/debugbundle/evidence_wire.rb +193 -0
  23. data/lib/debugbundle/exception_evidence.rb +179 -0
  24. data/lib/debugbundle/framework_operation.rb +13 -0
  25. data/lib/debugbundle/operation_context.rb +90 -0
  26. data/lib/debugbundle/rack/middleware.rb +24 -21
  27. data/lib/debugbundle/rack/relay_middleware.rb +4 -1
  28. data/lib/debugbundle/rails/railtie.rb +4 -1
  29. data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
  30. data/lib/debugbundle/relay/forwarder.rb +83 -0
  31. data/lib/debugbundle/relay/handler.rb +83 -19
  32. data/lib/debugbundle/remote_config.rb +5 -0
  33. data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
  34. data/lib/debugbundle/telemetry_privacy.rb +9 -1
  35. data/lib/debugbundle/transport.rb +49 -19
  36. data/lib/debugbundle/version.rb +1 -1
  37. data/lib/debugbundle.rb +11 -1
  38. metadata +19 -2
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'English'
4
5
  require 'json'
5
6
  require 'set'
6
7
  require 'time'
7
8
 
8
9
  require 'debugbundle/client_event_support'
10
+ require 'debugbundle/client_exception_support'
11
+ require 'debugbundle/client_evidence_support'
9
12
  require 'debugbundle/client_process_support'
10
13
  require 'debugbundle/client_queue_support'
11
14
  require 'debugbundle/config_worker'
@@ -91,6 +94,9 @@ module DebugBundle
91
94
  def initialize(transport: nil, time_provider: nil, random_provider: nil, config_fetcher: nil, **options)
92
95
  @owner_pid = Process.pid
93
96
  @config = Config.new(**options)
97
+ @closed = false
98
+ @retired = false
99
+ initialize_evidence
94
100
  @time_provider = time_provider || -> { Time.now.utc }
95
101
  @random_provider = random_provider || -> { rand }
96
102
  @redactor = Redaction::Redactor.new(
@@ -99,17 +105,7 @@ module DebugBundle
99
105
  @transport = transport || build_default_transport
100
106
  @config_fetcher = config_fetcher || build_default_config_fetcher(custom_transport: !transport.nil?)
101
107
  @context = {}
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 = {}
111
- @buffer_mutex = Mutex.new
112
- @flush_mutex = Mutex.new
108
+ initialize_buffer_ownership
113
109
  @probe_buffers = {}
114
110
  @suppression = Suppression::Tracker.new
115
111
  @last_event_at = nil
@@ -145,46 +141,6 @@ module DebugBundle
145
141
  capture_exception_internal(error, context: context, handled: handled, run_before_send: true)
146
142
  end
147
143
 
148
- def capture_exception_internal(error, context:, handled:, run_before_send:)
149
- return unless capture_enabled?
150
- return unless preflight_capacity?(3, 'exception')
151
-
152
- request_remote_config_poll_if_due
153
-
154
- merged_context = merge_context(context)
155
- payload = {
156
- 'name' => safe_exception_name(error),
157
- 'message' => safe_exception_message(error),
158
- 'stack' => safe_exception_stack(error),
159
- 'handled' => handled,
160
- 'request' => request_payload(merged_context['request']),
161
- 'response' => response_payload(merged_context['response']),
162
- 'runtime' => runtime_payload
163
- }
164
-
165
- causes = exception_causes(error)
166
-
167
- probe_data = probe_snapshot
168
- payload['probe_data'] = probe_data unless probe_data.empty?
169
-
170
- extra_context = merged_context.except('request', 'response', 'correlation')
171
- extra_context['causes'] = causes unless causes.empty?
172
-
173
- event = base_event('backend_exception', payload, extra_context)
174
-
175
- event_payload = event.fetch('payload')
176
- suppression_key = [
177
- event['event_type'],
178
- event_payload['name'],
179
- event_payload['message'],
180
- event_payload['stack']
181
- ].join(':')
182
- return unless @suppression.should_capture(suppression_key, now: monotonic_now)
183
-
184
- enqueue_event(event, skip_before_send: !run_before_send)
185
- end
186
- private :capture_exception_internal
187
-
188
144
  def capture_error(error, context: nil, handled: true) = capture_exception(error, context: context, handled: handled)
189
145
 
190
146
  def capture_log(message, level: :warning, context: nil)
@@ -363,6 +319,8 @@ module DebugBundle
363
319
  return false unless @config_fetcher
364
320
 
365
321
  response = @config_fetcher.call(@remote_config_etag)
322
+ return false if @closed
323
+
366
324
  status_code = response.fetch(:status_code, 500)
367
325
  if status_code == 304
368
326
  schedule_next_remote_config_poll
@@ -373,7 +331,9 @@ module DebugBundle
373
331
  return false
374
332
  end
375
333
 
376
- snapshot = RemoteConfig.parse(response.fetch(:body, {}), config.probes_poll_interval)
334
+ body = response.fetch(:body, {})
335
+ snapshot = RemoteConfig.parse(body, config.probes_poll_interval)
336
+ @evidence.observe(body, required: true, valid: !snapshot.nil? && @evidence.valid_config?(body))
377
337
  unless snapshot
378
338
  schedule_next_remote_config_poll
379
339
  return false
@@ -403,8 +363,17 @@ module DebugBundle
403
363
 
404
364
  def close
405
365
  ensure_current_process!
366
+ @closed = true
367
+ @operations.clear
368
+ @evidence = EvidenceDelivery.new
406
369
  @delivery_worker&.close
407
370
  @config_worker&.close
371
+ @buffer_mutex.synchronize { discard_closed_buffer_locked }
372
+ end
373
+
374
+ def dispose
375
+ @retired = true
376
+ close
408
377
  end
409
378
 
410
379
  def flush_now
@@ -417,20 +386,12 @@ module DebugBundle
417
386
  return false if @transport.nil?
418
387
  return false if rate_limited?
419
388
 
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
389
+ prepared = prepare_wire_batch(candidates)
430
390
  return true if prepared.empty?
431
391
 
432
392
  batch = prepared.map(&:first)
433
393
  wire_events = prepared.map(&:last)
394
+ return true if @closed
434
395
 
435
396
  result = Transport.coerce_result(
436
397
  @transport.call(
@@ -442,11 +403,13 @@ module DebugBundle
442
403
 
443
404
  case result.status_code
444
405
  when 200..299
445
- handle_successful_result(result, batch)
446
- when 429
406
+ accepted = handle_successful_result(result, batch)
407
+ @delivery_worker&.wake if @retry_at.nil? && buffered_event_count.positive?
408
+ accepted
409
+ when 429, 500..599
447
410
  @consecutive_failures += 1
448
- retry_after_seconds = (result.retry_after_seconds || 1).clamp(1, RETRY_AFTER_CAP_SECONDS)
449
- @retry_at = now + retry_after_seconds
411
+ retry_after_seconds = bounded_retry_hint(result.retry_after_seconds)
412
+ @retry_at = now + retry_after_seconds if result.status_code == 429 || !result.retry_after_seconds.nil?
450
413
  false
451
414
  when 400..499
452
415
  remove_buffered_events(batch)
@@ -468,22 +431,30 @@ module DebugBundle
468
431
  private :flush_now
469
432
 
470
433
  def finalized_event_for(event)
434
+ return nil if @closed
435
+
471
436
  event_id = event['event_id']
472
437
  cached, bypass = @buffer_mutex.synchronize do
473
438
  [@finalized_events[event_id], @hook_bypass.include?(event_id)]
474
439
  end
475
- return cached if cached
440
+ finalized = cached || (bypass || !config.before_send ? event : apply_before_send(event))
441
+ return nil if finalized.nil? || @closed
476
442
 
477
- finalized = bypass || !config.before_send ? event : apply_before_send(event)
478
- return nil if finalized.nil?
443
+ finalized = filter_evidence(finalized)
444
+ return nil unless finalized
479
445
  return nil unless post_hook_event_allowed?(finalized)
446
+ return nil unless reconcile_diagnostic(cached || event, finalized)
480
447
 
481
- cache_finalized_event(event, finalized)
448
+ retained = cache_finalized_event(event, finalized)
449
+ @buffer_mutex.synchronize { release_diagnostics_locked(event, finalized) } unless retained
450
+ retained
482
451
  end
483
452
  private :finalized_event_for
484
453
 
485
454
  def post_hook_event_allowed?(event)
486
455
  case event['event_type']
456
+ when 'diagnostic_context'
457
+ recovery_allowed? && event.dig('payload', 'kind') == 'recovery'
487
458
  when 'log_event'
488
459
  return false if @capture_policy.capture_logs == 'off'
489
460
 
@@ -515,9 +486,17 @@ module DebugBundle
515
486
 
516
487
  private
517
488
 
489
+ def bounded_retry_hint(value)
490
+ return 1 unless value.is_a?(Numeric) && value.real? && value.finite?
491
+
492
+ value.clamp(1, RETRY_AFTER_CAP_SECONDS)
493
+ end
494
+
518
495
  def handle_successful_result(result, batch)
519
- decision = Acknowledgement.decide(result.body, batch.length)
520
- return handle_protocol_failure if decision[:kind] == :protocol_failure
496
+ decision = Acknowledgement.decide(result.body, batch.length, required: @transport.is_a?(Transport::HttpTransport))
497
+ lost_evidence = !@closed && @evidence.observe(result.body, required: @transport.is_a?(Transport::HttpTransport),
498
+ valid: decision[:kind] == :acknowledged)
499
+ return handle_protocol_failure(result.retry_after_seconds) if decision[:kind] == :protocol_failure
521
500
 
522
501
  if decision[:kind] == :legacy
523
502
  remove_buffered_events(batch)
@@ -527,12 +506,13 @@ module DebugBundle
527
506
 
528
507
  retryable_indices = decision.fetch(:retryable_indices)
529
508
  retryable_events = retryable_indices.map { |index| batch.fetch(index) }
509
+ retryable_events |= downgrade_evidence_errors(batch, decision, lost_evidence)
530
510
  remove_buffered_events(batch - retryable_events)
531
511
  @last_event_at = now if decision.fetch(:accepted).positive?
532
512
 
533
513
  if retryable_events.any?
534
514
  @consecutive_failures += 1
535
- @retry_at = now + 1
515
+ @retry_at = now + bounded_retry_hint(result.retry_after_seconds)
536
516
  @acknowledgement_state = :degraded
537
517
  false
538
518
  else
@@ -543,9 +523,9 @@ module DebugBundle
543
523
  end
544
524
  end
545
525
 
546
- def handle_protocol_failure
526
+ def handle_protocol_failure(retry_hint)
547
527
  @consecutive_failures += 1
548
- @retry_at = now + 1
528
+ @retry_at = now + bounded_retry_hint(retry_hint)
549
529
  @acknowledgement_state = :degraded
550
530
  false
551
531
  end
@@ -9,11 +9,12 @@ module DebugBundle
9
9
 
10
10
  def capture_enabled?
11
11
  ensure_current_process!
12
- config.enabled? && config.configured?
12
+ !@closed && config.enabled? && config.configured?
13
13
  end
14
14
 
15
15
  def merge_context(context)
16
- merged = @context.merge(stringify_hash(context || {}))
16
+ scoped = @operations.current&.fetch(:correlation) || {}
17
+ merged = @context.merge(scoped).merge(stringify_hash(context || {}))
17
18
  @redactor.redact_value(merged)
18
19
  end
19
20
 
@@ -46,7 +47,13 @@ module DebugBundle
46
47
  }
47
48
  end
48
49
 
49
- def runtime_payload = Runtime.payload
50
+ def runtime_payload
51
+ snapshot = Runtime.payload
52
+ # Engine metadata belongs in the existing extensible runtime block;
53
+ # unknown top-level runtime keys are rejected by installed acceptors.
54
+ extras = snapshot.slice('engine', 'engine_version')
55
+ snapshot.except('engine', 'engine_version').merge('framework_extras' => extras)
56
+ end
50
57
 
51
58
  def exception_causes(error)
52
59
  causes = []
@@ -144,15 +151,7 @@ module DebugBundle
144
151
  { 'value' => '[REDACTED]' }
145
152
  end
146
153
 
147
- def apply_before_send(event)
148
- protected = protect_event(event)
149
- return nil unless protected
150
-
151
- prepared = BeforeSend.apply(protected, config.before_send)
152
- prepared && protect_event(prepared)
153
- end
154
-
155
- def protect_event(event)
154
+ def protect_event_fields(event)
156
155
  return nil unless TelemetryPrivacy.safe_event_identity?(event, additional_fields: config.redact_fields)
157
156
 
158
157
  fields = TelemetryPrivacy.protect(
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'debugbundle/diagnostic_budget'
4
+ require 'debugbundle/evidence_delivery'
5
+ require 'debugbundle/exception_evidence'
6
+ require 'debugbundle/operation_context'
7
+ require 'debugbundle/event_batch'
8
+
9
+ module DebugBundle
10
+ class Client
11
+ module EvidenceSupport
12
+ def with_operation_context(kind:, name: nil, limit_ms: nil, correlation: nil)
13
+ ensure_current_process!
14
+ unless @closed
15
+ lease = @operations.begin_scope(kind: kind, name: name, limit_ms: limit_ms, correlation: correlation)
16
+ end
17
+ yield
18
+ ensure
19
+ @operations.end_scope(lease)
20
+ end
21
+
22
+ def capture_recovery(observation = nil, context: nil, **options)
23
+ return unless capture_enabled? && recovery_allowed?
24
+
25
+ return if observation && !options.empty?
26
+
27
+ input = EvidenceValue.options(observation || options)
28
+ payload = EvidenceWire.recovery(input, @operations.scope)
29
+ return unless payload
30
+
31
+ event = base_event('diagnostic_context', payload, merge_context(context))
32
+ return unless EvidenceWire.valid_optional?(event)
33
+
34
+ enqueue_event(event)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ def apply_before_send(event)
42
+ protected = protect_event(event)
43
+ return nil unless protected
44
+
45
+ prepared = BeforeSend.apply(protected, config.before_send)
46
+ return nil unless prepared
47
+
48
+ result = protect_event(prepared)
49
+ return result if prepared.equal?(protected)
50
+
51
+ result if result && BeforeSend.valid?(result)
52
+ end
53
+
54
+ def protect_event(event)
55
+ protected = protect_event_fields(event)
56
+ return fit_protected_event(protected) if protected && EvidenceWire.valid_optional?(protected)
57
+
58
+ legacy = EvidenceWire.without_optional(event)
59
+ fit_protected_event(legacy && protect_event_fields(legacy))
60
+ rescue StandardError
61
+ legacy = EvidenceWire.without_optional(event)
62
+ fit_protected_event(legacy && protect_event_fields(legacy))
63
+ end
64
+
65
+ def fit_protected_event(event)
66
+ return event if @transport.is_a?(Transport::FileTransport)
67
+
68
+ EventBatch.fit(event)
69
+ end
70
+
71
+ def initialize_evidence
72
+ @evidence = EvidenceDelivery.new(config.evidence_capabilities)
73
+ @operations = OperationContext.new
74
+ @diagnostics = DiagnosticBudget.new
75
+ end
76
+
77
+ def recovery_allowed?
78
+ config.capture_recovery_observations && @evidence.supports?('recovery_v1') &&
79
+ @capture_policy.preset != 'minimal' && @capture_policy.capture_diagnostic_context != false
80
+ end
81
+
82
+ def evidence_context(context)
83
+ return context unless @evidence.supports?('recovery_v1') || @evidence.supports?('resource_observations_v1')
84
+
85
+ reference = EvidenceWire.capture_reference(context, @operations.scope)
86
+ reference ? context.except('failure_ref', 'evidence_scope').merge('evidence_reference' => reference) : context
87
+ end
88
+
89
+ def enrich_exception(event, retained, causes, fatal:)
90
+ return event unless config.capture_exception_evidence && @evidence.supports?('exception_evidence_v1')
91
+
92
+ metadata = {}
93
+ execution = @operations.snapshot
94
+ metadata['execution'] = execution if execution
95
+ reference = EvidenceWire.reference(event.dig('context', 'evidence_reference'))
96
+ metadata['evidence_reference'] = reference if reference
97
+ maximum = (fatal ? 4096 : 32_768) - EvidenceJson.bytes(metadata) - 32
98
+ graph = ExceptionEvidence.from_retained(root: retained, causes: causes, fatal: fatal, budget_bytes: maximum)
99
+ event['payload']['exception_evidence'] = graph if graph
100
+ event['payload']['execution'] = execution if execution
101
+ event
102
+ rescue StandardError
103
+ event
104
+ end
105
+
106
+ def filter_evidence(event)
107
+ features = @evidence.features
108
+ features -= ['exception_evidence_v1'] unless config.capture_exception_evidence
109
+ EvidenceWire.supported(event, features)
110
+ end
111
+
112
+ def reserve_diagnostic_locked(event, bytes)
113
+ event['event_type'] != 'diagnostic_context' ||
114
+ (recovery_allowed? && event.dig('payload', 'kind') == 'recovery' &&
115
+ @diagnostics.reserve(event['event_id'], event['payload'], bytes, now.to_f))
116
+ end
117
+
118
+ def reconcile_diagnostic(original, replacement)
119
+ @buffer_mutex.synchronize do
120
+ original_id = original['event_type'] == 'diagnostic_context' ? original['event_id'] : nil
121
+ if replacement['event_type'] == 'diagnostic_context'
122
+ return false unless recovery_allowed? && replacement.dig('payload', 'kind') == 'recovery'
123
+
124
+ payload = replacement['payload']
125
+ size = JSON.generate(replacement).bytesize
126
+ return original_id ? @diagnostics.replace(original_id, replacement['event_id'], payload, size,
127
+ now.to_f) : @diagnostics.reserve(replacement['event_id'],
128
+ payload, size, now.to_f)
129
+ end
130
+ @diagnostics.release([original_id]) if original_id
131
+ if %w[backend_exception frontend_exception].include?(replacement['event_type'])
132
+ reference = EvidenceWire.reference(replacement.dig('context', 'evidence_reference'))
133
+ if reference
134
+ @diagnostics.register_failure(reference, replacement['event_id'],
135
+ Time.iso8601(replacement['occurred_at']).to_f)
136
+ end
137
+ end
138
+ true
139
+ end
140
+ end
141
+
142
+ def release_diagnostics_locked(event, finalized = nil)
143
+ ids = []
144
+ ids << event['event_id'] if event['event_type'] == 'diagnostic_context'
145
+ ids << finalized['event_id'] if finalized && finalized['event_type'] == 'diagnostic_context'
146
+ @diagnostics.release(ids)
147
+ end
148
+
149
+ def downgrade_evidence_errors(batch, decision, lost)
150
+ return [] unless lost
151
+
152
+ decision.fetch(:terminal_errors).filter_map do |index, reason|
153
+ next unless reason == 'invalid_event'
154
+
155
+ original = batch.fetch(index)
156
+ finalized = @buffer_mutex.synchronize { @finalized_events[original['event_id']] || original }
157
+ next unless %w[backend_exception frontend_exception].include?(finalized['event_type'])
158
+
159
+ reduced = filter_evidence(finalized)
160
+ next if reduced == finalized
161
+
162
+ original if cache_finalized_event(original, reduced)
163
+ end
164
+ end
165
+ end
166
+
167
+ include EvidenceSupport
168
+ end
169
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ class Client
5
+ module ExceptionSupport
6
+ private
7
+
8
+ def capture_exception_internal(error, context:, handled:, run_before_send:)
9
+ return unless capture_enabled?
10
+ return unless preflight_capacity?(3, 'exception')
11
+
12
+ request_remote_config_poll_if_due
13
+
14
+ merged_context = evidence_context(merge_context(context))
15
+ payload = {
16
+ 'name' => safe_exception_name(error),
17
+ 'message' => safe_exception_message(error),
18
+ 'stack' => safe_exception_stack(error),
19
+ 'handled' => handled,
20
+ 'request' => request_payload(merged_context['request']),
21
+ 'response' => response_payload(merged_context['response']),
22
+ 'runtime' => runtime_payload
23
+ }
24
+
25
+ causes = exception_causes(error)
26
+ retained = payload.slice('name', 'message', 'stack')
27
+ payload['message'] = payload['name'] if payload['message'].strip.empty?
28
+ payload['stack'] = "#{payload['name']}: #{payload['message']}" if payload['stack'].empty?
29
+
30
+ probe_data = probe_snapshot
31
+ payload['probe_data'] = probe_data unless probe_data.empty?
32
+
33
+ extra_context = merged_context.dup
34
+ extra_context['causes'] = causes unless causes.empty?
35
+
36
+ event = base_event('backend_exception', payload, extra_context)
37
+
38
+ event_payload = event.fetch('payload')
39
+ suppression_key = [
40
+ event['event_type'],
41
+ event_payload['name'],
42
+ event_payload['message'],
43
+ event_payload['stack']
44
+ ].join(':')
45
+ return unless @suppression.should_capture(suppression_key, now: monotonic_now)
46
+
47
+ event = enrich_exception(event, retained, causes, fatal: !run_before_send)
48
+ enqueue_event(event, skip_before_send: !run_before_send)
49
+ end
50
+ end
51
+
52
+ include ExceptionSupport
53
+ end
54
+ end
@@ -13,19 +13,11 @@ module DebugBundle
13
13
  inherited_probes_enabled = @remote_config.probes_enabled
14
14
  # A fork inherits buffers, locks, and an unusable sender thread. Reset them
15
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
16
+ initialize_buffer_ownership
27
17
  @probe_buffers = {}
28
18
  @context = {}
19
+ @operations&.clear
20
+ initialize_evidence
29
21
  @suppression = Suppression::Tracker.new
30
22
  @last_event_at = nil
31
23
  @retry_at = nil
@@ -41,7 +33,8 @@ module DebugBundle
41
33
  @delivery_worker = nil
42
34
  @config_worker = nil
43
35
  @owner_pid = current_pid
44
- return unless config.enabled? && config.configured?
36
+ @closed = @retired
37
+ return unless !@closed && config.enabled? && config.configured?
45
38
 
46
39
  @delivery_worker = DeliveryWorker.new(
47
40
  interval: config.flush_interval,
@@ -5,6 +5,20 @@ module DebugBundle
5
5
  module QueueSupport
6
6
  private
7
7
 
8
+ def initialize_buffer_ownership
9
+ @buffer = []
10
+ @buffer_bytes = 0
11
+ @buffer_priority_counts = [0, 0, 0, 0]
12
+ @inflight_event_ids = Set.new
13
+ @inflight_priority_counts = [0, 0, 0, 0]
14
+ @event_sizes = {}
15
+ @pressure_drops = {}
16
+ @hook_bypass = Set.new
17
+ @finalized_events = {}
18
+ @buffer_mutex = Mutex.new
19
+ @flush_mutex = Mutex.new
20
+ end
21
+
8
22
  def preflight_capacity?(priority, kind)
9
23
  @buffer_mutex.synchronize do
10
24
  full = @buffer.length >= MAX_BUFFER_SIZE || @buffer_bytes >= MAX_BUFFER_BYTES
@@ -23,7 +37,10 @@ module DebugBundle
23
37
 
24
38
  event_size = JSON.generate(event).bytesize
25
39
  admitted, buffered = @buffer_mutex.synchronize do
40
+ next [false, @buffer.length] unless reserve_diagnostic_locked(event, event_size)
41
+
26
42
  unless admit_event_locked?(event, event_size)
43
+ release_diagnostics_locked(event)
27
44
  record_pressure_drop_locked(event) if record_pressure
28
45
  next [false, @buffer.length]
29
46
  end
@@ -118,7 +135,8 @@ module DebugBundle
118
135
  @buffer_priority_counts[event_priority(event)] -= 1
119
136
  @inflight_priority_counts[event_priority(event)] -= 1 if @inflight_event_ids.delete?(event_id)
120
137
  @hook_bypass.delete(event_id)
121
- @finalized_events.delete(event_id)
138
+ finalized = @finalized_events.delete(event_id)
139
+ release_diagnostics_locked(event, finalized)
122
140
  end
123
141
 
124
142
  def evictable_event_index(priority)
@@ -130,9 +148,13 @@ module DebugBundle
130
148
  def cache_finalized_event(event, finalized)
131
149
  # The original stays available for acknowledgement identity and retries, so
132
150
  # a distinct hook replacement owns additional bytes until removal.
151
+ finalized = event if finalized == event
133
152
  extra_bytes = finalized.equal?(event) ? 0 : JSON.generate(finalized).bytesize
134
153
  @buffer_mutex.synchronize do
135
- while @buffer_bytes + extra_bytes > MAX_BUFFER_BYTES
154
+ previous = @finalized_events[event['event_id']]
155
+ old_extra = previous && !previous.equal?(event) ? JSON.generate(previous).bytesize : 0
156
+ delta = extra_bytes - old_extra
157
+ while @buffer_bytes + delta > MAX_BUFFER_BYTES
136
158
  index = evictable_event_index(event_priority(event))
137
159
  unless index
138
160
  record_pressure_drop_locked(event)
@@ -142,8 +164,8 @@ module DebugBundle
142
164
  remove_event_ownership_locked(removed)
143
165
  record_pressure_drop_locked(removed)
144
166
  end
145
- @buffer_bytes += extra_bytes
146
- @event_sizes[event['event_id']] += extra_bytes
167
+ @buffer_bytes += delta
168
+ @event_sizes[event['event_id']] += delta
147
169
  @finalized_events[event['event_id']] = finalized
148
170
  end
149
171
  end
@@ -156,11 +178,44 @@ module DebugBundle
156
178
  end
157
179
  end
158
180
 
181
+ def prepare_wire_batch(candidates)
182
+ prepared = []
183
+ size = EventBatch::WRAPPER_BYTES
184
+ limit = @transport.is_a?(Transport::FileTransport) ? nil : EventBatch::MAX_BODY_BYTES
185
+ candidates.each do |event|
186
+ finalized = finalized_event_for(event)
187
+ unless finalized
188
+ remove_buffered_events([event])
189
+ next
190
+ end
191
+
192
+ cost = EventBatch.bytes(finalized) + (prepared.empty? ? 0 : 1)
193
+ break if limit && size + cost > limit
194
+
195
+ prepared << [event, finalized]
196
+ size += cost
197
+ end
198
+ prepared
199
+ end
200
+
159
201
  def release_buffered_batch
160
202
  @buffer_mutex.synchronize do
161
203
  @inflight_event_ids.clear
162
204
  @inflight_priority_counts = [0, 0, 0, 0]
205
+ discard_closed_buffer_locked if @closed
206
+ end
207
+ end
208
+
209
+ def discard_closed_buffer_locked
210
+ # A stopped sender discards queued work. An active send keeps ownership
211
+ # charged until its ensure block releases the last snapshot.
212
+ @buffer.reject! do |event|
213
+ next false if @inflight_event_ids.include?(event['event_id'])
214
+
215
+ remove_event_ownership_locked(event)
216
+ true
163
217
  end
218
+ @diagnostics = DiagnosticBudget.new if @buffer.empty?
164
219
  end
165
220
 
166
221
  def buffered_batch