findbug 0.2.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +375 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/findbug/application_controller.rb +105 -0
  8. data/app/controllers/findbug/dashboard_controller.rb +93 -0
  9. data/app/controllers/findbug/errors_controller.rb +129 -0
  10. data/app/controllers/findbug/performance_controller.rb +80 -0
  11. data/app/jobs/findbug/alert_job.rb +40 -0
  12. data/app/jobs/findbug/cleanup_job.rb +132 -0
  13. data/app/jobs/findbug/persist_job.rb +158 -0
  14. data/app/models/findbug/error_event.rb +197 -0
  15. data/app/models/findbug/performance_event.rb +237 -0
  16. data/app/views/findbug/dashboard/index.html.erb +199 -0
  17. data/app/views/findbug/errors/index.html.erb +137 -0
  18. data/app/views/findbug/errors/show.html.erb +185 -0
  19. data/app/views/findbug/performance/index.html.erb +168 -0
  20. data/app/views/findbug/performance/show.html.erb +203 -0
  21. data/app/views/layouts/findbug/application.html.erb +601 -0
  22. data/lib/findbug/alerts/channels/base.rb +75 -0
  23. data/lib/findbug/alerts/channels/discord.rb +155 -0
  24. data/lib/findbug/alerts/channels/email.rb +179 -0
  25. data/lib/findbug/alerts/channels/slack.rb +149 -0
  26. data/lib/findbug/alerts/channels/webhook.rb +143 -0
  27. data/lib/findbug/alerts/dispatcher.rb +126 -0
  28. data/lib/findbug/alerts/throttler.rb +110 -0
  29. data/lib/findbug/background_persister.rb +142 -0
  30. data/lib/findbug/capture/context.rb +301 -0
  31. data/lib/findbug/capture/exception_handler.rb +141 -0
  32. data/lib/findbug/capture/exception_subscriber.rb +228 -0
  33. data/lib/findbug/capture/message_handler.rb +104 -0
  34. data/lib/findbug/capture/middleware.rb +247 -0
  35. data/lib/findbug/configuration.rb +381 -0
  36. data/lib/findbug/engine.rb +109 -0
  37. data/lib/findbug/performance/instrumentation.rb +336 -0
  38. data/lib/findbug/performance/transaction.rb +193 -0
  39. data/lib/findbug/processing/data_scrubber.rb +163 -0
  40. data/lib/findbug/rails/controller_methods.rb +152 -0
  41. data/lib/findbug/railtie.rb +222 -0
  42. data/lib/findbug/storage/circuit_breaker.rb +223 -0
  43. data/lib/findbug/storage/connection_pool.rb +134 -0
  44. data/lib/findbug/storage/redis_buffer.rb +285 -0
  45. data/lib/findbug/tasks/findbug.rake +167 -0
  46. data/lib/findbug/version.rb +5 -0
  47. data/lib/findbug.rb +216 -0
  48. data/lib/generators/findbug/install_generator.rb +67 -0
  49. data/lib/generators/findbug/templates/POST_INSTALL +41 -0
  50. data/lib/generators/findbug/templates/create_findbug_error_events.rb +44 -0
  51. data/lib/generators/findbug/templates/create_findbug_performance_events.rb +47 -0
  52. data/lib/generators/findbug/templates/initializer.rb +157 -0
  53. data/sig/findbug.rbs +4 -0
  54. metadata +251 -0
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ module Alerts
5
+ # Dispatcher routes alerts to configured channels.
6
+ #
7
+ # ALERT FLOW
8
+ # ==========
9
+ #
10
+ # 1. Error captured → PersistJob runs
11
+ # 2. PersistJob calls Dispatcher.notify(error_event)
12
+ # 3. Dispatcher checks throttling (avoid spam)
13
+ # 4. Dispatcher sends to enabled channels (async via AlertJob)
14
+ #
15
+ # THROTTLING
16
+ # ==========
17
+ #
18
+ # If your app throws 1000 errors in a minute, you don't want 1000 Slack
19
+ # messages. Throttling limits alerts to one per error fingerprint per
20
+ # throttle period (default 5 minutes).
21
+ #
22
+ # CHANNEL PRIORITY
23
+ # ================
24
+ #
25
+ # Different channels for different severities:
26
+ # - Critical errors → All channels (email, Slack, etc.)
27
+ # - Warnings → Maybe just Slack
28
+ # - Info → Maybe just logged, no alerts
29
+ #
30
+ class Dispatcher
31
+ class << self
32
+ # Send alert for an error event
33
+ #
34
+ # @param error_event [ErrorEvent] the error to alert about
35
+ # @param async [Boolean] whether to send asynchronously (default: true)
36
+ #
37
+ def notify(error_event, async: true)
38
+ return unless Findbug.enabled?
39
+ return unless Findbug.config.alerts.any_enabled?
40
+ return unless should_alert?(error_event)
41
+ return if throttled?(error_event)
42
+
43
+ if async
44
+ Jobs::AlertJob.perform_later(error_event.id)
45
+ else
46
+ send_alerts(error_event)
47
+ end
48
+
49
+ record_alert(error_event)
50
+ end
51
+
52
+ # Actually send alerts to all enabled channels
53
+ #
54
+ # @param error_event [ErrorEvent] the error to alert about
55
+ #
56
+ def send_alerts(error_event)
57
+ alert_config = Findbug.config.alerts
58
+
59
+ alert_config.enabled_channels.each do |channel_name, config|
60
+ send_to_channel(channel_name, error_event, config)
61
+ rescue StandardError => e
62
+ Findbug.logger.error(
63
+ "[Findbug] Failed to send alert to #{channel_name}: #{e.message}"
64
+ )
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # Check if we should alert for this error
71
+ #
72
+ # You might not want to alert for:
73
+ # - Ignored errors
74
+ # - Info-level messages
75
+ # - Handled errors (depending on config)
76
+ #
77
+ def should_alert?(error_event)
78
+ # Don't alert for ignored errors
79
+ return false if error_event.status == ErrorEvent::STATUS_IGNORED
80
+
81
+ # Alert for errors and warnings, not info
82
+ %w[error warning].include?(error_event.severity)
83
+ end
84
+
85
+ # Check if this error is throttled
86
+ #
87
+ # We use Redis to track last alert time per fingerprint.
88
+ #
89
+ def throttled?(error_event)
90
+ Throttler.throttled?(error_event.fingerprint)
91
+ end
92
+
93
+ # Record that we sent an alert (for throttling)
94
+ def record_alert(error_event)
95
+ Throttler.record(error_event.fingerprint)
96
+ end
97
+
98
+ # Send to a specific channel
99
+ def send_to_channel(channel_name, error_event, config)
100
+ channel_class = channel_for(channel_name)
101
+ return unless channel_class
102
+
103
+ channel = channel_class.new(config)
104
+ channel.send_alert(error_event)
105
+ end
106
+
107
+ # Get the channel class for a channel name
108
+ def channel_for(channel_name)
109
+ case channel_name.to_sym
110
+ when :email
111
+ Channels::Email
112
+ when :slack
113
+ Channels::Slack
114
+ when :discord
115
+ Channels::Discord
116
+ when :webhook
117
+ Channels::Webhook
118
+ else
119
+ Findbug.logger.warn("[Findbug] Unknown alert channel: #{channel_name}")
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ module Alerts
5
+ # Throttler prevents alert spam by limiting how often we alert for the same error.
6
+ #
7
+ # THE PROBLEM
8
+ # ===========
9
+ #
10
+ # Without throttling:
11
+ # - 1000 users hit the same bug
12
+ # - 1000 Slack messages
13
+ # - Your team mutes the channel
14
+ # - You miss the NEXT important error
15
+ #
16
+ # With throttling:
17
+ # - First occurrence: Alert sent
18
+ # - Next 999 in 5 minutes: Throttled
19
+ # - 5 minutes later, if still happening: Another alert
20
+ #
21
+ # IMPLEMENTATION
22
+ # ==============
23
+ #
24
+ # We use Redis to store "last alerted at" timestamps:
25
+ #
26
+ # Key: findbug:alert:throttle:{fingerprint}
27
+ # Value: ISO8601 timestamp
28
+ # TTL: throttle_period
29
+ #
30
+ # If the key exists and isn't expired, we're throttled.
31
+ # Simple and fast.
32
+ #
33
+ class Throttler
34
+ THROTTLE_KEY_PREFIX = "findbug:alert:throttle:"
35
+
36
+ class << self
37
+ # Check if an alert is currently throttled
38
+ #
39
+ # @param fingerprint [String] error fingerprint
40
+ # @return [Boolean] true if throttled
41
+ #
42
+ def throttled?(fingerprint)
43
+ key = throttle_key(fingerprint)
44
+
45
+ Storage::ConnectionPool.with do |redis|
46
+ redis.exists?(key)
47
+ end
48
+ rescue StandardError => e
49
+ Findbug.logger.debug("[Findbug] Throttle check failed: #{e.message}")
50
+ false # If we can't check, allow the alert
51
+ end
52
+
53
+ # Record that we sent an alert (starts throttle period)
54
+ #
55
+ # @param fingerprint [String] error fingerprint
56
+ #
57
+ def record(fingerprint)
58
+ key = throttle_key(fingerprint)
59
+ ttl = throttle_period
60
+
61
+ Storage::ConnectionPool.with do |redis|
62
+ redis.setex(key, ttl, Time.now.utc.iso8601)
63
+ end
64
+ rescue StandardError => e
65
+ Findbug.logger.debug("[Findbug] Throttle record failed: #{e.message}")
66
+ end
67
+
68
+ # Clear throttle for a specific error (e.g., when error is resolved)
69
+ #
70
+ # @param fingerprint [String] error fingerprint
71
+ #
72
+ def clear(fingerprint)
73
+ key = throttle_key(fingerprint)
74
+
75
+ Storage::ConnectionPool.with do |redis|
76
+ redis.del(key)
77
+ end
78
+ rescue StandardError
79
+ # Ignore errors during cleanup
80
+ end
81
+
82
+ # Get remaining throttle time
83
+ #
84
+ # @param fingerprint [String] error fingerprint
85
+ # @return [Integer, nil] seconds remaining, or nil if not throttled
86
+ #
87
+ def remaining_seconds(fingerprint)
88
+ key = throttle_key(fingerprint)
89
+
90
+ Storage::ConnectionPool.with do |redis|
91
+ ttl = redis.ttl(key)
92
+ ttl.positive? ? ttl : nil
93
+ end
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ private
99
+
100
+ def throttle_key(fingerprint)
101
+ "#{THROTTLE_KEY_PREFIX}#{fingerprint}"
102
+ end
103
+
104
+ def throttle_period
105
+ Findbug.config.alerts.throttle_period
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ # BackgroundPersister runs a background thread that periodically moves
5
+ # events from the Redis buffer to the database.
6
+ #
7
+ # WHY A BACKGROUND THREAD?
8
+ # ========================
9
+ #
10
+ # We want Findbug to work "out of the box" without requiring users to:
11
+ # 1. Set up Sidekiq/ActiveJob
12
+ # 2. Configure recurring jobs
13
+ # 3. Run separate worker processes
14
+ #
15
+ # A background thread achieves this by running inside the Rails process.
16
+ #
17
+ # THREAD SAFETY
18
+ # =============
19
+ #
20
+ # - Uses Mutex for start/stop synchronization
21
+ # - Only one persister thread runs at a time
22
+ # - Safe to call start! multiple times (idempotent)
23
+ #
24
+ # GRACEFUL SHUTDOWN
25
+ # =================
26
+ #
27
+ # The thread checks a @running flag and exits cleanly when stopped.
28
+ # We also register an at_exit hook to ensure cleanup.
29
+ #
30
+ # LIMITATIONS
31
+ # ===========
32
+ #
33
+ # - Only persists in the process where it's started
34
+ # - In multi-process setups (Puma cluster), each process has its own thread
35
+ # - For high-volume apps, users should use the ActiveJob approach instead
36
+ #
37
+ class BackgroundPersister
38
+ DEFAULT_INTERVAL = 30 # seconds
39
+
40
+ class << self
41
+ def start!(interval: nil)
42
+ return if @running
43
+
44
+ @mutex ||= Mutex.new
45
+ @mutex.synchronize do
46
+ return if @running
47
+
48
+ @interval = interval || Findbug.config.persist_interval || DEFAULT_INTERVAL
49
+ @running = true
50
+ @thread = Thread.new { run_loop }
51
+ @thread.name = "findbug-persister"
52
+ @thread.abort_on_exception = false
53
+
54
+ Findbug.logger.info("[Findbug] Background persister started (interval: #{@interval}s)")
55
+ end
56
+ end
57
+
58
+ def stop!
59
+ return unless @running
60
+
61
+ @mutex.synchronize do
62
+ @running = false
63
+ @thread&.wakeup rescue nil # Wake from sleep
64
+ @thread&.join(5) # Wait up to 5 seconds
65
+ @thread = nil
66
+ Findbug.logger.info("[Findbug] Background persister stopped")
67
+ end
68
+ end
69
+
70
+ def running?
71
+ @running == true && @thread&.alive?
72
+ end
73
+
74
+ # Force an immediate persist (useful for testing)
75
+ def persist_now!
76
+ perform_persist
77
+ end
78
+
79
+ private
80
+
81
+ def run_loop
82
+ while @running
83
+ sleep(@interval)
84
+ next unless @running
85
+
86
+ perform_persist
87
+ end
88
+ rescue StandardError => e
89
+ Findbug.logger.error("[Findbug] Background persister crashed: #{e.message}")
90
+ @running = false
91
+ end
92
+
93
+ def perform_persist
94
+ return unless Findbug.enabled?
95
+
96
+ # Persist errors
97
+ persist_errors
98
+
99
+ # Persist performance events
100
+ persist_performance
101
+ rescue StandardError => e
102
+ Findbug.logger.error("[Findbug] Persist failed: #{e.message}")
103
+ end
104
+
105
+ def persist_errors
106
+ events = Findbug::Storage::RedisBuffer.pop_errors(batch_size)
107
+ return if events.empty?
108
+
109
+ persisted = 0
110
+ events.each do |event_data|
111
+ scrubbed = Findbug::Processing::DataScrubber.scrub(event_data)
112
+ Findbug::ErrorEvent.upsert_from_event(scrubbed)
113
+ persisted += 1
114
+ rescue StandardError => e
115
+ Findbug.logger.error("[Findbug] Failed to persist error: #{e.message}")
116
+ end
117
+
118
+ Findbug.logger.info("[Findbug] Persisted #{persisted}/#{events.size} errors") if persisted > 0
119
+ end
120
+
121
+ def persist_performance
122
+ events = Findbug::Storage::RedisBuffer.pop_performance(batch_size)
123
+ return if events.empty?
124
+
125
+ persisted = 0
126
+ events.each do |event_data|
127
+ scrubbed = Findbug::Processing::DataScrubber.scrub(event_data)
128
+ Findbug::PerformanceEvent.create_from_event(scrubbed)
129
+ persisted += 1
130
+ rescue StandardError => e
131
+ Findbug.logger.error("[Findbug] Failed to persist performance event: #{e.message}")
132
+ end
133
+
134
+ Findbug.logger.info("[Findbug] Persisted #{persisted}/#{events.size} performance events") if persisted > 0
135
+ end
136
+
137
+ def batch_size
138
+ Findbug.config.persist_batch_size || 100
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ module Capture
5
+ # Context stores request-scoped data that gets attached to errors.
6
+ #
7
+ # THREAD-LOCAL STORAGE
8
+ # ====================
9
+ #
10
+ # In a multi-threaded server like Puma, multiple requests run concurrently.
11
+ # Each request needs its OWN context - we can't share a global variable
12
+ # or Request A's user would appear on Request B's errors!
13
+ #
14
+ # Solution: Thread.current[:key] - a hash specific to each thread.
15
+ #
16
+ # Thread 1 (Request A):
17
+ # Context.set_user(id: 1)
18
+ # # Thread.current[:findbug_context] = { user: { id: 1 } }
19
+ #
20
+ # Thread 2 (Request B):
21
+ # Context.set_user(id: 2)
22
+ # # Thread.current[:findbug_context] = { user: { id: 2 } }
23
+ #
24
+ # Thread 1: Context.current[:user] → { id: 1 } ✓ Correct!
25
+ # Thread 2: Context.current[:user] → { id: 2 } ✓ Correct!
26
+ #
27
+ # WHAT GETS STORED?
28
+ # =================
29
+ #
30
+ # 1. User - who was affected
31
+ # 2. Tags - short key-value pairs for filtering
32
+ # 3. Extra - arbitrary data about the request
33
+ # 4. Breadcrumbs - trail of events before the error
34
+ # 5. Request - HTTP request details (auto-captured)
35
+ #
36
+ class Context
37
+ THREAD_KEY = :findbug_context
38
+ MAX_BREADCRUMBS = 50
39
+
40
+ class << self
41
+ # Get the current context hash
42
+ #
43
+ # @return [Hash] the current thread's context
44
+ #
45
+ def current
46
+ Thread.current[THREAD_KEY] ||= default_context
47
+ end
48
+
49
+ # Clear the context (call between requests)
50
+ #
51
+ # This MUST be called after each request to prevent context leaking.
52
+ # The Railtie sets this up via after_action.
53
+ #
54
+ def clear!
55
+ Thread.current[THREAD_KEY] = nil
56
+ end
57
+
58
+ # Set user information
59
+ #
60
+ # @param user_data [Hash] user attributes (id, email, username, etc.)
61
+ #
62
+ # @example
63
+ # Context.set_user(id: 123, email: "user@example.com")
64
+ #
65
+ def set_user(user_data)
66
+ current[:user] = scrub_user_data(user_data)
67
+ end
68
+
69
+ # Get current user
70
+ #
71
+ # @return [Hash, nil] the current user data
72
+ #
73
+ def user
74
+ current[:user]
75
+ end
76
+
77
+ # Add a tag
78
+ #
79
+ # Tags are short key-value pairs optimized for filtering.
80
+ # Unlike extra data, tags are indexed and searchable.
81
+ #
82
+ # @param key [String, Symbol] tag name
83
+ # @param value [String, Numeric, Boolean] tag value
84
+ #
85
+ # @example
86
+ # Context.add_tag(:environment, "production")
87
+ # Context.add_tag(:plan, "enterprise")
88
+ #
89
+ def add_tag(key, value)
90
+ current[:tags][key.to_sym] = value
91
+ end
92
+
93
+ # Get all tags
94
+ #
95
+ # @return [Hash] current tags
96
+ #
97
+ def tags
98
+ current[:tags]
99
+ end
100
+
101
+ # Merge extra data into context
102
+ #
103
+ # Extra data is arbitrary key-value pairs that provide more detail.
104
+ # Use this for non-indexed, detailed information.
105
+ #
106
+ # @param data [Hash] data to merge
107
+ #
108
+ # @example
109
+ # Context.merge(order_id: 456, cart_size: 3)
110
+ #
111
+ def merge(data)
112
+ current[:extra].merge!(data)
113
+ end
114
+
115
+ # Get extra data
116
+ #
117
+ # @return [Hash] current extra data
118
+ #
119
+ def extra
120
+ current[:extra]
121
+ end
122
+
123
+ # Add a breadcrumb
124
+ #
125
+ # Breadcrumbs are a chronological trail of events leading to an error.
126
+ # Think of them like a log, but attached to the error.
127
+ #
128
+ # @param breadcrumb [Hash] breadcrumb data
129
+ # @option breadcrumb [String] :message what happened
130
+ # @option breadcrumb [String] :category grouping category
131
+ # @option breadcrumb [Hash] :data additional data
132
+ # @option breadcrumb [String] :timestamp when it happened
133
+ #
134
+ # @example
135
+ # Context.add_breadcrumb(
136
+ # message: "User clicked checkout",
137
+ # category: "ui",
138
+ # data: { button: "checkout_btn" }
139
+ # )
140
+ #
141
+ def add_breadcrumb(breadcrumb)
142
+ crumbs = current[:breadcrumbs]
143
+
144
+ # Add timestamp if not provided
145
+ breadcrumb[:timestamp] ||= Time.now.utc.iso8601(3)
146
+
147
+ crumbs << breadcrumb
148
+
149
+ # Keep only the most recent breadcrumbs
150
+ # This prevents memory issues from long-running requests
151
+ crumbs.shift while crumbs.size > MAX_BREADCRUMBS
152
+ end
153
+
154
+ # Get all breadcrumbs
155
+ #
156
+ # @return [Array<Hash>] breadcrumbs in chronological order
157
+ #
158
+ def breadcrumbs
159
+ current[:breadcrumbs]
160
+ end
161
+
162
+ # Set request data (auto-populated by middleware)
163
+ #
164
+ # @param request_data [Hash] HTTP request information
165
+ #
166
+ def set_request(request_data)
167
+ current[:request] = request_data
168
+ end
169
+
170
+ # Get request data
171
+ #
172
+ # @return [Hash] HTTP request information
173
+ #
174
+ def request
175
+ current[:request]
176
+ end
177
+
178
+ # Get the complete context for capturing
179
+ #
180
+ # This returns all context data in a format ready for storage.
181
+ #
182
+ # @return [Hash] complete context
183
+ #
184
+ def to_h
185
+ ctx = current.dup
186
+ ctx.compact! # Remove nil values
187
+
188
+ # Convert breadcrumbs to array (it's already an array, but be explicit)
189
+ ctx[:breadcrumbs] = ctx[:breadcrumbs].dup if ctx[:breadcrumbs]
190
+
191
+ ctx
192
+ end
193
+
194
+ # Create context from a Rack request
195
+ #
196
+ # This extracts useful information from the HTTP request.
197
+ # Called automatically by the middleware.
198
+ #
199
+ # @param rack_request [Rack::Request] the Rack request object
200
+ # @return [Hash] extracted request data
201
+ #
202
+ def from_rack_request(rack_request)
203
+ {
204
+ method: rack_request.request_method,
205
+ url: rack_request.url,
206
+ path: rack_request.path,
207
+ query_string: scrub_query_string(rack_request.query_string),
208
+ headers: scrub_headers(extract_headers(rack_request)),
209
+ ip: rack_request.ip,
210
+ user_agent: rack_request.user_agent,
211
+ content_type: rack_request.content_type,
212
+ content_length: rack_request.content_length,
213
+ request_id: rack_request.env["action_dispatch.request_id"]
214
+ }
215
+ end
216
+
217
+ private
218
+
219
+ def default_context
220
+ {
221
+ user: nil,
222
+ tags: {},
223
+ extra: {},
224
+ breadcrumbs: [],
225
+ request: nil
226
+ }
227
+ end
228
+
229
+ # Scrub sensitive data from user info
230
+ def scrub_user_data(user_data)
231
+ return nil unless user_data
232
+
233
+ scrubbed = user_data.dup
234
+
235
+ # Never store password-related fields
236
+ Findbug.config.scrub_fields.each do |field|
237
+ scrubbed.delete(field.to_sym)
238
+ scrubbed.delete(field.to_s)
239
+ end
240
+
241
+ scrubbed
242
+ end
243
+
244
+ # Extract headers from Rack request
245
+ def extract_headers(rack_request)
246
+ headers = {}
247
+
248
+ rack_request.each_header do |key, value|
249
+ # HTTP headers in Rack are prefixed with HTTP_
250
+ next unless key.start_with?("HTTP_")
251
+
252
+ # Convert HTTP_X_FORWARDED_FOR to X-Forwarded-For
253
+ header_name = key.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")
254
+ headers[header_name] = value
255
+ end
256
+
257
+ # Add Content-Type and Content-Length (not prefixed with HTTP_)
258
+ headers["Content-Type"] = rack_request.content_type if rack_request.content_type
259
+ headers["Content-Length"] = rack_request.content_length.to_s if rack_request.content_length
260
+
261
+ headers
262
+ end
263
+
264
+ # Scrub sensitive headers
265
+ def scrub_headers(headers)
266
+ return {} unless Findbug.config.scrub_headers
267
+
268
+ sensitive_headers = %w[
269
+ Authorization
270
+ Cookie
271
+ X-Api-Key
272
+ X-Auth-Token
273
+ X-Access-Token
274
+ ] + Findbug.config.scrub_header_names
275
+
276
+ headers.transform_values.with_index do |value, _|
277
+ key = headers.keys[headers.values.index(value)]
278
+ if sensitive_headers.any? { |h| key.casecmp?(h) }
279
+ "[FILTERED]"
280
+ else
281
+ value
282
+ end
283
+ end
284
+ end
285
+
286
+ # Scrub sensitive query parameters
287
+ def scrub_query_string(query_string)
288
+ return nil if query_string.nil? || query_string.empty?
289
+
290
+ params = Rack::Utils.parse_query(query_string)
291
+
292
+ Findbug.config.scrub_fields.each do |field|
293
+ params[field] = "[FILTERED]" if params.key?(field)
294
+ end
295
+
296
+ Rack::Utils.build_query(params)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end