debugbundle 1.4.1 → 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,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe DebugBundle::Redaction::Redactor do
6
- it 'redacts segment-aware sensitive keys' do
7
- redacted = described_class.new.redact_value(
8
- {
9
- 'user_password' => 'super-secret',
10
- 'apiKey' => 'abc123',
11
- 'sessionId' => 'sess-1',
12
- 'safe' => { 'message' => 'ok' }
13
- }
14
- )
15
-
16
- expect(redacted).to include(
17
- 'user_password' => '[REDACTED]',
18
- 'apiKey' => '[REDACTED]',
19
- 'sessionId' => '[REDACTED]'
20
- )
21
- expect(redacted.fetch('safe')).to eq({ 'message' => 'ok' })
22
- end
23
-
24
- it 'protects against circular references' do
25
- value = {}
26
- value['self'] = value
27
-
28
- redacted = described_class.new.redact_value(value)
29
-
30
- expect(redacted.fetch('self')).to eq('[Circular]')
31
- end
32
-
33
- it 'supports additional sensitive fields from framework configuration' do
34
- redactor = described_class.new(
35
- sensitive_fields: DebugBundle::Redaction::DEFAULT_SENSITIVE_FIELDS + %w[patient_notes]
36
- )
37
-
38
- redacted = redactor.redact_value('patient_notes' => 'diagnosis details', 'safe' => 'ok')
39
-
40
- expect(redacted).to include('patient_notes' => '[REDACTED]', 'safe' => 'ok')
41
- end
42
- end
data/spec/relay_spec.rb DELETED
@@ -1,274 +0,0 @@
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 'preserves analytics correlation fields for analytics relay events' do
61
- analytics_event = browser_event.merge(
62
- 'event_type' => 'analytics_event',
63
- 'correlation' => {
64
- 'session_id' => 'session-analytics',
65
- 'visitor_id_hash' => "sha256:#{'a' * 64}",
66
- 'user_id_hash' => nil,
67
- 'trace_id' => 'trace-analytics',
68
- 'deploy_id' => 'deploy-analytics'
69
- },
70
- 'payload' => { 'kind' => 'page_view', 'privacy' => { 'mode' => 'standard', 'consent_granted' => true } }
71
- )
72
-
73
- Dir.mktmpdir do |directory|
74
- handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
75
- local_events_dir: directory)
76
- response = handler.handle(
77
- method: 'POST',
78
- headers: {
79
- 'host' => 'app.example.com',
80
- 'origin' => 'https://app.example.com',
81
- 'content-type' => 'application/json'
82
- },
83
- body: JSON.generate('batch' => [analytics_event]),
84
- ip_address: '127.0.0.2'
85
- )
86
-
87
- expect(response.status).to eq(202)
88
- file_name = Dir.children(directory).find { |entry| entry.end_with?('.events.json') }
89
- event = JSON.parse(File.read(File.join(directory, file_name))).fetch(0)
90
- expect(event.fetch('correlation')).to eq(analytics_event.fetch('correlation'))
91
- end
92
- end
93
-
94
- it 'rejects wrong origins' do
95
- handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server')
96
- response = handler.handle(
97
- method: 'POST',
98
- headers: {
99
- 'host' => 'app.example.com',
100
- 'origin' => 'https://evil.example.com',
101
- 'content-type' => 'application/json'
102
- },
103
- body: JSON.generate('batch' => [browser_event]),
104
- ip_address: '127.0.0.1'
105
- )
106
-
107
- expect(response.status).to eq(403)
108
- end
109
-
110
- it 'answers allowed cross-origin preflight without delivering events' do
111
- handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
112
- allowed_origins: ['https://web.example.com'])
113
- response = handler.handle(
114
- method: 'OPTIONS',
115
- headers: {
116
- 'host' => 'api.example.com',
117
- 'origin' => 'https://web.example.com',
118
- 'access-control-request-method' => 'POST',
119
- 'access-control-request-headers' => 'content-type'
120
- },
121
- body: '',
122
- ip_address: '127.0.0.1'
123
- )
124
-
125
- expect(response.status).to eq(204)
126
- expect(response.headers).to include(
127
- 'Access-Control-Allow-Origin' => 'https://web.example.com',
128
- 'Access-Control-Allow-Methods' => 'POST, OPTIONS'
129
- )
130
- end
131
-
132
- it 'adds CORS headers to accepted cross-origin relay posts' do
133
- handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
134
- allowed_origins: ['https://web.example.com'])
135
- response = handler.handle(
136
- method: 'POST',
137
- headers: {
138
- 'host' => 'api.example.com',
139
- 'origin' => 'https://web.example.com',
140
- 'content-type' => 'application/json'
141
- },
142
- body: JSON.generate('batch' => [browser_event]),
143
- ip_address: '127.0.0.1'
144
- )
145
-
146
- expect(response.status).to eq(202)
147
- expect(response.headers).to include('Access-Control-Allow-Origin' => 'https://web.example.com', 'Vary' => 'Origin')
148
- end
149
-
150
- it 'supports a shared rate-limit store interface' do
151
- Dir.mktmpdir do |directory|
152
- store = Class.new do
153
- attr_reader :writes
154
-
155
- def initialize
156
- @counts = Hash.new(0)
157
- @writes = []
158
- end
159
-
160
- def increment(key, amount, expires_in:)
161
- @writes << [key, expires_in]
162
- @counts[key] += amount
163
- end
164
- end.new
165
-
166
- handler = described_class.new(
167
- project_mode: :local_only,
168
- project_token: 'dbundle_proj_server',
169
- local_events_dir: directory,
170
- rate_limit_per_minute: 1,
171
- rate_limit_store: store
172
- )
173
-
174
- request = {
175
- method: 'POST',
176
- headers: {
177
- 'host' => 'app.example.com',
178
- 'origin' => 'https://app.example.com',
179
- 'content-type' => 'application/json'
180
- },
181
- body: JSON.generate('batch' => [browser_event]),
182
- ip_address: '127.0.0.1'
183
- }
184
-
185
- expect(handler.handle(request).status).to eq(202)
186
- expect(handler.handle(request).status).to eq(429)
187
- expect(store.writes.length).to eq(2)
188
- end
189
- end
190
-
191
- it 'writes durable spool files and forwards connected events' do
192
- Dir.mktmpdir do |directory|
193
- forwarded = []
194
- transport = Class.new do
195
- define_method(:initialize) do |forwarded|
196
- @forwarded = forwarded
197
- end
198
-
199
- define_method(:call) do |request|
200
- @forwarded << request
201
- DebugBundle::Transport::Result.new(status_code: 202)
202
- end
203
- end.new(forwarded)
204
-
205
- handler = described_class.new(
206
- project_mode: :connected,
207
- project_token: 'dbundle_proj_server',
208
- spool_dir: directory,
209
- forward_transport: transport
210
- )
211
-
212
- response = handler.handle(
213
- method: 'POST',
214
- headers: {
215
- 'host' => 'app.example.com',
216
- 'origin' => 'https://app.example.com',
217
- 'content-type' => 'application/json'
218
- },
219
- body: JSON.generate('batch' => [browser_event]),
220
- ip_address: '127.0.0.1'
221
- )
222
-
223
- expect(response.status).to eq(202)
224
- expect(Dir.children(directory).any? { |entry| entry.end_with?('.events.json') }).to be(true)
225
- expect(forwarded.length).to eq(1)
226
- expect(forwarded.fetch(0).fetch(:project_token)).to eq('dbundle_proj_server')
227
- end
228
- end
229
-
230
- it 'exposes a Rack relay adapter' do
231
- Dir.mktmpdir do |directory|
232
- middleware = DebugBundle::Rack::RelayMiddleware.new(
233
- nil,
234
- handler: described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
235
- local_events_dir: directory)
236
- )
237
-
238
- status, headers, body = middleware.call(
239
- 'REQUEST_METHOD' => 'POST',
240
- 'HTTP_HOST' => 'app.example.com',
241
- 'HTTP_ORIGIN' => 'https://app.example.com',
242
- 'CONTENT_TYPE' => 'application/json',
243
- 'REMOTE_ADDR' => '127.0.0.1',
244
- 'rack.input' => StringIO.new(JSON.generate('batch' => [browser_event]))
245
- )
246
-
247
- expect(status).to eq(202)
248
- expect(headers).to include('Content-Type' => 'application/json')
249
- expect(headers).to include('Access-Control-Allow-Origin' => 'https://app.example.com')
250
- expect(JSON.parse(body.fetch(0))).to include('accepted' => 1)
251
- end
252
- end
253
-
254
- it 'exposes Rack preflight headers' do
255
- middleware = DebugBundle::Rack::RelayMiddleware.new(
256
- nil,
257
- handler: described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
258
- allowed_origins: ['https://web.example.com'])
259
- )
260
-
261
- status, headers, body = middleware.call(
262
- 'REQUEST_METHOD' => 'OPTIONS',
263
- 'HTTP_HOST' => 'api.example.com',
264
- 'HTTP_ORIGIN' => 'https://web.example.com',
265
- 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST',
266
- 'REMOTE_ADDR' => '127.0.0.1',
267
- 'rack.input' => StringIO.new('')
268
- )
269
-
270
- expect(status).to eq(204)
271
- expect(headers).to include('Access-Control-Allow-Origin' => 'https://web.example.com')
272
- expect(body.fetch(0)).to eq('')
273
- end
274
- end
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
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',
10
- label_pattern: 'checkout.*',
11
- service: 'checkout',
12
- environment: '*',
13
- expires_at: now + 60
14
- )
15
-
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)
28
- end
29
-
30
- it 'parses policy, probe directives, polling, and symbol-key fallbacks' do
31
- snapshot = described_class.parse(
32
- {
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
54
- )
55
-
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'])
62
- end
63
-
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([])
79
- end
80
- end
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe 'repository metadata' do
6
- let(:repo_root) { File.expand_path('..', __dir__) }
7
-
8
- def read_repository_file(path)
9
- File.read(File.join(repo_root, path))
10
- end
11
-
12
- it 'ships the smoke harness and wires it into workflows' do
13
- %w[
14
- Makefile
15
- smoke/run_app_driven_smoke.rb
16
- smoke/app_driven_smoke_runner.rb
17
- .github/workflows/ci.yml
18
- .github/workflows/release.yml
19
- ].each do |relative_path|
20
- expect(File).to exist(File.join(repo_root, relative_path))
21
- end
22
-
23
- makefile = read_repository_file('Makefile')
24
- ci_workflow = read_repository_file('.github/workflows/ci.yml')
25
- release_workflow = read_repository_file('.github/workflows/release.yml')
26
-
27
- [
28
- '.PHONY: smoke',
29
- '.PHONY: smoke-published',
30
- 'smoke/run_app_driven_smoke.rb --source local',
31
- 'smoke/run_app_driven_smoke.rb --source published --version $(VERSION)'
32
- ].each do |fragment|
33
- expect(makefile).to include(fragment)
34
- end
35
-
36
- expect(ci_workflow).to include('make smoke')
37
- expect(release_workflow).to include('make smoke')
38
- expect(release_workflow).to include('make smoke-published VERSION=${RELEASE_VERSION}')
39
- end
40
-
41
- it 'documents the Ruby release documentation gates in the README' do
42
- readme = read_repository_file('README.md')
43
-
44
- [
45
- '## Configuration Reference',
46
- 'Configuration sources and precedence:',
47
- 'Capture-policy fields are server-owned',
48
- '## Install Examples by Mode',
49
- '## Runtime and Framework Support',
50
- '## Dependency Alignment',
51
- '## Browser Relay',
52
- '## Service Naming',
53
- '## Safe Startup and Status',
54
- '## First-Event Verification',
55
- 'make smoke',
56
- 'same-origin',
57
- 'allowed origins',
58
- 'rate limiting',
59
- 'credential isolation',
60
- 'missing token'
61
- ].each do |fragment|
62
- expect(readme).to include(fragment)
63
- end
64
- end
65
- end
@@ -1,66 +0,0 @@
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('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
@@ -1,50 +0,0 @@
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('context').fetch('job')).to include(
44
- 'class' => 'CheckoutWorker',
45
- 'queue' => 'critical',
46
- 'jid' => 'jid-1',
47
- 'retry_count' => 2
48
- )
49
- end
50
- end
data/spec/spec_helper.rb DELETED
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'simplecov'
4
-
5
- SimpleCov.start do
6
- add_filter '/spec/'
7
-
8
- minimum_coverage_threshold = ENV.fetch('SIMPLECOV_MINIMUM_COVERAGE', '80').to_i
9
- if minimum_coverage_threshold.positive?
10
- minimum_coverage minimum_coverage_threshold
11
- minimum_coverage_by_file minimum_coverage_threshold
12
- end
13
- end
14
-
15
- require 'debugbundle'
16
-
17
- RSpec.configure do |config|
18
- config.disable_monkey_patching!
19
- config.example_status_persistence_file_path = '.rspec_status'
20
- config.expect_with :rspec do |expectations|
21
- expectations.syntax = :expect
22
- end
23
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'spec_helper'
4
-
5
- RSpec.describe DebugBundle::Suppression::Tracker do
6
- it 'allows the first three identical events and then suppresses' do
7
- tracker = described_class.new
8
-
9
- captures = 5.times.map { |index| tracker.should_capture('same-error', now: index.to_f) }
10
- aggregates = tracker.drain_aggregates(now: 5.0)
11
-
12
- expect(captures).to eq([true, true, true, false, false])
13
- expect(aggregates.length).to eq(1)
14
- expect(aggregates.fetch(0)).to include('suppressed_count' => 2)
15
- end
16
- end