debugbundle 1.4.0 → 1.5.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.
@@ -1,74 +0,0 @@
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 DELETED
@@ -1,432 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
- require 'json'
5
- require 'tmpdir'
6
-
7
- RSpec.describe DebugBundle::Client do
8
- let(:transport_events) { [] }
9
- let(:transport) do
10
- Class.new do
11
- define_method(:initialize) do |transport_events|
12
- @transport_events = transport_events
13
- end
14
-
15
- define_method(:call) do |request|
16
- @transport_events << request
17
- DebugBundle::Transport::Result.new(status_code: 202)
18
- end
19
- end.new(transport_events)
20
- end
21
-
22
- it 'buffers and flushes canonical events' do
23
- client = described_class.new(
24
- project_token: 'dbundle_proj_test',
25
- service: 'checkout-api',
26
- environment: 'production',
27
- transport: transport
28
- )
29
-
30
- client.set_context(:account_id, 'acct_123')
31
- client.capture_log('payment retry failed', level: :error, context: { trace_id: 'trace-1' })
32
-
33
- expect(client.buffered_event_count).to eq(1)
34
- expect(client.flush).to be(true)
35
- expect(client.last_event_at).not_to be_nil
36
-
37
- payload = transport_events.fetch(0)
38
- event = payload.fetch(:events).fetch(0)
39
-
40
- expect(event.fetch('event_type')).to eq('log_event')
41
- expect(event.fetch('schema_version')).to eq('2026-03-01')
42
- expect(event.fetch('sdk_name')).to eq('@debugbundle/sdk-ruby')
43
- expect(event.fetch('service')).to include('name' => 'checkout-api', 'environment' => 'production')
44
- expect(event.fetch('correlation')).to include('trace_id' => 'trace-1')
45
- expect(event.fetch('payload')).to include('level' => 'error', 'message' => 'payment retry failed')
46
- end
47
-
48
- it 'captures explicit exception blocks and re-raises' do
49
- client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
50
-
51
- expect do
52
- client.with_exception_capture(context: { request_id: 'req-1' }) do
53
- raise ArgumentError, 'invalid checkout'
54
- end
55
- end.to raise_error(ArgumentError, 'invalid checkout')
56
-
57
- client.flush
58
- event = transport_events.fetch(0).fetch(:events).fetch(0)
59
- expect(event.fetch('event_type')).to eq('backend_exception')
60
- expect(event.fetch('payload')).to include('handled' => false)
61
- expect(event.fetch('correlation')).to include('request_id' => 'req-1')
62
- end
63
-
64
- it 'registers at-exit capture once and flushes shutdown exceptions' do
65
- client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
66
- registered_callback = nil
67
-
68
- allow(client).to receive(:at_exit) do |&block|
69
- registered_callback = block
70
- end
71
-
72
- expect(client.capture_exceptions).to be(true)
73
- expect(client.capture_at_exit).to be(false)
74
- expect(registered_callback).not_to be_nil
75
-
76
- begin
77
- raise 'shutdown boom'
78
- rescue RuntimeError
79
- registered_callback.call
80
- end
81
-
82
- event = transport_events.fetch(0).fetch(:events).fetch(0)
83
- expect(event.fetch('event_type')).to eq('backend_exception')
84
- expect(event.fetch('payload')).to include('handled' => false, 'message' => 'shutdown boom')
85
- end
86
-
87
- it 'captures unhandled thread exceptions after hooks are armed' do
88
- client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
89
-
90
- allow(client).to receive(:at_exit)
91
-
92
- expect(client.capture_exceptions).to be(true)
93
-
94
- thread = Thread.new do
95
- raise 'thread boom'
96
- end
97
-
98
- expect { thread.value }.to raise_error(RuntimeError, 'thread boom')
99
-
100
- event = transport_events.fetch(0).fetch(:events).fetch(0)
101
- expect(event.fetch('event_type')).to eq('backend_exception')
102
- expect(event.fetch('payload')).to include('handled' => false, 'message' => 'thread boom')
103
- end
104
-
105
- it 'suppresses duplicate exceptions and emits an aggregate' do
106
- client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
107
- error = RuntimeError.new('boom')
108
- error.set_backtrace(['app/models/checkout.rb:10'])
109
-
110
- 5.times { client.capture_exception(error) }
111
-
112
- expect(client.buffered_event_count).to eq(3)
113
- client.flush
114
-
115
- flushed_events = transport_events.fetch(0).fetch(:events)
116
- expect(flushed_events.count { |event| event.fetch('event_type') == 'backend_exception' }).to eq(3)
117
- expect(flushed_events.count { |event| event.fetch('event_type') == 'error_suppressed' }).to eq(1)
118
- end
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
-
218
- it 'backs off after 429 responses without dropping buffered events' do
219
- retry_transport = Class.new do
220
- def initialize
221
- @calls = 0
222
- end
223
-
224
- def call(_request)
225
- @calls += 1
226
- return DebugBundle::Transport::Result.new(status_code: 429, retry_after_seconds: 1) if @calls == 1
227
-
228
- DebugBundle::Transport::Result.new(status_code: 202)
229
- end
230
- end.new
231
-
232
- current_time = Time.utc(2026, 5, 23, 12, 0, 0)
233
- time_provider = -> { current_time }
234
-
235
- client = described_class.new(project_token: 'dbundle_proj_test', transport: retry_transport,
236
- time_provider: time_provider)
237
- client.capture_log('worker started', level: :warning)
238
-
239
- expect(client.flush).to be(false)
240
- expect(client.buffered_event_count).to eq(1)
241
- expect(client.status).to eq(:degraded)
242
-
243
- current_time += 2
244
- expect(client.flush).to be(true)
245
- expect(client.buffered_event_count).to eq(0)
246
- end
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
-
346
- it 'defaults development captures to secure local event files' do
347
- Dir.mktmpdir do |directory|
348
- client = described_class.new(project_token: 'dbundle_proj_test', local_events_dir: directory)
349
-
350
- client.capture_log('local event', level: :error)
351
- expect(client.flush).to be(true)
352
-
353
- file_name = Dir.children(directory).find { |entry| entry.end_with?('.events.json') }
354
- event = JSON.parse(File.read(File.join(directory, file_name))).fetch(0)
355
-
356
- expect(event.fetch('event_type')).to eq('log_event')
357
- expect(event.fetch('payload')).to include('message' => 'local event')
358
- end
359
- end
360
-
361
- it 'does not raise for invalid project mode configuration' do
362
- expect do
363
- described_class.new(project_token: 'dbundle_proj_test', project_mode: :surprising, transport: transport)
364
- end.not_to raise_error
365
- end
366
-
367
- it 'applies sample rate before buffering events' do
368
- client = described_class.new(
369
- project_token: 'dbundle_proj_test',
370
- transport: transport,
371
- sample_rate: 0.25,
372
- random_provider: -> { 0.9 }
373
- )
374
-
375
- client.capture_log('sampled out', level: :error)
376
-
377
- expect(client.buffered_event_count).to eq(0)
378
- end
379
-
380
- it 'does not include local OS account names in runtime facts' do
381
- client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
382
-
383
- client.capture_exception(RuntimeError.new('privacy check'))
384
- client.flush
385
-
386
- runtime = transport_events.fetch(0).fetch(:events).fetch(0).fetch('payload').fetch('runtime')
387
- expect(runtime).not_to have_key('user')
388
- end
389
-
390
- it 'builds a default remote config fetcher for connected remote environments' do
391
- fetcher_instances = []
392
- fetcher_class = Class.new do
393
- define_method(:initialize) do |endpoint, project_token:, sdk_name:, sdk_version:|
394
- fetcher_instances << {
395
- endpoint: endpoint,
396
- project_token: project_token,
397
- sdk_name: sdk_name,
398
- sdk_version: sdk_version
399
- }
400
- end
401
-
402
- define_method(:call) do |_etag|
403
- {
404
- status_code: 200,
405
- etag: 'cfg-default',
406
- body: {
407
- capture_policy: {
408
- preset: 'minimal',
409
- capture_logs: 'error',
410
- capture_request_events: 'failures_only',
411
- capture_breadcrumbs: 'local_only',
412
- capture_probe_events: 'buffer_only',
413
- immediate_client_error_statuses: []
414
- }
415
- }
416
- }
417
- end
418
- end
419
-
420
- stub_const('DebugBundle::Transport::HttpConfigFetcher', fetcher_class)
421
-
422
- client = described_class.new(project_token: 'dbundle_proj_test', environment: 'production')
423
- client.capture_log('warning dropped by default fetched policy', level: :warning)
424
-
425
- expect(fetcher_instances.fetch(0)).to include(
426
- endpoint: DebugBundle::Config::DEFAULT_ENDPOINT,
427
- project_token: 'dbundle_proj_test',
428
- sdk_name: '@debugbundle/sdk-ruby'
429
- )
430
- expect(client.buffered_event_count).to eq(0)
431
- end
432
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe DebugBundle do
6
- before do
7
- described_class.client = DebugBundle::Client.new
8
- end
9
-
10
- it 'exposes the universal singleton interface' do
11
- client = described_class.init(project_token: 'dbundle_proj_test', service: 'checkout-api')
12
-
13
- expect(client).to be_a(DebugBundle::Client)
14
- expect(described_class).to respond_to(:init)
15
- expect(described_class).to respond_to(:capture_exception)
16
- expect(described_class).to respond_to(:capture_error)
17
- expect(described_class).to respond_to(:capture_log)
18
- expect(described_class).to respond_to(:capture_request)
19
- expect(described_class).to respond_to(:capture_message)
20
- expect(described_class).to respond_to(:set_context)
21
- expect(described_class).to respond_to(:probe)
22
- expect(described_class).to respond_to(:flush)
23
- expect(described_class).to respond_to(:status)
24
- expect(described_class).to respond_to(:last_event_at)
25
- end
26
-
27
- it 'arms exception capture when initialized' do
28
- client = instance_double(DebugBundle::Client)
29
- allow(DebugBundle::Client).to receive(:new).and_return(client)
30
- allow(client).to receive(:capture_exceptions).and_return(true)
31
-
32
- expect(client).to receive(:capture_exceptions)
33
- expect(described_class.init(project_token: 'dbundle_proj_test')).to be(client)
34
- end
35
-
36
- it 'reports a degraded status without a configured project token' do
37
- described_class.client = DebugBundle::Client.new
38
-
39
- expect(described_class.status).to eq(:degraded)
40
- end
41
-
42
- it 'records the last event timestamp on flush' do
43
- transport = Class.new do
44
- def call(_request)
45
- DebugBundle::Transport::Result.new(status_code: 202)
46
- end
47
- end.new
48
-
49
- described_class.client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport)
50
- described_class.capture_log('worker started', level: :warning)
51
-
52
- expect { described_class.flush }.to change(described_class, :last_event_at).from(nil)
53
- end
54
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
- require 'json'
5
- require 'tmpdir'
6
-
7
- RSpec.describe DebugBundle::Transport::FileTransport do
8
- it 'derives the remote SDK config endpoint from the events endpoint' do
9
- expect(DebugBundle::Transport.sdk_config_endpoint('https://api.debugbundle.com/v1/events')).to eq(
10
- 'https://api.debugbundle.com/v1/sdk/config'
11
- )
12
- expect(DebugBundle::Transport.sdk_config_endpoint('https://api.debugbundle.com/events')).to eq(
13
- 'https://api.debugbundle.com/sdk/config'
14
- )
15
- expect(DebugBundle::Transport.sdk_config_endpoint('https://api.debugbundle.com/v1/sdk/config')).to eq(
16
- 'https://api.debugbundle.com/v1/sdk/config'
17
- )
18
- end
19
-
20
- it 'writes local event batches with secure permissions' do
21
- Dir.mktmpdir do |directory|
22
- transport = described_class.new(directory)
23
- result = transport.call(
24
- project_token: 'dbundle_proj_test',
25
- service_name: 'checkout-api',
26
- events: [{ 'event_type' => 'log_event', 'payload' => { 'message' => 'ok' } }]
27
- )
28
-
29
- expect(result.status_code).to eq(202)
30
-
31
- file_path = Dir.children(directory).find { |name| name.end_with?('.events.json') }
32
- expect(file_path).not_to be_nil
33
-
34
- full_path = File.join(directory, file_path)
35
- parsed = JSON.parse(File.read(full_path))
36
- expect(parsed.length).to eq(1)
37
- expect(parsed.fetch(0)).to include('event_type' => 'log_event')
38
- expect(File.stat(full_path).mode & 0o777).to eq(0o600)
39
- expect(File.stat(directory).mode & 0o777).to eq(0o700)
40
- end
41
- end
42
-
43
- it 'rejects traversal-style local event directories without raising into the caller' do
44
- transport = described_class.new('../debugbundle-events')
45
-
46
- result = transport.call(
47
- project_token: 'dbundle_proj_test',
48
- service_name: 'checkout-api',
49
- events: [{ 'event_type' => 'log_event', 'payload' => { 'message' => 'ok' } }]
50
- )
51
-
52
- expect(result.status_code).to eq(500)
53
- end
54
- end