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.
- checksums.yaml +4 -4
- data/README.md +133 -0
- data/lib/debugbundle/acknowledgement.rb +6 -2
- data/lib/debugbundle/before_send.rb +58 -30
- data/lib/debugbundle/bounded_http_response.rb +24 -0
- data/lib/debugbundle/canonical_event_schema.rb +137 -0
- data/lib/debugbundle/client.rb +55 -75
- data/lib/debugbundle/client_event_support.rb +11 -12
- data/lib/debugbundle/client_evidence_support.rb +169 -0
- data/lib/debugbundle/client_exception_support.rb +54 -0
- data/lib/debugbundle/client_process_support.rb +5 -12
- data/lib/debugbundle/client_queue_support.rb +59 -4
- data/lib/debugbundle/config.rb +15 -2
- data/lib/debugbundle/diagnostic_budget.rb +155 -0
- data/lib/debugbundle/event-envelope.schema.json +4904 -0
- data/lib/debugbundle/event_batch.rb +48 -0
- data/lib/debugbundle/evidence_delivery.rb +50 -0
- data/lib/debugbundle/evidence_graph.rb +73 -0
- data/lib/debugbundle/evidence_json.rb +55 -0
- data/lib/debugbundle/evidence_resources.rb +60 -0
- data/lib/debugbundle/evidence_value.rb +107 -0
- data/lib/debugbundle/evidence_wire.rb +193 -0
- data/lib/debugbundle/exception_evidence.rb +179 -0
- data/lib/debugbundle/framework_operation.rb +13 -0
- data/lib/debugbundle/operation_context.rb +90 -0
- data/lib/debugbundle/rack/middleware.rb +24 -21
- data/lib/debugbundle/rack/relay_middleware.rb +4 -1
- data/lib/debugbundle/rails/railtie.rb +4 -1
- data/lib/debugbundle/rails/relay_endpoint.rb +2 -1
- data/lib/debugbundle/relay/forwarder.rb +83 -0
- data/lib/debugbundle/relay/handler.rb +83 -19
- data/lib/debugbundle/remote_config.rb +5 -0
- data/lib/debugbundle/sidekiq/server_middleware.rb +22 -18
- data/lib/debugbundle/telemetry_privacy.rb +9 -1
- data/lib/debugbundle/transport.rb +49 -19
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +11 -1
- metadata +19 -2
data/lib/debugbundle/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
440
|
+
finalized = cached || (bypass || !config.before_send ? event : apply_before_send(event))
|
|
441
|
+
return nil if finalized.nil? || @closed
|
|
476
442
|
|
|
477
|
-
finalized =
|
|
478
|
-
return 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
|
-
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
146
|
-
@event_sizes[event['event_id']] +=
|
|
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
|