debugbundle 1.2.0 → 1.3.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,402 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
- require 'base64'
5
- require 'json'
6
- require 'openssl'
7
4
 
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',
5
+ RSpec.describe DebugBundle::RemoteConfig do
6
+ it 'matches directives by expiry, scope, exact labels, wildcard labels, and prefixes' do
7
+ now = Time.iso8601('2026-07-27T08:00:00Z')
8
+ directive = described_class::Directive.new(
9
+ id: 'directive',
225
10
  label_pattern: 'checkout.*',
226
- service: '*',
11
+ service: 'checkout',
227
12
  environment: '*',
228
- trigger_expires_at: (Time.now.utc + 60).iso8601
13
+ expires_at: now + 60
229
14
  )
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
15
 
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'])
16
+ expect(directive.active?(label: 'checkout', service: 'checkout', environment: 'test', now: now)).to be(true)
17
+ expect(directive.active?(label: 'checkout.total', service: 'checkout', environment: 'test', now: now)).to be(true)
18
+ expect(directive.active?(label: 'cart.total', service: 'checkout', environment: 'test', now: now)).to be(false)
19
+ expect(directive.active?(label: 'checkout.total', service: 'orders', environment: 'test', now: now)).to be(false)
20
+ expect(
21
+ directive.active?(label: 'checkout.total', service: 'checkout', environment: 'test', now: now + 61)
22
+ ).to be(false)
23
+
24
+ wildcard = directive.dup
25
+ wildcard.label_pattern = '*'
26
+ wildcard.service = '*'
27
+ expect(wildcard.active?(label: 'anything', service: 'orders', environment: 'test', now: now)).to be(true)
275
28
  end
276
29
 
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'
30
+ it 'parses policy, probe directives, polling, and symbol-key fallbacks' do
31
+ snapshot = described_class.parse(
285
32
  {
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
33
+ probes_enabled: false,
34
+ remote_probes_enabled: true,
35
+ poll_interval_ms: 500,
36
+ trigger_token_key: 'key',
37
+ capture_policy: {
38
+ preset: 'investigative',
39
+ immediate_client_error_statuses: [409, '422'],
40
+ immediate_client_error_path_rules: [
41
+ { status_code: 422, path_pattern: '/checkout/*', methods: %w[post POST] }
42
+ ]
43
+ },
44
+ active_probes: [
45
+ {
46
+ id: 'directive',
47
+ label_pattern: 'checkout.*',
48
+ expires_at: '2026-07-27T09:00:00Z'
49
+ },
50
+ { id: 'invalid', expires_at: 'invalid' }
51
+ ]
52
+ },
53
+ 60
310
54
  )
311
55
 
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'])
56
+ expect(snapshot.probes_enabled).to be(false)
57
+ expect(snapshot.remote_probes_enabled).to be(true)
58
+ expect(snapshot.poll_interval_seconds).to eq(1)
59
+ expect(snapshot.directives.length).to eq(1)
60
+ expect(snapshot.capture_policy.immediate_client_error_statuses).to eq([409])
61
+ expect(snapshot.capture_policy.immediate_client_error_path_rules.first.http_methods).to eq(['POST'])
320
62
  end
321
63
 
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')
64
+ it 'rejects malformed directives and path-rule bounds' do
65
+ expect(described_class.parse(nil, 60)).to be_nil
66
+ expect(described_class.parse_capture_policy(nil)).to be_nil
67
+ expect(described_class.parse_directive(nil)).to be_nil
68
+ expect(described_class.parse_time(nil)).to be_nil
69
+ expect(described_class.parse_time('bad')).to be_nil
70
+ expect(described_class.valid_path_pattern?('relative')).to be(false)
71
+ expect(described_class.valid_path_pattern?('/path?query')).to be(false)
72
+ expect(described_class.valid_path_pattern?('/bad/*/suffix')).to be(false)
73
+ expect(described_class.parse_immediate_client_error_path_rules(Array.new(26, {}))).to eq([])
74
+ expect(
75
+ described_class.parse_immediate_client_error_path_rules(
76
+ [{ status_code: 500, path_pattern: '/bad', methods: ['TRACE'] }]
77
+ )
78
+ ).to eq([])
401
79
  end
402
80
  end
data/spec/spec_helper.rb CHANGED
@@ -6,7 +6,10 @@ SimpleCov.start do
6
6
  add_filter '/spec/'
7
7
 
8
8
  minimum_coverage_threshold = ENV.fetch('SIMPLECOV_MINIMUM_COVERAGE', '80').to_i
9
- minimum_coverage minimum_coverage_threshold if minimum_coverage_threshold.positive?
9
+ if minimum_coverage_threshold.positive?
10
+ minimum_coverage minimum_coverage_threshold
11
+ minimum_coverage_by_file minimum_coverage_threshold
12
+ end
10
13
  end
11
14
 
12
15
  require 'debugbundle'
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debugbundle
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DebugBundle
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-17 00:00:00.000000000 Z
10
+ date: 2026-07-28 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -49,7 +49,10 @@ files:
49
49
  - README.md
50
50
  - debugbundle.gemspec
51
51
  - lib/debugbundle.rb
52
+ - lib/debugbundle/acknowledgement.rb
53
+ - lib/debugbundle/before_send.rb
52
54
  - lib/debugbundle/client.rb
55
+ - lib/debugbundle/client_event_support.rb
53
56
  - lib/debugbundle/config.rb
54
57
  - lib/debugbundle/logging.rb
55
58
  - lib/debugbundle/rack/middleware.rb
@@ -67,6 +70,7 @@ files:
67
70
  - lib/debugbundle/transport.rb
68
71
  - lib/debugbundle/trigger_token.rb
69
72
  - lib/debugbundle/version.rb
73
+ - spec/before_send_spec.rb
70
74
  - spec/client_spec.rb
71
75
  - spec/debugbundle_spec.rb
72
76
  - spec/file_transport_spec.rb