debugbundle 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/debugbundle/acknowledgement.rb +59 -0
- data/lib/debugbundle/before_send.rb +172 -0
- data/lib/debugbundle/client.rb +99 -225
- data/lib/debugbundle/client_event_support.rb +247 -0
- data/lib/debugbundle/config.rb +5 -2
- data/lib/debugbundle/rails.rb +4 -0
- data/lib/debugbundle/transport.rb +13 -3
- data/lib/debugbundle/version.rb +1 -1
- data/lib/debugbundle.rb +2 -0
- data/spec/before_send_spec.rb +74 -0
- data/spec/client_spec.rb +196 -0
- data/spec/remote_config_spec.rb +63 -385
- data/spec/spec_helper.rb +4 -1
- metadata +6 -2
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugBundle
|
|
4
|
+
class Client
|
|
5
|
+
module EventSupport
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def capture_enabled? = config.enabled? && config.configured?
|
|
9
|
+
|
|
10
|
+
def merge_context(context)
|
|
11
|
+
merged = @context.merge(stringify_hash(context || {}))
|
|
12
|
+
@redactor.redact_value(merged)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def stringify_hash(value)
|
|
16
|
+
return {} unless value.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
19
|
+
result[key.to_s] = nested_value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def request_payload(request)
|
|
24
|
+
source = object_to_hash(request)
|
|
25
|
+
{
|
|
26
|
+
'method' => source['method'] || 'UNKNOWN',
|
|
27
|
+
'path' => source['path'] || '/',
|
|
28
|
+
'query' => @redactor.redact_value(source['query'] || {}),
|
|
29
|
+
'headers' => sanitized_headers(source['headers'] || {}),
|
|
30
|
+
'body' => @redactor.redact_value(source['body'] || {})
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def response_payload(response)
|
|
35
|
+
source = object_to_hash(response)
|
|
36
|
+
{
|
|
37
|
+
'status_code' => source['status_code'] || source['status'] || 0,
|
|
38
|
+
'headers' => sanitized_headers(source['headers'] || {}),
|
|
39
|
+
'body' => @redactor.redact_value(source['body'] || {})
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def runtime_payload = Runtime.payload
|
|
44
|
+
|
|
45
|
+
def exception_causes(error)
|
|
46
|
+
causes = []
|
|
47
|
+
current = error.cause
|
|
48
|
+
|
|
49
|
+
while current
|
|
50
|
+
causes << {
|
|
51
|
+
'name' => current.class.name,
|
|
52
|
+
'message' => current.message.to_s,
|
|
53
|
+
'stack' => Array(current.backtrace).join("\n")
|
|
54
|
+
}
|
|
55
|
+
current = current.cause
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
causes
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def probe_snapshot
|
|
62
|
+
items = @probe_buffers.values.flatten.map do |entry|
|
|
63
|
+
entry.merge('activation_id' => nil)
|
|
64
|
+
end
|
|
65
|
+
return {} if items.empty?
|
|
66
|
+
|
|
67
|
+
{ 'version' => 1, 'items' => items }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resolve_probe_value(data, block)
|
|
71
|
+
[true, block ? block.call : data]
|
|
72
|
+
rescue StandardError
|
|
73
|
+
[false, nil]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_probe_data(value)
|
|
77
|
+
redacted = @redactor.redact_value(value)
|
|
78
|
+
redacted.is_a?(Hash) ? redacted : { 'value' => redacted }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def apply_before_send(event)
|
|
82
|
+
BeforeSend.apply(event, config.before_send)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def enqueue_event(event)
|
|
86
|
+
return unless sampled_in?
|
|
87
|
+
|
|
88
|
+
@buffer_mutex.synchronize do
|
|
89
|
+
@buffer << event
|
|
90
|
+
@buffer.shift while @buffer.length > MAX_BUFFER_SIZE
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def buffered_batch
|
|
95
|
+
@buffer_mutex.synchronize { @buffer.dup }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def remove_buffered_events(events)
|
|
99
|
+
event_ids = events.map { |event| event['event_id'] }
|
|
100
|
+
@buffer_mutex.synchronize do
|
|
101
|
+
@buffer.reject! { |event| event_ids.include?(event['event_id']) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def sampled_in?
|
|
106
|
+
return false if config.sample_rate <= 0.0
|
|
107
|
+
return true if config.sample_rate >= 1.0
|
|
108
|
+
|
|
109
|
+
@random_provider.call.to_f < config.sample_rate
|
|
110
|
+
rescue StandardError
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def append_suppression_aggregates
|
|
115
|
+
@suppression.drain_aggregates(now: monotonic_now).each do |aggregate|
|
|
116
|
+
event = apply_before_send(base_event('error_suppressed', aggregate, {}))
|
|
117
|
+
enqueue_event(event) if event
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def base_event(event_type, payload, context)
|
|
122
|
+
redacted_payload = @redactor.redact_value(payload)
|
|
123
|
+
preserve_redacted_probe_data!(event_type, payload, redacted_payload)
|
|
124
|
+
event = {
|
|
125
|
+
'schema_version' => SCHEMA_VERSION,
|
|
126
|
+
'event_id' => SecureRandom.uuid,
|
|
127
|
+
'event_type' => event_type,
|
|
128
|
+
'project_token' => config.project_token,
|
|
129
|
+
'sdk_name' => SDK_NAME,
|
|
130
|
+
'sdk_version' => DebugBundle::VERSION,
|
|
131
|
+
'service' => {
|
|
132
|
+
'name' => service_name,
|
|
133
|
+
'runtime' => 'ruby',
|
|
134
|
+
'framework' => context['framework'],
|
|
135
|
+
'environment' => environment_name
|
|
136
|
+
},
|
|
137
|
+
'occurred_at' => now.iso8601,
|
|
138
|
+
'correlation' => correlation_payload(context),
|
|
139
|
+
'payload' => redacted_payload
|
|
140
|
+
}
|
|
141
|
+
envelope_context = event_context(context)
|
|
142
|
+
event['context'] = envelope_context unless envelope_context.empty?
|
|
143
|
+
event
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Probe values are redacted before entering the in-memory probe buffer. Re-running
|
|
147
|
+
# the depth limiter after nesting them inside an event would replace valid scalar
|
|
148
|
+
# and list values with truncation markers.
|
|
149
|
+
def preserve_redacted_probe_data!(event_type, payload, redacted_payload)
|
|
150
|
+
if event_type == 'backend_exception' && payload.key?('probe_data')
|
|
151
|
+
preserve_probe_snapshot_values!(payload, redacted_payload)
|
|
152
|
+
elsif event_type == 'probe_event' && payload.key?('data')
|
|
153
|
+
redacted_payload['data'] = payload['data']
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def preserve_probe_snapshot_values!(payload, redacted_payload)
|
|
158
|
+
original_items = payload.dig('probe_data', 'items')
|
|
159
|
+
redacted_items = redacted_payload.dig('probe_data', 'items')
|
|
160
|
+
return unless original_items.is_a?(Array) && redacted_items.is_a?(Array)
|
|
161
|
+
|
|
162
|
+
original_items.zip(redacted_items).each do |original, redacted|
|
|
163
|
+
redacted['data'] = original['data'] if original.is_a?(Hash) && redacted.is_a?(Hash)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def service_name = config.service || DEFAULT_SERVICE_NAME
|
|
168
|
+
|
|
169
|
+
def environment_name = config.environment || DEFAULT_ENVIRONMENT
|
|
170
|
+
|
|
171
|
+
def correlation_payload(context)
|
|
172
|
+
request = object_to_hash(context['request'])
|
|
173
|
+
correlation = object_to_hash(context['correlation'])
|
|
174
|
+
{
|
|
175
|
+
'request_id' => correlation['request_id'] || request['request_id'] || context['request_id'],
|
|
176
|
+
'trace_id' => correlation['trace_id'] || request['trace_id'] || context['trace_id'],
|
|
177
|
+
'session_id' => correlation['session_id'] || context['session_id'],
|
|
178
|
+
'user_id_hash' => correlation['user_id_hash'] || context['user_id_hash']
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def event_context(context)
|
|
183
|
+
object_to_hash(context).except(
|
|
184
|
+
'request',
|
|
185
|
+
'response',
|
|
186
|
+
'correlation',
|
|
187
|
+
'request_id',
|
|
188
|
+
'trace_id',
|
|
189
|
+
'session_id',
|
|
190
|
+
'user_id_hash'
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def object_to_hash(value)
|
|
195
|
+
case value
|
|
196
|
+
when Hash
|
|
197
|
+
stringify_hash(value)
|
|
198
|
+
else
|
|
199
|
+
return stringify_hash(value.to_h) if value.respond_to?(:to_h)
|
|
200
|
+
return stringify_hash(value.to_hash) if value.respond_to?(:to_hash)
|
|
201
|
+
|
|
202
|
+
{}
|
|
203
|
+
end
|
|
204
|
+
rescue StandardError
|
|
205
|
+
{}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def sanitized_headers(headers)
|
|
209
|
+
stringify_hash(headers).each_with_object({}) do |(key, value), result|
|
|
210
|
+
normalized_key = key.to_s.downcase
|
|
211
|
+
next unless DEFAULT_HEADER_ALLOWLIST.include?(normalized_key)
|
|
212
|
+
|
|
213
|
+
result[normalized_key] = @redactor.redact_value(value)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def normalize_level(level)
|
|
218
|
+
candidate = level.to_s.strip.downcase.to_sym
|
|
219
|
+
return candidate if LOG_LEVEL_RANKS.key?(candidate)
|
|
220
|
+
|
|
221
|
+
:warning
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def level_enabled?(level)
|
|
225
|
+
threshold = [normalize_level(config.log_level), policy_log_level].max_by do |entry|
|
|
226
|
+
LOG_LEVEL_RANKS.fetch(entry)
|
|
227
|
+
end
|
|
228
|
+
LOG_LEVEL_RANKS.fetch(level) >= LOG_LEVEL_RANKS.fetch(threshold)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def policy_log_level
|
|
232
|
+
case @capture_policy.capture_logs
|
|
233
|
+
when 'off'
|
|
234
|
+
:fatal
|
|
235
|
+
when 'error'
|
|
236
|
+
:error
|
|
237
|
+
when 'info'
|
|
238
|
+
:info
|
|
239
|
+
else
|
|
240
|
+
:warning
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
include EventSupport
|
|
246
|
+
end
|
|
247
|
+
end
|
data/lib/debugbundle/config.rb
CHANGED
|
@@ -39,7 +39,8 @@ module DebugBundle
|
|
|
39
39
|
:max_probe_labels,
|
|
40
40
|
:max_probe_entries_per_label,
|
|
41
41
|
:probe_flush_on_error,
|
|
42
|
-
:probes_poll_interval
|
|
42
|
+
:probes_poll_interval,
|
|
43
|
+
:before_send
|
|
43
44
|
|
|
44
45
|
def initialize(
|
|
45
46
|
project_token: nil,
|
|
@@ -61,7 +62,8 @@ module DebugBundle
|
|
|
61
62
|
max_probe_labels: DEFAULT_MAX_PROBE_LABELS,
|
|
62
63
|
max_probe_entries_per_label: DEFAULT_MAX_PROBE_ENTRIES_PER_LABEL,
|
|
63
64
|
probe_flush_on_error: DEFAULT_PROBE_FLUSH_ON_ERROR,
|
|
64
|
-
probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL
|
|
65
|
+
probes_poll_interval: DEFAULT_PROBES_POLL_INTERVAL,
|
|
66
|
+
before_send: nil
|
|
65
67
|
)
|
|
66
68
|
@project_token = project_token
|
|
67
69
|
@enabled = enabled
|
|
@@ -89,6 +91,7 @@ module DebugBundle
|
|
|
89
91
|
)
|
|
90
92
|
@probe_flush_on_error = probe_flush_on_error
|
|
91
93
|
@probes_poll_interval = normalize_positive_number(probes_poll_interval, DEFAULT_PROBES_POLL_INTERVAL)
|
|
94
|
+
@before_send = before_send.respond_to?(:call) ? before_send : nil
|
|
92
95
|
freeze
|
|
93
96
|
end
|
|
94
97
|
|
data/lib/debugbundle/rails.rb
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
begin
|
|
4
4
|
require 'rails/railtie'
|
|
5
|
+
# The gem is intentionally usable without Rails; this branch is exercised by
|
|
6
|
+
# the clean non-Rails gem smoke rather than the Rails-enabled coverage process.
|
|
7
|
+
# :nocov:
|
|
5
8
|
rescue LoadError
|
|
6
9
|
nil
|
|
7
10
|
end
|
|
11
|
+
# :nocov:
|
|
8
12
|
|
|
9
13
|
require_relative 'rails/relay_endpoint'
|
|
10
14
|
require_relative 'rails/railtie' if defined?(Rails::Railtie)
|
|
@@ -10,7 +10,7 @@ module DebugBundle
|
|
|
10
10
|
module Transport
|
|
11
11
|
RETRY_AFTER_CAP_SECONDS = 300
|
|
12
12
|
|
|
13
|
-
Result = Struct.new(:status_code, :retry_after_seconds, keyword_init: true)
|
|
13
|
+
Result = Struct.new(:status_code, :retry_after_seconds, :body, keyword_init: true)
|
|
14
14
|
|
|
15
15
|
def self.sdk_config_endpoint(events_endpoint)
|
|
16
16
|
uri = URI.parse(events_endpoint)
|
|
@@ -36,7 +36,8 @@ module DebugBundle
|
|
|
36
36
|
|
|
37
37
|
Result.new(
|
|
38
38
|
status_code: result.status_code.to_i,
|
|
39
|
-
retry_after_seconds: result.respond_to?(:retry_after_seconds) ? result.retry_after_seconds : nil
|
|
39
|
+
retry_after_seconds: result.respond_to?(:retry_after_seconds) ? result.retry_after_seconds : nil,
|
|
40
|
+
body: result.respond_to?(:body) ? result.body : nil
|
|
40
41
|
)
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -62,7 +63,8 @@ module DebugBundle
|
|
|
62
63
|
|
|
63
64
|
Result.new(
|
|
64
65
|
status_code: response.code.to_i,
|
|
65
|
-
retry_after_seconds: parse_retry_after(response['Retry-After'])
|
|
66
|
+
retry_after_seconds: parse_retry_after(response['Retry-After']),
|
|
67
|
+
body: parse_body(response.body)
|
|
66
68
|
)
|
|
67
69
|
rescue StandardError
|
|
68
70
|
Result.new(status_code: 500)
|
|
@@ -70,6 +72,14 @@ module DebugBundle
|
|
|
70
72
|
|
|
71
73
|
private
|
|
72
74
|
|
|
75
|
+
def parse_body(body)
|
|
76
|
+
return nil if body.to_s.empty?
|
|
77
|
+
|
|
78
|
+
JSON.parse(body)
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
body
|
|
81
|
+
end
|
|
82
|
+
|
|
73
83
|
def parse_retry_after(value)
|
|
74
84
|
return nil if value.nil? || value.strip.empty?
|
|
75
85
|
|
data/lib/debugbundle/version.rb
CHANGED
data/lib/debugbundle.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'debugbundle/redaction'
|
|
4
|
+
require_relative 'debugbundle/acknowledgement'
|
|
5
|
+
require_relative 'debugbundle/before_send'
|
|
4
6
|
require_relative 'debugbundle/rack/middleware'
|
|
5
7
|
require_relative 'debugbundle/logging'
|
|
6
8
|
require_relative 'debugbundle/remote_config'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe DebugBundle::BeforeSend do
|
|
6
|
+
let(:base_event) do
|
|
7
|
+
{
|
|
8
|
+
'schema_version' => '2026-03-01',
|
|
9
|
+
'event_id' => '11111111-1111-4111-8111-111111111111',
|
|
10
|
+
'event_type' => 'log_event',
|
|
11
|
+
'occurred_at' => '2026-07-27T08:00:00Z',
|
|
12
|
+
'sdk_name' => '@debugbundle/sdk-ruby',
|
|
13
|
+
'sdk_version' => '1.3.0',
|
|
14
|
+
'service' => { 'name' => 'ruby-test', 'environment' => 'test' },
|
|
15
|
+
'payload' => { 'level' => 'error', 'message' => 'failure', 'attributes' => {} }
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'validates every closed canonical payload variant' do
|
|
20
|
+
payloads = {
|
|
21
|
+
'backend_exception' => {
|
|
22
|
+
'name' => 'RuntimeError', 'message' => 'failure', 'stack' => 'stack', 'handled' => true,
|
|
23
|
+
'request' => {}, 'response' => {}, 'runtime' => {}, 'probe_data' => {}
|
|
24
|
+
},
|
|
25
|
+
'request_event' => {
|
|
26
|
+
'method' => 'GET', 'path' => '/', 'query' => {}, 'headers' => {},
|
|
27
|
+
'response_status' => 503, 'duration_ms' => 10, 'response_headers' => {}
|
|
28
|
+
},
|
|
29
|
+
'frontend_breadcrumb' => { 'breadcrumb_type' => 'navigation', 'data' => {} },
|
|
30
|
+
'frontend_exception' => {
|
|
31
|
+
'name' => 'Error', 'message' => 'failure', 'stack' => 'stack',
|
|
32
|
+
'breadcrumbs' => [], 'probe_data' => {}
|
|
33
|
+
},
|
|
34
|
+
'deploy_metadata' => {
|
|
35
|
+
'commit_sha' => 'abc', 'version' => '1', 'branch' => 'main',
|
|
36
|
+
'environment' => 'test', 'deployed_at' => '2026-07-27T08:00:00Z'
|
|
37
|
+
},
|
|
38
|
+
'error_suppressed' => {
|
|
39
|
+
'fingerprint' => 'fp', 'suppressed_count' => 2, 'window_seconds' => 60,
|
|
40
|
+
'first_seen' => '2026-07-27T08:00:00Z', 'last_seen' => '2026-07-27T08:01:00Z'
|
|
41
|
+
},
|
|
42
|
+
'probe_event' => {
|
|
43
|
+
'label' => 'cart.total', 'data' => { 'value' => 2 },
|
|
44
|
+
'activation_id' => nil, 'probe_label_pattern' => 'cart.*'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
payloads.each do |event_type, payload|
|
|
49
|
+
event = base_event.merge('event_type' => event_type, 'payload' => payload)
|
|
50
|
+
expect(described_class.valid?(event)).to be_truthy, event_type
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'rejects malformed roots, timestamps, payload fields, and typed values' do
|
|
55
|
+
expect(described_class.valid?([])).to be(false)
|
|
56
|
+
expect(described_class.valid?(base_event.merge('unexpected' => true))).to be(false)
|
|
57
|
+
expect(described_class.valid?(base_event.merge('event_id' => 'not-a-uuid'))).to be(false)
|
|
58
|
+
expect(described_class.valid?(base_event.merge('occurred_at' => 'not-a-time'))).to be(false)
|
|
59
|
+
expect(
|
|
60
|
+
described_class.valid?(
|
|
61
|
+
base_event.merge('payload' => base_event['payload'].merge('unexpected' => true))
|
|
62
|
+
)
|
|
63
|
+
).to be(false)
|
|
64
|
+
expect(described_class.valid?(base_event.merge('event_type' => 'unknown'))).to be_falsey
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'covers strict scalar helpers used by request, suppression, and probe validation' do
|
|
68
|
+
expect(described_class.non_negative_number?(Float::INFINITY)).to be(false)
|
|
69
|
+
expect(described_class.non_negative_integer?(-1)).to be(false)
|
|
70
|
+
expect(described_class.positive_integer?(0)).to be(false)
|
|
71
|
+
expect(described_class.timestamp?('invalid')).to be(false)
|
|
72
|
+
expect(described_class.nullable_uuid?('invalid')).to be(false)
|
|
73
|
+
end
|
|
74
|
+
end
|
data/spec/client_spec.rb
CHANGED
|
@@ -117,6 +117,104 @@ RSpec.describe DebugBundle::Client do
|
|
|
117
117
|
expect(flushed_events.count { |event| event.fetch('event_type') == 'error_suppressed' }).to eq(1)
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
it 'wraps list, scalar, and nil probe data before exception attachment' do
|
|
121
|
+
client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
|
|
122
|
+
client.probe('list', %w[first second])
|
|
123
|
+
client.probe('scalar', 42)
|
|
124
|
+
client.probe('nil', nil)
|
|
125
|
+
client.capture_exception(RuntimeError.new('boom'))
|
|
126
|
+
|
|
127
|
+
client.flush
|
|
128
|
+
|
|
129
|
+
exception = transport_events.fetch(0).fetch(:events).find do |event|
|
|
130
|
+
event.fetch('event_type') == 'backend_exception'
|
|
131
|
+
end
|
|
132
|
+
items = exception.fetch('payload').fetch('probe_data').fetch('items')
|
|
133
|
+
expect(items.map { |item| item.fetch('data') }).to eq(
|
|
134
|
+
[
|
|
135
|
+
{ 'value' => %w[first second] },
|
|
136
|
+
{ 'value' => 42 },
|
|
137
|
+
{ 'value' => nil }
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'swallows probe callback failures' do
|
|
143
|
+
client = described_class.new(project_token: 'dbundle_proj_test', transport: transport)
|
|
144
|
+
|
|
145
|
+
expect { client.probe('unsafe') { raise 'callback failed' } }.not_to raise_error
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'runs before_send after redaction and mutates before queueing' do
|
|
149
|
+
observed_passwords = []
|
|
150
|
+
client = described_class.new(
|
|
151
|
+
project_token: 'dbundle_proj_test',
|
|
152
|
+
transport: transport,
|
|
153
|
+
before_send: lambda do |event|
|
|
154
|
+
observed_passwords << event.fetch('context').fetch('password')
|
|
155
|
+
event.fetch('payload')['message'] = 'mutated'
|
|
156
|
+
event
|
|
157
|
+
end
|
|
158
|
+
)
|
|
159
|
+
client.capture_message('original', level: :error, context: { password: 'secret' })
|
|
160
|
+
|
|
161
|
+
client.flush
|
|
162
|
+
|
|
163
|
+
expect(observed_passwords).to eq(['[REDACTED]'])
|
|
164
|
+
expect(transport_events.fetch(0).fetch(:events).fetch(0).dig('payload', 'message')).to eq('mutated')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'handles before_send drop, invalid return, failure, and sampling safely' do
|
|
168
|
+
calls = 0
|
|
169
|
+
dropping_client = described_class.new(
|
|
170
|
+
project_token: 'dbundle_proj_test',
|
|
171
|
+
transport: transport,
|
|
172
|
+
before_send: lambda do |_event|
|
|
173
|
+
calls += 1
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
)
|
|
177
|
+
dropping_client.capture_message('drop', level: :error)
|
|
178
|
+
dropping_client.flush
|
|
179
|
+
expect(calls).to eq(1)
|
|
180
|
+
expect(transport_events).to be_empty
|
|
181
|
+
|
|
182
|
+
invalid_client = described_class.new(
|
|
183
|
+
project_token: 'dbundle_proj_test',
|
|
184
|
+
transport: transport,
|
|
185
|
+
before_send: ->(_event) { { 'invalid' => true } }
|
|
186
|
+
)
|
|
187
|
+
invalid_client.capture_message('preserve invalid', level: :error)
|
|
188
|
+
invalid_client.flush
|
|
189
|
+
|
|
190
|
+
failing_client = described_class.new(
|
|
191
|
+
project_token: 'dbundle_proj_test',
|
|
192
|
+
transport: transport,
|
|
193
|
+
before_send: ->(_event) { raise 'hook failed' }
|
|
194
|
+
)
|
|
195
|
+
failing_client.capture_message('preserve failure', level: :error)
|
|
196
|
+
failing_client.flush
|
|
197
|
+
|
|
198
|
+
expect(transport_events.map { |request| request.fetch(:events).fetch(0).dig('payload', 'message') }).to eq(
|
|
199
|
+
['preserve invalid', 'preserve failure']
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
sampled_calls = 0
|
|
203
|
+
sampled_client = described_class.new(
|
|
204
|
+
project_token: 'dbundle_proj_test',
|
|
205
|
+
transport: transport,
|
|
206
|
+
sample_rate: 0,
|
|
207
|
+
before_send: lambda do |event|
|
|
208
|
+
sampled_calls += 1
|
|
209
|
+
event
|
|
210
|
+
end
|
|
211
|
+
)
|
|
212
|
+
sampled_client.capture_message('sampled out', level: :error)
|
|
213
|
+
sampled_client.flush
|
|
214
|
+
expect(sampled_calls).to eq(1)
|
|
215
|
+
expect(transport_events.length).to eq(2)
|
|
216
|
+
end
|
|
217
|
+
|
|
120
218
|
it 'backs off after 429 responses without dropping buffered events' do
|
|
121
219
|
retry_transport = Class.new do
|
|
122
220
|
def initialize
|
|
@@ -147,6 +245,104 @@ RSpec.describe DebugBundle::Client do
|
|
|
147
245
|
expect(client.buffered_event_count).to eq(0)
|
|
148
246
|
end
|
|
149
247
|
|
|
248
|
+
it 'retries only the indexed retryable rejection from an acknowledgement' do
|
|
249
|
+
requests = []
|
|
250
|
+
responses = [
|
|
251
|
+
DebugBundle::Transport::Result.new(
|
|
252
|
+
status_code: 202,
|
|
253
|
+
body: {
|
|
254
|
+
'accepted' => 1,
|
|
255
|
+
'rejected' => 1,
|
|
256
|
+
'errors' => [{ 'index' => 1, 'reason' => 'rate_limited' }]
|
|
257
|
+
}
|
|
258
|
+
),
|
|
259
|
+
DebugBundle::Transport::Result.new(
|
|
260
|
+
status_code: 202,
|
|
261
|
+
body: { 'accepted' => 1, 'rejected' => 0, 'errors' => [] }
|
|
262
|
+
)
|
|
263
|
+
]
|
|
264
|
+
acknowledgement_transport = lambda do |request|
|
|
265
|
+
requests << request
|
|
266
|
+
responses.shift
|
|
267
|
+
end
|
|
268
|
+
current_time = Time.utc(2026, 5, 23, 12, 0, 0)
|
|
269
|
+
client = described_class.new(
|
|
270
|
+
project_token: 'dbundle_proj_test',
|
|
271
|
+
transport: acknowledgement_transport,
|
|
272
|
+
time_provider: -> { current_time }
|
|
273
|
+
)
|
|
274
|
+
client.capture_log('accepted', level: :warning)
|
|
275
|
+
client.capture_log('retry', level: :warning)
|
|
276
|
+
|
|
277
|
+
expect(client.flush).to be(false)
|
|
278
|
+
expect(client.buffered_event_count).to eq(1)
|
|
279
|
+
expect(client.last_event_at).not_to be_nil
|
|
280
|
+
expect(client.status).to eq(:degraded)
|
|
281
|
+
|
|
282
|
+
current_time += 2
|
|
283
|
+
expect(client.flush).to be(true)
|
|
284
|
+
expect(requests.fetch(1).fetch(:events).map { |event| event.dig('payload', 'message') }).to eq(['retry'])
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it 'removes terminal rejections without reporting delivery success' do
|
|
288
|
+
calls = 0
|
|
289
|
+
acknowledgement_transport = lambda do |_request|
|
|
290
|
+
calls += 1
|
|
291
|
+
DebugBundle::Transport::Result.new(
|
|
292
|
+
status_code: 202,
|
|
293
|
+
body: {
|
|
294
|
+
'accepted' => 0,
|
|
295
|
+
'rejected' => 1,
|
|
296
|
+
'errors' => [{ 'index' => 0, 'reason' => 'capture_policy_rejected' }]
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
client = described_class.new(project_token: 'dbundle_proj_test', transport: acknowledgement_transport)
|
|
301
|
+
client.capture_log('terminal', level: :warning)
|
|
302
|
+
|
|
303
|
+
expect(client.flush).to be(false)
|
|
304
|
+
expect(client.buffered_event_count).to eq(0)
|
|
305
|
+
expect(client.last_event_at).to be_nil
|
|
306
|
+
expect(client.status).to eq(:disconnected)
|
|
307
|
+
expect(client.flush).to be(true)
|
|
308
|
+
expect(calls).to eq(1)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it 'retains the full batch after an inconsistent acknowledgement' do
|
|
312
|
+
requests = []
|
|
313
|
+
responses = [
|
|
314
|
+
DebugBundle::Transport::Result.new(
|
|
315
|
+
status_code: 202,
|
|
316
|
+
body: { 'accepted' => 1, 'rejected' => 0, 'errors' => [] }
|
|
317
|
+
),
|
|
318
|
+
DebugBundle::Transport::Result.new(
|
|
319
|
+
status_code: 202,
|
|
320
|
+
body: { 'accepted' => 2, 'rejected' => 0, 'errors' => [] }
|
|
321
|
+
)
|
|
322
|
+
]
|
|
323
|
+
acknowledgement_transport = lambda do |request|
|
|
324
|
+
requests << request
|
|
325
|
+
responses.shift
|
|
326
|
+
end
|
|
327
|
+
current_time = Time.utc(2026, 5, 23, 12, 0, 0)
|
|
328
|
+
client = described_class.new(
|
|
329
|
+
project_token: 'dbundle_proj_test',
|
|
330
|
+
transport: acknowledgement_transport,
|
|
331
|
+
time_provider: -> { current_time }
|
|
332
|
+
)
|
|
333
|
+
client.capture_log('first', level: :warning)
|
|
334
|
+
client.capture_log('second', level: :warning)
|
|
335
|
+
|
|
336
|
+
expect(client.flush).to be(false)
|
|
337
|
+
expect(client.buffered_event_count).to eq(2)
|
|
338
|
+
expect(client.last_event_at).to be_nil
|
|
339
|
+
expect(client.status).to eq(:degraded)
|
|
340
|
+
|
|
341
|
+
current_time += 2
|
|
342
|
+
expect(client.flush).to be(true)
|
|
343
|
+
expect(requests.fetch(1).fetch(:events).length).to eq(2)
|
|
344
|
+
end
|
|
345
|
+
|
|
150
346
|
it 'defaults development captures to secure local event files' do
|
|
151
347
|
Dir.mktmpdir do |directory|
|
|
152
348
|
client = described_class.new(project_token: 'dbundle_proj_test', local_events_dir: directory)
|