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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'securerandom'
6
+ require 'uri'
7
+ require 'fileutils'
8
+
9
+ module DebugBundle
10
+ module Transport
11
+ RETRY_AFTER_CAP_SECONDS = 300
12
+
13
+ Result = Struct.new(:status_code, :retry_after_seconds, keyword_init: true)
14
+
15
+ def self.sdk_config_endpoint(events_endpoint)
16
+ uri = URI.parse(events_endpoint)
17
+ normalized_path = uri.path.to_s.sub(%r{/+$}, '')
18
+ uri.path = if normalized_path.end_with?('/sdk/config')
19
+ normalized_path
20
+ elsif normalized_path.end_with?('/events')
21
+ normalized_path.sub(%r{/events\z}, '/sdk/config')
22
+ else
23
+ "#{normalized_path}/sdk/config"
24
+ end
25
+ uri.query = nil
26
+ uri.fragment = nil
27
+ uri.to_s
28
+ rescue URI::InvalidURIError
29
+ events_endpoint.to_s.sub(%r{/events\z}, '/sdk/config')
30
+ end
31
+
32
+ def self.coerce_result(result)
33
+ return result if result.is_a?(Result)
34
+
35
+ raise TypeError, 'unsupported transport result' unless result.respond_to?(:status_code)
36
+
37
+ Result.new(
38
+ status_code: result.status_code.to_i,
39
+ retry_after_seconds: result.respond_to?(:retry_after_seconds) ? result.retry_after_seconds : nil
40
+ )
41
+ end
42
+
43
+ class HttpTransport
44
+ def initialize(endpoint)
45
+ @uri = URI.parse(endpoint)
46
+ end
47
+
48
+ def call(request)
49
+ http = Net::HTTP.new(@uri.host, @uri.port)
50
+ http.use_ssl = @uri.scheme == 'https'
51
+ http.open_timeout = 5
52
+ http.read_timeout = 5
53
+
54
+ response = http.post(
55
+ @uri.request_uri,
56
+ JSON.generate(events: request.fetch(:events)),
57
+ {
58
+ 'Authorization' => "Bearer #{request.fetch(:project_token)}",
59
+ 'Content-Type' => 'application/json'
60
+ }
61
+ )
62
+
63
+ Result.new(
64
+ status_code: response.code.to_i,
65
+ retry_after_seconds: parse_retry_after(response['Retry-After'])
66
+ )
67
+ rescue StandardError
68
+ Result.new(status_code: 500)
69
+ end
70
+
71
+ private
72
+
73
+ def parse_retry_after(value)
74
+ return nil if value.nil? || value.strip.empty?
75
+
76
+ seconds = Integer(Float(value))
77
+ seconds.clamp(0, RETRY_AFTER_CAP_SECONDS)
78
+ rescue ArgumentError, TypeError
79
+ nil
80
+ end
81
+ end
82
+
83
+ class HttpConfigFetcher
84
+ def initialize(endpoint, project_token:, sdk_name:, sdk_version:)
85
+ @uri = URI.parse(Transport.sdk_config_endpoint(endpoint))
86
+ @project_token = project_token
87
+ @sdk_name = sdk_name
88
+ @sdk_version = sdk_version
89
+ end
90
+
91
+ def call(etag = nil)
92
+ http = Net::HTTP.new(@uri.host, @uri.port)
93
+ http.use_ssl = @uri.scheme == 'https'
94
+ http.open_timeout = 5
95
+ http.read_timeout = 5
96
+
97
+ request = Net::HTTP::Get.new(@uri.request_uri)
98
+ request['Authorization'] = "Bearer #{@project_token}"
99
+ request['Accept'] = 'application/json'
100
+ request['X-DebugBundle-SDK'] = @sdk_name
101
+ request['X-DebugBundle-SDK-Version'] = @sdk_version
102
+ request['If-None-Match'] = etag if etag
103
+
104
+ response = http.request(request)
105
+ body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
106
+ { status_code: response.code.to_i, etag: response['ETag'], body: body }
107
+ rescue StandardError
108
+ { status_code: 500, body: {} }
109
+ end
110
+ end
111
+
112
+ class FileTransport
113
+ FILE_MODE = 0o600
114
+ DIRECTORY_MODE = 0o700
115
+
116
+ def initialize(directory)
117
+ @raw_directory = directory.to_s
118
+ @directory = File.expand_path(directory)
119
+ end
120
+
121
+ def call(request)
122
+ ensure_secure_directory!(@directory)
123
+
124
+ timestamp = Time.now.utc.strftime('%Y%m%dT%H%M%S')
125
+ service_fragment = request.fetch(:service_name, 'service').to_s.gsub(/[^a-zA-Z0-9_-]+/, '-')
126
+ token_fragment = SecureRandom.hex(12)
127
+ temp_path = File.join(@directory, "#{timestamp}-#{token_fragment}.tmp")
128
+ final_path = File.join(@directory, "#{timestamp}-#{token_fragment}-#{service_fragment}.events.json")
129
+
130
+ payload = JSON.generate(
131
+ request.fetch(:events)
132
+ )
133
+
134
+ write_secure_file(temp_path, payload)
135
+ reject_symlink!(final_path)
136
+ File.rename(temp_path, final_path)
137
+ File.chmod(FILE_MODE, final_path)
138
+
139
+ Result.new(status_code: 202)
140
+ rescue StandardError
141
+ Result.new(status_code: 500)
142
+ ensure
143
+ File.delete(temp_path) if defined?(temp_path) && temp_path && File.exist?(temp_path)
144
+ end
145
+
146
+ private
147
+
148
+ def ensure_secure_directory!(directory)
149
+ parent = File.dirname(directory)
150
+ raise ArgumentError, 'invalid directory' if @raw_directory.split(%r{[\\/]+}).include?('..')
151
+ raise ArgumentError, 'invalid directory' if directory.include?('..')
152
+
153
+ ensure_existing_path_is_not_symlink!(parent)
154
+ FileUtils.mkdir_p(directory, mode: DIRECTORY_MODE)
155
+ ensure_existing_path_is_not_symlink!(directory)
156
+ File.chmod(DIRECTORY_MODE, directory)
157
+ end
158
+
159
+ def ensure_existing_path_is_not_symlink!(path)
160
+ current = File.expand_path(path)
161
+
162
+ loop do
163
+ break unless File.exist?(current)
164
+
165
+ reject_symlink!(current)
166
+ parent = File.dirname(current)
167
+ break if parent == current
168
+
169
+ current = parent
170
+ end
171
+ end
172
+
173
+ def reject_symlink!(path)
174
+ return unless File.exist?(path)
175
+
176
+ raise IOError, 'symlink_path_rejected' if File.lstat(path).symlink?
177
+ end
178
+
179
+ def write_secure_file(path, payload)
180
+ flags = File::WRONLY | File::CREAT | File::EXCL
181
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
182
+ File.open(path, flags, FILE_MODE) do |handle|
183
+ handle.write(payload)
184
+ handle.flush
185
+ handle.fsync
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'openssl'
6
+
7
+ module DebugBundle
8
+ module TriggerToken
9
+ HEADER_NAME = 'x-debugbundle-probe-trigger'
10
+ QUERY_PARAMETER_NAME = '_debug_probe'
11
+ TOKEN_PREFIX = 'dbundle_probe_'
12
+
13
+ def self.resolve_request_directives(request:, trigger_token_key:)
14
+ return [] if request.nil? || trigger_token_key.to_s.empty?
15
+
16
+ token = extract_token(request)
17
+ return [] unless token&.start_with?(TOKEN_PREFIX)
18
+
19
+ payload_segment, signature_segment = split_token(token.delete_prefix(TOKEN_PREFIX))
20
+ return [] unless payload_segment && signature_segment
21
+ return [] unless valid_signature?(payload_segment, signature_segment, trigger_token_key)
22
+
23
+ payload = decode_payload(payload_segment)
24
+ return [] unless payload
25
+ return [] if payload[:expires_at] <= Time.now.utc
26
+
27
+ [
28
+ RemoteConfig::Directive.new(
29
+ id: payload[:activation_id],
30
+ label_pattern: payload[:label_pattern],
31
+ service: payload[:service],
32
+ environment: payload[:environment],
33
+ expires_at: payload[:expires_at]
34
+ )
35
+ ]
36
+ end
37
+
38
+ def self.extract_token(request)
39
+ headers = request[:headers] || request['headers'] || {}
40
+ header_token = extract_map_value(headers, HEADER_NAME, case_insensitive: true)
41
+ return header_token if header_token
42
+
43
+ query = request[:query] || request['query'] || {}
44
+ extract_map_value(query, QUERY_PARAMETER_NAME, case_insensitive: false)
45
+ end
46
+
47
+ def self.split_token(token)
48
+ separator_index = token.index('.')
49
+ return [nil, nil] unless separator_index&.positive? && separator_index < (token.length - 1)
50
+
51
+ [token[0...separator_index], token[(separator_index + 1)..]]
52
+ end
53
+
54
+ def self.decode_payload(payload_segment)
55
+ decoded = base64url_decode(payload_segment)
56
+ return nil unless decoded
57
+
58
+ parsed = JSON.parse(decoded)
59
+ return nil unless parsed.is_a?(Hash)
60
+
61
+ activation_id = parsed['activation_id']
62
+ label_pattern = parsed['label_pattern']
63
+ service = parsed['service']
64
+ environment = parsed['environment']
65
+ expires_at = Time.iso8601(parsed['trigger_expires_at'])
66
+
67
+ return nil if [activation_id, label_pattern, service, environment].any? { |value| value.to_s.empty? }
68
+
69
+ {
70
+ activation_id: activation_id,
71
+ label_pattern: label_pattern,
72
+ service: service,
73
+ environment: environment,
74
+ expires_at: expires_at
75
+ }
76
+ rescue JSON::ParserError, ArgumentError, TypeError
77
+ nil
78
+ end
79
+
80
+ def self.valid_signature?(payload_segment, signature_segment, trigger_token_key)
81
+ expected = OpenSSL::HMAC.digest('sha256', trigger_token_key, payload_segment)
82
+ actual = base64url_decode_bytes(signature_segment)
83
+ return false unless actual && actual.bytesize == expected.bytesize
84
+
85
+ secure_compare(expected, actual)
86
+ end
87
+
88
+ def self.secure_compare(left, right)
89
+ result = 0
90
+ left.bytes.zip(right.bytes) do |left_byte, right_byte|
91
+ result |= left_byte ^ right_byte
92
+ end
93
+ result.zero?
94
+ end
95
+
96
+ def self.extract_map_value(mapping, target_key, case_insensitive:)
97
+ mapping.each do |key, value|
98
+ matches = case_insensitive ? key.to_s.downcase == target_key.downcase : key.to_s == target_key
99
+ next unless matches
100
+
101
+ return value if value.is_a?(String) && !value.empty?
102
+ return value.first if value.is_a?(Array) && value.first.is_a?(String) && !value.first.empty?
103
+ end
104
+
105
+ nil
106
+ end
107
+
108
+ def self.base64url_decode(value)
109
+ decoded = base64url_decode_bytes(value)
110
+ decoded&.force_encoding('UTF-8')
111
+ end
112
+
113
+ def self.base64url_decode_bytes(value)
114
+ padded = value.dup
115
+ remainder = padded.length % 4
116
+ padded += '=' * (4 - remainder) if remainder.positive?
117
+ Base64.urlsafe_decode64(padded)
118
+ rescue ArgumentError
119
+ nil
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'debugbundle/redaction'
4
+ require_relative 'debugbundle/rack/middleware'
5
+ require_relative 'debugbundle/logging'
6
+ require_relative 'debugbundle/remote_config'
7
+ require_relative 'debugbundle/relay'
8
+ require_relative 'debugbundle/rails/relay_endpoint'
9
+ require_relative 'debugbundle/sidekiq/server_middleware'
10
+ require_relative 'debugbundle/suppression'
11
+ require_relative 'debugbundle/transport'
12
+ require_relative 'debugbundle/trigger_token'
13
+ require_relative 'debugbundle/client'
14
+ require_relative 'debugbundle/config'
15
+ require_relative 'debugbundle/version'
16
+
17
+ require_relative 'debugbundle/rails' if defined?(Rails::Railtie)
18
+
19
+ module DebugBundle
20
+ class << self
21
+ def init(**options)
22
+ self.client = Client.new(**options)
23
+ client.capture_exceptions
24
+ client
25
+ end
26
+
27
+ def client
28
+ @client ||= Client.new
29
+ end
30
+
31
+ attr_writer :client
32
+
33
+ def capture_exception(error, context: nil)
34
+ client.capture_exception(error, context: context)
35
+ end
36
+
37
+ def capture_error(error, context: nil)
38
+ client.capture_error(error, context: context)
39
+ end
40
+
41
+ def capture_log(message, level: :warning, context: nil)
42
+ client.capture_log(message, level: level, context: context)
43
+ end
44
+
45
+ def capture_request(request, response, context: nil)
46
+ client.capture_request(request, response, context: context)
47
+ end
48
+
49
+ def capture_message(message, level: nil, context: nil)
50
+ client.capture_message(message, level: level, context: context)
51
+ end
52
+
53
+ def set_context(key, value)
54
+ client.set_context(key, value)
55
+ end
56
+
57
+ def probe(label, data = nil, heavy: false, &block)
58
+ client.probe(label, data, heavy: heavy, &block)
59
+ end
60
+
61
+ def capture_exceptions
62
+ client.capture_exceptions
63
+ end
64
+
65
+ def capture_at_exit
66
+ client.capture_at_exit
67
+ end
68
+
69
+ def with_exception_capture(context: nil, &block)
70
+ client.with_exception_capture(context: context, &block)
71
+ end
72
+
73
+ def capture_logger(logger = ::Logger.new($stdout))
74
+ client.capture_logger(logger)
75
+ end
76
+
77
+ def capture_semantic_logger
78
+ client.capture_semantic_logger
79
+ end
80
+
81
+ def flush
82
+ client.flush
83
+ end
84
+
85
+ def status
86
+ client.status
87
+ end
88
+
89
+ def last_event_at
90
+ client.last_event_at
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+ require 'tmpdir'
6
+
7
+ RSpec.describe DebugBundle::Client 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 'buffers and flushes canonical events' do
23
+ client = described_class.new(
24
+ project_token: 'dbundle_proj_test',
25
+ service: 'checkout-api',
26
+ environment: 'production',
27
+ transport: transport
28
+ )
29
+
30
+ client.set_context(:account_id, 'acct_123')
31
+ client.capture_log('payment retry failed', level: :error, context: { trace_id: 'trace-1' })
32
+
33
+ expect(client.buffered_event_count).to eq(1)
34
+ expect(client.flush).to be(true)
35
+ expect(client.last_event_at).not_to be_nil
36
+
37
+ payload = transport_events.fetch(0)
38
+ event = payload.fetch(:events).fetch(0)
39
+
40
+ expect(event.fetch('event_type')).to eq('log_event')
41
+ expect(event.fetch('schema_version')).to eq('2026-03-01')
42
+ expect(event.fetch('sdk_name')).to eq('@debugbundle/sdk-ruby')
43
+ expect(event.fetch('service')).to include('name' => 'checkout-api', 'environment' => 'production')
44
+ expect(event.fetch('correlation')).to include('trace_id' => 'trace-1')
45
+ expect(event.fetch('payload')).to include('level' => 'error', 'message' => 'payment retry failed')
46
+ end
47
+
48
+ it 'captures explicit exception blocks and re-raises' do
49
+ client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
50
+
51
+ expect do
52
+ client.with_exception_capture(context: { request_id: 'req-1' }) do
53
+ raise ArgumentError, 'invalid checkout'
54
+ end
55
+ end.to raise_error(ArgumentError, 'invalid checkout')
56
+
57
+ client.flush
58
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
59
+ expect(event.fetch('event_type')).to eq('backend_exception')
60
+ expect(event.fetch('payload')).to include('handled' => false)
61
+ expect(event.fetch('correlation')).to include('request_id' => 'req-1')
62
+ end
63
+
64
+ it 'registers at-exit capture once and flushes shutdown exceptions' do
65
+ client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
66
+ registered_callback = nil
67
+
68
+ allow(client).to receive(:at_exit) do |&block|
69
+ registered_callback = block
70
+ end
71
+
72
+ expect(client.capture_exceptions).to be(true)
73
+ expect(client.capture_at_exit).to be(false)
74
+ expect(registered_callback).not_to be_nil
75
+
76
+ begin
77
+ raise 'shutdown boom'
78
+ rescue RuntimeError
79
+ registered_callback.call
80
+ end
81
+
82
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
83
+ expect(event.fetch('event_type')).to eq('backend_exception')
84
+ expect(event.fetch('payload')).to include('handled' => false, 'message' => 'shutdown boom')
85
+ end
86
+
87
+ it 'captures unhandled thread exceptions after hooks are armed' do
88
+ client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
89
+
90
+ allow(client).to receive(:at_exit)
91
+
92
+ expect(client.capture_exceptions).to be(true)
93
+
94
+ thread = Thread.new do
95
+ raise 'thread boom'
96
+ end
97
+
98
+ expect { thread.value }.to raise_error(RuntimeError, 'thread boom')
99
+
100
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
101
+ expect(event.fetch('event_type')).to eq('backend_exception')
102
+ expect(event.fetch('payload')).to include('handled' => false, 'message' => 'thread boom')
103
+ end
104
+
105
+ it 'suppresses duplicate exceptions and emits an aggregate' do
106
+ client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
107
+ error = RuntimeError.new('boom')
108
+ error.set_backtrace(['app/models/checkout.rb:10'])
109
+
110
+ 5.times { client.capture_exception(error) }
111
+
112
+ expect(client.buffered_event_count).to eq(3)
113
+ client.flush
114
+
115
+ flushed_events = transport_events.fetch(0).fetch(:events)
116
+ expect(flushed_events.count { |event| event.fetch('event_type') == 'backend_exception' }).to eq(3)
117
+ expect(flushed_events.count { |event| event.fetch('event_type') == 'error_suppressed' }).to eq(1)
118
+ end
119
+
120
+ it 'backs off after 429 responses without dropping buffered events' do
121
+ retry_transport = Class.new do
122
+ def initialize
123
+ @calls = 0
124
+ end
125
+
126
+ def call(_request)
127
+ @calls += 1
128
+ return DebugBundle::Transport::Result.new(status_code: 429, retry_after_seconds: 1) if @calls == 1
129
+
130
+ DebugBundle::Transport::Result.new(status_code: 202)
131
+ end
132
+ end.new
133
+
134
+ current_time = Time.utc(2026, 5, 23, 12, 0, 0)
135
+ time_provider = -> { current_time }
136
+
137
+ client = described_class.new(project_token: 'dbundle_proj_test', transport: retry_transport,
138
+ time_provider: time_provider)
139
+ client.capture_log('worker started', level: :warning)
140
+
141
+ expect(client.flush).to be(false)
142
+ expect(client.buffered_event_count).to eq(1)
143
+ expect(client.status).to eq(:degraded)
144
+
145
+ current_time += 2
146
+ expect(client.flush).to be(true)
147
+ expect(client.buffered_event_count).to eq(0)
148
+ end
149
+
150
+ it 'defaults development captures to secure local event files' do
151
+ Dir.mktmpdir do |directory|
152
+ client = described_class.new(project_token: 'dbundle_proj_test', local_events_dir: directory)
153
+
154
+ client.capture_log('local event', level: :error)
155
+ expect(client.flush).to be(true)
156
+
157
+ file_name = Dir.children(directory).find { |entry| entry.end_with?('.events.json') }
158
+ event = JSON.parse(File.read(File.join(directory, file_name))).fetch(0)
159
+
160
+ expect(event.fetch('event_type')).to eq('log_event')
161
+ expect(event.fetch('payload')).to include('message' => 'local event')
162
+ end
163
+ end
164
+
165
+ it 'does not raise for invalid project mode configuration' do
166
+ expect do
167
+ described_class.new(project_token: 'dbundle_proj_test', project_mode: :surprising, transport: transport)
168
+ end.not_to raise_error
169
+ end
170
+
171
+ it 'applies sample rate before buffering events' do
172
+ client = described_class.new(
173
+ project_token: 'dbundle_proj_test',
174
+ transport: transport,
175
+ sample_rate: 0.25,
176
+ random_provider: -> { 0.9 }
177
+ )
178
+
179
+ client.capture_log('sampled out', level: :error)
180
+
181
+ expect(client.buffered_event_count).to eq(0)
182
+ end
183
+
184
+ it 'does not include local OS account names in runtime facts' do
185
+ client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
186
+
187
+ client.capture_exception(RuntimeError.new('privacy check'))
188
+ client.flush
189
+
190
+ runtime = transport_events.fetch(0).fetch(:events).fetch(0).fetch('payload').fetch('runtime')
191
+ expect(runtime).not_to have_key('user')
192
+ end
193
+
194
+ it 'builds a default remote config fetcher for connected remote environments' do
195
+ fetcher_instances = []
196
+ fetcher_class = Class.new do
197
+ define_method(:initialize) do |endpoint, project_token:, sdk_name:, sdk_version:|
198
+ fetcher_instances << {
199
+ endpoint: endpoint,
200
+ project_token: project_token,
201
+ sdk_name: sdk_name,
202
+ sdk_version: sdk_version
203
+ }
204
+ end
205
+
206
+ define_method(:call) do |_etag|
207
+ {
208
+ status_code: 200,
209
+ etag: 'cfg-default',
210
+ body: {
211
+ capture_policy: {
212
+ preset: 'minimal',
213
+ capture_logs: 'error',
214
+ capture_request_events: 'failures_only',
215
+ capture_breadcrumbs: 'local_only',
216
+ capture_probe_events: 'buffer_only',
217
+ immediate_client_error_statuses: []
218
+ }
219
+ }
220
+ }
221
+ end
222
+ end
223
+
224
+ stub_const('DebugBundle::Transport::HttpConfigFetcher', fetcher_class)
225
+
226
+ client = described_class.new(project_token: 'dbundle_proj_test', environment: 'production')
227
+ client.capture_log('warning dropped by default fetched policy', level: :warning)
228
+
229
+ expect(fetcher_instances.fetch(0)).to include(
230
+ endpoint: DebugBundle::Config::DEFAULT_ENDPOINT,
231
+ project_token: 'dbundle_proj_test',
232
+ sdk_name: '@debugbundle/sdk-ruby'
233
+ )
234
+ expect(client.buffered_event_count).to eq(0)
235
+ end
236
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe DebugBundle do
6
+ before do
7
+ described_class.client = DebugBundle::Client.new
8
+ end
9
+
10
+ it 'exposes the universal singleton interface' do
11
+ client = described_class.init(project_token: 'dbundle_proj_test', service: 'checkout-api')
12
+
13
+ expect(client).to be_a(DebugBundle::Client)
14
+ expect(described_class).to respond_to(:init)
15
+ expect(described_class).to respond_to(:capture_exception)
16
+ expect(described_class).to respond_to(:capture_error)
17
+ expect(described_class).to respond_to(:capture_log)
18
+ expect(described_class).to respond_to(:capture_request)
19
+ expect(described_class).to respond_to(:capture_message)
20
+ expect(described_class).to respond_to(:set_context)
21
+ expect(described_class).to respond_to(:probe)
22
+ expect(described_class).to respond_to(:flush)
23
+ expect(described_class).to respond_to(:status)
24
+ expect(described_class).to respond_to(:last_event_at)
25
+ end
26
+
27
+ it 'arms exception capture when initialized' do
28
+ client = instance_double(DebugBundle::Client)
29
+ allow(DebugBundle::Client).to receive(:new).and_return(client)
30
+ allow(client).to receive(:capture_exceptions).and_return(true)
31
+
32
+ expect(client).to receive(:capture_exceptions)
33
+ expect(described_class.init(project_token: 'dbundle_proj_test')).to be(client)
34
+ end
35
+
36
+ it 'reports a degraded status without a configured project token' do
37
+ described_class.client = DebugBundle::Client.new
38
+
39
+ expect(described_class.status).to eq(:degraded)
40
+ end
41
+
42
+ it 'records the last event timestamp on flush' do
43
+ transport = Class.new do
44
+ def call(_request)
45
+ DebugBundle::Transport::Result.new(status_code: 202)
46
+ end
47
+ end.new
48
+
49
+ described_class.client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', transport: transport)
50
+ described_class.capture_log('worker started', level: :warning)
51
+
52
+ expect { described_class.flush }.to change(described_class, :last_event_at).from(nil)
53
+ end
54
+ end