debugbundle 2.0.1 → 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 +127 -0
- 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 +41 -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 +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 +43 -17
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +11 -1
- metadata +19 -2
|
@@ -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
|
data/lib/debugbundle/config.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'debugbundle/evidence_wire'
|
|
4
|
+
|
|
3
5
|
module DebugBundle
|
|
4
6
|
class Config
|
|
5
7
|
DEFAULT_ENDPOINT = 'https://api.debugbundle.com/v1/events'
|
|
@@ -40,7 +42,10 @@ module DebugBundle
|
|
|
40
42
|
:max_probe_entries_per_label,
|
|
41
43
|
:probe_flush_on_error,
|
|
42
44
|
:probes_poll_interval,
|
|
43
|
-
:before_send
|
|
45
|
+
:before_send,
|
|
46
|
+
:capture_exception_evidence,
|
|
47
|
+
:capture_recovery_observations,
|
|
48
|
+
:evidence_capabilities
|
|
44
49
|
|
|
45
50
|
def initialize(
|
|
46
51
|
project_token: nil,
|
|
@@ -63,7 +68,10 @@ module DebugBundle
|
|
|
63
68
|
max_probe_entries_per_label: DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL,
|
|
64
69
|
probe_flush_on_error: DEFAULT_PROBE_FLUSH_ON_ERROR,
|
|
65
70
|
probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL,
|
|
66
|
-
before_send: nil
|
|
71
|
+
before_send: nil,
|
|
72
|
+
capture_exception_evidence: true,
|
|
73
|
+
capture_recovery_observations: false,
|
|
74
|
+
evidence_capabilities: nil
|
|
67
75
|
)
|
|
68
76
|
@project_token = project_token
|
|
69
77
|
@enabled = enabled
|
|
@@ -92,6 +100,11 @@ module DebugBundle
|
|
|
92
100
|
@probe_flush_on_error = probe_flush_on_error
|
|
93
101
|
@probes_poll_interval = normalize_positive_number(probes_poll_interval, DEFAULT_PROBES_POLL_INTERVAL)
|
|
94
102
|
@before_send = before_send.respond_to?(:call) ? before_send : nil
|
|
103
|
+
@capture_exception_evidence = !false.equal?(capture_exception_evidence)
|
|
104
|
+
@capture_recovery_observations = true.equal?(capture_recovery_observations)
|
|
105
|
+
@evidence_capabilities = EvidenceWire.descriptor(EvidenceValue.options(evidence_capabilities))
|
|
106
|
+
@evidence_capabilities['features'].freeze
|
|
107
|
+
@evidence_capabilities.freeze
|
|
95
108
|
freeze
|
|
96
109
|
end
|
|
97
110
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module DebugBundle
|
|
6
|
+
# Caller holds the destination's buffer mutex. Metadata and pending serialized
|
|
7
|
+
# ownership share a 64 KiB budget; ACK/retry cannot renew an expired window.
|
|
8
|
+
class DiagnosticBudget
|
|
9
|
+
def initialize
|
|
10
|
+
@windows = {}
|
|
11
|
+
@pending = {}
|
|
12
|
+
@held = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def accounted_bytes = @held + @windows.values.sum { |window| window[:cost] }
|
|
16
|
+
|
|
17
|
+
def key(payload)
|
|
18
|
+
scope = payload.fetch('scope')
|
|
19
|
+
JSON.generate([payload.fetch('failure_ref'), scope.fetch('kind'), scope['operation_id'] || scope['page_id']])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def metadata_cost(scope, window) = JSON.generate([scope, window.except(:cost)]).bytesize
|
|
23
|
+
|
|
24
|
+
def prune(now)
|
|
25
|
+
@windows.delete_if { |_key, window| window[:pending].zero? && now >= window[:starts] + 300 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def window(now)
|
|
29
|
+
{ starts: now, failure_at: nil, failure_id: nil, attempts: [], observations: 0,
|
|
30
|
+
resource: false, ambiguous: false, pending: 0, cost: 0 }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def register_failure(payload, event_id, now)
|
|
34
|
+
return false unless now.is_a?(Numeric) && now.finite?
|
|
35
|
+
|
|
36
|
+
prune(now)
|
|
37
|
+
scope = key(payload)
|
|
38
|
+
previous = @windows[scope]
|
|
39
|
+
if previous && previous[:failure_id] && previous[:failure_id] != event_id
|
|
40
|
+
previous[:ambiguous] = true
|
|
41
|
+
previous[:cost] = metadata_cost(scope, previous)
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
return false if previous.nil? && @windows.length >= 50
|
|
45
|
+
|
|
46
|
+
value = previous ? previous.dup : window(now)
|
|
47
|
+
value[:failure_id] = event_id
|
|
48
|
+
value[:failure_at] ||= now
|
|
49
|
+
store(scope, previous, value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def store(scope, previous, value, extra = 0)
|
|
53
|
+
value[:cost] = metadata_cost(scope, value)
|
|
54
|
+
return false if accounted_bytes - (previous ? previous[:cost] : 0) + value[:cost] + extra > 65_536
|
|
55
|
+
|
|
56
|
+
@windows[scope] = value
|
|
57
|
+
!value[:ambiguous]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reserve(event_id, payload, bytes, now)
|
|
61
|
+
return false unless valid_size?(bytes) && now.is_a?(Numeric) && now.finite? && !@pending.key?(event_id)
|
|
62
|
+
|
|
63
|
+
prune(now)
|
|
64
|
+
scope = key(payload)
|
|
65
|
+
previous = @windows[scope]
|
|
66
|
+
return false if (previous.nil? && @windows.length >= 50) || (previous && previous[:ambiguous])
|
|
67
|
+
|
|
68
|
+
value = previous ? previous.merge(attempts: previous[:attempts].dup) : window(now)
|
|
69
|
+
return false if now < value[:starts] || now > (value[:failure_at] || value[:starts]) + 300
|
|
70
|
+
return false if value[:observations] >= 20 || !admit_kind?(value, payload, now)
|
|
71
|
+
|
|
72
|
+
value[:pending] += 1
|
|
73
|
+
value[:observations] += 1
|
|
74
|
+
cost = bytes + event_id.bytesize + scope.bytesize + 64
|
|
75
|
+
return false unless store(scope, previous, value, cost)
|
|
76
|
+
|
|
77
|
+
@pending[event_id] =
|
|
78
|
+
{ scope: scope, kind: payload['kind'], attempt: payload['attempt_id'], size: bytes, cost: cost }
|
|
79
|
+
@held += cost
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def admit_kind?(value, payload, now)
|
|
84
|
+
if payload['kind'] == 'recovery'
|
|
85
|
+
attempt = payload['attempt_id']
|
|
86
|
+
return false if !value[:attempts].include?(attempt) && value[:attempts].length >= 5
|
|
87
|
+
|
|
88
|
+
value[:attempts] |= [attempt]
|
|
89
|
+
else
|
|
90
|
+
return false if value[:resource] || value[:failure_id] != payload['source_event_id']
|
|
91
|
+
return false unless value[:failure_at] && now <= value[:failure_at] + 30
|
|
92
|
+
|
|
93
|
+
value[:resource] = true
|
|
94
|
+
end
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def replace(original_id, event_id, payload, bytes, now)
|
|
99
|
+
entry = @pending[original_id]
|
|
100
|
+
same_scope = entry && entry[:scope] == key(payload)
|
|
101
|
+
unless same_scope && entry[:kind] == payload['kind'] && entry[:attempt] == payload['attempt_id']
|
|
102
|
+
release([original_id])
|
|
103
|
+
return reserve(event_id, payload, bytes, now)
|
|
104
|
+
end
|
|
105
|
+
if event_id != original_id
|
|
106
|
+
if @pending.key?(event_id)
|
|
107
|
+
release([original_id])
|
|
108
|
+
return false
|
|
109
|
+
end
|
|
110
|
+
@pending[event_id] = @pending.delete(original_id)
|
|
111
|
+
delta = event_id.bytesize - original_id.bytesize
|
|
112
|
+
entry[:cost] += delta
|
|
113
|
+
@held += delta
|
|
114
|
+
end
|
|
115
|
+
if !adjust(event_id, bytes) || !eligible(event_id, payload)
|
|
116
|
+
release([event_id])
|
|
117
|
+
return false
|
|
118
|
+
end
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def adjust(event_id, bytes)
|
|
123
|
+
entry = @pending[event_id]
|
|
124
|
+
return false unless entry && valid_size?(bytes) && accounted_bytes + bytes - entry[:size] <= 65_536
|
|
125
|
+
|
|
126
|
+
@held += bytes - entry[:size]
|
|
127
|
+
entry[:cost] += bytes - entry[:size]
|
|
128
|
+
entry[:size] = bytes
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def eligible(event_id, payload)
|
|
133
|
+
entry = @pending[event_id]
|
|
134
|
+
value = @windows[key(payload)]
|
|
135
|
+
entry && entry[:scope] == key(payload) && value && !value[:ambiguous] &&
|
|
136
|
+
(payload['kind'] == 'recovery' || value[:failure_id] == payload['source_event_id'])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def release(event_ids)
|
|
140
|
+
event_ids.compact.uniq.each do |id|
|
|
141
|
+
entry = @pending.delete(id)
|
|
142
|
+
next unless entry
|
|
143
|
+
|
|
144
|
+
@held -= entry[:cost]
|
|
145
|
+
value = @windows[entry[:scope]]
|
|
146
|
+
next unless value
|
|
147
|
+
|
|
148
|
+
value[:pending] = [value[:pending] - 1, 0].max
|
|
149
|
+
value[:cost] = metadata_cost(entry[:scope], value)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def valid_size?(size) = size.is_a?(Integer) && size.between?(0, 65_536)
|
|
154
|
+
end
|
|
155
|
+
end
|