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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "socket"
5
+
6
+ module Findbug
7
+ module Capture
8
+ # ExceptionHandler provides the public API for capturing exceptions.
9
+ #
10
+ # This is used by:
11
+ # - Findbug.capture_exception (public API)
12
+ # - Manual captures in user code
13
+ #
14
+ # It's separate from Middleware/Subscriber because those are automatic.
15
+ # This is for explicit, manual captures.
16
+ #
17
+ # WHEN TO USE MANUAL CAPTURE
18
+ # ==========================
19
+ #
20
+ # 1. Handled exceptions you still want to track:
21
+ #
22
+ # begin
23
+ # external_api.call
24
+ # rescue ExternalAPIError => e
25
+ # Findbug.capture_exception(e)
26
+ # # Handle gracefully...
27
+ # end
28
+ #
29
+ # 2. Exceptions in background jobs (if not auto-captured):
30
+ #
31
+ # class HardWorker
32
+ # def perform
33
+ # do_work
34
+ # rescue => e
35
+ # Findbug.capture_exception(e)
36
+ # raise # Re-raise for Sidekiq retry
37
+ # end
38
+ # end
39
+ #
40
+ # 3. Exceptions with extra context:
41
+ #
42
+ # Findbug.capture_exception(e, order_id: order.id, action: "payment")
43
+ #
44
+ class ExceptionHandler
45
+ class << self
46
+ # Capture an exception
47
+ #
48
+ # @param exception [Exception] the exception to capture
49
+ # @param extra_context [Hash] additional context for this error
50
+ #
51
+ def capture(exception, extra_context = {})
52
+ return unless Findbug.enabled?
53
+ return unless should_capture?(exception)
54
+
55
+ event_data = build_event_data(exception, extra_context)
56
+ Storage::RedisBuffer.push_error(event_data)
57
+ rescue StandardError => e
58
+ Findbug.logger.error("[Findbug] ExceptionHandler failed: #{e.message}")
59
+ end
60
+
61
+ private
62
+
63
+ def should_capture?(exception)
64
+ Findbug.config.should_capture_exception?(exception)
65
+ end
66
+
67
+ def build_event_data(exception, extra_context)
68
+ # Get current context and merge with extra
69
+ context = Context.to_h
70
+ context[:extra] = (context[:extra] || {}).merge(extra_context)
71
+
72
+ {
73
+ exception_class: exception.class.name,
74
+ message: exception.message,
75
+ backtrace: clean_backtrace(exception.backtrace),
76
+ severity: "error",
77
+ handled: true, # Manual captures are "handled"
78
+ source: "manual",
79
+ context: context,
80
+ fingerprint: generate_fingerprint(exception),
81
+ captured_at: Time.now.utc.iso8601(3),
82
+ environment: Findbug.config.environment,
83
+ release: Findbug.config.release,
84
+ server: server_info
85
+ }
86
+ end
87
+
88
+ def clean_backtrace(backtrace)
89
+ return [] unless backtrace
90
+
91
+ backtrace.first(50).map do |line|
92
+ if defined?(Rails.root) && Rails.root
93
+ line.sub(Rails.root.to_s + "/", "")
94
+ else
95
+ line
96
+ end
97
+ end
98
+ end
99
+
100
+ def generate_fingerprint(exception)
101
+ components = [
102
+ exception.class.name,
103
+ normalize_message(exception.message),
104
+ top_frame(exception.backtrace)
105
+ ]
106
+
107
+ Digest::SHA256.hexdigest(components.join("\n"))
108
+ end
109
+
110
+ def normalize_message(message)
111
+ return "" unless message
112
+
113
+ message
114
+ .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "{uuid}")
115
+ .gsub(/\b\d+\.?\d*\b/, "{number}")
116
+ .gsub(/'[^']*'/, "'{string}'")
117
+ .gsub(/"[^"]*"/, '"{string}"')
118
+ end
119
+
120
+ def top_frame(backtrace)
121
+ return "" unless backtrace&.any?
122
+
123
+ app_line = backtrace.find do |line|
124
+ line.include?("/app/") || line.include?("/lib/")
125
+ end
126
+
127
+ (app_line || backtrace.first).to_s
128
+ end
129
+
130
+ def server_info
131
+ {
132
+ hostname: Socket.gethostname,
133
+ pid: Process.pid,
134
+ ruby_version: RUBY_VERSION,
135
+ rails_version: (Rails.version if defined?(Rails))
136
+ }
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ module Capture
5
+ # ExceptionSubscriber integrates with Rails 7's ErrorReporter.
6
+ #
7
+ # RAILS ERROR REPORTER (Rails 7+)
8
+ # ===============================
9
+ #
10
+ # Rails 7 introduced a centralized error reporting API:
11
+ #
12
+ # Rails.error.handle { risky_operation } # Swallows error, reports it
13
+ # Rails.error.record { risky_operation } # Re-raises, but reports first
14
+ #
15
+ # Third-party gems can subscribe to receive ALL reported errors:
16
+ #
17
+ # Rails.error.subscribe(MySubscriber.new)
18
+ #
19
+ # This is better than just middleware because it catches:
20
+ # - Errors handled gracefully with Rails.error.handle
21
+ # - Background job errors
22
+ # - Errors in non-request contexts
23
+ #
24
+ # HOW IT WORKS
25
+ # ============
26
+ #
27
+ # 1. Rails catches an exception
28
+ # 2. Rails calls Rails.error.report(exception, ...)
29
+ # 3. Rails calls our subscriber's #report method
30
+ # 4. We capture the exception asynchronously
31
+ #
32
+ class ExceptionSubscriber
33
+ # Called by Rails when an error is reported
34
+ #
35
+ # @param error [Exception] the exception that occurred
36
+ # @param handled [Boolean] whether the error was handled
37
+ # @param severity [Symbol] :error, :warning, or :info
38
+ # @param context [Hash] additional context from Rails
39
+ # @param source [String] where the error came from
40
+ #
41
+ def report(error, handled:, severity:, context:, source: nil)
42
+ return unless Findbug.enabled?
43
+ return unless should_capture?(error)
44
+
45
+ # Build event data
46
+ event_data = build_event_data(error, handled, severity, context, source)
47
+
48
+ # Push to Redis buffer (async, non-blocking)
49
+ Storage::RedisBuffer.push_error(event_data)
50
+ rescue StandardError => e
51
+ # CRITICAL: Never let Findbug crash your app
52
+ Findbug.logger.error("[Findbug] ExceptionSubscriber failed: #{e.message}")
53
+ end
54
+
55
+ private
56
+
57
+ def should_capture?(error)
58
+ Findbug.config.should_capture_exception?(error)
59
+ end
60
+
61
+ def build_event_data(error, handled, severity, rails_context, source)
62
+ {
63
+ # Exception details
64
+ exception_class: error.class.name,
65
+ message: error.message,
66
+ backtrace: clean_backtrace(error.backtrace),
67
+
68
+ # Metadata
69
+ severity: map_severity(severity),
70
+ handled: handled,
71
+ source: source,
72
+
73
+ # Context from Findbug
74
+ context: Context.to_h,
75
+
76
+ # Context from Rails
77
+ rails_context: sanitize_context(rails_context),
78
+
79
+ # Fingerprint for grouping
80
+ fingerprint: generate_fingerprint(error),
81
+
82
+ # Timing
83
+ captured_at: Time.now.utc.iso8601(3),
84
+
85
+ # Environment info
86
+ environment: Findbug.config.environment,
87
+ release: Findbug.config.release,
88
+ server: server_info
89
+ }
90
+ end
91
+
92
+ # Clean up the backtrace
93
+ #
94
+ # WHY CLEAN BACKTRACE?
95
+ # --------------------
96
+ # Raw backtraces include:
97
+ # - Full file paths (privacy concern, also verbose)
98
+ # - Gem internals (not useful for debugging YOUR code)
99
+ # - Framework internals (noisy)
100
+ #
101
+ # We clean it to show only relevant lines.
102
+ #
103
+ def clean_backtrace(backtrace)
104
+ return [] unless backtrace
105
+
106
+ # Limit to reasonable size
107
+ backtrace = backtrace.first(50)
108
+
109
+ backtrace.map do |line|
110
+ # Replace full paths with relative paths
111
+ line.sub(Rails.root.to_s + "/", "") if defined?(Rails.root)
112
+ line
113
+ end
114
+ end
115
+
116
+ # Map Rails severity to our severity levels
117
+ def map_severity(severity)
118
+ case severity
119
+ when :error then "error"
120
+ when :warning then "warning"
121
+ when :info then "info"
122
+ else "error"
123
+ end
124
+ end
125
+
126
+ # Sanitize context from Rails (may contain non-serializable objects)
127
+ def sanitize_context(context)
128
+ return {} unless context.is_a?(Hash)
129
+
130
+ context.transform_values do |value|
131
+ case value
132
+ when String, Numeric, TrueClass, FalseClass, NilClass
133
+ value
134
+ when Array
135
+ value.map { |v| sanitize_value(v) }
136
+ when Hash
137
+ sanitize_context(value)
138
+ else
139
+ value.to_s
140
+ end
141
+ end
142
+ rescue StandardError
143
+ {}
144
+ end
145
+
146
+ def sanitize_value(value)
147
+ case value
148
+ when String, Numeric, TrueClass, FalseClass, NilClass
149
+ value
150
+ else
151
+ value.to_s
152
+ end
153
+ end
154
+
155
+ # Generate a fingerprint for grouping similar errors
156
+ #
157
+ # WHAT IS FINGERPRINTING?
158
+ # -----------------------
159
+ # Multiple occurrences of the "same" error should be grouped together.
160
+ # But what makes two errors "the same"?
161
+ #
162
+ # We use:
163
+ # 1. Exception class name (e.g., "NoMethodError")
164
+ # 2. Exception message (normalized to remove variable parts)
165
+ # 3. Top stack frame (where the error originated)
166
+ #
167
+ # This groups errors by WHERE they happened and WHAT type they are.
168
+ #
169
+ def generate_fingerprint(error)
170
+ components = [
171
+ error.class.name,
172
+ normalize_message(error.message),
173
+ top_frame(error.backtrace)
174
+ ]
175
+
176
+ Digest::SHA256.hexdigest(components.join("\n"))
177
+ end
178
+
179
+ # Normalize error message for fingerprinting
180
+ #
181
+ # WHY NORMALIZE?
182
+ # --------------
183
+ # Error messages often contain variable data:
184
+ # "undefined method `foo' for nil:NilClass"
185
+ # "Couldn't find User with ID=123"
186
+ # "Connection timed out after 30.5 seconds"
187
+ #
188
+ # If we used these raw, each user ID would create a new "group".
189
+ # We normalize by replacing:
190
+ # - Numbers with {number}
191
+ # - UUIDs with {uuid}
192
+ # - Quoted strings with {string}
193
+ #
194
+ def normalize_message(message)
195
+ return "" unless message
196
+
197
+ message
198
+ .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "{uuid}")
199
+ .gsub(/\b\d+\.?\d*\b/, "{number}")
200
+ .gsub(/'[^']*'/, "'{string}'")
201
+ .gsub(/"[^"]*"/, '"{string}"')
202
+ .gsub(/ID=\d+/, "ID={number}")
203
+ end
204
+
205
+ # Get the top relevant stack frame
206
+ def top_frame(backtrace)
207
+ return "" unless backtrace&.any?
208
+
209
+ # Skip framework internals, find first app line
210
+ app_line = backtrace.find do |line|
211
+ line.include?("/app/") || line.include?("/lib/")
212
+ end
213
+
214
+ (app_line || backtrace.first).to_s
215
+ end
216
+
217
+ # Collect server information
218
+ def server_info
219
+ {
220
+ hostname: Socket.gethostname,
221
+ pid: Process.pid,
222
+ ruby_version: RUBY_VERSION,
223
+ rails_version: (Rails.version if defined?(Rails))
224
+ }
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "socket"
5
+
6
+ module Findbug
7
+ module Capture
8
+ # MessageHandler captures non-exception events (messages).
9
+ #
10
+ # WHY CAPTURE MESSAGES?
11
+ # =====================
12
+ #
13
+ # Not every important event is an exception. Sometimes you want to track:
14
+ #
15
+ # - Security events: "User exceeded rate limit"
16
+ # - Business events: "Payment failed validation"
17
+ # - Warnings: "External API response slow"
18
+ # - Debug info: "Cache miss for critical key"
19
+ #
20
+ # These aren't exceptions, but you want to see them in your error dashboard
21
+ # alongside actual errors.
22
+ #
23
+ # USAGE
24
+ # =====
25
+ #
26
+ # Findbug.capture_message("User exceeded rate limit", :warning, user_id: 123)
27
+ # Findbug.capture_message("Payment validation failed", :error, order_id: 456)
28
+ # Findbug.capture_message("Scheduled task completed", :info, duration: 45.2)
29
+ #
30
+ class MessageHandler
31
+ class << self
32
+ # Capture a message
33
+ #
34
+ # @param message [String] the message to capture
35
+ # @param level [Symbol] severity level (:info, :warning, :error)
36
+ # @param extra_context [Hash] additional context
37
+ #
38
+ def capture(message, level = :info, extra_context = {})
39
+ return unless Findbug.enabled?
40
+
41
+ event_data = build_event_data(message, level, extra_context)
42
+ Storage::RedisBuffer.push_error(event_data)
43
+ rescue StandardError => e
44
+ Findbug.logger.error("[Findbug] MessageHandler failed: #{e.message}")
45
+ end
46
+
47
+ private
48
+
49
+ def build_event_data(message, level, extra_context)
50
+ context = Context.to_h
51
+ context[:extra] = (context[:extra] || {}).merge(extra_context)
52
+
53
+ {
54
+ # For messages, we use a synthetic "exception class"
55
+ exception_class: "Findbug::Message",
56
+ message: message,
57
+ backtrace: caller_backtrace,
58
+
59
+ severity: level.to_s,
60
+ handled: true,
61
+ source: "message",
62
+
63
+ context: context,
64
+ fingerprint: generate_fingerprint(message, level),
65
+
66
+ captured_at: Time.now.utc.iso8601(3),
67
+ environment: Findbug.config.environment,
68
+ release: Findbug.config.release,
69
+ server: server_info
70
+ }
71
+ end
72
+
73
+ # Get a clean backtrace from the caller
74
+ def caller_backtrace
75
+ # Skip Findbug internals, show where the message was captured
76
+ caller.drop_while { |line| line.include?("/findbug/") }
77
+ .first(20)
78
+ .map do |line|
79
+ if defined?(Rails.root) && Rails.root
80
+ line.sub(Rails.root.to_s + "/", "")
81
+ else
82
+ line
83
+ end
84
+ end
85
+ end
86
+
87
+ def generate_fingerprint(message, level)
88
+ # For messages, fingerprint by the literal message + level
89
+ # We don't normalize because messages are intentional, not variable
90
+ Digest::SHA256.hexdigest("#{level}:#{message}")
91
+ end
92
+
93
+ def server_info
94
+ {
95
+ hostname: Socket.gethostname,
96
+ pid: Process.pid,
97
+ ruby_version: RUBY_VERSION,
98
+ rails_version: (Rails.version if defined?(Rails))
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end