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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 126aea22f92db0369dc85cf08b493d4a91691a316c7c9acfe01ef758e7406fda
4
- data.tar.gz: e6dba95f670a89995a35c6024e73317ebc18347764bb786fefd807181b23d06b
3
+ metadata.gz: c33eb05bc99ae435c838069ade718b123f897eae489769a3aca8969289cdd5b2
4
+ data.tar.gz: 13628e1335f3597e46c1457a20b8f242972e5da2f54971cf69c9f5fef08ecf3e
5
5
  SHA512:
6
- metadata.gz: e59add670d8eec20f3dd739101315b6b352ef991652c797a42cd92e9d3d92b3587c50d9382ef62f36b5f47a15101e14dfd03e6cf556c1cd2fe83d9b5100f4275
7
- data.tar.gz: 7771fad92d01afb8b0d6398701e9cb0e1a5199b6f492474cd24b1d5698fddbb24c93a9cc26c29be02523e6d19b08475615d6b52d36adea54185134a4f167eb37
6
+ metadata.gz: 7c0e7b78a867a41fb326300ca20f816760cc43d4534109824c1ced09072a025eb54efaaceb3c16cc0f8d8ef686ecf065e99ee3a2c8b4f99ac50922d267184c25
7
+ data.tar.gz: 5d16ab5d4bddf01eb39fb65269c624d941ab5f9820b89e253c8c9131c528fff935f48d5d56f14b06a1e8d015a173f7412c937ba37476583eb1c32cd367643b21
data/README.md CHANGED
@@ -314,7 +314,7 @@ This repository also ships a clean-install app-driven smoke harness that validat
314
314
 
315
315
  ```sh
316
316
  make smoke
317
- make smoke-published VERSION=1.1.2
317
+ make smoke-published VERSION=1.3.0
318
318
  ```
319
319
 
320
320
  `make smoke` builds the gem, installs it into a fresh RubyGems home, drives a Rack request plus a browser relay batch through the public SDK surface, validates event envelope shape, and confirms the mock ingestion endpoint receives the expected service, environment, SDK metadata, and correlation fields.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugBundle
4
+ module Acknowledgement
5
+ RETRYABLE_REASONS = %w[
6
+ rate_limited
7
+ monthly_quota_exceeded
8
+ analytics_quota_exceeded
9
+ ].freeze
10
+ FIELDS = %w[accepted rejected errors].freeze
11
+
12
+ module_function
13
+
14
+ def decide(body, batch_length)
15
+ return { kind: :legacy } unless body.is_a?(Hash) && FIELDS.any? { |field| body.key?(field) }
16
+ return protocol_failure unless FIELDS.all? { |field| body.key?(field) }
17
+
18
+ accepted = body['accepted']
19
+ rejected = body['rejected']
20
+ errors = body['errors']
21
+ return protocol_failure unless count?(accepted) && count?(rejected) && errors.is_a?(Array)
22
+ return protocol_failure unless accepted + rejected == batch_length && errors.length == rejected
23
+
24
+ seen = {}
25
+ retryable_indices = []
26
+ terminal_errors = []
27
+ errors.each do |error|
28
+ return protocol_failure unless error.is_a?(Hash)
29
+
30
+ index = error['index']
31
+ reason = error['reason']
32
+ return protocol_failure unless index.is_a?(Integer) && index.between?(0, batch_length - 1)
33
+ return protocol_failure unless reason.is_a?(String) && !reason.empty? && !seen[index]
34
+
35
+ seen[index] = true
36
+ if RETRYABLE_REASONS.include?(reason)
37
+ retryable_indices << index
38
+ else
39
+ terminal_errors << [index, reason]
40
+ end
41
+ end
42
+
43
+ {
44
+ kind: :acknowledged,
45
+ accepted: accepted,
46
+ retryable_indices: retryable_indices,
47
+ terminal_errors: terminal_errors
48
+ }
49
+ end
50
+
51
+ def count?(value)
52
+ value.is_a?(Integer) && value >= 0
53
+ end
54
+
55
+ def protocol_failure
56
+ { kind: :protocol_failure }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module DebugBundle
6
+ module BeforeSend
7
+ REQUIRED_PAYLOAD_FIELDS = {
8
+ 'backend_exception' => %w[name message stack handled request response runtime],
9
+ 'request_event' => %w[method path query headers response_status duration_ms],
10
+ 'log_event' => %w[level message attributes],
11
+ 'frontend_breadcrumb' => %w[breadcrumb_type data],
12
+ 'frontend_exception' => %w[name message stack],
13
+ 'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
14
+ 'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen],
15
+ 'probe_event' => %w[label data activation_id probe_label_pattern]
16
+ }.freeze
17
+ ALLOWED_PAYLOAD_FIELDS = {
18
+ 'backend_exception' => %w[name message stack handled request response runtime probe_data],
19
+ 'request_event' => %w[
20
+ method path query headers body response_status duration_ms route_template
21
+ response_headers response_body device
22
+ ],
23
+ 'log_event' => %w[level message attributes device],
24
+ 'frontend_breadcrumb' => %w[breadcrumb_type route data device],
25
+ 'frontend_exception' => %w[
26
+ name message stack route browser breadcrumbs device browser_event
27
+ rejection_reason dom_context probe_data
28
+ ],
29
+ 'deploy_metadata' => %w[commit_sha version branch environment deployed_at],
30
+ 'error_suppressed' => %w[fingerprint suppressed_count window_seconds first_seen last_seen device],
31
+ 'probe_event' => %w[label data activation_id probe_label_pattern device]
32
+ }.freeze
33
+ ROOT_FIELDS = %w[
34
+ schema_version event_id event_type project_token project_id sdk_name sdk_version
35
+ service occurred_at correlation context payload
36
+ ].freeze
37
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
38
+
39
+ module_function
40
+
41
+ def apply(event, hook)
42
+ return event unless hook
43
+
44
+ result = hook.call(Marshal.load(Marshal.dump(event)))
45
+ return nil if result.nil?
46
+
47
+ valid?(result) ? result : event
48
+ rescue StandardError
49
+ event
50
+ end
51
+
52
+ def valid?(event)
53
+ return false unless event.is_a?(Hash)
54
+ return false unless (event.keys - ROOT_FIELDS).empty?
55
+ return false unless %w[schema_version event_id event_type occurred_at sdk_name sdk_version].all? do |field|
56
+ event[field].is_a?(String) && !event[field].empty?
57
+ end
58
+ return false unless UUID_PATTERN.match?(event['event_id'])
59
+
60
+ Time.iso8601(event['occurred_at'])
61
+ service = event['service']
62
+ payload = event['payload']
63
+ fields = REQUIRED_PAYLOAD_FIELDS[event['event_type']]
64
+ allowed_fields = ALLOWED_PAYLOAD_FIELDS[event['event_type']]
65
+ service.is_a?(Hash) &&
66
+ service['name'].is_a?(String) && !service['name'].empty? &&
67
+ service['environment'].is_a?(String) && !service['environment'].empty? &&
68
+ payload.is_a?(Hash) &&
69
+ fields &&
70
+ allowed_fields &&
71
+ (payload.keys - allowed_fields).empty? &&
72
+ fields.all? { |field| payload.key?(field) } &&
73
+ valid_payload_shape?(event['event_type'], payload)
74
+ rescue ArgumentError
75
+ false
76
+ end
77
+
78
+ def valid_payload_shape?(event_type, payload)
79
+ case event_type
80
+ when 'backend_exception'
81
+ valid_backend_exception?(payload)
82
+ when 'request_event'
83
+ valid_request_event?(payload)
84
+ when 'log_event'
85
+ non_empty_strings?(payload, 'level', 'message') && payload['attributes'].is_a?(Hash)
86
+ when 'frontend_breadcrumb'
87
+ non_empty_strings?(payload, 'breadcrumb_type') && payload['data'].is_a?(Hash)
88
+ when 'frontend_exception'
89
+ valid_frontend_exception?(payload)
90
+ when 'deploy_metadata'
91
+ valid_deploy_metadata?(payload)
92
+ when 'error_suppressed'
93
+ valid_error_suppressed?(payload)
94
+ when 'probe_event'
95
+ valid_probe_event?(payload)
96
+ else
97
+ false
98
+ end
99
+ end
100
+
101
+ def valid_backend_exception?(payload)
102
+ non_empty_strings?(payload, 'name', 'message', 'stack') &&
103
+ [true, false].include?(payload['handled']) &&
104
+ %w[request response runtime].all? { |field| payload[field].is_a?(Hash) } &&
105
+ optional_hash?(payload, 'probe_data')
106
+ end
107
+
108
+ def valid_request_event?(payload)
109
+ non_empty_strings?(payload, 'method', 'path') &&
110
+ payload['query'].is_a?(Hash) &&
111
+ payload['headers'].is_a?(Hash) &&
112
+ non_negative_number?(payload['response_status']) &&
113
+ non_negative_number?(payload['duration_ms']) &&
114
+ optional_hash?(payload, 'response_headers')
115
+ end
116
+
117
+ def valid_frontend_exception?(payload)
118
+ non_empty_strings?(payload, 'name', 'message', 'stack') &&
119
+ (!payload.key?('breadcrumbs') || payload['breadcrumbs'].is_a?(Array)) &&
120
+ optional_hash?(payload, 'probe_data')
121
+ end
122
+
123
+ def valid_deploy_metadata?(payload)
124
+ non_empty_strings?(payload, 'commit_sha', 'version', 'branch', 'environment') &&
125
+ timestamp?(payload['deployed_at'])
126
+ end
127
+
128
+ def valid_error_suppressed?(payload)
129
+ non_empty_strings?(payload, 'fingerprint') &&
130
+ non_negative_integer?(payload['suppressed_count']) &&
131
+ positive_integer?(payload['window_seconds']) &&
132
+ timestamp?(payload['first_seen']) &&
133
+ timestamp?(payload['last_seen'])
134
+ end
135
+
136
+ def valid_probe_event?(payload)
137
+ non_empty_strings?(payload, 'label', 'probe_label_pattern') &&
138
+ payload['data'].is_a?(Hash) &&
139
+ nullable_uuid?(payload['activation_id'])
140
+ end
141
+
142
+ def non_empty_strings?(payload, *fields)
143
+ fields.all? { |field| payload[field].is_a?(String) && !payload[field].strip.empty? }
144
+ end
145
+
146
+ def optional_hash?(payload, field)
147
+ !payload.key?(field) || payload[field].is_a?(Hash)
148
+ end
149
+
150
+ def non_negative_number?(value)
151
+ value.is_a?(Numeric) && value.finite? && value >= 0
152
+ end
153
+
154
+ def non_negative_integer?(value)
155
+ value.is_a?(Integer) && value >= 0
156
+ end
157
+
158
+ def positive_integer?(value)
159
+ value.is_a?(Integer) && value.positive?
160
+ end
161
+
162
+ def timestamp?(value)
163
+ value.is_a?(String) && Time.iso8601(value)
164
+ rescue ArgumentError
165
+ false
166
+ end
167
+
168
+ def nullable_uuid?(value)
169
+ value.nil? || (value.is_a?(String) && UUID_PATTERN.match?(value))
170
+ end
171
+ end
172
+ end
@@ -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