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,54 @@
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
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'stringio'
5
+
6
+ RSpec.describe 'stdlib logger integration' do
7
+ it 'captures log events without changing logger output' do
8
+ transport_events = []
9
+ transport = Class.new do
10
+ define_method(:initialize) do |transport_events|
11
+ @transport_events = transport_events
12
+ end
13
+
14
+ define_method(:call) do |request|
15
+ @transport_events << request
16
+ DebugBundle::Transport::Result.new(status_code: 202)
17
+ end
18
+ end.new(transport_events)
19
+
20
+ output = StringIO.new
21
+ logger = Logger.new(output)
22
+ logger.progname = 'checkout'
23
+
24
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport)
25
+ client.capture_logger(logger)
26
+ client.capture_logger(logger)
27
+
28
+ logger.warn('payment retry failed')
29
+ client.flush
30
+
31
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
32
+
33
+ expect(output.string).to include('payment retry failed')
34
+ expect(event.fetch('event_type')).to eq('log_event')
35
+ expect(event.fetch('payload')).to include('message' => 'payment retry failed', 'level' => 'warning')
36
+ expect(event.fetch('payload').fetch('attributes')).to include('logger_name' => 'checkout')
37
+ end
38
+
39
+ it 'registers a semantic logger appender when SemanticLogger is available' do
40
+ transport_events = []
41
+ transport = Class.new do
42
+ define_method(:initialize) do |transport_events|
43
+ @transport_events = transport_events
44
+ end
45
+
46
+ define_method(:call) do |request|
47
+ @transport_events << request
48
+ DebugBundle::Transport::Result.new(status_code: 202)
49
+ end
50
+ end.new(transport_events)
51
+
52
+ semantic_logger = Module.new do
53
+ class << self
54
+ attr_reader :appenders
55
+
56
+ def add_appender(appender:)
57
+ @appenders ||= []
58
+ @appenders << appender
59
+ end
60
+ end
61
+ end
62
+
63
+ stub_const('SemanticLogger', semantic_logger)
64
+
65
+ log_entry = Struct.new(:message, :level, :name, :payload, :tags).new(
66
+ 'semantic failure',
67
+ :error,
68
+ 'semantic-checkout',
69
+ { order_id: 123 },
70
+ %w[payments critical]
71
+ )
72
+
73
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport)
74
+ appender = client.capture_semantic_logger
75
+ appender.log(log_entry)
76
+ client.flush
77
+
78
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
79
+
80
+ expect(SemanticLogger.appenders).to include(appender)
81
+ expect(event.fetch('payload')).to include('message' => 'semantic failure', 'level' => 'error')
82
+ expect(event.fetch('payload').fetch('attributes')).to include(
83
+ 'logger_name' => 'semantic-checkout',
84
+ 'payload' => { 'order_id' => 123 },
85
+ 'tags' => %w[payments critical]
86
+ )
87
+ end
88
+
89
+ it 'guards semantic logger capture against recursive SDK logging' do
90
+ capture_count = 0
91
+ appender = nil
92
+ log_entry = Struct.new(:message, :level, :name, :payload, :tags).new(
93
+ 'semantic recursion check',
94
+ :error,
95
+ 'semantic-checkout',
96
+ {},
97
+ []
98
+ )
99
+ recursive_client = Class.new do
100
+ define_method(:initialize) do |on_capture|
101
+ @on_capture = on_capture
102
+ end
103
+
104
+ define_method(:capture_log) do |_message, level:, context:|
105
+ @on_capture.call(level, context)
106
+ end
107
+ end.new(lambda do |_level, _context|
108
+ capture_count += 1
109
+ appender.log(log_entry)
110
+ end)
111
+
112
+ appender = DebugBundle::Logging::SemanticLoggerAppender.new(client: recursive_client)
113
+
114
+ appender.log(log_entry)
115
+
116
+ expect(capture_count).to eq(1)
117
+ end
118
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'rack'
5
+ require 'rack/mock'
6
+
7
+ RSpec.describe 'Rack integration' 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 'captures request context in a real Rack builder stack' do
23
+ client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', service: 'rack-checkout', transport: transport)
24
+ app = Rack::Builder.new do
25
+ use DebugBundle::Rack::Middleware, client: client
26
+ run ->(_env) { [422, { 'Content-Type' => 'text/plain' }, ['ok']] }
27
+ end.to_app
28
+
29
+ response = Rack::MockRequest.new(app).get(
30
+ '/checkout?cart_id=123',
31
+ 'HTTP_X_REQUEST_ID' => 'req-rack',
32
+ 'HTTP_X_DEBUGBUNDLE_TRACE_ID' => 'trace-rack'
33
+ )
34
+
35
+ client.flush
36
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
37
+
38
+ expect(response.status).to eq(422)
39
+ expect(response.body).to eq('ok')
40
+ expect(event.fetch('event_type')).to eq('request_event')
41
+ expect(event.fetch('correlation')).to include('request_id' => 'req-rack', 'trace_id' => 'trace-rack')
42
+ expect(event.fetch('payload')).to include('path' => '/checkout', 'method' => 'GET', 'response_status' => 422)
43
+ end
44
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'base64'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ RSpec.describe DebugBundle::Rack::Middleware do
9
+ let(:transport_events) { [] }
10
+ let(:client) do
11
+ transport = 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
+
22
+ config_fetcher = lambda do |_etag|
23
+ {
24
+ status_code: 200,
25
+ etag: 'rack-etag',
26
+ body: {
27
+ capture_policy: {
28
+ preset: 'balanced',
29
+ capture_logs: 'warning',
30
+ capture_request_events: 'all',
31
+ capture_breadcrumbs: 'exception_only',
32
+ capture_probe_events: 'buffer_only',
33
+ immediate_client_error_statuses: []
34
+ }
35
+ }
36
+ }
37
+ end
38
+
39
+ DebugBundle::Client.new(
40
+ project_token: 'dbundle_proj_test',
41
+ service: 'checkout-api',
42
+ transport: transport,
43
+ config_fetcher: config_fetcher
44
+ )
45
+ end
46
+
47
+ it 'captures request metadata and preserves the response' do
48
+ app = lambda do |_env|
49
+ [201, { 'Content-Type' => 'application/json', 'Set-Cookie' => 'secret' }, ['ok']]
50
+ end
51
+
52
+ middleware = described_class.new(app, client: client)
53
+ status, headers, body = middleware.call(
54
+ 'REQUEST_METHOD' => 'POST',
55
+ 'PATH_INFO' => '/checkout',
56
+ 'QUERY_STRING' => 'cart_id=123',
57
+ 'HTTP_X_REQUEST_ID' => 'req-1',
58
+ 'HTTP_X_DEBUGBUNDLE_TRACE_ID' => 'trace-1',
59
+ 'HTTP_AUTHORIZATION' => 'Bearer secret',
60
+ 'HTTP_USER_AGENT' => 'RSpec'
61
+ )
62
+
63
+ client.flush
64
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
65
+
66
+ expect(status).to eq(201)
67
+ expect(headers).to include('Content-Type' => 'application/json')
68
+ expect(body).to eq(['ok'])
69
+ expect(event.fetch('event_type')).to eq('request_event')
70
+ expect(event.fetch('correlation')).to include('request_id' => 'req-1', 'trace_id' => 'trace-1')
71
+ expect(event.fetch('payload').fetch('headers')).to include('user-agent' => 'RSpec')
72
+ expect(event.fetch('payload').fetch('headers')).not_to have_key('authorization')
73
+ expect(event.fetch('payload').fetch('response_headers')).to include('content-type' => 'application/json')
74
+ expect(event.fetch('payload').fetch('response_headers')).not_to have_key('set-cookie')
75
+ end
76
+
77
+ it 'captures exceptions and re-raises them' do
78
+ app = lambda do |_env|
79
+ raise 'checkout failure'
80
+ end
81
+
82
+ middleware = described_class.new(app, client: client)
83
+
84
+ expect do
85
+ middleware.call(
86
+ 'REQUEST_METHOD' => 'GET',
87
+ 'PATH_INFO' => '/checkout',
88
+ 'QUERY_STRING' => '',
89
+ 'HTTP_X_REQUEST_ID' => 'req-2'
90
+ )
91
+ end.to raise_error(RuntimeError, 'checkout failure')
92
+
93
+ client.flush
94
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
95
+
96
+ expect(event.fetch('event_type')).to eq('backend_exception')
97
+ expect(event.fetch('payload')).to include('handled' => false)
98
+ expect(event.fetch('correlation')).to include('request_id' => 'req-2')
99
+ end
100
+
101
+ it 'activates trigger-token probe shipping for a single request' do
102
+ trigger_key = 'trigger-secret'
103
+ expires_at = (Time.now.utc + 60).iso8601
104
+ payload_json = JSON.generate(
105
+ activation_id: 'probe-1',
106
+ label_pattern: 'checkout.*',
107
+ service: '*',
108
+ environment: '*',
109
+ trigger_expires_at: expires_at
110
+ )
111
+ payload_segment = Base64.urlsafe_encode64(payload_json, padding: false)
112
+ signature = OpenSSL::HMAC.digest('sha256', trigger_key, payload_segment)
113
+ token = "dbundle_probe_#{payload_segment}.#{Base64.urlsafe_encode64(signature, padding: false)}"
114
+
115
+ transport = Class.new do
116
+ define_method(:initialize) do |transport_events|
117
+ @transport_events = transport_events
118
+ end
119
+
120
+ define_method(:call) do |request|
121
+ @transport_events << request
122
+ DebugBundle::Transport::Result.new(status_code: 202)
123
+ end
124
+ end.new(transport_events)
125
+
126
+ config_fetcher = lambda do |_etag|
127
+ {
128
+ status_code: 200,
129
+ etag: 'rack-trigger-etag',
130
+ body: {
131
+ probes_enabled: true,
132
+ remote_probes_enabled: true,
133
+ trigger_token_key: trigger_key,
134
+ capture_policy: {
135
+ preset: 'balanced',
136
+ capture_logs: 'warning',
137
+ capture_request_events: 'all',
138
+ capture_breadcrumbs: 'exception_only',
139
+ capture_probe_events: 'buffer_only',
140
+ immediate_client_error_statuses: []
141
+ }
142
+ }
143
+ }
144
+ end
145
+
146
+ trigger_client = DebugBundle::Client.new(
147
+ project_token: 'dbundle_proj_test',
148
+ service: 'checkout-api',
149
+ transport: transport,
150
+ config_fetcher: config_fetcher
151
+ )
152
+
153
+ app = lambda do |_env|
154
+ trigger_client.probe('checkout.tax', { region: 'us-east-1' })
155
+ [200, { 'Content-Type' => 'application/json' }, ['ok']]
156
+ end
157
+
158
+ middleware = described_class.new(app, client: trigger_client)
159
+ middleware.call(
160
+ 'REQUEST_METHOD' => 'GET',
161
+ 'PATH_INFO' => '/checkout',
162
+ 'QUERY_STRING' => '',
163
+ 'HTTP_X_DEBUGBUNDLE_PROBE_TRIGGER' => token,
164
+ 'HTTP_X_REQUEST_ID' => 'req-trigger'
165
+ )
166
+
167
+ trigger_client.flush
168
+ events = transport_events.fetch(0).fetch(:events)
169
+ probe_event = events.find { |entry| entry.fetch('event_type') == 'probe_event' }
170
+
171
+ expect(probe_event).not_to be_nil
172
+ expect(probe_event.fetch('payload')).to include(
173
+ 'label' => 'checkout.tax',
174
+ 'activation_id' => 'probe-1',
175
+ 'probe_label_pattern' => 'checkout.*'
176
+ )
177
+ end
178
+
179
+ it 'captures Rails route, controller, and action metadata when present' do
180
+ app = lambda do |_env|
181
+ [422, { 'Content-Type' => 'application/json' }, ['invalid']]
182
+ end
183
+
184
+ middleware = described_class.new(app, client: client)
185
+ middleware.call(
186
+ 'REQUEST_METHOD' => 'POST',
187
+ 'PATH_INFO' => '/patients/123/checkouts',
188
+ 'QUERY_STRING' => '',
189
+ 'action_dispatch.request_id' => 'req-rails',
190
+ 'action_dispatch.route_uri_pattern' => '/patients/:patient_id/checkouts',
191
+ 'action_dispatch.request.parameters' => {
192
+ 'controller' => 'checkouts',
193
+ 'action' => 'create'
194
+ }
195
+ )
196
+
197
+ client.flush
198
+ payload = transport_events.fetch(0).fetch(:events).fetch(0).fetch('payload')
199
+
200
+ expect(payload).to include(
201
+ 'route_template' => '/patients/:patient_id/checkouts',
202
+ 'controller' => 'checkouts',
203
+ 'action' => 'create'
204
+ )
205
+ end
206
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'logger'
6
+ require 'rack/test'
7
+ require 'rails'
8
+ require 'debugbundle/rails'
9
+
10
+ RSpec.describe 'Rails Railtie integration' do
11
+ include Rack::Test::Methods
12
+
13
+ def app
14
+ @app ||= build_rails_app
15
+ end
16
+
17
+ after do
18
+ @app = nil
19
+ DebugBundle.client = nil
20
+ Rails.instance_variable_set(:@application, nil)
21
+ end
22
+
23
+ it 'mounts the configured relay route and forwards browser events' do
24
+ post '/relay/browser',
25
+ JSON.generate('batch' => [browser_event]),
26
+ {
27
+ 'CONTENT_TYPE' => 'application/json',
28
+ 'HTTP_HOST' => 'app.example.com',
29
+ 'HTTP_ORIGIN' => 'https://app.example.com'
30
+ }
31
+
32
+ expect(last_response.status).to eq(202)
33
+ expect(last_response.headers.fetch('Content-Type')).to include('application/json')
34
+ expect(JSON.parse(last_response.body)).to include('accepted' => 1, 'rejected' => 0)
35
+ expect(@transport_events.fetch(0).fetch(:events).fetch(0).fetch('service')).to include(
36
+ 'name' => 'rails-checkout',
37
+ 'environment' => 'production'
38
+ )
39
+
40
+ post '/debugbundle/browser',
41
+ JSON.generate('batch' => [browser_event]),
42
+ {
43
+ 'CONTENT_TYPE' => 'application/json',
44
+ 'HTTP_HOST' => 'app.example.com',
45
+ 'HTTP_ORIGIN' => 'https://app.example.com'
46
+ }
47
+
48
+ expect(last_response.status).to eq(404)
49
+ end
50
+
51
+ def build_rails_app
52
+ @transport_events = []
53
+ forward_transport = Class.new do
54
+ define_method(:initialize) do |transport_events|
55
+ @transport_events = transport_events
56
+ end
57
+
58
+ define_method(:call) do |request|
59
+ @transport_events << request
60
+ DebugBundle::Transport::Result.new(status_code: 202)
61
+ end
62
+ end.new(@transport_events)
63
+
64
+ app_class = Class.new(Rails::Application) do
65
+ config.root = File.expand_path('..', __dir__)
66
+ config.eager_load = false
67
+ config.secret_key_base = 'test-secret'
68
+ config.logger = Logger.new(nil)
69
+ end
70
+
71
+ app_class.config.hosts << 'app.example.com' if app_class.config.respond_to?(:hosts)
72
+ app_class.config.debugbundle.project_token = 'dbundle_proj_test'
73
+ app_class.config.debugbundle.service = 'rails-checkout'
74
+ app_class.config.debugbundle.environment = 'production'
75
+ app_class.config.debugbundle.relay_durable_write = false
76
+ app_class.config.debugbundle.relay_allowed_origins = ['https://app.example.com']
77
+ app_class.config.debugbundle.relay_forward_transport = forward_transport
78
+ app_class.config.debugbundle.relay_path = '/relay/browser'
79
+ app_class.initialize!
80
+ app_class
81
+ end
82
+
83
+ def browser_event
84
+ {
85
+ 'schema_version' => '2026-03-01',
86
+ 'event_id' => 'evt-1',
87
+ 'event_type' => 'frontend_exception',
88
+ 'sdk_name' => '@debugbundle/sdk-browser',
89
+ 'sdk_version' => '0.1.0',
90
+ 'occurred_at' => Time.now.utc.iso8601,
91
+ 'service' => { 'name' => 'browser-app', 'environment' => 'production' },
92
+ 'correlation' => { 'trace_id' => 'trace-1' },
93
+ 'payload' => { 'message' => 'boom' }
94
+ }
95
+ end
96
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'stringio'
6
+
7
+ RSpec.describe DebugBundle::Rails::RelayEndpoint do
8
+ let(:relay_options_class) do
9
+ Struct.new(
10
+ :project_mode,
11
+ :project_token,
12
+ :service,
13
+ :environment,
14
+ :relay_durable_write,
15
+ :relay_allowed_origins,
16
+ :relay_forward_transport,
17
+ :relay_rate_limit_per_minute,
18
+ :relay_handler,
19
+ keyword_init: true
20
+ )
21
+ end
22
+ let(:app_config_class) { Struct.new(:debugbundle, keyword_init: true) }
23
+ let(:fake_app_class) { Struct.new(:config, keyword_init: true) }
24
+
25
+ it 'builds a relay handler from Rails config and forwards browser events' do
26
+ transport_events = []
27
+ forward_transport = Class.new do
28
+ define_method(:initialize) do |transport_events|
29
+ @transport_events = transport_events
30
+ end
31
+
32
+ define_method(:call) do |request|
33
+ @transport_events << request
34
+ DebugBundle::Transport::Result.new(status_code: 202)
35
+ end
36
+ end.new(transport_events)
37
+
38
+ app = fake_app_class.new(
39
+ config: app_config_class.new(
40
+ debugbundle: relay_options_class.new(
41
+ project_mode: :connected,
42
+ project_token: 'dbundle_proj_test',
43
+ service: 'rails-checkout',
44
+ environment: 'production',
45
+ relay_durable_write: false,
46
+ relay_allowed_origins: ['https://app.example.com'],
47
+ relay_forward_transport: forward_transport,
48
+ relay_rate_limit_per_minute: 10
49
+ )
50
+ )
51
+ )
52
+
53
+ endpoint = described_class.new(app: app)
54
+ status, headers, body = endpoint.call(
55
+ 'REQUEST_METHOD' => 'POST',
56
+ 'CONTENT_TYPE' => 'application/json',
57
+ 'HOST' => 'app.example.com',
58
+ 'HTTP_ORIGIN' => 'https://app.example.com',
59
+ 'rack.input' => StringIO.new(
60
+ JSON.generate(
61
+ 'batch' => [
62
+ {
63
+ 'schema_version' => '2026-03-01',
64
+ 'event_id' => 'evt-1',
65
+ 'event_type' => 'frontend_exception',
66
+ 'sdk_name' => '@debugbundle/sdk-browser',
67
+ 'sdk_version' => '0.1.0',
68
+ 'occurred_at' => Time.now.utc.iso8601,
69
+ 'service' => { 'name' => 'browser-app', 'environment' => 'production' },
70
+ 'correlation' => { 'trace_id' => 'trace-1' },
71
+ 'payload' => { 'message' => 'boom' }
72
+ }
73
+ ]
74
+ )
75
+ )
76
+ )
77
+
78
+ expect(status).to eq(202)
79
+ expect(headers).to include('Content-Type' => 'application/json')
80
+ expect(JSON.parse(body.join)).to include('accepted' => 1, 'rejected' => 0)
81
+ expect(transport_events.fetch(0).fetch(:events).fetch(0).fetch('service')).to include(
82
+ 'name' => 'rails-checkout',
83
+ 'environment' => 'production'
84
+ )
85
+ end
86
+
87
+ it 'uses an explicit relay handler override when provided' do
88
+ custom_handler = Class.new do
89
+ attr_reader :requests
90
+
91
+ def initialize
92
+ @requests = []
93
+ end
94
+
95
+ def handle(request)
96
+ @requests << request
97
+ DebugBundle::Relay::Response.new(status: 202, body: { 'accepted' => 0, 'rejected' => 0, 'errors' => [] })
98
+ end
99
+ end.new
100
+
101
+ app = fake_app_class.new(
102
+ config: app_config_class.new(
103
+ debugbundle: relay_options_class.new(
104
+ relay_handler: custom_handler
105
+ )
106
+ )
107
+ )
108
+
109
+ endpoint = described_class.new(app: app)
110
+ status, = endpoint.call(
111
+ 'REQUEST_METHOD' => 'POST',
112
+ 'CONTENT_TYPE' => 'application/json',
113
+ 'HOST' => 'example.com',
114
+ 'HTTP_ORIGIN' => 'https://example.com',
115
+ 'rack.input' => StringIO.new('{"batch":[]}')
116
+ )
117
+
118
+ expect(status).to eq(202)
119
+ expect(custom_handler.requests.fetch(0)).to include(method: 'POST')
120
+ end
121
+ end
@@ -0,0 +1,42 @@
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