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.
- checksums.yaml +7 -0
- data/Gemfile +17 -0
- data/Makefile +43 -0
- data/README.md +168 -0
- data/debugbundle.gemspec +30 -0
- data/lib/debugbundle/client.rb +724 -0
- data/lib/debugbundle/config.rb +144 -0
- data/lib/debugbundle/logging.rb +77 -0
- data/lib/debugbundle/rack/middleware.rb +94 -0
- data/lib/debugbundle/rack/relay_middleware.rb +37 -0
- data/lib/debugbundle/rails/railtie.rb +35 -0
- data/lib/debugbundle/rails/relay_endpoint.rb +100 -0
- data/lib/debugbundle/rails.rb +10 -0
- data/lib/debugbundle/redaction.rb +151 -0
- data/lib/debugbundle/relay/handler.rb +231 -0
- data/lib/debugbundle/relay.rb +4 -0
- data/lib/debugbundle/remote_config.rb +153 -0
- data/lib/debugbundle/runtime.rb +22 -0
- data/lib/debugbundle/sidekiq/server_middleware.rb +34 -0
- data/lib/debugbundle/suppression.rb +121 -0
- data/lib/debugbundle/transport.rb +190 -0
- data/lib/debugbundle/trigger_token.rb +122 -0
- data/lib/debugbundle/version.rb +5 -0
- data/lib/debugbundle.rb +93 -0
- data/spec/client_spec.rb +236 -0
- data/spec/debugbundle_spec.rb +54 -0
- data/spec/file_transport_spec.rb +54 -0
- data/spec/logger_integration_spec.rb +118 -0
- data/spec/rack_integration_spec.rb +44 -0
- data/spec/rack_middleware_spec.rb +206 -0
- data/spec/rails_railtie_spec.rb +96 -0
- data/spec/rails_relay_spec.rb +121 -0
- data/spec/redaction_spec.rb +42 -0
- data/spec/relay_spec.rb +178 -0
- data/spec/remote_config_spec.rb +402 -0
- data/spec/sidekiq_integration_spec.rb +66 -0
- data/spec/sidekiq_middleware_spec.rb +50 -0
- data/spec/spec_helper.rb +20 -0
- data/spec/suppression_spec.rb +16 -0
- 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
|
data/lib/debugbundle.rb
ADDED
|
@@ -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
|
data/spec/client_spec.rb
ADDED
|
@@ -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
|