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.
@@ -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
@@ -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
 
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DebugBundle
4
- VERSION = '1.2.0'
4
+ VERSION = '1.3.0'
5
5
  end
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)