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.
@@ -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
 
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DebugBundle
4
- VERSION = '1.2.0'
4
+ VERSION = '1.4.0'
5
5
  end
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)