debugbundle 1.2.0 → 1.4.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/LICENSE +202 -0
- data/README.md +2 -2
- data/debugbundle.gemspec +2 -2
- data/lib/debugbundle/acknowledgement.rb +59 -0
- data/lib/debugbundle/before_send.rb +172 -0
- data/lib/debugbundle/client.rb +99 -225
- data/lib/debugbundle/client_event_support.rb +247 -0
- data/lib/debugbundle/config.rb +5 -2
- data/lib/debugbundle/rails.rb +4 -0
- data/lib/debugbundle/transport.rb +13 -3
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +2 -0
- data/spec/before_send_spec.rb +74 -0
- data/spec/client_spec.rb +196 -0
- data/spec/remote_config_spec.rb +63 -385
- data/spec/spec_helper.rb +4 -1
- metadata +8 -3
data/lib/debugbundle/config.rb
CHANGED
|
@@ -39,7 +39,8 @@ module DebugBundle
|
|
|
39
39
|
:max_probe_labels,
|
|
40
40
|
:max_probe_entries_per_label,
|
|
41
41
|
:probe_flush_on_error,
|
|
42
|
-
:probes_poll_interval
|
|
42
|
+
:probes_poll_interval,
|
|
43
|
+
:before_send
|
|
43
44
|
|
|
44
45
|
def initialize(
|
|
45
46
|
project_token: nil,
|
|
@@ -61,7 +62,8 @@ module DebugBundle
|
|
|
61
62
|
max_probe_labels: DEFAULT_MAX_PROBE_LABELS,
|
|
62
63
|
max_probe_entries_per_label: DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL,
|
|
63
64
|
probe_flush_on_error: DEFAULT_PROBE_FLUSH_ON_ERROR,
|
|
64
|
-
probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL
|
|
65
|
+
probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL,
|
|
66
|
+
before_send: nil
|
|
65
67
|
)
|
|
66
68
|
@project_token = project_token
|
|
67
69
|
@enabled = enabled
|
|
@@ -89,6 +91,7 @@ module DebugBundle
|
|
|
89
91
|
)
|
|
90
92
|
@probe_flush_on_error = probe_flush_on_error
|
|
91
93
|
@probes_poll_interval = normalize_positive_number(probes_poll_interval, DEFAULT_PROBES_POLL_INTERVAL)
|
|
94
|
+
@before_send = before_send.respond_to?(:call) ? before_send : nil
|
|
92
95
|
freeze
|
|
93
96
|
end
|
|
94
97
|
|
data/lib/debugbundle/rails.rb
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
begin
|
|
4
4
|
require 'rails/railtie'
|
|
5
|
+
# The gem is intentionally usable without Rails; this branch is exercised by
|
|
6
|
+
# the clean non-Rails gem smoke rather than the Rails-enabled coverage process.
|
|
7
|
+
# :nocov:
|
|
5
8
|
rescue LoadError
|
|
6
9
|
nil
|
|
7
10
|
end
|
|
11
|
+
# :nocov:
|
|
8
12
|
|
|
9
13
|
require_relative 'rails/relay_endpoint'
|
|
10
14
|
require_relative 'rails/railtie' if defined?(Rails::Railtie)
|
|
@@ -10,7 +10,7 @@ module DebugBundle
|
|
|
10
10
|
module Transport
|
|
11
11
|
RETRY_AFTER_CAP_SECONDS = 300
|
|
12
12
|
|
|
13
|
-
Result = Struct.new(:status_code, :retry_after_seconds, keyword_init: true)
|
|
13
|
+
Result = Struct.new(:status_code, :retry_after_seconds, :body, keyword_init: true)
|
|
14
14
|
|
|
15
15
|
def self.sdk_config_endpoint(events_endpoint)
|
|
16
16
|
uri = URI.parse(events_endpoint)
|
|
@@ -36,7 +36,8 @@ module DebugBundle
|
|
|
36
36
|
|
|
37
37
|
Result.new(
|
|
38
38
|
status_code: result.status_code.to_i,
|
|
39
|
-
retry_after_seconds: result.respond_to?(:retry_after_seconds) ? result.retry_after_seconds : nil
|
|
39
|
+
retry_after_seconds: result.respond_to?(:retry_after_seconds) ? result.retry_after_seconds : nil,
|
|
40
|
+
body: result.respond_to?(:body) ? result.body : nil
|
|
40
41
|
)
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -62,7 +63,8 @@ module DebugBundle
|
|
|
62
63
|
|
|
63
64
|
Result.new(
|
|
64
65
|
status_code: response.code.to_i,
|
|
65
|
-
retry_after_seconds: parse_retry_after(response['Retry-After'])
|
|
66
|
+
retry_after_seconds: parse_retry_after(response['Retry-After']),
|
|
67
|
+
body: parse_body(response.body)
|
|
66
68
|
)
|
|
67
69
|
rescue StandardError
|
|
68
70
|
Result.new(status_code: 500)
|
|
@@ -70,6 +72,14 @@ module DebugBundle
|
|
|
70
72
|
|
|
71
73
|
private
|
|
72
74
|
|
|
75
|
+
def parse_body(body)
|
|
76
|
+
return nil if body.to_s.empty?
|
|
77
|
+
|
|
78
|
+
JSON.parse(body)
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
body
|
|
81
|
+
end
|
|
82
|
+
|
|
73
83
|
def parse_retry_after(value)
|
|
74
84
|
return nil if value.nil? || value.strip.empty?
|
|
75
85
|
|
data/lib/debugbundle/version.rb
CHANGED
data/lib/debugbundle.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'debugbundle/redaction'
|
|
4
|
+
require_relative 'debugbundle/acknowledgement'
|
|
5
|
+
require_relative 'debugbundle/before_send'
|
|
4
6
|
require_relative 'debugbundle/rack/middleware'
|
|
5
7
|
require_relative 'debugbundle/logging'
|
|
6
8
|
require_relative 'debugbundle/remote_config'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe DebugBundle::BeforeSend do
|
|
6
|
+
let(:base_event) do
|
|
7
|
+
{
|
|
8
|
+
'schema_version' => '2026-03-01',
|
|
9
|
+
'event_id' => '11111111-1111-4111-8111-111111111111',
|
|
10
|
+
'event_type' => 'log_event',
|
|
11
|
+
'occurred_at' => '2026-07-27T08:00:00Z',
|
|
12
|
+
'sdk_name' => '@debugbundle/sdk-ruby',
|
|
13
|
+
'sdk_version' => '1.3.0',
|
|
14
|
+
'service' => { 'name' => 'ruby-test', 'environment' => 'test' },
|
|
15
|
+
'payload' => { 'level' => 'error', 'message' => 'failure', 'attributes' => {} }
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'validates every closed canonical payload variant' do
|
|
20
|
+
payloads = {
|
|
21
|
+
'backend_exception' => {
|
|
22
|
+
'name' => 'RuntimeError', 'message' => 'failure', 'stack' => 'stack', 'handled' => true,
|
|
23
|
+
'request' => {}, 'response' => {}, 'runtime' => {}, 'probe_data' => {}
|
|
24
|
+
},
|
|
25
|
+
'request_event' => {
|
|
26
|
+
'method' => 'GET', 'path' => '/', 'query' => {}, 'headers' => {},
|
|
27
|
+
'response_status' => 503, 'duration_ms' => 10, 'response_headers' => {}
|
|
28
|
+
},
|
|
29
|
+
'frontend_breadcrumb' => { 'breadcrumb_type' => 'navigation', 'data' => {} },
|
|
30
|
+
'frontend_exception' => {
|
|
31
|
+
'name' => 'Error', 'message' => 'failure', 'stack' => 'stack',
|
|
32
|
+
'breadcrumbs' => [], 'probe_data' => {}
|
|
33
|
+
},
|
|
34
|
+
'deploy_metadata' => {
|
|
35
|
+
'commit_sha' => 'abc', 'version' => '1', 'branch' => 'main',
|
|
36
|
+
'environment' => 'test', 'deployed_at' => '2026-07-27T08:00:00Z'
|
|
37
|
+
},
|
|
38
|
+
'error_suppressed' => {
|
|
39
|
+
'fingerprint' => 'fp', 'suppressed_count' => 2, 'window_seconds' => 60,
|
|
40
|
+
'first_seen' => '2026-07-27T08:00:00Z', 'last_seen' => '2026-07-27T08:01:00Z'
|
|
41
|
+
},
|
|
42
|
+
'probe_event' => {
|
|
43
|
+
'label' => 'cart.total', 'data' => { 'value' => 2 },
|
|
44
|
+
'activation_id' => nil, 'probe_label_pattern' => 'cart.*'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
payloads.each do |event_type, payload|
|
|
49
|
+
event = base_event.merge('event_type' => event_type, 'payload' => payload)
|
|
50
|
+
expect(described_class.valid?(event)).to be_truthy, event_type
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'rejects malformed roots, timestamps, payload fields, and typed values' do
|
|
55
|
+
expect(described_class.valid?([])).to be(false)
|
|
56
|
+
expect(described_class.valid?(base_event.merge('unexpected' => true))).to be(false)
|
|
57
|
+
expect(described_class.valid?(base_event.merge('event_id' => 'not-a-uuid'))).to be(false)
|
|
58
|
+
expect(described_class.valid?(base_event.merge('occurred_at' => 'not-a-time'))).to be(false)
|
|
59
|
+
expect(
|
|
60
|
+
described_class.valid?(
|
|
61
|
+
base_event.merge('payload' => base_event['payload'].merge('unexpected' => true))
|
|
62
|
+
)
|
|
63
|
+
).to be(false)
|
|
64
|
+
expect(described_class.valid?(base_event.merge('event_type' => 'unknown'))).to be_falsey
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'covers strict scalar helpers used by request, suppression, and probe validation' do
|
|
68
|
+
expect(described_class.non_negative_number?(Float::INFINITY)).to be(false)
|
|
69
|
+
expect(described_class.non_negative_integer?(-1)).to be(false)
|
|
70
|
+
expect(described_class.positive_integer?(0)).to be(false)
|
|
71
|
+
expect(described_class.timestamp?('invalid')).to be(false)
|
|
72
|
+
expect(described_class.nullable_uuid?('invalid')).to be(false)
|
|
73
|
+
end
|
|
74
|
+
end
|
data/spec/client_spec.rb
CHANGED
|
@@ -117,6 +117,104 @@ RSpec.describe DebugBundle::Client do
|
|
|
117
117
|
expect(flushed_events.count { |event| event.fetch('event_type') == 'error_suppressed' }).to eq(1)
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
it 'wraps list, scalar, and nil probe data before exception attachment' do
|
|
121
|
+
client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
|
|
122
|
+
client.probe('list', %w[first second])
|
|
123
|
+
client.probe('scalar', 42)
|
|
124
|
+
client.probe('nil', nil)
|
|
125
|
+
client.capture_exception(RuntimeError.new('boom'))
|
|
126
|
+
|
|
127
|
+
client.flush
|
|
128
|
+
|
|
129
|
+
exception = transport_events.fetch(0).fetch(:events).find do |event|
|
|
130
|
+
event.fetch('event_type') == 'backend_exception'
|
|
131
|
+
end
|
|
132
|
+
items = exception.fetch('payload').fetch('probe_data').fetch('items')
|
|
133
|
+
expect(items.map { |item| item.fetch('data') }).to eq(
|
|
134
|
+
[
|
|
135
|
+
{ 'value' => %w[first second] },
|
|
136
|
+
{ 'value' => 42 },
|
|
137
|
+
{ 'value' => nil }
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'swallows probe callback failures' do
|
|
143
|
+
client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
|
|
144
|
+
|
|
145
|
+
expect { client.probe('unsafe') { raise 'callback failed' } }.not_to raise_error
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'runs before_send after redaction and mutates before queueing' do
|
|
149
|
+
observed_passwords = []
|
|
150
|
+
client = described_class.new(
|
|
151
|
+
project_token: 'dbundle_proj_test',
|
|
152
|
+
transport: transport,
|
|
153
|
+
before_send: lambda do |event|
|
|
154
|
+
observed_passwords << event.fetch('context').fetch('password')
|
|
155
|
+
event.fetch('payload')['message'] = 'mutated'
|
|
156
|
+
event
|
|
157
|
+
end
|
|
158
|
+
)
|
|
159
|
+
client.capture_message('original', level: :error, context: { password: 'secret' })
|
|
160
|
+
|
|
161
|
+
client.flush
|
|
162
|
+
|
|
163
|
+
expect(observed_passwords).to eq(['[REDACTED]'])
|
|
164
|
+
expect(transport_events.fetch(0).fetch(:events).fetch(0).dig('payload', 'message')).to eq('mutated')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'handles before_send drop, invalid return, failure, and sampling safely' do
|
|
168
|
+
calls = 0
|
|
169
|
+
dropping_client = described_class.new(
|
|
170
|
+
project_token: 'dbundle_proj_test',
|
|
171
|
+
transport: transport,
|
|
172
|
+
before_send: lambda do |_event|
|
|
173
|
+
calls += 1
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
)
|
|
177
|
+
dropping_client.capture_message('drop', level: :error)
|
|
178
|
+
dropping_client.flush
|
|
179
|
+
expect(calls).to eq(1)
|
|
180
|
+
expect(transport_events).to be_empty
|
|
181
|
+
|
|
182
|
+
invalid_client = described_class.new(
|
|
183
|
+
project_token: 'dbundle_proj_test',
|
|
184
|
+
transport: transport,
|
|
185
|
+
before_send: ->(_event) { { 'invalid' => true } }
|
|
186
|
+
)
|
|
187
|
+
invalid_client.capture_message('preserve invalid', level: :error)
|
|
188
|
+
invalid_client.flush
|
|
189
|
+
|
|
190
|
+
failing_client = described_class.new(
|
|
191
|
+
project_token: 'dbundle_proj_test',
|
|
192
|
+
transport: transport,
|
|
193
|
+
before_send: ->(_event) { raise 'hook failed' }
|
|
194
|
+
)
|
|
195
|
+
failing_client.capture_message('preserve failure', level: :error)
|
|
196
|
+
failing_client.flush
|
|
197
|
+
|
|
198
|
+
expect(transport_events.map { |request| request.fetch(:events).fetch(0).dig('payload', 'message') }).to eq(
|
|
199
|
+
['preserve invalid', 'preserve failure']
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
sampled_calls = 0
|
|
203
|
+
sampled_client = described_class.new(
|
|
204
|
+
project_token: 'dbundle_proj_test',
|
|
205
|
+
transport: transport,
|
|
206
|
+
sample_rate: 0,
|
|
207
|
+
before_send: lambda do |event|
|
|
208
|
+
sampled_calls += 1
|
|
209
|
+
event
|
|
210
|
+
end
|
|
211
|
+
)
|
|
212
|
+
sampled_client.capture_message('sampled out', level: :error)
|
|
213
|
+
sampled_client.flush
|
|
214
|
+
expect(sampled_calls).to eq(1)
|
|
215
|
+
expect(transport_events.length).to eq(2)
|
|
216
|
+
end
|
|
217
|
+
|
|
120
218
|
it 'backs off after 429 responses without dropping buffered events' do
|
|
121
219
|
retry_transport = Class.new do
|
|
122
220
|
def initialize
|
|
@@ -147,6 +245,104 @@ RSpec.describe DebugBundle::Client do
|
|
|
147
245
|
expect(client.buffered_event_count).to eq(0)
|
|
148
246
|
end
|
|
149
247
|
|
|
248
|
+
it 'retries only the indexed retryable rejection from an acknowledgement' do
|
|
249
|
+
requests = []
|
|
250
|
+
responses = [
|
|
251
|
+
DebugBundle::Transport::Result.new(
|
|
252
|
+
status_code: 202,
|
|
253
|
+
body: {
|
|
254
|
+
'accepted' => 1,
|
|
255
|
+
'rejected' => 1,
|
|
256
|
+
'errors' => [{ 'index' => 1, 'reason' => 'rate_limited' }]
|
|
257
|
+
}
|
|
258
|
+
),
|
|
259
|
+
DebugBundle::Transport::Result.new(
|
|
260
|
+
status_code: 202,
|
|
261
|
+
body: { 'accepted' => 1, 'rejected' => 0, 'errors' => [] }
|
|
262
|
+
)
|
|
263
|
+
]
|
|
264
|
+
acknowledgement_transport = lambda do |request|
|
|
265
|
+
requests << request
|
|
266
|
+
responses.shift
|
|
267
|
+
end
|
|
268
|
+
current_time = Time.utc(2026, 5, 23, 12, 0, 0)
|
|
269
|
+
client = described_class.new(
|
|
270
|
+
project_token: 'dbundle_proj_test',
|
|
271
|
+
transport: acknowledgement_transport,
|
|
272
|
+
time_provider: -> { current_time }
|
|
273
|
+
)
|
|
274
|
+
client.capture_log('accepted', level: :warning)
|
|
275
|
+
client.capture_log('retry', level: :warning)
|
|
276
|
+
|
|
277
|
+
expect(client.flush).to be(false)
|
|
278
|
+
expect(client.buffered_event_count).to eq(1)
|
|
279
|
+
expect(client.last_event_at).not_to be_nil
|
|
280
|
+
expect(client.status).to eq(:degraded)
|
|
281
|
+
|
|
282
|
+
current_time += 2
|
|
283
|
+
expect(client.flush).to be(true)
|
|
284
|
+
expect(requests.fetch(1).fetch(:events).map { |event| event.dig('payload', 'message') }).to eq(['retry'])
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it 'removes terminal rejections without reporting delivery success' do
|
|
288
|
+
calls = 0
|
|
289
|
+
acknowledgement_transport = lambda do |_request|
|
|
290
|
+
calls += 1
|
|
291
|
+
DebugBundle::Transport::Result.new(
|
|
292
|
+
status_code: 202,
|
|
293
|
+
body: {
|
|
294
|
+
'accepted' => 0,
|
|
295
|
+
'rejected' => 1,
|
|
296
|
+
'errors' => [{ 'index' => 0, 'reason' => 'capture_policy_rejected' }]
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
client = described_class.new(project_token: 'dbundle_proj_test', transport: acknowledgement_transport)
|
|
301
|
+
client.capture_log('terminal', level: :warning)
|
|
302
|
+
|
|
303
|
+
expect(client.flush).to be(false)
|
|
304
|
+
expect(client.buffered_event_count).to eq(0)
|
|
305
|
+
expect(client.last_event_at).to be_nil
|
|
306
|
+
expect(client.status).to eq(:disconnected)
|
|
307
|
+
expect(client.flush).to be(true)
|
|
308
|
+
expect(calls).to eq(1)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it 'retains the full batch after an inconsistent acknowledgement' do
|
|
312
|
+
requests = []
|
|
313
|
+
responses = [
|
|
314
|
+
DebugBundle::Transport::Result.new(
|
|
315
|
+
status_code: 202,
|
|
316
|
+
body: { 'accepted' => 1, 'rejected' => 0, 'errors' => [] }
|
|
317
|
+
),
|
|
318
|
+
DebugBundle::Transport::Result.new(
|
|
319
|
+
status_code: 202,
|
|
320
|
+
body: { 'accepted' => 2, 'rejected' => 0, 'errors' => [] }
|
|
321
|
+
)
|
|
322
|
+
]
|
|
323
|
+
acknowledgement_transport = lambda do |request|
|
|
324
|
+
requests << request
|
|
325
|
+
responses.shift
|
|
326
|
+
end
|
|
327
|
+
current_time = Time.utc(2026, 5, 23, 12, 0, 0)
|
|
328
|
+
client = described_class.new(
|
|
329
|
+
project_token: 'dbundle_proj_test',
|
|
330
|
+
transport: acknowledgement_transport,
|
|
331
|
+
time_provider: -> { current_time }
|
|
332
|
+
)
|
|
333
|
+
client.capture_log('first', level: :warning)
|
|
334
|
+
client.capture_log('second', level: :warning)
|
|
335
|
+
|
|
336
|
+
expect(client.flush).to be(false)
|
|
337
|
+
expect(client.buffered_event_count).to eq(2)
|
|
338
|
+
expect(client.last_event_at).to be_nil
|
|
339
|
+
expect(client.status).to eq(:degraded)
|
|
340
|
+
|
|
341
|
+
current_time += 2
|
|
342
|
+
expect(client.flush).to be(true)
|
|
343
|
+
expect(requests.fetch(1).fetch(:events).length).to eq(2)
|
|
344
|
+
end
|
|
345
|
+
|
|
150
346
|
it 'defaults development captures to secure local event files' do
|
|
151
347
|
Dir.mktmpdir do |directory|
|
|
152
348
|
client = described_class.new(project_token: 'dbundle_proj_test', local_events_dir: directory)
|