debugbundle 0.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +17 -0
  3. data/Makefile +43 -0
  4. data/README.md +168 -0
  5. data/debugbundle.gemspec +30 -0
  6. data/lib/debugbundle/client.rb +724 -0
  7. data/lib/debugbundle/config.rb +144 -0
  8. data/lib/debugbundle/logging.rb +77 -0
  9. data/lib/debugbundle/rack/middleware.rb +94 -0
  10. data/lib/debugbundle/rack/relay_middleware.rb +37 -0
  11. data/lib/debugbundle/rails/railtie.rb +35 -0
  12. data/lib/debugbundle/rails/relay_endpoint.rb +100 -0
  13. data/lib/debugbundle/rails.rb +10 -0
  14. data/lib/debugbundle/redaction.rb +151 -0
  15. data/lib/debugbundle/relay/handler.rb +231 -0
  16. data/lib/debugbundle/relay.rb +4 -0
  17. data/lib/debugbundle/remote_config.rb +153 -0
  18. data/lib/debugbundle/runtime.rb +22 -0
  19. data/lib/debugbundle/sidekiq/server_middleware.rb +34 -0
  20. data/lib/debugbundle/suppression.rb +121 -0
  21. data/lib/debugbundle/transport.rb +190 -0
  22. data/lib/debugbundle/trigger_token.rb +122 -0
  23. data/lib/debugbundle/version.rb +5 -0
  24. data/lib/debugbundle.rb +93 -0
  25. data/spec/client_spec.rb +236 -0
  26. data/spec/debugbundle_spec.rb +54 -0
  27. data/spec/file_transport_spec.rb +54 -0
  28. data/spec/logger_integration_spec.rb +118 -0
  29. data/spec/rack_integration_spec.rb +44 -0
  30. data/spec/rack_middleware_spec.rb +206 -0
  31. data/spec/rails_railtie_spec.rb +96 -0
  32. data/spec/rails_relay_spec.rb +121 -0
  33. data/spec/redaction_spec.rb +42 -0
  34. data/spec/relay_spec.rb +178 -0
  35. data/spec/remote_config_spec.rb +402 -0
  36. data/spec/sidekiq_integration_spec.rb +66 -0
  37. data/spec/sidekiq_middleware_spec.rb +50 -0
  38. data/spec/spec_helper.rb +20 -0
  39. data/spec/suppression_spec.rb +16 -0
  40. metadata +113 -0
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'stringio'
6
+ require 'tmpdir'
7
+
8
+ RSpec.describe DebugBundle::Relay::Handler do
9
+ let(:browser_event) do
10
+ {
11
+ 'schema_version' => '2026-03-01',
12
+ 'event_id' => 'evt_1',
13
+ 'event_type' => 'frontend_exception',
14
+ 'sdk_version' => '0.1.0',
15
+ 'occurred_at' => Time.now.utc.iso8601,
16
+ 'project_token' => 'browser-token',
17
+ 'organization_id' => 'org_1',
18
+ 'sdk_name' => 'spoofed',
19
+ 'service' => {
20
+ 'name' => 'checkout-web',
21
+ 'environment' => 'production'
22
+ },
23
+ 'correlation' => {
24
+ 'trace_id' => 'trace-1',
25
+ 'request_id' => 'req-1',
26
+ 'session_id' => nil,
27
+ 'user_id_hash' => 'user-1'
28
+ },
29
+ 'payload' => { 'message' => 'boom' }
30
+ }
31
+ end
32
+
33
+ it 'validates, sanitizes, and writes local-only relay batches' do
34
+ Dir.mktmpdir do |directory|
35
+ handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
36
+ local_events_dir: directory)
37
+ response = handler.handle(
38
+ method: 'POST',
39
+ headers: {
40
+ 'host' => 'app.example.com',
41
+ 'origin' => 'https://app.example.com',
42
+ 'content-type' => 'application/json'
43
+ },
44
+ body: JSON.generate('batch' => [browser_event]),
45
+ ip_address: '127.0.0.1'
46
+ )
47
+
48
+ expect(response.status).to eq(202)
49
+
50
+ file_name = Dir.children(directory).find { |entry| entry.end_with?('.events.json') }
51
+ parsed = JSON.parse(File.read(File.join(directory, file_name)))
52
+ event = parsed.fetch(0)
53
+
54
+ expect(event).to include('sdk_name' => '@debugbundle/sdk-browser', 'project_token' => 'dbundle_proj_server')
55
+ expect(event).not_to have_key('organization_id')
56
+ expect(event.fetch('correlation')).to include('trace_id' => 'trace-1', 'request_id' => 'req-1')
57
+ end
58
+ end
59
+
60
+ it 'rejects wrong origins' do
61
+ handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server')
62
+ response = handler.handle(
63
+ method: 'POST',
64
+ headers: {
65
+ 'host' => 'app.example.com',
66
+ 'origin' => 'https://evil.example.com',
67
+ 'content-type' => 'application/json'
68
+ },
69
+ body: JSON.generate('batch' => [browser_event]),
70
+ ip_address: '127.0.0.1'
71
+ )
72
+
73
+ expect(response.status).to eq(403)
74
+ end
75
+
76
+ it 'supports a shared rate-limit store interface' do
77
+ Dir.mktmpdir do |directory|
78
+ store = Class.new do
79
+ attr_reader :writes
80
+
81
+ def initialize
82
+ @counts = Hash.new(0)
83
+ @writes = []
84
+ end
85
+
86
+ def increment(key, amount, expires_in:)
87
+ @writes << [key, expires_in]
88
+ @counts[key] += amount
89
+ end
90
+ end.new
91
+
92
+ handler = described_class.new(
93
+ project_mode: :local_only,
94
+ project_token: 'dbundle_proj_server',
95
+ local_events_dir: directory,
96
+ rate_limit_per_minute: 1,
97
+ rate_limit_store: store
98
+ )
99
+
100
+ request = {
101
+ method: 'POST',
102
+ headers: {
103
+ 'host' => 'app.example.com',
104
+ 'origin' => 'https://app.example.com',
105
+ 'content-type' => 'application/json'
106
+ },
107
+ body: JSON.generate('batch' => [browser_event]),
108
+ ip_address: '127.0.0.1'
109
+ }
110
+
111
+ expect(handler.handle(request).status).to eq(202)
112
+ expect(handler.handle(request).status).to eq(429)
113
+ expect(store.writes.length).to eq(2)
114
+ end
115
+ end
116
+
117
+ it 'writes durable spool files and forwards connected events' do
118
+ Dir.mktmpdir do |directory|
119
+ forwarded = []
120
+ transport = Class.new do
121
+ define_method(:initialize) do |forwarded|
122
+ @forwarded = forwarded
123
+ end
124
+
125
+ define_method(:call) do |request|
126
+ @forwarded << request
127
+ DebugBundle::Transport::Result.new(status_code: 202)
128
+ end
129
+ end.new(forwarded)
130
+
131
+ handler = described_class.new(
132
+ project_mode: :connected,
133
+ project_token: 'dbundle_proj_server',
134
+ spool_dir: directory,
135
+ forward_transport: transport
136
+ )
137
+
138
+ response = handler.handle(
139
+ method: 'POST',
140
+ headers: {
141
+ 'host' => 'app.example.com',
142
+ 'origin' => 'https://app.example.com',
143
+ 'content-type' => 'application/json'
144
+ },
145
+ body: JSON.generate('batch' => [browser_event]),
146
+ ip_address: '127.0.0.1'
147
+ )
148
+
149
+ expect(response.status).to eq(202)
150
+ expect(Dir.children(directory).any? { |entry| entry.end_with?('.events.json') }).to be(true)
151
+ expect(forwarded.length).to eq(1)
152
+ expect(forwarded.fetch(0).fetch(:project_token)).to eq('dbundle_proj_server')
153
+ end
154
+ end
155
+
156
+ it 'exposes a Rack relay adapter' do
157
+ Dir.mktmpdir do |directory|
158
+ middleware = DebugBundle::Rack::RelayMiddleware.new(
159
+ nil,
160
+ handler: described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
161
+ local_events_dir: directory)
162
+ )
163
+
164
+ status, headers, body = middleware.call(
165
+ 'REQUEST_METHOD' => 'POST',
166
+ 'HTTP_HOST' => 'app.example.com',
167
+ 'HTTP_ORIGIN' => 'https://app.example.com',
168
+ 'CONTENT_TYPE' => 'application/json',
169
+ 'REMOTE_ADDR' => '127.0.0.1',
170
+ 'rack.input' => StringIO.new(JSON.generate('batch' => [browser_event]))
171
+ )
172
+
173
+ expect(status).to eq(202)
174
+ expect(headers).to include('Content-Type' => 'application/json')
175
+ expect(JSON.parse(body.fetch(0))).to include('accepted' => 1)
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'base64'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ RSpec.describe 'remote config and capture policy' do
9
+ let(:transport_events) { [] }
10
+ let(:transport) do
11
+ Class.new do
12
+ define_method(:initialize) do |transport_events|
13
+ @transport_events = transport_events
14
+ end
15
+
16
+ define_method(:call) do |request|
17
+ @transport_events << request
18
+ DebugBundle::Transport::Result.new(status_code: 202)
19
+ end
20
+ end.new(transport_events)
21
+ end
22
+
23
+ it 'drops logs below the server-owned capture policy' do
24
+ fetcher = lambda do |_etag|
25
+ {
26
+ status_code: 200,
27
+ etag: 'etag-1',
28
+ body: {
29
+ capture_policy: {
30
+ preset: 'minimal',
31
+ capture_logs: 'error',
32
+ capture_request_events: 'failures_only',
33
+ capture_breadcrumbs: 'local_only',
34
+ capture_probe_events: 'buffer_only',
35
+ immediate_client_error_statuses: []
36
+ }
37
+ }
38
+ }
39
+ end
40
+
41
+ client = DebugBundle::Client.new(
42
+ project_token: 'dbundle_proj_test',
43
+ transport: transport,
44
+ config_fetcher: fetcher,
45
+ log_level: :info
46
+ )
47
+
48
+ client.capture_log('info message', level: :info)
49
+ client.capture_log('error message', level: :error)
50
+ client.flush
51
+
52
+ events = transport_events.fetch(0).fetch(:events)
53
+ expect(events.length).to eq(1)
54
+ expect(events.fetch(0).fetch('payload')).to include('message' => 'error message')
55
+ end
56
+
57
+ it 'captures configured immediate client error request events even when the preset is minimal' do
58
+ fetcher = lambda do |_etag|
59
+ {
60
+ status_code: 200,
61
+ etag: 'etag-2',
62
+ body: {
63
+ capture_policy: {
64
+ preset: 'minimal',
65
+ capture_logs: 'error',
66
+ capture_request_events: 'off',
67
+ capture_breadcrumbs: 'local_only',
68
+ capture_probe_events: 'buffer_only',
69
+ immediate_client_error_statuses: [422]
70
+ }
71
+ }
72
+ }
73
+ end
74
+
75
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport, config_fetcher: fetcher)
76
+ client.capture_request({ method: 'POST', path: '/checkout', headers: {} }, { status_code: 422, headers: {} })
77
+ client.flush
78
+
79
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
80
+ expect(event.fetch('event_type')).to eq('request_event')
81
+ expect(event.fetch('payload')).to include('response_status' => 422)
82
+ end
83
+
84
+ it 'ships standalone probe events when a remote directive matches' do
85
+ fetcher = lambda do |_etag|
86
+ {
87
+ status_code: 200,
88
+ etag: 'etag-3',
89
+ body: {
90
+ probes_enabled: true,
91
+ remote_probes_enabled: true,
92
+ active_probes: [
93
+ {
94
+ id: 'probe-1',
95
+ label_pattern: 'checkout.*',
96
+ service: '*',
97
+ environment: '*',
98
+ expires_at: (Time.now.utc + 60).iso8601
99
+ }
100
+ ],
101
+ capture_policy: {
102
+ preset: 'balanced',
103
+ capture_logs: 'warning',
104
+ capture_request_events: 'failures_only',
105
+ capture_breadcrumbs: 'exception_only',
106
+ capture_probe_events: 'standalone_when_activated',
107
+ immediate_client_error_statuses: []
108
+ }
109
+ }
110
+ }
111
+ end
112
+
113
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport, config_fetcher: fetcher)
114
+ client.probe('checkout.tax', { region: 'us-east-1' })
115
+ client.flush
116
+
117
+ event = transport_events.fetch(0).fetch(:events).find { |entry| entry.fetch('event_type') == 'probe_event' }
118
+ expect(event).not_to be_nil
119
+ expect(event.fetch('payload')).to include(
120
+ 'label' => 'checkout.tax',
121
+ 'activation_id' => 'probe-1',
122
+ 'probe_label_pattern' => 'checkout.*'
123
+ )
124
+ end
125
+
126
+ it 'keeps heavy probes dormant unless a remote directive matches' do
127
+ active_fetcher = lambda do |_etag|
128
+ {
129
+ status_code: 200,
130
+ etag: 'etag-heavy-active',
131
+ body: {
132
+ probes_enabled: true,
133
+ remote_probes_enabled: true,
134
+ active_probes: [
135
+ {
136
+ id: 'probe-heavy-1',
137
+ label_pattern: 'checkout.*',
138
+ service: '*',
139
+ environment: '*',
140
+ expires_at: (Time.now.utc + 60).iso8601
141
+ }
142
+ ],
143
+ capture_policy: {
144
+ preset: 'balanced',
145
+ capture_logs: 'warning',
146
+ capture_request_events: 'failures_only',
147
+ capture_breadcrumbs: 'exception_only',
148
+ capture_probe_events: 'standalone_when_activated',
149
+ immediate_client_error_statuses: []
150
+ }
151
+ }
152
+ }
153
+ end
154
+
155
+ dormant_fetcher = lambda do |_etag|
156
+ {
157
+ status_code: 200,
158
+ etag: 'etag-heavy-dormant',
159
+ body: {
160
+ probes_enabled: true,
161
+ remote_probes_enabled: true,
162
+ active_probes: [],
163
+ capture_policy: {
164
+ preset: 'balanced',
165
+ capture_logs: 'warning',
166
+ capture_request_events: 'failures_only',
167
+ capture_breadcrumbs: 'exception_only',
168
+ capture_probe_events: 'standalone_when_activated',
169
+ immediate_client_error_statuses: []
170
+ }
171
+ }
172
+ }
173
+ end
174
+
175
+ active_calls = 0
176
+ dormant_calls = 0
177
+
178
+ active_client = DebugBundle::Client.new(
179
+ project_token: 'dbundle_proj_test',
180
+ transport: transport,
181
+ config_fetcher: lambda do |etag|
182
+ active_calls += 1
183
+ active_fetcher.call(etag)
184
+ end
185
+ )
186
+ dormant_client = DebugBundle::Client.new(
187
+ project_token: 'dbundle_proj_test',
188
+ transport: transport,
189
+ config_fetcher: lambda do |etag|
190
+ dormant_calls += 1
191
+ dormant_fetcher.call(etag)
192
+ end
193
+ )
194
+
195
+ active_probe_data = nil
196
+ active_client.probe('checkout.tax', heavy: true) do
197
+ active_probe_data = { region: 'us-east-1' }
198
+ end
199
+
200
+ dormant_probe_called = false
201
+ dormant_client.probe('checkout.tax', heavy: true) do
202
+ dormant_probe_called = true
203
+ { region: 'us-east-1' }
204
+ end
205
+
206
+ active_client.flush
207
+
208
+ probe_event = transport_events.fetch(0).fetch(:events).find { |entry| entry.fetch('event_type') == 'probe_event' }
209
+ expect(active_calls).to eq(1)
210
+ expect(dormant_calls).to eq(1)
211
+ expect(active_probe_data).to eq({ region: 'us-east-1' })
212
+ expect(dormant_probe_called).to be(false)
213
+ expect(probe_event).not_to be_nil
214
+ expect(probe_event.fetch('payload')).to include(
215
+ 'label' => 'checkout.tax',
216
+ 'data' => { 'region' => 'us-east-1' },
217
+ 'activation_id' => 'probe-heavy-1'
218
+ )
219
+ end
220
+
221
+ it 'only ships trigger-token probe directives when remote policy is buffer-only' do
222
+ trigger_key = 'trigger-secret'
223
+ payload_json = JSON.generate(
224
+ activation_id: 'trigger-1',
225
+ label_pattern: 'checkout.*',
226
+ service: '*',
227
+ environment: '*',
228
+ trigger_expires_at: (Time.now.utc + 60).iso8601
229
+ )
230
+ payload_segment = Base64.urlsafe_encode64(payload_json, padding: false)
231
+ signature = OpenSSL::HMAC.digest('sha256', trigger_key, payload_segment)
232
+ token = "dbundle_probe_#{payload_segment}.#{Base64.urlsafe_encode64(signature, padding: false)}"
233
+
234
+ fetcher = lambda do |_etag|
235
+ {
236
+ status_code: 200,
237
+ etag: 'etag-trigger-buffer-only',
238
+ body: {
239
+ probes_enabled: true,
240
+ remote_probes_enabled: true,
241
+ trigger_token_key: trigger_key,
242
+ active_probes: [
243
+ {
244
+ id: 'remote-1',
245
+ label_pattern: 'checkout.*',
246
+ service: '*',
247
+ environment: '*',
248
+ expires_at: (Time.now.utc + 60).iso8601
249
+ }
250
+ ],
251
+ capture_policy: {
252
+ preset: 'balanced',
253
+ capture_logs: 'warning',
254
+ capture_request_events: 'failures_only',
255
+ capture_breadcrumbs: 'exception_only',
256
+ capture_probe_events: 'buffer_only',
257
+ immediate_client_error_statuses: []
258
+ }
259
+ }
260
+ }
261
+ end
262
+
263
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport, config_fetcher: fetcher)
264
+ client.with_request_trigger(headers: { 'x-debugbundle-probe-trigger' => token }, query: {}) do
265
+ client.probe('checkout.tax', { region: 'us-east-1' })
266
+ end
267
+ client.flush
268
+
269
+ probe_events = transport_events.fetch(0).fetch(:events).select do |entry|
270
+ entry.fetch('event_type') == 'probe_event'
271
+ end
272
+ activation_ids = probe_events.map { |event| event.fetch('payload').fetch('activation_id') }
273
+
274
+ expect(activation_ids).to eq(['trigger-1'])
275
+ end
276
+
277
+ it 'refreshes the paid-tier config on the polling interval during client activity' do
278
+ current_time = Time.utc(2026, 5, 23, 12, 0, 0)
279
+ time_provider = -> { current_time }
280
+ calls = 0
281
+ fetcher = lambda do |_etag|
282
+ calls += 1
283
+
284
+ capture_logs = calls == 1 ? 'info' : 'error'
285
+ {
286
+ status_code: 200,
287
+ etag: "etag-#{calls}",
288
+ body: {
289
+ probes_enabled: true,
290
+ remote_probes_enabled: true,
291
+ poll_interval_ms: 1000,
292
+ capture_policy: {
293
+ preset: 'balanced',
294
+ capture_logs: capture_logs,
295
+ capture_request_events: 'failures_only',
296
+ capture_breadcrumbs: 'exception_only',
297
+ capture_probe_events: 'buffer_only',
298
+ immediate_client_error_statuses: []
299
+ }
300
+ }
301
+ }
302
+ end
303
+
304
+ client = DebugBundle::Client.new(
305
+ project_token: 'dbundle_proj_test',
306
+ transport: transport,
307
+ config_fetcher: fetcher,
308
+ time_provider: time_provider,
309
+ log_level: :info
310
+ )
311
+
312
+ client.capture_log('first info', level: :info)
313
+ current_time += 2
314
+ client.capture_log('second info', level: :info)
315
+ client.flush
316
+
317
+ events = transport_events.fetch(0).fetch(:events)
318
+ expect(calls).to eq(2)
319
+ expect(events.map { |event| event.fetch('payload').fetch('message') }).to eq(['first info'])
320
+ end
321
+
322
+ it 'retries remote config polling after startup failure on the next interval' do
323
+ current_time = Time.utc(2026, 5, 23, 12, 0, 0)
324
+ time_provider = -> { current_time }
325
+ calls = 0
326
+ fetcher = lambda do |_etag|
327
+ calls += 1
328
+ return { status_code: 500 } if calls == 1
329
+
330
+ {
331
+ status_code: 200,
332
+ etag: 'etag-recovered',
333
+ body: {
334
+ probes_enabled: true,
335
+ remote_probes_enabled: true,
336
+ poll_interval_ms: 1000,
337
+ capture_policy: {
338
+ preset: 'minimal',
339
+ capture_logs: 'error',
340
+ capture_request_events: 'failures_only',
341
+ capture_breadcrumbs: 'local_only',
342
+ capture_probe_events: 'buffer_only',
343
+ immediate_client_error_statuses: []
344
+ }
345
+ }
346
+ }
347
+ end
348
+
349
+ client = DebugBundle::Client.new(
350
+ project_token: 'dbundle_proj_test',
351
+ transport: transport,
352
+ config_fetcher: fetcher,
353
+ time_provider: time_provider,
354
+ log_level: :info
355
+ )
356
+
357
+ current_time += 61
358
+ client.capture_log('recovered info', level: :info)
359
+
360
+ expect(calls).to eq(2)
361
+ expect(client.buffered_event_count).to eq(0)
362
+ end
363
+
364
+ it 'does not continue polling after the server marks the project free tier' do
365
+ current_time = Time.utc(2026, 5, 23, 12, 0, 0)
366
+ time_provider = -> { current_time }
367
+ calls = 0
368
+ fetcher = lambda do |_etag|
369
+ calls += 1
370
+ {
371
+ status_code: 200,
372
+ etag: 'etag-free',
373
+ body: {
374
+ probes_enabled: true,
375
+ remote_probes_enabled: false,
376
+ capture_policy: {
377
+ preset: 'balanced',
378
+ capture_logs: 'warning',
379
+ capture_request_events: 'failures_only',
380
+ capture_breadcrumbs: 'exception_only',
381
+ capture_probe_events: 'buffer_only',
382
+ immediate_client_error_statuses: []
383
+ }
384
+ }
385
+ }
386
+ end
387
+
388
+ client = DebugBundle::Client.new(
389
+ project_token: 'dbundle_proj_test',
390
+ transport: transport,
391
+ config_fetcher: fetcher,
392
+ time_provider: time_provider
393
+ )
394
+
395
+ current_time += 120
396
+ client.capture_log('warning event', level: :warning)
397
+ client.flush
398
+
399
+ expect(calls).to eq(1)
400
+ expect(transport_events.fetch(0).fetch(:events).fetch(0).fetch('payload')).to include('message' => 'warning event')
401
+ end
402
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ begin
6
+ require 'sidekiq'
7
+ rescue LoadError
8
+ nil
9
+ end
10
+
11
+ if defined?(Sidekiq)
12
+ RSpec.describe 'Sidekiq integration' do
13
+ let(:transport_events) { [] }
14
+ let(:transport) do
15
+ Class.new do
16
+ define_method(:initialize) do |transport_events|
17
+ @transport_events = transport_events
18
+ end
19
+
20
+ define_method(:call) do |request|
21
+ @transport_events << request
22
+ DebugBundle::Transport::Result.new(status_code: 202)
23
+ end
24
+ end.new(transport_events)
25
+ end
26
+
27
+ it 'registers and executes through the Sidekiq middleware chain' do
28
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport)
29
+ chain = Sidekiq::Middleware::Chain.new
30
+ job = {
31
+ 'class' => 'CheckoutWorker',
32
+ 'queue' => 'critical',
33
+ 'jid' => 'jid-chain-1',
34
+ 'retry_count' => 1,
35
+ 'args' => [{ 'charge_id' => 'ch_123' }],
36
+ 'trace_id' => 'trace-chain-1'
37
+ }
38
+
39
+ chain.add(DebugBundle::Sidekiq::ServerMiddleware, client: client)
40
+
41
+ expect(chain.entries.map(&:klass)).to include(DebugBundle::Sidekiq::ServerMiddleware)
42
+ expect do
43
+ chain.invoke(Object.new, job, 'critical') do
44
+ raise 'chain failure'
45
+ end
46
+ end.to raise_error(RuntimeError, 'chain failure')
47
+
48
+ client.flush
49
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
50
+
51
+ expect(event.fetch('event_type')).to eq('backend_exception')
52
+ expect(event.fetch('correlation')).to include('trace_id' => 'trace-chain-1')
53
+ expect(event.fetch('payload').fetch('context').fetch('job')).to include(
54
+ 'class' => 'CheckoutWorker',
55
+ 'queue' => 'critical',
56
+ 'jid' => 'jid-chain-1'
57
+ )
58
+ end
59
+ end
60
+ else
61
+ RSpec.describe 'Sidekiq integration' do
62
+ it 'requires the sidekiq gem for integration validation' do
63
+ skip 'sidekiq gem not installed'
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe DebugBundle::Sidekiq::ServerMiddleware do
6
+ let(:transport_events) { [] }
7
+ let(:transport) do
8
+ Class.new do
9
+ define_method(:initialize) do |transport_events|
10
+ @transport_events = transport_events
11
+ end
12
+
13
+ define_method(:call) do |request|
14
+ @transport_events << request
15
+ DebugBundle::Transport::Result.new(status_code: 202)
16
+ end
17
+ end.new(transport_events)
18
+ end
19
+
20
+ it 'captures job exceptions and preserves retries by re-raising' do
21
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport)
22
+ middleware = described_class.new(client: client)
23
+ job = {
24
+ 'class' => 'CheckoutWorker',
25
+ 'queue' => 'critical',
26
+ 'jid' => 'jid-1',
27
+ 'retry_count' => 2,
28
+ 'args' => [123, { 'charge_id' => 'ch_1' }],
29
+ 'trace_id' => 'trace-job-1'
30
+ }
31
+
32
+ expect do
33
+ middleware.call(Object.new, job, 'critical') do
34
+ raise 'worker failed'
35
+ end
36
+ end.to raise_error(RuntimeError, 'worker failed')
37
+
38
+ client.flush
39
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
40
+
41
+ expect(event.fetch('event_type')).to eq('backend_exception')
42
+ expect(event.fetch('correlation')).to include('trace_id' => 'trace-job-1')
43
+ expect(event.fetch('payload').fetch('context').fetch('job')).to include(
44
+ 'class' => 'CheckoutWorker',
45
+ 'queue' => 'critical',
46
+ 'jid' => 'jid-1',
47
+ 'retry_count' => 2
48
+ )
49
+ end
50
+ end