debugbundle 1.0.0 → 1.1.2

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: 4b4c0b584220369f1090b505a818ba607e548173faca6018d24e26e4cb902c88
4
- data.tar.gz: 4faa4b7b09b784f5f320f997f24fb8918106b3f88785ba795dbe0ae2a7a237c5
3
+ metadata.gz: 2233153d82d41df1121182d5e7f217f02c4b1a0c7348386505c0406e09db3e86
4
+ data.tar.gz: d1b0169d3968aa5c11a2eda1751bf9612afa63109818359d48e7ab1f94384c49
5
5
  SHA512:
6
- metadata.gz: 5ceb4089417df9cc82cee9d5f0c3a9e7ea11e4bc69b517a2c9ab5dfeb637e6fb3c57f5bd01a2425cf52fdae66bda463f3b8eff638f37aa556202968478b4060f
7
- data.tar.gz: 4f64e58b96b26f1808514010ade909e6a5b6423cd71fcb83037bb9e54c6cb25b71a2f279ef065a348050c1196ab5a8d6b89a503f26a4e72144d5b6a9215dad44
6
+ metadata.gz: 279871a9cf53e67fbbd269eaddc86c6003428993729d81c1b6586aa787780687c29b8a602716cbad9e1f33ca829564a9b936aaad282611c6594e66073d2654d1
7
+ data.tar.gz: 6b3b419131b880e0073322aff591c8dabfb5343374331246504b99fb31e1deeb1e79c17769c8a2ce7ad4288b81c186e0d0cd63eaa1ac631a709d13659f0f3554
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.0.0
317
+ make smoke-published VERSION=1.1.2
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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'digest'
4
4
  require 'time'
5
+ require 'uri'
5
6
 
6
7
  require 'debugbundle/runtime'
7
8
 
@@ -23,7 +24,6 @@ module DebugBundle
23
24
  ].freeze
24
25
  BALANCED_IMMEDIATE_REQUEST_STATUSES = [408, 423, 424, 425, 429].freeze
25
26
  INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = (BALANCED_IMMEDIATE_REQUEST_STATUSES + [409]).freeze
26
- BALANCED_ANOMALY_REQUEST_STATUSES = [400, 401, 403, 404, 409, 410, 422].freeze
27
27
  LOCAL_ENVIRONMENTS = %w[development local test].freeze
28
28
  REQUEST_TRIGGER_DIRECTIVES_KEY = :__debugbundle_request_trigger_directives__
29
29
  THREAD_HOOK_MUTEX = Mutex.new
@@ -41,9 +41,7 @@ module DebugBundle
41
41
  class << self
42
42
  attr_accessor :thread_exception_client
43
43
 
44
- def dispatch_thread_exception(error)
45
- thread_exception_client&.__send__(:capture_thread_exception, error)
46
- end
44
+ def dispatch_thread_exception(error) = thread_exception_client&.__send__(:capture_thread_exception, error)
47
45
 
48
46
  def install_thread_exception_hook!
49
47
  THREAD_HOOK_MUTEX.synchronize do
@@ -128,23 +126,20 @@ module DebugBundle
128
126
  }
129
127
 
130
128
  causes = exception_causes(error)
131
- payload['causes'] = causes unless causes.empty?
132
129
 
133
130
  probe_data = probe_snapshot
134
131
  payload['probe_data'] = probe_data unless probe_data.empty?
135
132
 
136
133
  extra_context = merged_context.except('request', 'response', 'correlation')
137
- payload['context'] = extra_context unless extra_context.empty?
134
+ extra_context['causes'] = causes unless causes.empty?
138
135
 
139
136
  suppression_key = [payload['name'], payload['message'], payload['stack']].join(':')
140
137
  return unless @suppression.should_capture(suppression_key, now: monotonic_now)
141
138
 
142
- enqueue_event(base_event('backend_exception', payload, merged_context))
139
+ enqueue_event(base_event('backend_exception', payload, extra_context))
143
140
  end
144
141
 
145
- def capture_error(error, context: nil, handled: true)
146
- capture_exception(error, context: context, handled: handled)
147
- end
142
+ def capture_error(error, context: nil, handled: true) = capture_exception(error, context: context, handled: handled)
148
143
 
149
144
  def capture_log(message, level: :warning, context: nil)
150
145
  return unless capture_enabled?
@@ -172,7 +167,7 @@ module DebugBundle
172
167
  sanitized_request = request_payload(request)
173
168
  sanitized_response = response_payload(response)
174
169
  response_status = (sanitized_response['status_code'] || 0).to_i
175
- return unless capture_request_event?(response_status)
170
+ return unless capture_request_event?(response_status, sanitized_request)
176
171
 
177
172
  payload = {
178
173
  'method' => sanitized_request['method'],
@@ -183,8 +178,6 @@ module DebugBundle
183
178
  'response_status' => response_status,
184
179
  'duration_ms' => extract_duration_ms(merged_context, sanitized_response),
185
180
  'route_template' => merged_context['route_template'],
186
- 'controller' => merged_context['controller'],
187
- 'action' => merged_context['action'],
188
181
  'response_headers' => sanitized_response['headers'],
189
182
  'response_body' => sanitized_response['body']
190
183
  }
@@ -371,9 +364,7 @@ module DebugBundle
371
364
  :healthy
372
365
  end
373
366
 
374
- def buffered_event_count
375
- @buffer_mutex.synchronize { @buffer.length }
376
- end
367
+ def buffered_event_count = @buffer_mutex.synchronize { @buffer.length }
377
368
 
378
369
  private
379
370
 
@@ -400,9 +391,7 @@ module DebugBundle
400
391
  )
401
392
  end
402
393
 
403
- def capture_enabled?
404
- config.enabled? && config.configured?
405
- end
394
+ def capture_enabled? = config.enabled? && config.configured?
406
395
 
407
396
  def merge_context(context)
408
397
  merged = @context.merge(stringify_hash(context || {}))
@@ -420,8 +409,8 @@ module DebugBundle
420
409
  def request_payload(request)
421
410
  source = object_to_hash(request)
422
411
  {
423
- 'method' => source['method'],
424
- 'path' => source['path'],
412
+ 'method' => source['method'] || 'UNKNOWN',
413
+ 'path' => source['path'] || '/',
425
414
  'query' => @redactor.redact_value(source['query'] || {}),
426
415
  'headers' => sanitized_headers(source['headers'] || {}),
427
416
  'body' => @redactor.redact_value(source['body'] || {})
@@ -437,9 +426,7 @@ module DebugBundle
437
426
  }
438
427
  end
439
428
 
440
- def runtime_payload
441
- Runtime.payload
442
- end
429
+ def runtime_payload = Runtime.payload
443
430
 
444
431
  def exception_causes(error)
445
432
  causes = []
@@ -502,7 +489,7 @@ module DebugBundle
502
489
  end
503
490
 
504
491
  def base_event(event_type, payload, context)
505
- {
492
+ event = {
506
493
  'schema_version' => SCHEMA_VERSION,
507
494
  'event_id' => SecureRandom.uuid,
508
495
  'event_type' => event_type,
@@ -519,15 +506,14 @@ module DebugBundle
519
506
  'correlation' => correlation_payload(context),
520
507
  'payload' => @redactor.redact_value(payload)
521
508
  }
509
+ envelope_context = event_context(context)
510
+ event['context'] = envelope_context unless envelope_context.empty?
511
+ event
522
512
  end
523
513
 
524
- def service_name
525
- config.service || DEFAULT_SERVICE_NAME
526
- end
514
+ def service_name = config.service || DEFAULT_SERVICE_NAME
527
515
 
528
- def environment_name
529
- config.environment || DEFAULT_ENVIRONMENT
530
- end
516
+ def environment_name = config.environment || DEFAULT_ENVIRONMENT
531
517
 
532
518
  def correlation_payload(context)
533
519
  request = object_to_hash(context['request'])
@@ -540,6 +526,18 @@ module DebugBundle
540
526
  }
541
527
  end
542
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
+
543
541
  def object_to_hash(value)
544
542
  case value
545
543
  when Hash
@@ -591,14 +589,20 @@ module DebugBundle
591
589
  end
592
590
  end
593
591
 
594
- def capture_request_event?(status_code)
592
+ def capture_request_event?(status_code, request)
595
593
  mode = @capture_policy.capture_request_events
596
- immediate_statuses = immediate_request_statuses
597
- anomaly_statuses = anomaly_request_statuses
598
594
 
599
595
  return true if mode == 'all'
600
- return true if immediate_statuses.include?(status_code)
601
- return true if mode == 'failures_only' && anomaly_statuses.include?(status_code)
596
+ return true if immediate_request_event?(status_code, request)
597
+ return true if mode == 'failures_only' && status_code >= 500
598
+
599
+ false
600
+ end
601
+
602
+ def immediate_request_event?(status_code, request)
603
+ return true if status_code >= 500
604
+ return true if immediate_request_statuses.include?(status_code)
605
+ return true if matching_immediate_client_error_path_rule?(status_code, request)
602
606
 
603
607
  false
604
608
  end
@@ -616,10 +620,34 @@ module DebugBundle
616
620
  statuses + Array(@capture_policy.immediate_client_error_statuses)
617
621
  end
618
622
 
619
- def anomaly_request_statuses
620
- return [] if @capture_policy.preset == 'minimal'
623
+ def matching_immediate_client_error_path_rule?(status_code, request)
624
+ return false unless (400..499).cover?(status_code)
625
+
626
+ path = normalize_request_path(request['path'] || request['url'])
627
+ method = request['method'].to_s.upcase
628
+ Array(@capture_policy.immediate_client_error_path_rules).any? do |rule|
629
+ next false unless rule.status_code == status_code
630
+ next false if !rule.http_methods.empty? && !rule.http_methods.include?(method)
631
+
632
+ if rule.path_pattern.end_with?('*')
633
+ path.start_with?(rule.path_pattern.delete_suffix('*'))
634
+ else
635
+ path == rule.path_pattern
636
+ end
637
+ end
638
+ end
639
+
640
+ def normalize_request_path(value)
641
+ begin
642
+ uri = URI.parse(value.to_s)
643
+ return uri.path if uri.path && !uri.path.empty?
644
+ rescue URI::InvalidURIError
645
+ # Fall through to the lightweight path-only fallback.
646
+ end
647
+ fallback = value.to_s.split('?', 2).first.to_s.split('#', 2).first
648
+ return fallback if fallback.start_with?('/') && !fallback.empty?
621
649
 
622
- BALANCED_ANOMALY_REQUEST_STATUSES
650
+ '/'
623
651
  end
624
652
 
625
653
  def matching_probe_directives(label)
@@ -630,9 +658,7 @@ module DebugBundle
630
658
  end
631
659
  end
632
660
 
633
- def current_request_trigger_directives
634
- Array(Thread.current[REQUEST_TRIGGER_DIRECTIVES_KEY])
635
- end
661
+ def current_request_trigger_directives = Array(Thread.current[REQUEST_TRIGGER_DIRECTIVES_KEY])
636
662
 
637
663
  def matching_request_trigger_directives(label)
638
664
  current_request_trigger_directives.select do |directive|
@@ -671,13 +697,9 @@ module DebugBundle
671
697
  0
672
698
  end
673
699
 
674
- def rate_limited?
675
- @retry_at && @retry_at > now
676
- end
700
+ def rate_limited? = @retry_at && @retry_at > now
677
701
 
678
- def local_environment?
679
- LOCAL_ENVIRONMENTS.include?(environment_name.to_s)
680
- end
702
+ def local_environment? = LOCAL_ENVIRONMENTS.include?(environment_name.to_s)
681
703
 
682
704
  def poll_remote_config_if_due!
683
705
  return unless @config_fetcher
@@ -713,12 +735,8 @@ module DebugBundle
713
735
  nil
714
736
  end
715
737
 
716
- def now
717
- @time_provider.call
718
- end
738
+ def now = @time_provider.call
719
739
 
720
- def monotonic_now
721
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
722
- end
740
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
723
741
  end
724
742
  end
@@ -11,9 +11,12 @@ module DebugBundle
11
11
  :capture_breadcrumbs,
12
12
  :capture_probe_events,
13
13
  :immediate_client_error_statuses,
14
+ :immediate_client_error_path_rules,
14
15
  keyword_init: true
15
16
  )
16
17
 
18
+ ImmediateClientErrorPathRule = Struct.new(:status_code, :path_pattern, :http_methods, keyword_init: true)
19
+
17
20
  Directive = Struct.new(:id, :label_pattern, :service, :environment, :expires_at, keyword_init: true) do
18
21
  def active?(label:, service:, environment:, now:)
19
22
  return false if expires_at <= now
@@ -69,7 +72,8 @@ module DebugBundle
69
72
  capture_request_events: 'failures_only',
70
73
  capture_breadcrumbs: 'local_only',
71
74
  capture_probe_events: 'buffer_only',
72
- immediate_client_error_statuses: []
75
+ immediate_client_error_statuses: [],
76
+ immediate_client_error_path_rules: []
73
77
  )
74
78
  end
75
79
 
@@ -80,7 +84,8 @@ module DebugBundle
80
84
  capture_request_events: 'failures_only',
81
85
  capture_breadcrumbs: 'exception_only',
82
86
  capture_probe_events: 'buffer_only',
83
- immediate_client_error_statuses: []
87
+ immediate_client_error_statuses: [],
88
+ immediate_client_error_path_rules: []
84
89
  )
85
90
  end
86
91
 
@@ -122,10 +127,48 @@ module DebugBundle
122
127
  capture_probe_events: payload['capture_probe_events'] || payload[:capture_probe_events] || 'buffer_only',
123
128
  immediate_client_error_statuses: Array(
124
129
  payload['immediate_client_error_statuses'] || payload[:immediate_client_error_statuses]
125
- ).grep(Integer)
130
+ ).grep(Integer),
131
+ immediate_client_error_path_rules: parse_immediate_client_error_path_rules(
132
+ payload['immediate_client_error_path_rules'] || payload[:immediate_client_error_path_rules]
133
+ )
126
134
  )
127
135
  end
128
136
 
137
+ def self.parse_immediate_client_error_path_rules(value)
138
+ entries = Array(value)
139
+ return [] if entries.empty? || entries.length > 25
140
+
141
+ entries.filter_map do |entry|
142
+ next unless entry.is_a?(Hash)
143
+
144
+ status_code = entry['status_code'] || entry[:status_code]
145
+ path_pattern = entry['path_pattern'] || entry[:path_pattern]
146
+ raw_methods = Array(entry['methods'] || entry[:methods])
147
+ next unless status_code.is_a?(Integer) && (400..499).cover?(status_code)
148
+ next unless valid_path_pattern?(path_pattern)
149
+ next if raw_methods.length > 7
150
+
151
+ http_methods = raw_methods.map { |method| method.to_s.upcase }.uniq
152
+ next unless http_methods.all? { |method| %w[GET POST PUT PATCH DELETE HEAD OPTIONS].include?(method) }
153
+
154
+ ImmediateClientErrorPathRule.new(
155
+ status_code: status_code,
156
+ path_pattern: path_pattern,
157
+ http_methods: http_methods
158
+ )
159
+ end
160
+ end
161
+
162
+ def self.valid_path_pattern?(value)
163
+ return false unless value.is_a?(String)
164
+ return false if value.empty? || value.length > 256
165
+ return false unless value.start_with?('/')
166
+ return false if value.include?('?') || value.include?('#')
167
+
168
+ wildcard_index = value.index('*')
169
+ wildcard_index.nil? || wildcard_index == value.length - 1
170
+ end
171
+
129
172
  def self.parse_directive(payload)
130
173
  return nil unless payload.is_a?(Hash)
131
174
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DebugBundle
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.2'
5
5
  end
@@ -57,11 +57,16 @@
57
57
  "event_id": { "type": "string", "format": "uuid" },
58
58
  "event_type": { "type": "string", "minLength": 1 },
59
59
  "project_token": { "type": "string", "minLength": 1 },
60
+ "project_id": { "type": ["string", "null"], "format": "uuid" },
60
61
  "sdk_name": { "type": "string", "minLength": 1 },
61
62
  "sdk_version": { "type": "string", "minLength": 1 },
62
63
  "service": { "$ref": "#/$defs/service" },
63
64
  "occurred_at": { "type": "string", "format": "date-time" },
64
65
  "correlation": { "$ref": "#/$defs/correlation" },
66
+ "context": {
67
+ "type": "object",
68
+ "additionalProperties": true
69
+ },
65
70
  "payload": {}
66
71
  }
67
72
  },
@@ -23,7 +23,7 @@ RSpec.describe 'Rack integration' do
23
23
  client = DebugBundle::Client.new(project_token: 'dbundle_proj_test', service: 'rack-checkout', transport: transport)
24
24
  app = Rack::Builder.new do
25
25
  use DebugBundle::Rack::Middleware, client: client
26
- run ->(_env) { [422, { 'Content-Type' => 'text/plain' }, ['ok']] }
26
+ run ->(_env) { [503, { 'Content-Type' => 'text/plain' }, ['ok']] }
27
27
  end.to_app
28
28
 
29
29
  response = Rack::MockRequest.new(app).get(
@@ -35,10 +35,10 @@ RSpec.describe 'Rack integration' do
35
35
  client.flush
36
36
  event = transport_events.fetch(0).fetch(:events).fetch(0)
37
37
 
38
- expect(response.status).to eq(422)
38
+ expect(response.status).to eq(503)
39
39
  expect(response.body).to eq('ok')
40
40
  expect(event.fetch('event_type')).to eq('request_event')
41
41
  expect(event.fetch('correlation')).to include('request_id' => 'req-rack', 'trace_id' => 'trace-rack')
42
- expect(event.fetch('payload')).to include('path' => '/checkout', 'method' => 'GET', 'response_status' => 422)
42
+ expect(event.fetch('payload')).to include('path' => '/checkout', 'method' => 'GET', 'response_status' => 503)
43
43
  end
44
44
  end
@@ -195,10 +195,14 @@ RSpec.describe DebugBundle::Rack::Middleware do
195
195
  )
196
196
 
197
197
  client.flush
198
- payload = transport_events.fetch(0).fetch(:events).fetch(0).fetch('payload')
198
+ event = transport_events.fetch(0).fetch(:events).fetch(0)
199
+ payload = event.fetch('payload')
200
+ context = event.fetch('context')
199
201
 
200
202
  expect(payload).to include(
201
- 'route_template' => '/patients/:patient_id/checkouts',
203
+ 'route_template' => '/patients/:patient_id/checkouts'
204
+ )
205
+ expect(context).to include(
202
206
  'controller' => 'checkouts',
203
207
  'action' => 'create'
204
208
  )
@@ -50,7 +50,7 @@ if defined?(Sidekiq)
50
50
 
51
51
  expect(event.fetch('event_type')).to eq('backend_exception')
52
52
  expect(event.fetch('correlation')).to include('trace_id' => 'trace-chain-1')
53
- expect(event.fetch('payload').fetch('context').fetch('job')).to include(
53
+ expect(event.fetch('context').fetch('job')).to include(
54
54
  'class' => 'CheckoutWorker',
55
55
  'queue' => 'critical',
56
56
  'jid' => 'jid-chain-1'
@@ -40,7 +40,7 @@ RSpec.describe DebugBundle::Sidekiq::ServerMiddleware do
40
40
 
41
41
  expect(event.fetch('event_type')).to eq('backend_exception')
42
42
  expect(event.fetch('correlation')).to include('trace_id' => 'trace-job-1')
43
- expect(event.fetch('payload').fetch('context').fetch('job')).to include(
43
+ expect(event.fetch('context').fetch('job')).to include(
44
44
  'class' => 'CheckoutWorker',
45
45
  'queue' => 'critical',
46
46
  'jid' => 'jid-1',
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debugbundle
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - DebugBundle
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-31 00:00:00.000000000 Z
10
+ date: 2026-06-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64