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,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> •
|
|
119
|
+
<span>#{error_event.environment}</span> •
|
|
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
|