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.
- checksums.yaml +4 -4
- data/README.md +131 -1
- 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 +50 -67
- 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 +64 -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/exception_identity.rb +42 -0
- data/lib/debugbundle/framework_operation.rb +13 -0
- data/lib/debugbundle/logging.rb +2 -1
- 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 +43 -5
- data/lib/debugbundle/transport.rb +43 -17
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +11 -1
- metadata +20 -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
+
finalized = cached || (bypass || !config.before_send ? event : apply_before_send(event))
|
|
450
|
+
return nil if finalized.nil? || @closed
|
|
476
451
|
|
|
477
|
-
finalized =
|
|
478
|
-
return 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
|
-
|
|
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,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
|
-
|
|
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
|