debugbundle 2.0.1 → 2.1.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +131 -1
  3. data/lib/debugbundle/before_send.rb +58 -30
  4. data/lib/debugbundle/bounded_http_response.rb +24 -0
  5. data/lib/debugbundle/canonical_event_schema.rb +137 -0
  6. data/lib/debugbundle/client.rb +50 -67
  7. data/lib/debugbundle/client_event_support.rb +11 -12
  8. data/lib/debugbundle/client_evidence_support.rb +169 -0
  9. data/lib/debugbundle/client_exception_support.rb +64 -0
  10. data/lib/debugbundle/client_process_support.rb +5 -12
  11. data/lib/debugbundle/client_queue_support.rb +59 -4
  12. data/lib/debugbundle/config.rb +15 -2
  13. data/lib/debugbundle/diagnostic_budget.rb +155 -0
  14. data/lib/debugbundle/event-envelope.schema.json +4904 -0
  15. data/lib/debugbundle/event_batch.rb +48 -0
  16. data/lib/debugbundle/evidence_delivery.rb +50 -0
  17. data/lib/debugbundle/evidence_graph.rb +73 -0
  18. data/lib/debugbundle/evidence_json.rb +55 -0
  19. data/lib/debugbundle/evidence_resources.rb +60 -0
  20. data/lib/debugbundle/evidence_value.rb +107 -0
  21. data/lib/debugbundle/evidence_wire.rb +193 -0
  22. data/lib/debugbundle/exception_evidence.rb +179 -0
  23. data/lib/debugbundle/exception_identity.rb +42 -0
  24. data/lib/debugbundle/framework_operation.rb +13 -0
  25. data/lib/debugbundle/logging.rb +2 -1
  26. data/lib/debugbundle/operation_context.rb +90 -0
  27. data/lib/debugbundle/rack/middleware.rb +24 -21
  28. data/lib/debugbundle/rack/relay_middleware.rb +4 -1
  29. data/lib/debugbundle/rails/railtie.rb +4 -1
  30. data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
  31. data/lib/debugbundle/relay/forwarder.rb +83 -0
  32. data/lib/debugbundle/relay/handler.rb +83 -19
  33. data/lib/debugbundle/remote_config.rb +5 -0
  34. data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
  35. data/lib/debugbundle/telemetry_privacy.rb +43 -5
  36. data/lib/debugbundle/transport.rb +43 -17
  37. data/lib/debugbundle/version.rb +1 -1
  38. data/lib/debugbundle.rb +11 -1
  39. metadata +20 -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)
@@ -193,6 +149,15 @@ module DebugBundle
193
149
  normalized_level = normalize_level(level || :warning)
194
150
  return unless level_enabled?(normalized_level)
195
151
 
152
+ error = message.is_a?(Exception) ? message : context.is_a?(Hash) && (context[:exception] || context['exception'])
153
+ if %i[error critical].include?(normalized_level) && error.is_a?(Exception)
154
+ fields = (context || {}).reject { |key, _value| %w[exception].include?(key.to_s) }
155
+ fields[:logger_name] ||= 'logger'
156
+ fields[:log_message] = message if message.is_a?(String)
157
+ capture_exception(error, context: fields)
158
+ return
159
+ end
160
+
196
161
  priority = LOG_LEVEL_RANKS.fetch(normalized_level) >= LOG_LEVEL_RANKS.fetch(:error) ? 2 : 0
197
162
  return unless preflight_capacity?(priority, normalized_level.to_s)
198
163
 
@@ -363,6 +328,8 @@ module DebugBundle
363
328
  return false unless @config_fetcher
364
329
 
365
330
  response = @config_fetcher.call(@remote_config_etag)
331
+ return false if @closed
332
+
366
333
  status_code = response.fetch(:status_code, 500)
367
334
  if status_code == 304
368
335
  schedule_next_remote_config_poll
@@ -373,7 +340,9 @@ module DebugBundle
373
340
  return false
374
341
  end
375
342
 
376
- snapshot = RemoteConfig.parse(response.fetch(:body, {}), config.probes_poll_interval)
343
+ body = response.fetch(:body, {})
344
+ snapshot = RemoteConfig.parse(body, config.probes_poll_interval)
345
+ @evidence.observe(body, required: true, valid: !snapshot.nil? && @evidence.valid_config?(body))
377
346
  unless snapshot
378
347
  schedule_next_remote_config_poll
379
348
  return false
@@ -403,8 +372,17 @@ module DebugBundle
403
372
 
404
373
  def close
405
374
  ensure_current_process!
375
+ @closed = true
376
+ @operations.clear
377
+ @evidence = EvidenceDelivery.new
406
378
  @delivery_worker&.close
407
379
  @config_worker&.close
380
+ @buffer_mutex.synchronize { discard_closed_buffer_locked }
381
+ end
382
+
383
+ def dispose
384
+ @retired = true
385
+ close
408
386
  end
409
387
 
410
388
  def flush_now
@@ -417,20 +395,12 @@ module DebugBundle
417
395
  return false if @transport.nil?
418
396
  return false if rate_limited?
419
397
 
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
398
+ prepared = prepare_wire_batch(candidates)
430
399
  return true if prepared.empty?
431
400
 
432
401
  batch = prepared.map(&:first)
433
402
  wire_events = prepared.map(&:last)
403
+ return true if @closed
434
404
 
435
405
  result = Transport.coerce_result(
436
406
  @transport.call(
@@ -442,7 +412,9 @@ module DebugBundle
442
412
 
443
413
  case result.status_code
444
414
  when 200..299
445
- handle_successful_result(result, batch)
415
+ accepted = handle_successful_result(result, batch)
416
+ @delivery_worker&.wake if @retry_at.nil? && buffered_event_count.positive?
417
+ accepted
446
418
  when 429, 500..599
447
419
  @consecutive_failures += 1
448
420
  retry_after_seconds = bounded_retry_hint(result.retry_after_seconds)
@@ -468,22 +440,30 @@ module DebugBundle
468
440
  private :flush_now
469
441
 
470
442
  def finalized_event_for(event)
443
+ return nil if @closed
444
+
471
445
  event_id = event['event_id']
472
446
  cached, bypass = @buffer_mutex.synchronize do
473
447
  [@finalized_events[event_id], @hook_bypass.include?(event_id)]
474
448
  end
475
- return cached if cached
449
+ finalized = cached || (bypass || !config.before_send ? event : apply_before_send(event))
450
+ return nil if finalized.nil? || @closed
476
451
 
477
- finalized = bypass || !config.before_send ? event : apply_before_send(event)
478
- return nil if finalized.nil?
452
+ finalized = filter_evidence(finalized)
453
+ return nil unless finalized
479
454
  return nil unless post_hook_event_allowed?(finalized)
455
+ return nil unless reconcile_diagnostic(cached || event, finalized)
480
456
 
481
- cache_finalized_event(event, finalized)
457
+ retained = cache_finalized_event(event, finalized)
458
+ @buffer_mutex.synchronize { release_diagnostics_locked(event, finalized) } unless retained
459
+ retained
482
460
  end
483
461
  private :finalized_event_for
484
462
 
485
463
  def post_hook_event_allowed?(event)
486
464
  case event['event_type']
465
+ when 'diagnostic_context'
466
+ recovery_allowed? && event.dig('payload', 'kind') == 'recovery'
487
467
  when 'log_event'
488
468
  return false if @capture_policy.capture_logs == 'off'
489
469
 
@@ -523,6 +503,8 @@ module DebugBundle
523
503
 
524
504
  def handle_successful_result(result, batch)
525
505
  decision = Acknowledgement.decide(result.body, batch.length, required: @transport.is_a?(Transport::HttpTransport))
506
+ lost_evidence = !@closed && @evidence.observe(result.body, required: @transport.is_a?(Transport::HttpTransport),
507
+ valid: decision[:kind] == :acknowledged)
526
508
  return handle_protocol_failure(result.retry_after_seconds) if decision[:kind] == :protocol_failure
527
509
 
528
510
  if decision[:kind] == :legacy
@@ -533,6 +515,7 @@ module DebugBundle
533
515
 
534
516
  retryable_indices = decision.fetch(:retryable_indices)
535
517
  retryable_events = retryable_indices.map { |index| batch.fetch(index) }
518
+ retryable_events |= downgrade_evidence_errors(batch, decision, lost_evidence)
536
519
  remove_buffered_events(batch - retryable_events)
537
520
  @last_event_at = now if decision.fetch(:accepted).positive?
538
521
 
@@ -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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'debugbundle/exception_identity'
4
+
5
+ module DebugBundle
6
+ class Client
7
+ module ExceptionSupport
8
+ private
9
+
10
+ def capture_exception_internal(error, context:, handled:, run_before_send:)
11
+ return unless capture_enabled?
12
+ return unless preflight_capacity?(3, 'exception')
13
+
14
+ merged_context = evidence_context(merge_context(context))
15
+ @exception_identity ||= ExceptionIdentity.new
16
+ logged = merged_context.key?('logger_name')
17
+ automatic = !run_before_send || logged
18
+ scope = config.before_send ? nil : @operations.scope
19
+ return unless @exception_identity.admit?(error, scope, automatic)
20
+
21
+ request_remote_config_poll_if_due
22
+
23
+ payload = {
24
+ 'name' => safe_exception_name(error),
25
+ 'message' => safe_exception_message(error),
26
+ 'stack' => safe_exception_stack(error),
27
+ 'handled' => handled,
28
+ 'request' => request_payload(merged_context['request']),
29
+ 'response' => response_payload(merged_context['response']),
30
+ 'runtime' => runtime_payload
31
+ }
32
+
33
+ causes = exception_causes(error)
34
+ retained = payload.slice('name', 'message', 'stack')
35
+ payload['message'] = payload['name'] if payload['message'].strip.empty?
36
+ payload['stack'] = "#{payload['name']}: #{payload['message']}" if payload['stack'].empty?
37
+
38
+ probe_data = probe_snapshot
39
+ payload['probe_data'] = probe_data unless probe_data.empty?
40
+
41
+ extra_context = merged_context.dup
42
+ extra_context['causes'] = causes unless causes.empty?
43
+
44
+ event = base_event('backend_exception', payload, extra_context)
45
+
46
+ event_payload = event.fetch('payload')
47
+ suppression_key = [
48
+ event['event_type'],
49
+ event_payload['name'],
50
+ event_payload['message'],
51
+ event_payload['stack']
52
+ ].join(':')
53
+ return unless @suppression.should_capture(suppression_key, now: monotonic_now)
54
+
55
+ event = enrich_exception(event, retained, causes, fatal: !run_before_send)
56
+ return unless enqueue_event(event, skip_before_send: !run_before_send)
57
+
58
+ @exception_identity.admit?(error, scope, automatic, accepted: true)
59
+ end
60
+ end
61
+
62
+ include ExceptionSupport
63
+ end
64
+ 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