debugbundle 1.4.1 → 2.0.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -5
  3. data/lib/debugbundle/before_send.rb +1 -1
  4. data/lib/debugbundle/client.rb +189 -65
  5. data/lib/debugbundle/client_event_support.rb +96 -38
  6. data/lib/debugbundle/client_process_support.rb +64 -0
  7. data/lib/debugbundle/client_queue_support.rb +183 -0
  8. data/lib/debugbundle/config_worker.rb +60 -0
  9. data/lib/debugbundle/delivery_worker.rb +103 -0
  10. data/lib/debugbundle/rack/middleware.rb +13 -3
  11. data/lib/debugbundle/redaction.rb +7 -8
  12. data/lib/debugbundle/relay/handler.rb +9 -1
  13. data/lib/debugbundle/safe_input.rb +40 -0
  14. data/lib/debugbundle/suppression.rb +62 -20
  15. data/lib/debugbundle/telemetry_privacy.rb +204 -0
  16. data/lib/debugbundle/version.rb +1 -1
  17. data/lib/debugbundle.rb +1 -0
  18. metadata +8 -23
  19. data/Gemfile +0 -17
  20. data/Makefile +0 -58
  21. data/debugbundle.gemspec +0 -30
  22. data/spec/before_send_spec.rb +0 -74
  23. data/spec/client_spec.rb +0 -432
  24. data/spec/debugbundle_spec.rb +0 -54
  25. data/spec/file_transport_spec.rb +0 -54
  26. data/spec/fixtures/event-envelope.schema.json +0 -142
  27. data/spec/logger_integration_spec.rb +0 -202
  28. data/spec/rack_integration_spec.rb +0 -44
  29. data/spec/rack_middleware_spec.rb +0 -210
  30. data/spec/rails_railtie_spec.rb +0 -96
  31. data/spec/rails_relay_spec.rb +0 -121
  32. data/spec/redaction_spec.rb +0 -42
  33. data/spec/relay_spec.rb +0 -274
  34. data/spec/remote_config_spec.rb +0 -80
  35. data/spec/repository_metadata_spec.rb +0 -65
  36. data/spec/sidekiq_integration_spec.rb +0 -66
  37. data/spec/sidekiq_middleware_spec.rb +0 -50
  38. data/spec/spec_helper.rb +0 -23
  39. data/spec/suppression_spec.rb +0 -16
@@ -182,7 +182,7 @@ module DebugBundle
182
182
  payload = candidate['payload']
183
183
  return nil unless service.is_a?(Hash) && payload.is_a?(Hash)
184
184
 
185
- {
185
+ sanitized = {
186
186
  'schema_version' => candidate['schema_version'].to_s,
187
187
  'event_id' => candidate['event_id'].to_s,
188
188
  'event_type' => event_type,
@@ -197,6 +197,14 @@ module DebugBundle
197
197
  'payload' => payload,
198
198
  'project_token' => @project_token
199
199
  }
200
+ return nil unless TelemetryPrivacy.safe_event_identity?(sanitized)
201
+
202
+ fields = TelemetryPrivacy.protect({ 'service' => sanitized['service'], 'payload' => sanitized['payload'] })
203
+ return nil unless fields['service'].is_a?(Hash) && fields['payload'].is_a?(Hash)
204
+
205
+ sanitized.merge('service' => fields['service'], 'payload' => fields['payload'])
206
+ rescue StandardError
207
+ nil
200
208
  end
201
209
 
202
210
  def sanitize_correlation(value, event_type)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module DebugBundle
6
+ # Built-in scalar extraction for capture paths that must not run application renderers.
7
+ module SafeInput
8
+ module_function
9
+
10
+ def key(value)
11
+ is_a = Object.instance_method(:is_a?)
12
+ return String.instance_method(:[]).bind_call(value, 0, 128) if is_a.bind_call(value, String)
13
+ return Symbol.instance_method(:to_s).bind_call(value)[0, 128] if is_a.bind_call(value, Symbol)
14
+
15
+ if is_a.bind_call(value, Integer) && Integer.instance_method(:bit_length).bind_call(value) <= 512
16
+ return Integer.instance_method(:to_s).bind_call(value)
17
+ end
18
+
19
+ nil
20
+ rescue StandardError
21
+ nil
22
+ end
23
+
24
+ def request_path(value)
25
+ return '/' unless Object.instance_method(:is_a?).bind_call(value, String)
26
+
27
+ raw = String.instance_method(:[]).bind_call(value, 0, 2_048)
28
+ begin
29
+ uri = URI.parse(raw)
30
+ return uri.path if uri.path && !uri.path.empty?
31
+ rescue URI::InvalidURIError
32
+ # Fall through to the bounded path-only fallback.
33
+ end
34
+ fallback = raw.split('?', 2).first.to_s.split('#', 2).first
35
+ fallback.start_with?('/') && !fallback.empty? ? fallback : '/'
36
+ rescue StandardError
37
+ '/'
38
+ end
39
+ end
40
+ end
@@ -11,6 +11,7 @@ module DebugBundle
11
11
  LOOP_RESET_AFTER_SECONDS = 60.0
12
12
  LOOP_CHECKPOINT_SECONDS = 30.0
13
13
  MAX_NORMAL_EVENTS_PER_WINDOW = 3
14
+ MAX_TRACKED_FINGERPRINTS = 2_048
14
15
 
15
16
  State = Struct.new(
16
17
  :window_started_at,
@@ -29,14 +30,28 @@ module DebugBundle
29
30
  class Tracker
30
31
  def initialize
31
32
  @states = {}
33
+ @mutex = Mutex.new
34
+ @overflow_count = 0
35
+ @overflow_first_seen_at = nil
36
+ @overflow_last_seen_at = nil
32
37
  end
33
38
 
34
39
  def should_capture(key, now:)
35
- state = (@states[key] ||= new_state(now))
40
+ locked = @mutex.try_lock
41
+ return true unless locked
42
+
43
+ fingerprint = Digest::SHA256.hexdigest(key)
44
+ state = @states.delete(fingerprint)
45
+ if state.nil? && @states.length >= MAX_TRACKED_FINGERPRINTS
46
+ _, evicted = @states.shift
47
+ record_overflow(evicted) if evicted.pending_suppressed_count.positive?
48
+ end
49
+ state ||= new_state(now)
50
+ @states[fingerprint] = state
36
51
 
37
52
  if state.suppression_mode && (now - state.last_seen_at) >= LOOP_RESET_AFTER_SECONDS
38
- @states[key] = new_state(now)
39
- state = @states[key]
53
+ @states[fingerprint] = new_state(now)
54
+ state = @states[fingerprint]
40
55
  end
41
56
 
42
57
  if (now - state.window_started_at) >= DUPLICATE_WINDOW_SECONDS
@@ -65,31 +80,58 @@ module DebugBundle
65
80
 
66
81
  mark_suppressed(state, now)
67
82
  false
83
+ ensure
84
+ @mutex.unlock if locked
68
85
  end
69
86
 
70
87
  def drain_aggregates(now:)
71
- @states.each_with_object([]) do |(key, state), aggregates|
72
- next if state.pending_suppressed_count.zero?
73
- next if state.pending_first_seen_at.nil? || state.pending_last_seen_at.nil?
74
- next if checkpoint_not_due?(state, now)
75
-
76
- aggregates << {
77
- 'fingerprint' => Digest::SHA256.hexdigest(key),
78
- 'suppressed_count' => state.pending_suppressed_count,
79
- 'first_seen' => Time.at(state.pending_first_seen_at).utc.iso8601,
80
- 'last_seen' => Time.at(state.pending_last_seen_at).utc.iso8601,
81
- 'window_seconds' => DUPLICATE_WINDOW_SECONDS.to_i
82
- }
83
-
84
- state.pending_suppressed_count = 0
85
- state.pending_first_seen_at = nil
86
- state.pending_last_seen_at = nil
87
- state.last_aggregate_emitted_at = now
88
+ @mutex.synchronize do
89
+ aggregates = @states.each_with_object([]) do |(fingerprint, state), results|
90
+ next if state.pending_suppressed_count.zero?
91
+ next if state.pending_first_seen_at.nil? || state.pending_last_seen_at.nil?
92
+ next if checkpoint_not_due?(state, now)
93
+
94
+ results << {
95
+ 'fingerprint' => fingerprint,
96
+ 'suppressed_count' => state.pending_suppressed_count,
97
+ 'first_seen' => Time.at(state.pending_first_seen_at).utc.iso8601,
98
+ 'last_seen' => Time.at(state.pending_last_seen_at).utc.iso8601,
99
+ 'window_seconds' => DUPLICATE_WINDOW_SECONDS.to_i
100
+ }
101
+
102
+ state.pending_suppressed_count = 0
103
+ state.pending_first_seen_at = nil
104
+ state.pending_last_seen_at = nil
105
+ state.last_aggregate_emitted_at = now
106
+ end
107
+ append_overflow_aggregate(aggregates)
108
+ aggregates
88
109
  end
89
110
  end
90
111
 
91
112
  private
92
113
 
114
+ def record_overflow(state)
115
+ @overflow_first_seen_at ||= state.pending_first_seen_at
116
+ @overflow_count += state.pending_suppressed_count
117
+ @overflow_last_seen_at = state.pending_last_seen_at
118
+ end
119
+
120
+ def append_overflow_aggregate(aggregates)
121
+ return if @overflow_count.zero?
122
+
123
+ aggregates << {
124
+ 'fingerprint' => Digest::SHA256.hexdigest('ruby:suppression-state-pressure'),
125
+ 'suppressed_count' => @overflow_count,
126
+ 'first_seen' => Time.at(@overflow_first_seen_at).utc.iso8601,
127
+ 'last_seen' => Time.at(@overflow_last_seen_at).utc.iso8601,
128
+ 'window_seconds' => DUPLICATE_WINDOW_SECONDS.to_i
129
+ }
130
+ @overflow_count = 0
131
+ @overflow_first_seen_at = nil
132
+ @overflow_last_seen_at = nil
133
+ end
134
+
93
135
  def new_state(now)
94
136
  State.new(
95
137
  window_started_at: now,
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ module DebugBundle
7
+ # Mandatory bounded policy for application data; protocol-owned identifiers stay outside this projection.
8
+ module TelemetryPrivacy
9
+ REDACTED = '[REDACTED]'
10
+ MAX_BYTES = 262_144
11
+ FIELDS = %w[
12
+ password secret token api_key apikey access_token refresh_token private_key passwd
13
+ card_number credit_card cvv cvc pin expiry phone bearer session_id otp
14
+ verification_code authorization cookie ssn client_secret x_api_key set_cookie
15
+ proxy_authorization accessToken refreshToken privateKey clientSecret
16
+ ].freeze
17
+ ASSIGNMENT_VALUE_PATTERN = '(?:"[^"]*"|\'[^\']*\'|[^\s&,;]+)'
18
+ DEFAULT_ASSIGNMENT_PATTERN = Regexp.new(
19
+ "\\b(#{FIELDS.map { |field| Regexp.escape(field) }.join('|')})\\b([\"']?\\s*[:=]\\s*)#{ASSIGNMENT_VALUE_PATTERN}",
20
+ Regexp::IGNORECASE
21
+ )
22
+
23
+ module_function
24
+
25
+ def protect(value, additional_fields: [])
26
+ raise ArgumentError, 'unsafe_input' if additional_fields.length > 128
27
+ unless additional_fields.all? { |field| field.is_a?(String) && field.length.between?(1, 64) }
28
+ raise ArgumentError, 'unsafe_input'
29
+ end
30
+
31
+ work = { nodes: 0, bytes: 0, seen: {}.compare_by_identity,
32
+ fields: (FIELDS + additional_fields).map { |field| canonical(field) },
33
+ extra: additional_fields,
34
+ assignment_pattern: assignment_pattern(additional_fields) }
35
+ result = visit(value, work, 0, true)
36
+ raise ArgumentError, 'budget_exceeded' if JSON.generate(result).bytesize > MAX_BYTES
37
+
38
+ result
39
+ rescue JSON::GeneratorError, Encoding::InvalidByteSequenceError
40
+ raise ArgumentError, 'unsafe_input'
41
+ end
42
+
43
+ def safe_event_identity?(event, additional_fields: [])
44
+ correlation = event['correlation'] || {}
45
+ return false unless correlation.is_a?(Hash) && correlation.size <= 8
46
+
47
+ values = event.values_at('schema_version', 'sdk_name', 'sdk_version') + correlation.values
48
+ values.all? do |value|
49
+ value.nil? || (value.is_a?(String) && protect(value, additional_fields: additional_fields) == value)
50
+ end
51
+ end
52
+
53
+ def canonical(value)
54
+ value.downcase.gsub(/[^a-z0-9]/, '')
55
+ end
56
+
57
+ def assignment_pattern(additional_fields)
58
+ return DEFAULT_ASSIGNMENT_PATTERN if additional_fields.empty?
59
+
60
+ terms = (FIELDS + additional_fields).map { |field| Regexp.escape(field) }.join('|')
61
+ Regexp.new("\\b(#{terms})\\b([\"']?\\s*[:=]\\s*)#{ASSIGNMENT_VALUE_PATTERN}", Regexp::IGNORECASE)
62
+ end
63
+
64
+ def sensitive_key?(key, fields)
65
+ segments = key.gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase.split(/[^a-z0-9]+/)
66
+ segments.each_index.any? do |start|
67
+ combined = +''
68
+ segments.drop(start).any? do |segment|
69
+ combined << segment
70
+ fields.include?(combined)
71
+ end
72
+ end
73
+ end
74
+
75
+ def visit(value, work, depth, structured)
76
+ work[:nodes] += 1
77
+ raise ArgumentError, 'budget_exceeded' if work[:nodes] > 4096
78
+ return REDACTED if depth > 16
79
+
80
+ case value
81
+ when String then string(value, work, structured)
82
+ when Hash, Array then collection(value, work, depth, structured)
83
+ when NilClass, TrueClass, FalseClass, Integer then value
84
+ when Float
85
+ raise ArgumentError, 'unsafe_input' unless value.finite?
86
+
87
+ value
88
+ else
89
+ raise ArgumentError, 'unsafe_input'
90
+ end
91
+ end
92
+
93
+ def collection(value, work, depth, structured)
94
+ marked = false
95
+ return '[Circular]' if work[:seen].key?(value)
96
+ return REDACTED if value.length > 256
97
+
98
+ work[:seen][value] = true
99
+ marked = true
100
+ if value.is_a?(Array)
101
+ value.map { |item| visit(item, work, depth + 1, structured) }
102
+ else
103
+ value.each_with_object({}) do |(key, nested), result|
104
+ raise ArgumentError, 'unsafe_input' unless key.is_a?(String) || key.is_a?(Symbol)
105
+
106
+ name = key.to_s
107
+ next if name.bytesize > 128
108
+
109
+ count_bytes(work, name)
110
+ next if scrub_text(name, work) != name
111
+
112
+ result[name] = sensitive_key?(name, work[:fields]) ? REDACTED : visit(nested, work, depth + 1, structured)
113
+ end
114
+ end
115
+ ensure
116
+ work[:seen].delete(value) if marked
117
+ end
118
+
119
+ def count_bytes(work, value)
120
+ work[:bytes] += value.bytesize
121
+ raise ArgumentError, 'budget_exceeded' if work[:bytes] > MAX_BYTES
122
+ end
123
+
124
+ def string(value, work, structured)
125
+ raise ArgumentError, 'unsafe_input' unless value.valid_encoding?
126
+ return REDACTED if value.bytesize > 16_384
127
+
128
+ count_bytes(work, value)
129
+ if structured && value.start_with?('{', '[')
130
+ begin
131
+ nested = JSON.parse(value, max_nesting: 17)
132
+ return JSON.generate(visit(nested, work, 0, false)) if nested.is_a?(Hash) || nested.is_a?(Array)
133
+ rescue JSON::ParserError
134
+ # Malformed structured content with a credential label is withheld below.
135
+ end
136
+ end
137
+ result = scrub_text(value, work)
138
+ return REDACTED if result == value && value.start_with?('{', '[') &&
139
+ /(?:password|token|secret|authorization|cookie)["']?\s*[:=]/i.match?(value)
140
+
141
+ result
142
+ end
143
+
144
+ def valid_card?(candidate)
145
+ digits = candidate.delete(' -')
146
+ return false unless digits.length.between?(13, 19) && digits.chars.uniq.length > 1
147
+
148
+ digits.reverse.chars.each_with_index.sum do |char, index|
149
+ number = char.to_i * (index.odd? ? 2 : 1)
150
+ number > 9 ? number - 9 : number
151
+ end.modulo(10).zero?
152
+ end
153
+
154
+ def scrub_text(value, work, scan_urls: true)
155
+ return REDACTED if /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.match?(value) &&
156
+ !/-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.match?(value)
157
+
158
+ result = value
159
+ if /(?:password|token|secret|authorization|cookie)%3[ad]/i.match?(result)
160
+ result = URI.decode_www_form_component(result)
161
+ end
162
+ pem = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i
163
+ result = result.gsub(pem, REDACTED)
164
+ result = result.gsub(/\b(Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]*/i, '\\1: [REDACTED]')
165
+ result = result.gsub(%r{\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]{6,}}i, '\\1 [REDACTED]')
166
+ result = result.gsub(/\bdbundle_(?:proj|mem|probe|agent)_[A-Za-z0-9_-]+\b/, REDACTED)
167
+ result = result.gsub(work[:assignment_pattern], '\\1\\2[REDACTED]')
168
+ result = result.gsub(/(?<![A-Za-z0-9_-])(?:[0-9][ -]?){12,18}[0-9](?![A-Za-z0-9_-])/) do |candidate|
169
+ valid_card?(candidate) ? REDACTED : candidate
170
+ end
171
+ return result unless scan_urls
172
+
173
+ result.gsub(%r{\bhttps?://[^\s<>"']+}i) do |candidate|
174
+ raw = candidate.sub(/[).,;]+\z/, '')
175
+ scrub_url(raw, work) + candidate.delete_prefix(raw)
176
+ end
177
+ rescue URI::InvalidURIError, ArgumentError
178
+ REDACTED
179
+ end
180
+
181
+ def scrub_url(raw, work)
182
+ uri = URI.parse(raw)
183
+ return REDACTED unless uri.host
184
+
185
+ if uri.userinfo
186
+ uri.user = 'REDACTED'
187
+ uri.password = nil
188
+ end
189
+ uri.path = '/' if uri.path.empty?
190
+ if uri.query
191
+ uri.query = URI.encode_www_form(URI.decode_www_form(uri.query).filter_map do |key, value|
192
+ next if key.bytesize > 128 || scrub_text(key, work, scan_urls: false) != key
193
+
194
+ unsafe = sensitive_key?(key, work[:fields]) || scrub_text(value, work, scan_urls: false) != value
195
+ [key, unsafe ? REDACTED : value]
196
+ end)
197
+ end
198
+ uri.fragment = nil
199
+ uri.to_s
200
+ rescue URI::InvalidURIError
201
+ REDACTED
202
+ end
203
+ end
204
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DebugBundle
4
- VERSION = '1.4.1'
4
+ VERSION = '2.0.0'
5
5
  end
data/lib/debugbundle.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'debugbundle/redaction'
4
+ require_relative 'debugbundle/telemetry_privacy'
4
5
  require_relative 'debugbundle/acknowledgement'
5
6
  require_relative 'debugbundle/before_send'
6
7
  require_relative 'debugbundle/rack/middleware'
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.4.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DebugBundle
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-15 00:00:00.000000000 Z
10
+ date: 2026-09-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -44,17 +44,18 @@ executables: []
44
44
  extensions: []
45
45
  extra_rdoc_files: []
46
46
  files:
47
- - Gemfile
48
47
  - LICENSE
49
- - Makefile
50
48
  - README.md
51
- - debugbundle.gemspec
52
49
  - lib/debugbundle.rb
53
50
  - lib/debugbundle/acknowledgement.rb
54
51
  - lib/debugbundle/before_send.rb
55
52
  - lib/debugbundle/client.rb
56
53
  - lib/debugbundle/client_event_support.rb
54
+ - lib/debugbundle/client_process_support.rb
55
+ - lib/debugbundle/client_queue_support.rb
57
56
  - lib/debugbundle/config.rb
57
+ - lib/debugbundle/config_worker.rb
58
+ - lib/debugbundle/delivery_worker.rb
58
59
  - lib/debugbundle/logging.rb
59
60
  - lib/debugbundle/rack/middleware.rb
60
61
  - lib/debugbundle/rack/relay_middleware.rb
@@ -66,29 +67,13 @@ files:
66
67
  - lib/debugbundle/relay/handler.rb
67
68
  - lib/debugbundle/remote_config.rb
68
69
  - lib/debugbundle/runtime.rb
70
+ - lib/debugbundle/safe_input.rb
69
71
  - lib/debugbundle/sidekiq/server_middleware.rb
70
72
  - lib/debugbundle/suppression.rb
73
+ - lib/debugbundle/telemetry_privacy.rb
71
74
  - lib/debugbundle/transport.rb
72
75
  - lib/debugbundle/trigger_token.rb
73
76
  - lib/debugbundle/version.rb
74
- - spec/before_send_spec.rb
75
- - spec/client_spec.rb
76
- - spec/debugbundle_spec.rb
77
- - spec/file_transport_spec.rb
78
- - spec/fixtures/event-envelope.schema.json
79
- - spec/logger_integration_spec.rb
80
- - spec/rack_integration_spec.rb
81
- - spec/rack_middleware_spec.rb
82
- - spec/rails_railtie_spec.rb
83
- - spec/rails_relay_spec.rb
84
- - spec/redaction_spec.rb
85
- - spec/relay_spec.rb
86
- - spec/remote_config_spec.rb
87
- - spec/repository_metadata_spec.rb
88
- - spec/sidekiq_integration_spec.rb
89
- - spec/sidekiq_middleware_spec.rb
90
- - spec/spec_helper.rb
91
- - spec/suppression_spec.rb
92
77
  homepage: https://debugbundle.com/docs/sdks/ruby
93
78
  licenses:
94
79
  - Apache-2.0
data/Gemfile DELETED
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- gemspec
6
-
7
- group :development, :test do
8
- gem 'json_schemer', '~> 2.4'
9
- gem 'rack-test', '~> 2.1'
10
- gem 'rails', '~> 7.1'
11
- gem 'rake', '~> 13.2'
12
- gem 'rspec', '~> 3.13'
13
- gem 'rubocop', '~> 1.76', require: false
14
- gem 'sidekiq', '~> 7.0', require: false
15
- gem 'simplecov', '~> 0.22', require: false
16
- gem 'webmock', '~> 3.25'
17
- end
data/Makefile DELETED
@@ -1,58 +0,0 @@
1
- SHELL := /bin/sh
2
-
3
- RUBY_IMAGE ?= ruby:3.4.2
4
- WORKDIR := /workspace
5
- BUNDLE_GEMFILE ?= Gemfile
6
- VERSION ?= $(shell ruby -e 'require "./lib/debugbundle/version"; print DebugBundle::VERSION')
7
-
8
- DOCKER_RUN = docker run --rm -t \
9
- -v "$(PWD):$(WORKDIR)" \
10
- -w "$(WORKDIR)" \
11
- $(RUBY_IMAGE)
12
-
13
- BUNDLE_ENV = BUNDLE_GEMFILE="$(BUNDLE_GEMFILE)"
14
-
15
- .PHONY: bundle-install test lint build shell compat-rack compat-rails compat-sidekiq compat
16
- .PHONY: smoke
17
- .PHONY: smoke-published
18
- .PHONY: test-focused check-docker
19
- check-docker:
20
- docker run --rm -v "$(CURDIR):$(WORKDIR)" -w "$(WORKDIR)" $(RUBY_IMAGE) sh -lc 'BUNDLE_PATH=vendor/bundle bundle exec rubocop && BUNDLE_PATH=vendor/bundle bundle exec rspec && gem build debugbundle.gemspec'
21
-
22
- test-focused:
23
- docker run --rm -v "$(CURDIR):$(WORKDIR)" -w "$(WORKDIR)" $(RUBY_IMAGE) sh -lc 'BUNDLE_PATH=vendor/bundle SIMPLECOV_MINIMUM_COVERAGE=0 bundle exec rspec $(TEST_FILES)'
24
-
25
- bundle-install:
26
- $(DOCKER_RUN) sh -lc "$(BUNDLE_ENV) bundle config set path vendor/bundle && $(BUNDLE_ENV) bundle install"
27
-
28
- test:
29
- $(DOCKER_RUN) sh -lc "$(BUNDLE_ENV) bundle config set path vendor/bundle && $(BUNDLE_ENV) bundle install && $(BUNDLE_ENV) bundle exec rspec"
30
-
31
- lint:
32
- $(DOCKER_RUN) sh -lc "$(BUNDLE_ENV) bundle config set path vendor/bundle && $(BUNDLE_ENV) bundle install && $(BUNDLE_ENV) bundle exec rubocop"
33
-
34
- build:
35
- $(DOCKER_RUN) sh -lc "$(BUNDLE_ENV) bundle config set path vendor/bundle && $(BUNDLE_ENV) bundle install && gem build debugbundle.gemspec"
36
-
37
- smoke:
38
- $(DOCKER_RUN) sh -lc "gem build debugbundle.gemspec && ruby smoke/run_app_driven_smoke.rb --source local --version $(VERSION)"
39
-
40
- smoke-published:
41
- $(DOCKER_RUN) sh -lc "ruby smoke/run_app_driven_smoke.rb --source published --version $(VERSION)"
42
-
43
- compat-rack:
44
- docker run --rm -t -v "$(PWD):$(WORKDIR)" -w "$(WORKDIR)" ruby:3.1.6 sh -lc 'SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rack_2_2.gemfile" bundle config set path vendor/bundle && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rack_2_2.gemfile" bundle install && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rack_2_2.gemfile" bundle exec rspec spec/rack_integration_spec.rb spec/rack_middleware_spec.rb spec/relay_spec.rb'
45
- docker run --rm -t -v "$(PWD):$(WORKDIR)" -w "$(WORKDIR)" ruby:3.4.2 sh -lc 'SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rack_3.gemfile" bundle config set path vendor/bundle && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rack_3.gemfile" bundle install && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rack_3.gemfile" bundle exec rspec spec/rack_integration_spec.rb spec/rack_middleware_spec.rb spec/relay_spec.rb'
46
-
47
- compat-rails:
48
- docker run --rm -t -v "$(PWD):$(WORKDIR)" -w "$(WORKDIR)" ruby:3.1.6 sh -lc 'SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rails_7_0.gemfile" bundle config set path vendor/bundle && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rails_7_0.gemfile" bundle install && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rails_7_0.gemfile" bundle exec rspec spec/rails_relay_spec.rb spec/rails_railtie_spec.rb'
49
- docker run --rm -t -v "$(PWD):$(WORKDIR)" -w "$(WORKDIR)" ruby:3.4.2 sh -lc 'SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rails_7_1.gemfile" bundle config set path vendor/bundle && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rails_7_1.gemfile" bundle install && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/rails_7_1.gemfile" bundle exec rspec spec/rails_relay_spec.rb spec/rails_railtie_spec.rb'
50
-
51
- compat-sidekiq:
52
- docker run --rm -t -v "$(PWD):$(WORKDIR)" -w "$(WORKDIR)" ruby:3.2 sh -lc 'SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/sidekiq_7.gemfile" bundle config set path vendor/bundle && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/sidekiq_7.gemfile" bundle install && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/sidekiq_7.gemfile" bundle exec rspec spec/sidekiq_middleware_spec.rb spec/sidekiq_integration_spec.rb'
53
- docker run --rm -t -v "$(PWD):$(WORKDIR)" -w "$(WORKDIR)" ruby:3.4.2 sh -lc 'SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/sidekiq_8.gemfile" bundle config set path vendor/bundle && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/sidekiq_8.gemfile" bundle install && SIMPLECOV_MINIMUM_COVERAGE=0 BUNDLE_GEMFILE="gemfiles/sidekiq_8.gemfile" bundle exec rspec spec/sidekiq_middleware_spec.rb spec/sidekiq_integration_spec.rb'
54
-
55
- compat: compat-rack compat-rails compat-sidekiq
56
-
57
- shell:
58
- $(DOCKER_RUN) sh
data/debugbundle.gemspec DELETED
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'lib/debugbundle/version'
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = 'debugbundle'
7
- spec.version = DebugBundle::VERSION
8
- spec.authors = ['DebugBundle']
9
- spec.email = ['support@debugbundle.com']
10
-
11
- spec.summary = 'DebugBundle SDK for Ruby'
12
- spec.description = 'Production-ready error, request, log, and probe capture for Ruby services.'
13
- spec.homepage = 'https://debugbundle.com/docs/sdks/ruby'
14
- spec.license = 'Apache-2.0'
15
- spec.required_ruby_version = '>= 3.1'
16
-
17
- spec.metadata = {
18
- 'homepage_uri' => spec.homepage,
19
- 'source_code_uri' => 'https://github.com/debugbundle/debugbundle-ruby',
20
- 'changelog_uri' => 'https://github.com/debugbundle/debugbundle-ruby/blob/main/CHANGELOG.md',
21
- 'rubygems_mfa_required' => 'true'
22
- }
23
-
24
- spec.files = Dir.glob('{lib,spec}/**/*') + %w[Gemfile Makefile README.md LICENSE debugbundle.gemspec]
25
- spec.bindir = 'exe'
26
- spec.require_paths = ['lib']
27
-
28
- spec.add_dependency 'base64', '~> 0.2'
29
- spec.add_dependency 'logger', '~> 1.6'
30
- end
@@ -1,74 +0,0 @@
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