debugbundle 1.2.0 → 1.4.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.
@@ -4,6 +4,7 @@ require 'digest'
4
4
  require 'time'
5
5
  require 'uri'
6
6
 
7
+ require 'debugbundle/client_event_support'
7
8
  require 'debugbundle/runtime'
8
9
 
9
10
  module DebugBundle
@@ -96,6 +97,7 @@ module DebugBundle
96
97
  @last_event_at = nil
97
98
  @retry_at = nil
98
99
  @consecutive_failures = 0
100
+ @acknowledgement_state = nil
99
101
  @at_exit_registered = false
100
102
  @thread_exception_registered = false
101
103
  @logger_bindings = {}
@@ -110,6 +112,10 @@ module DebugBundle
110
112
  end
111
113
 
112
114
  def capture_exception(error, context: nil, handled: true)
115
+ capture_exception_internal(error, context: context, handled: handled, run_before_send: true)
116
+ end
117
+
118
+ def capture_exception_internal(error, context:, handled:, run_before_send:)
113
119
  return unless capture_enabled?
114
120
 
115
121
  poll_remote_config_if_due!
@@ -133,11 +139,22 @@ module DebugBundle
133
139
  extra_context = merged_context.except('request', 'response', 'correlation')
134
140
  extra_context['causes'] = causes unless causes.empty?
135
141
 
136
- suppression_key = [payload['name'], payload['message'], payload['stack']].join(':')
142
+ event = base_event('backend_exception', payload, extra_context)
143
+ event = apply_before_send(event) if run_before_send
144
+ return if event.nil?
145
+
146
+ event_payload = event.fetch('payload')
147
+ suppression_key = [
148
+ event['event_type'],
149
+ event_payload['name'],
150
+ event_payload['message'],
151
+ event_payload['stack']
152
+ ].join(':')
137
153
  return unless @suppression.should_capture(suppression_key, now: monotonic_now)
138
154
 
139
- enqueue_event(base_event('backend_exception', payload, extra_context))
155
+ enqueue_event(event)
140
156
  end
157
+ private :capture_exception_internal
141
158
 
142
159
  def capture_error(error, context: nil, handled: true) = capture_exception(error, context: context, handled: handled)
143
160
 
@@ -147,7 +164,6 @@ module DebugBundle
147
164
  poll_remote_config_if_due!
148
165
 
149
166
  normalized_level = normalize_level(level || :warning)
150
- return unless level_enabled?(normalized_level)
151
167
 
152
168
  merged_context = merge_context(context)
153
169
  payload = {
@@ -155,7 +171,10 @@ module DebugBundle
155
171
  'message' => message.to_s,
156
172
  'attributes' => merged_context
157
173
  }
158
- enqueue_event(base_event('log_event', payload, merged_context))
174
+ event = apply_before_send(base_event('log_event', payload, merged_context))
175
+ return if event.nil? || !level_enabled?(normalized_level)
176
+
177
+ enqueue_event(event)
159
178
  end
160
179
 
161
180
  def capture_request(request, response, context: nil)
@@ -167,7 +186,6 @@ module DebugBundle
167
186
  sanitized_request = request_payload(request)
168
187
  sanitized_response = response_payload(response)
169
188
  response_status = (sanitized_response['status_code'] || 0).to_i
170
- return unless capture_request_event?(response_status, sanitized_request)
171
189
 
172
190
  payload = {
173
191
  'method' => sanitized_request['method'],
@@ -181,7 +199,12 @@ module DebugBundle
181
199
  'response_headers' => sanitized_response['headers'],
182
200
  'response_body' => sanitized_response['body']
183
201
  }
184
- enqueue_event(base_event('request_event', payload, merged_context.merge('request' => sanitized_request)))
202
+ event = apply_before_send(
203
+ base_event('request_event', payload, merged_context.merge('request' => sanitized_request))
204
+ )
205
+ return if event.nil? || !capture_request_event?(response_status, sanitized_request)
206
+
207
+ enqueue_event(event)
185
208
  end
186
209
 
187
210
  def capture_message(message, level: nil, context: nil)
@@ -203,18 +226,22 @@ module DebugBundle
203
226
  if heavy
204
227
  return if matching_directives.empty?
205
228
 
206
- raw_value = block ? block.call : data
207
- emit_probe_events(label.to_s, @redactor.redact_value(raw_value), matching_directives)
229
+ resolved, raw_value = resolve_probe_value(data, block)
230
+ return unless resolved
231
+
232
+ emit_probe_events(label.to_s, normalize_probe_data(raw_value), matching_directives)
208
233
  return
209
234
  end
210
235
 
211
236
  return if !@probe_buffers.key?(label) && @probe_buffers.size >= config.max_probe_labels
212
237
 
213
- raw_value = block ? block.call : data
238
+ resolved, raw_value = resolve_probe_value(data, block)
239
+ return unless resolved
240
+
214
241
  entry = {
215
242
  'label' => label.to_s,
216
- 'data' => @redactor.redact_value(raw_value),
217
- 'occurred_at' => now.iso8601
243
+ 'data' => normalize_probe_data(raw_value),
244
+ 'timestamp' => now.iso8601
218
245
  }
219
246
 
220
247
  bucket = (@probe_buffers[label.to_s] ||= [])
@@ -240,7 +267,13 @@ module DebugBundle
240
267
  error = $ERROR_INFO
241
268
  next unless error.is_a?(Exception)
242
269
 
243
- client.capture_exception(error, handled: false)
270
+ client.__send__(
271
+ :capture_exception_internal,
272
+ error,
273
+ context: nil,
274
+ handled: false,
275
+ run_before_send: false
276
+ )
244
277
  client.flush
245
278
  end
246
279
  true
@@ -329,11 +362,7 @@ module DebugBundle
329
362
 
330
363
  case result.status_code
331
364
  when 200..299
332
- remove_buffered_events(batch)
333
- @retry_at = nil
334
- @consecutive_failures = 0
335
- @last_event_at = now
336
- true
365
+ handle_successful_result(result, batch)
337
366
  when 429
338
367
  @consecutive_failures += 1
339
368
  retry_after_seconds = (result.retry_after_seconds || 1).clamp(1, RETRY_AFTER_CAP_SECONDS)
@@ -358,6 +387,7 @@ module DebugBundle
358
387
  def status
359
388
  return :disconnected unless config.enabled?
360
389
  return :degraded unless config.configured?
390
+ return @acknowledgement_state if @acknowledgement_state
361
391
  return :disconnected if @consecutive_failures >= 3
362
392
  return :degraded if rate_limited?
363
393
 
@@ -368,6 +398,48 @@ module DebugBundle
368
398
 
369
399
  private
370
400
 
401
+ def handle_successful_result(result, batch)
402
+ decision = Acknowledgement.decide(result.body, batch.length)
403
+ return handle_protocol_failure if decision[:kind] == :protocol_failure
404
+
405
+ if decision[:kind] == :legacy
406
+ remove_buffered_events(batch)
407
+ record_success
408
+ return true
409
+ end
410
+
411
+ retryable_indices = decision.fetch(:retryable_indices)
412
+ retryable_events = retryable_indices.map { |index| batch.fetch(index) }
413
+ remove_buffered_events(batch - retryable_events)
414
+ @last_event_at = now if decision.fetch(:accepted).positive?
415
+
416
+ if retryable_events.any?
417
+ @consecutive_failures += 1
418
+ @retry_at = now + 1
419
+ @acknowledgement_state = :degraded
420
+ false
421
+ else
422
+ @retry_at = nil
423
+ @consecutive_failures = 0
424
+ @acknowledgement_state = decision.fetch(:accepted).positive? ? nil : :disconnected
425
+ decision.fetch(:accepted).positive?
426
+ end
427
+ end
428
+
429
+ def handle_protocol_failure
430
+ @consecutive_failures += 1
431
+ @retry_at = now + 1
432
+ @acknowledgement_state = :degraded
433
+ false
434
+ end
435
+
436
+ def record_success
437
+ @retry_at = nil
438
+ @consecutive_failures = 0
439
+ @acknowledgement_state = nil
440
+ @last_event_at = now
441
+ end
442
+
371
443
  def build_default_transport
372
444
  return nil unless config.enabled?
373
445
 
@@ -391,204 +463,6 @@ module DebugBundle
391
463
  )
392
464
  end
393
465
 
394
- def capture_enabled? = config.enabled? && config.configured?
395
-
396
- def merge_context(context)
397
- merged = @context.merge(stringify_hash(context || {}))
398
- @redactor.redact_value(merged)
399
- end
400
-
401
- def stringify_hash(value)
402
- return {} unless value.is_a?(Hash)
403
-
404
- value.each_with_object({}) do |(key, nested_value), result|
405
- result[key.to_s] = nested_value
406
- end
407
- end
408
-
409
- def request_payload(request)
410
- source = object_to_hash(request)
411
- {
412
- 'method' => source['method'] || 'UNKNOWN',
413
- 'path' => source['path'] || '/',
414
- 'query' => @redactor.redact_value(source['query'] || {}),
415
- 'headers' => sanitized_headers(source['headers'] || {}),
416
- 'body' => @redactor.redact_value(source['body'] || {})
417
- }
418
- end
419
-
420
- def response_payload(response)
421
- source = object_to_hash(response)
422
- {
423
- 'status_code' => source['status_code'] || source['status'] || 0,
424
- 'headers' => sanitized_headers(source['headers'] || {}),
425
- 'body' => @redactor.redact_value(source['body'] || {})
426
- }
427
- end
428
-
429
- def runtime_payload = Runtime.payload
430
-
431
- def exception_causes(error)
432
- causes = []
433
- current = error.cause
434
-
435
- while current
436
- causes << {
437
- 'name' => current.class.name,
438
- 'message' => current.message.to_s,
439
- 'stack' => Array(current.backtrace).join("\n")
440
- }
441
- current = current.cause
442
- end
443
-
444
- causes
445
- end
446
-
447
- def probe_snapshot
448
- items = @probe_buffers.values.flatten.map do |entry|
449
- entry.merge('activation_id' => nil)
450
- end
451
- return {} if items.empty?
452
-
453
- { 'version' => 1, 'items' => items }
454
- end
455
-
456
- def enqueue_event(event)
457
- return unless sampled_in?
458
-
459
- @buffer_mutex.synchronize do
460
- @buffer << event
461
- @buffer.shift while @buffer.length > MAX_BUFFER_SIZE
462
- end
463
- end
464
-
465
- def buffered_batch
466
- @buffer_mutex.synchronize { @buffer.dup }
467
- end
468
-
469
- def remove_buffered_events(events)
470
- event_ids = events.map { |event| event['event_id'] }
471
- @buffer_mutex.synchronize do
472
- @buffer.reject! { |event| event_ids.include?(event['event_id']) }
473
- end
474
- end
475
-
476
- def sampled_in?
477
- return false if config.sample_rate <= 0.0
478
- return true if config.sample_rate >= 1.0
479
-
480
- @random_provider.call.to_f < config.sample_rate
481
- rescue StandardError
482
- true
483
- end
484
-
485
- def append_suppression_aggregates
486
- @suppression.drain_aggregates(now: monotonic_now).each do |aggregate|
487
- enqueue_event(base_event('error_suppressed', aggregate, {}))
488
- end
489
- end
490
-
491
- def base_event(event_type, payload, context)
492
- event = {
493
- 'schema_version' => SCHEMA_VERSION,
494
- 'event_id' => SecureRandom.uuid,
495
- 'event_type' => event_type,
496
- 'project_token' => config.project_token,
497
- 'sdk_name' => SDK_NAME,
498
- 'sdk_version' => DebugBundle::VERSION,
499
- 'service' => {
500
- 'name' => service_name,
501
- 'runtime' => 'ruby',
502
- 'framework' => context['framework'],
503
- 'environment' => environment_name
504
- },
505
- 'occurred_at' => now.iso8601,
506
- 'correlation' => correlation_payload(context),
507
- 'payload' => @redactor.redact_value(payload)
508
- }
509
- envelope_context = event_context(context)
510
- event['context'] = envelope_context unless envelope_context.empty?
511
- event
512
- end
513
-
514
- def service_name = config.service || DEFAULT_SERVICE_NAME
515
-
516
- def environment_name = config.environment || DEFAULT_ENVIRONMENT
517
-
518
- def correlation_payload(context)
519
- request = object_to_hash(context['request'])
520
- correlation = object_to_hash(context['correlation'])
521
- {
522
- 'request_id' => correlation['request_id'] || request['request_id'] || context['request_id'],
523
- 'trace_id' => correlation['trace_id'] || request['trace_id'] || context['trace_id'],
524
- 'session_id' => correlation['session_id'] || context['session_id'],
525
- 'user_id_hash' => correlation['user_id_hash'] || context['user_id_hash']
526
- }
527
- end
528
-
529
- def event_context(context)
530
- object_to_hash(context).except(
531
- 'request',
532
- 'response',
533
- 'correlation',
534
- 'request_id',
535
- 'trace_id',
536
- 'session_id',
537
- 'user_id_hash'
538
- )
539
- end
540
-
541
- def object_to_hash(value)
542
- case value
543
- when Hash
544
- stringify_hash(value)
545
- else
546
- if value.respond_to?(:to_h)
547
- stringify_hash(value.to_h)
548
- elsif value.respond_to?(:to_hash)
549
- stringify_hash(value.to_hash)
550
- else
551
- {}
552
- end
553
- end
554
- rescue StandardError
555
- {}
556
- end
557
-
558
- def sanitized_headers(headers)
559
- stringify_hash(headers).each_with_object({}) do |(key, value), result|
560
- normalized_key = key.to_s.downcase
561
- next unless DEFAULT_HEADER_ALLOWLIST.include?(normalized_key)
562
-
563
- result[normalized_key] = @redactor.redact_value(value)
564
- end
565
- end
566
-
567
- def normalize_level(level)
568
- candidate = level.to_s.strip.downcase.to_sym
569
- return candidate if LOG_LEVEL_RANKS.key?(candidate)
570
-
571
- :warning
572
- end
573
-
574
- def level_enabled?(level)
575
- threshold = [normalize_level(config.log_level), policy_log_level].max_by { |entry| LOG_LEVEL_RANKS.fetch(entry) }
576
- LOG_LEVEL_RANKS.fetch(level) >= LOG_LEVEL_RANKS.fetch(threshold)
577
- end
578
-
579
- def policy_log_level
580
- case @capture_policy.capture_logs
581
- when 'off'
582
- :fatal
583
- when 'error'
584
- :error
585
- when 'info'
586
- :info
587
- else
588
- :warning
589
- end
590
- end
591
-
592
466
  def capture_request_event?(status_code, request)
593
467
  mode = @capture_policy.capture_request_events
594
468
 
@@ -667,15 +541,12 @@ module DebugBundle
667
541
  end
668
542
 
669
543
  def emit_probe_events(label, data, matching_directives)
670
- allowed_directives = if @capture_policy.capture_probe_events == 'standalone_when_activated'
671
- matching_directives
672
- else
673
- matching_request_trigger_directives(label)
674
- end
675
- return if allowed_directives.empty?
676
-
677
- allowed_directives.each do |directive|
678
- enqueue_event(
544
+ request_directives = matching_request_trigger_directives(label)
545
+ candidate_directives = (matching_directives + request_directives).uniq(&:id)
546
+ return if candidate_directives.empty?
547
+
548
+ candidate_directives.each do |directive|
549
+ event = apply_before_send(
679
550
  base_event(
680
551
  'probe_event',
681
552
  {
@@ -687,6 +558,9 @@ module DebugBundle
687
558
  {}
688
559
  )
689
560
  )
561
+ allowed = request_directives.include?(directive) ||
562
+ @capture_policy.capture_probe_events == 'standalone_when_activated'
563
+ enqueue_event(event) if event && allowed
690
564
  end
691
565
  end
692
566
 
@@ -729,7 +603,7 @@ module DebugBundle
729
603
  end
730
604
 
731
605
  def capture_thread_exception(error)
732
- capture_exception(error, handled: false)
606
+ capture_exception_internal(error, context: nil, handled: false, run_before_send: false)
733
607
  flush
734
608
  rescue StandardError
735
609
  nil
@@ -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