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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Findbug
8
+ module Alerts
9
+ module Channels
10
+ # Discord sends alerts via Discord webhooks.
11
+ #
12
+ # CONFIGURATION
13
+ # =============
14
+ #
15
+ # config.alerts do |alerts|
16
+ # alerts.discord(
17
+ # enabled: true,
18
+ # webhook_url: ENV["DISCORD_WEBHOOK_URL"],
19
+ # username: "Findbug", # optional
20
+ # avatar_url: "https://..." # optional
21
+ # )
22
+ # end
23
+ #
24
+ # SETTING UP DISCORD WEBHOOK
25
+ # ==========================
26
+ #
27
+ # 1. Go to your Discord server settings
28
+ # 2. Navigate to Integrations > Webhooks
29
+ # 3. Create a new webhook
30
+ # 4. Copy the webhook URL
31
+ #
32
+ # Discord webhooks are similar to Slack but use a different payload format.
33
+ #
34
+ class Discord < Base
35
+ def send_alert(error_event)
36
+ webhook_url = config[:webhook_url]
37
+ return if webhook_url.blank?
38
+
39
+ payload = build_payload(error_event)
40
+ post_to_webhook(webhook_url, payload)
41
+ end
42
+
43
+ private
44
+
45
+ def build_payload(error_event)
46
+ {
47
+ username: config[:username] || "Findbug",
48
+ avatar_url: config[:avatar_url],
49
+ embeds: [build_embed(error_event)]
50
+ }.compact
51
+ end
52
+
53
+ def build_embed(error_event)
54
+ embed = {
55
+ title: error_event.exception_class.truncate(256),
56
+ description: error_event.message.to_s.truncate(2048),
57
+ color: severity_color_decimal(error_event.severity),
58
+ url: error_url(error_event),
59
+ fields: build_fields(error_event),
60
+ footer: {
61
+ text: "Findbug | #{error_event.environment}"
62
+ },
63
+ timestamp: error_event.last_seen_at.iso8601
64
+ }
65
+
66
+ embed.compact
67
+ end
68
+
69
+ def build_fields(error_event)
70
+ fields = []
71
+
72
+ fields << {
73
+ name: "Severity",
74
+ value: error_event.severity.upcase,
75
+ inline: true
76
+ }
77
+
78
+ fields << {
79
+ name: "Occurrences",
80
+ value: error_event.occurrence_count.to_s,
81
+ inline: true
82
+ }
83
+
84
+ if error_event.release_version
85
+ fields << {
86
+ name: "Release",
87
+ value: error_event.release_version.to_s.truncate(100),
88
+ inline: true
89
+ }
90
+ end
91
+
92
+ # Add backtrace (limited to Discord field limits)
93
+ if error_event.backtrace_lines.any?
94
+ backtrace = error_event.backtrace_lines.first(5).join("\n")
95
+ fields << {
96
+ name: "Backtrace",
97
+ value: "```\n#{backtrace.truncate(1000)}\n```",
98
+ inline: false
99
+ }
100
+ end
101
+
102
+ # Add user info
103
+ if error_event.user
104
+ user_info = [
105
+ error_event.user["email"],
106
+ error_event.user["id"] ? "ID: #{error_event.user['id']}" : nil
107
+ ].compact.join("\n")
108
+
109
+ fields << {
110
+ name: "User",
111
+ value: user_info,
112
+ inline: false
113
+ } if user_info.present?
114
+ end
115
+
116
+ fields
117
+ end
118
+
119
+ # Discord uses decimal color values
120
+ def severity_color_decimal(severity)
121
+ case severity
122
+ when "error" then 14_423_100 # #dc3545 in decimal
123
+ when "warning" then 16_761_095 # #ffc107
124
+ when "info" then 1_548_984 # #17a2b8
125
+ else 7_107_965 # #6c757d
126
+ end
127
+ end
128
+
129
+ def post_to_webhook(webhook_url, payload)
130
+ uri = URI.parse(webhook_url)
131
+
132
+ http = Net::HTTP.new(uri.host, uri.port)
133
+ http.use_ssl = (uri.scheme == "https")
134
+ http.open_timeout = 5
135
+ http.read_timeout = 5
136
+
137
+ request = Net::HTTP::Post.new(uri.path)
138
+ request["Content-Type"] = "application/json"
139
+ request.body = payload.to_json
140
+
141
+ response = http.request(request)
142
+
143
+ # Discord returns 204 No Content on success
144
+ unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPNoContent)
145
+ Findbug.logger.error(
146
+ "[Findbug] Discord webhook failed: #{response.code} #{response.body}"
147
+ )
148
+ end
149
+ rescue StandardError => e
150
+ Findbug.logger.error("[Findbug] Discord alert failed: #{e.message}")
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Findbug
4
+ module Alerts
5
+ module Channels
6
+ # Email sends alert emails via ActionMailer.
7
+ #
8
+ # CONFIGURATION
9
+ # =============
10
+ #
11
+ # config.alerts do |alerts|
12
+ # alerts.email(
13
+ # enabled: true,
14
+ # recipients: ["dev-team@example.com", "oncall@example.com"],
15
+ # from: "findbug@example.com" # optional
16
+ # )
17
+ # end
18
+ #
19
+ # REQUIREMENTS
20
+ # ============
21
+ #
22
+ # ActionMailer must be configured in your Rails app.
23
+ # The gem doesn't configure SMTP - it uses your app's mailer config.
24
+ #
25
+ class Email < Base
26
+ def send_alert(error_event)
27
+ recipients = config[:recipients]
28
+ return if recipients.blank?
29
+
30
+ # Use ActionMailer if available
31
+ if defined?(ActionMailer::Base)
32
+ FindbugMailer.error_alert(error_event, recipients).deliver_later
33
+ else
34
+ Findbug.logger.warn("[Findbug] ActionMailer not available for email alerts")
35
+ end
36
+ end
37
+ end
38
+
39
+ # Simple mailer for error alerts
40
+ #
41
+ # We define this inline because it's simple and self-contained.
42
+ # Users can override by creating their own FindbugMailer.
43
+ #
44
+ class FindbugMailer < ActionMailer::Base
45
+ default from: -> { Findbug.config.alerts.channel(:email)&.dig(:from) || "findbug@localhost" }
46
+
47
+ def error_alert(error_event, recipients)
48
+ @error = error_event
49
+ @error_url = build_error_url(error_event)
50
+
51
+ subject = "[#{Rails.env}] #{error_event.exception_class}: #{error_event.message.to_s.truncate(50)}"
52
+
53
+ mail(
54
+ to: recipients,
55
+ subject: subject
56
+ ) do |format|
57
+ format.text { render plain: build_text_body(error_event) }
58
+ format.html { render html: build_html_body(error_event).html_safe }
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def build_text_body(error_event)
65
+ <<~TEXT
66
+ ERROR ALERT
67
+ ===========
68
+
69
+ Exception: #{error_event.exception_class}
70
+ Message: #{error_event.message}
71
+ Severity: #{error_event.severity.upcase}
72
+ Environment: #{error_event.environment}
73
+
74
+ Occurrences: #{error_event.occurrence_count}
75
+ First seen: #{error_event.first_seen_at}
76
+ Last seen: #{error_event.last_seen_at}
77
+
78
+ #{@error_url ? "View in dashboard: #{@error_url}" : ""}
79
+
80
+ BACKTRACE
81
+ ---------
82
+ #{error_event.backtrace_lines.first(10).join("\n")}
83
+
84
+ CONTEXT
85
+ -------
86
+ #{format_context(error_event)}
87
+ TEXT
88
+ end
89
+
90
+ def build_html_body(error_event)
91
+ <<~HTML
92
+ <!DOCTYPE html>
93
+ <html>
94
+ <head>
95
+ <style>
96
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
97
+ .container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
98
+ .header { background: #dc3545; color: white; padding: 20px; }
99
+ .header h1 { margin: 0; font-size: 18px; }
100
+ .content { padding: 20px; }
101
+ .meta { color: #666; font-size: 14px; margin-bottom: 20px; }
102
+ .section { margin-bottom: 20px; }
103
+ .section h3 { margin: 0 0 10px 0; font-size: 14px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 5px; }
104
+ .backtrace { background: #f8f9fa; padding: 10px; font-family: monospace; font-size: 12px; overflow-x: auto; border-radius: 4px; }
105
+ .btn { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; }
106
+ code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <div class="container">
111
+ <div class="header">
112
+ <h1>#{error_event.exception_class}</h1>
113
+ </div>
114
+ <div class="content">
115
+ <p><strong>#{h(error_event.message)}</strong></p>
116
+
117
+ <div class="meta">
118
+ <span>#{error_event.severity.upcase}</span> &bull;
119
+ <span>#{error_event.environment}</span> &bull;
120
+ <span>#{error_event.occurrence_count} occurrence(s)</span>
121
+ </div>
122
+
123
+ #{@error_url ? "<p><a href=\"#{@error_url}\" class=\"btn\">View in Dashboard</a></p>" : ""}
124
+
125
+ <div class="section">
126
+ <h3>Backtrace</h3>
127
+ <div class="backtrace">#{error_event.backtrace_lines.first(10).map { |l| h(l) }.join("<br>")}</div>
128
+ </div>
129
+
130
+ <div class="section">
131
+ <h3>Request Info</h3>
132
+ #{format_request_html(error_event)}
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </body>
137
+ </html>
138
+ HTML
139
+ end
140
+
141
+ def format_context(error_event)
142
+ request = error_event.request
143
+ return "No request context" unless request
144
+
145
+ [
146
+ "Method: #{request['method']}",
147
+ "Path: #{request['path']}",
148
+ "IP: #{request['ip']}",
149
+ "User Agent: #{request['user_agent']}"
150
+ ].join("\n")
151
+ end
152
+
153
+ def format_request_html(error_event)
154
+ request = error_event.request
155
+ return "<p>No request context</p>" unless request
156
+
157
+ <<~HTML
158
+ <p>
159
+ <code>#{request['method']}</code> #{h(request['path'])}<br>
160
+ IP: #{request['ip']}<br>
161
+ User Agent: #{h(request['user_agent'].to_s.truncate(100))}
162
+ </p>
163
+ HTML
164
+ end
165
+
166
+ def build_error_url(error_event)
167
+ base_url = ENV.fetch("FINDBUG_BASE_URL", nil)
168
+ return nil unless base_url
169
+
170
+ "#{base_url}#{Findbug.config.web_path}/errors/#{error_event.id}"
171
+ end
172
+
173
+ def h(text)
174
+ ERB::Util.html_escape(text)
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Findbug
8
+ module Alerts
9
+ module Channels
10
+ # Slack sends alerts via Slack incoming webhooks.
11
+ #
12
+ # CONFIGURATION
13
+ # =============
14
+ #
15
+ # config.alerts do |alerts|
16
+ # alerts.slack(
17
+ # enabled: true,
18
+ # webhook_url: ENV["SLACK_WEBHOOK_URL"],
19
+ # channel: "#errors", # optional, overrides webhook default
20
+ # username: "Findbug", # optional
21
+ # icon_emoji: ":bug:" # optional
22
+ # )
23
+ # end
24
+ #
25
+ # SETTING UP SLACK WEBHOOK
26
+ # ========================
27
+ #
28
+ # 1. Go to https://api.slack.com/apps
29
+ # 2. Create a new app (or use existing)
30
+ # 3. Add "Incoming Webhooks" feature
31
+ # 4. Create a webhook for your channel
32
+ # 5. Copy the webhook URL
33
+ #
34
+ class Slack < Base
35
+ def send_alert(error_event)
36
+ webhook_url = config[:webhook_url]
37
+ return if webhook_url.blank?
38
+
39
+ payload = build_payload(error_event)
40
+ post_to_webhook(webhook_url, payload)
41
+ end
42
+
43
+ private
44
+
45
+ def build_payload(error_event)
46
+ {
47
+ channel: config[:channel],
48
+ username: config[:username] || "Findbug",
49
+ icon_emoji: config[:icon_emoji] || ":bug:",
50
+ attachments: [build_attachment(error_event)]
51
+ }.compact
52
+ end
53
+
54
+ def build_attachment(error_event)
55
+ {
56
+ color: severity_color(error_event.severity),
57
+ title: "#{error_event.exception_class}",
58
+ title_link: error_url(error_event),
59
+ text: error_event.message.to_s.truncate(500),
60
+ fields: build_fields(error_event),
61
+ footer: "Findbug | #{error_event.environment}",
62
+ ts: error_event.last_seen_at.to_i
63
+ }.compact
64
+ end
65
+
66
+ def build_fields(error_event)
67
+ fields = []
68
+
69
+ fields << {
70
+ title: "Occurrences",
71
+ value: error_event.occurrence_count.to_s,
72
+ short: true
73
+ }
74
+
75
+ fields << {
76
+ title: "Severity",
77
+ value: error_event.severity.upcase,
78
+ short: true
79
+ }
80
+
81
+ if error_event.release_version
82
+ fields << {
83
+ title: "Release",
84
+ value: error_event.release_version.to_s.truncate(20),
85
+ short: true
86
+ }
87
+ end
88
+
89
+ # Add first backtrace line
90
+ if error_event.backtrace_lines.any?
91
+ fields << {
92
+ title: "Location",
93
+ value: "`#{error_event.backtrace_lines.first.truncate(80)}`",
94
+ short: false
95
+ }
96
+ end
97
+
98
+ # Add user info if present
99
+ if error_event.user
100
+ user_info = [
101
+ error_event.user["email"],
102
+ error_event.user["id"] ? "ID: #{error_event.user['id']}" : nil
103
+ ].compact.join(" | ")
104
+
105
+ fields << {
106
+ title: "User",
107
+ value: user_info,
108
+ short: false
109
+ } if user_info.present?
110
+ end
111
+
112
+ fields
113
+ end
114
+
115
+ def severity_color(severity)
116
+ case severity
117
+ when "error" then "#dc3545" # Red
118
+ when "warning" then "#ffc107" # Yellow
119
+ when "info" then "#17a2b8" # Blue
120
+ else "#6c757d" # Gray
121
+ end
122
+ end
123
+
124
+ def post_to_webhook(webhook_url, payload)
125
+ uri = URI.parse(webhook_url)
126
+
127
+ http = Net::HTTP.new(uri.host, uri.port)
128
+ http.use_ssl = (uri.scheme == "https")
129
+ http.open_timeout = 5
130
+ http.read_timeout = 5
131
+
132
+ request = Net::HTTP::Post.new(uri.path)
133
+ request["Content-Type"] = "application/json"
134
+ request.body = payload.to_json
135
+
136
+ response = http.request(request)
137
+
138
+ unless response.is_a?(Net::HTTPSuccess)
139
+ Findbug.logger.error(
140
+ "[Findbug] Slack webhook failed: #{response.code} #{response.body}"
141
+ )
142
+ end
143
+ rescue StandardError => e
144
+ Findbug.logger.error("[Findbug] Slack alert failed: #{e.message}")
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Findbug
8
+ module Alerts
9
+ module Channels
10
+ # Webhook sends alerts to a generic HTTP endpoint.
11
+ #
12
+ # CONFIGURATION
13
+ # =============
14
+ #
15
+ # config.alerts do |alerts|
16
+ # alerts.webhook(
17
+ # enabled: true,
18
+ # url: "https://your-service.com/findbug-webhook",
19
+ # headers: {
20
+ # "Authorization" => "Bearer #{ENV['WEBHOOK_TOKEN']}",
21
+ # "X-Custom-Header" => "value"
22
+ # },
23
+ # method: "POST" # optional, defaults to POST
24
+ # )
25
+ # end
26
+ #
27
+ # PAYLOAD FORMAT
28
+ # ==============
29
+ #
30
+ # The webhook receives a JSON payload with the full error event:
31
+ #
32
+ # {
33
+ # "event_type": "error",
34
+ # "error": {
35
+ # "id": 123,
36
+ # "exception_class": "NoMethodError",
37
+ # "message": "undefined method...",
38
+ # "severity": "error",
39
+ # "occurrence_count": 5,
40
+ # "first_seen_at": "2024-01-01T00:00:00Z",
41
+ # "last_seen_at": "2024-01-01T01:00:00Z",
42
+ # "environment": "production",
43
+ # "release": "abc123",
44
+ # "backtrace": [...],
45
+ # "context": {...}
46
+ # }
47
+ # }
48
+ #
49
+ # USE CASES
50
+ # =========
51
+ #
52
+ # - Custom alerting systems
53
+ # - Integration with internal tools
54
+ # - PagerDuty/OpsGenie (if no native integration)
55
+ # - Log aggregation services
56
+ # - Custom notification services
57
+ #
58
+ class Webhook < Base
59
+ def send_alert(error_event)
60
+ url = config[:url]
61
+ return if url.blank?
62
+
63
+ payload = build_payload(error_event)
64
+ post_to_webhook(url, payload)
65
+ end
66
+
67
+ private
68
+
69
+ def build_payload(error_event)
70
+ {
71
+ event_type: "error",
72
+ timestamp: Time.now.utc.iso8601,
73
+ findbug_version: Findbug::VERSION,
74
+ error: {
75
+ id: error_event.id,
76
+ fingerprint: error_event.fingerprint,
77
+ exception_class: error_event.exception_class,
78
+ message: error_event.message,
79
+ severity: error_event.severity,
80
+ status: error_event.status,
81
+ handled: error_event.handled,
82
+ occurrence_count: error_event.occurrence_count,
83
+ first_seen_at: error_event.first_seen_at&.iso8601,
84
+ last_seen_at: error_event.last_seen_at&.iso8601,
85
+ environment: error_event.environment,
86
+ release: error_event.release_version,
87
+ backtrace: error_event.backtrace_lines,
88
+ context: error_event.context,
89
+ user: error_event.user,
90
+ request: error_event.request,
91
+ tags: error_event.tags,
92
+ url: error_url(error_event)
93
+ }
94
+ }
95
+ end
96
+
97
+ def post_to_webhook(url, payload)
98
+ uri = URI.parse(url)
99
+
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = (uri.scheme == "https")
102
+ http.open_timeout = 5
103
+ http.read_timeout = 10
104
+
105
+ method = config[:method]&.upcase || "POST"
106
+ request = build_request(method, uri, payload)
107
+
108
+ # Add custom headers
109
+ (config[:headers] || {}).each do |key, value|
110
+ request[key] = value
111
+ end
112
+
113
+ response = http.request(request)
114
+
115
+ unless response.is_a?(Net::HTTPSuccess)
116
+ Findbug.logger.error(
117
+ "[Findbug] Webhook failed: #{response.code} #{response.body.to_s.truncate(200)}"
118
+ )
119
+ end
120
+ rescue StandardError => e
121
+ Findbug.logger.error("[Findbug] Webhook alert failed: #{e.message}")
122
+ end
123
+
124
+ def build_request(method, uri, payload)
125
+ case method
126
+ when "POST"
127
+ request = Net::HTTP::Post.new(uri.request_uri)
128
+ request["Content-Type"] = "application/json"
129
+ request.body = payload.to_json
130
+ request
131
+ when "PUT"
132
+ request = Net::HTTP::Put.new(uri.request_uri)
133
+ request["Content-Type"] = "application/json"
134
+ request.body = payload.to_json
135
+ request
136
+ else
137
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end