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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +375 -0
- data/Rakefile +12 -0
- data/app/controllers/findbug/application_controller.rb +105 -0
- data/app/controllers/findbug/dashboard_controller.rb +93 -0
- data/app/controllers/findbug/errors_controller.rb +129 -0
- data/app/controllers/findbug/performance_controller.rb +80 -0
- data/app/jobs/findbug/alert_job.rb +40 -0
- data/app/jobs/findbug/cleanup_job.rb +132 -0
- data/app/jobs/findbug/persist_job.rb +158 -0
- data/app/models/findbug/error_event.rb +197 -0
- data/app/models/findbug/performance_event.rb +237 -0
- data/app/views/findbug/dashboard/index.html.erb +199 -0
- data/app/views/findbug/errors/index.html.erb +137 -0
- data/app/views/findbug/errors/show.html.erb +185 -0
- data/app/views/findbug/performance/index.html.erb +168 -0
- data/app/views/findbug/performance/show.html.erb +203 -0
- data/app/views/layouts/findbug/application.html.erb +601 -0
- data/lib/findbug/alerts/channels/base.rb +75 -0
- data/lib/findbug/alerts/channels/discord.rb +155 -0
- data/lib/findbug/alerts/channels/email.rb +179 -0
- data/lib/findbug/alerts/channels/slack.rb +149 -0
- data/lib/findbug/alerts/channels/webhook.rb +143 -0
- data/lib/findbug/alerts/dispatcher.rb +126 -0
- data/lib/findbug/alerts/throttler.rb +110 -0
- data/lib/findbug/background_persister.rb +142 -0
- data/lib/findbug/capture/context.rb +301 -0
- data/lib/findbug/capture/exception_handler.rb +141 -0
- data/lib/findbug/capture/exception_subscriber.rb +228 -0
- data/lib/findbug/capture/message_handler.rb +104 -0
- data/lib/findbug/capture/middleware.rb +247 -0
- data/lib/findbug/configuration.rb +381 -0
- data/lib/findbug/engine.rb +109 -0
- data/lib/findbug/performance/instrumentation.rb +336 -0
- data/lib/findbug/performance/transaction.rb +193 -0
- data/lib/findbug/processing/data_scrubber.rb +163 -0
- data/lib/findbug/rails/controller_methods.rb +152 -0
- data/lib/findbug/railtie.rb +222 -0
- data/lib/findbug/storage/circuit_breaker.rb +223 -0
- data/lib/findbug/storage/connection_pool.rb +134 -0
- data/lib/findbug/storage/redis_buffer.rb +285 -0
- data/lib/findbug/tasks/findbug.rake +167 -0
- data/lib/findbug/version.rb +5 -0
- data/lib/findbug.rb +216 -0
- data/lib/generators/findbug/install_generator.rb +67 -0
- data/lib/generators/findbug/templates/POST_INSTALL +41 -0
- data/lib/generators/findbug/templates/create_findbug_error_events.rb +44 -0
- data/lib/generators/findbug/templates/create_findbug_performance_events.rb +47 -0
- data/lib/generators/findbug/templates/initializer.rb +157 -0
- data/sig/findbug.rbs +4 -0
- 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
|