rails_error_dashboard 0.1.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/MIT-LICENSE +20 -0
- data/README.md +858 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/rails_error_dashboard/application.css +15 -0
- data/app/controllers/rails_error_dashboard/application_controller.rb +12 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +123 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/application_job.rb +4 -0
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +116 -0
- data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +19 -0
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +105 -0
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +166 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +108 -0
- data/app/mailers/rails_error_dashboard/application_mailer.rb +8 -0
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +27 -0
- data/app/models/rails_error_dashboard/application_record.rb +5 -0
- data/app/models/rails_error_dashboard/error_log.rb +185 -0
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/app/views/layouts/rails_error_dashboard/application.html.erb +17 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +351 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +200 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +32 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +237 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +334 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +294 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +40 -0
- data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +13 -0
- data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +10 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +27 -0
- data/lib/generators/rails_error_dashboard/install/templates/README +33 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +64 -0
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +40 -0
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +60 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +134 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +35 -0
- data/lib/rails_error_dashboard/configuration.rb +83 -0
- data/lib/rails_error_dashboard/engine.rb +20 -0
- data/lib/rails_error_dashboard/error_reporter.rb +35 -0
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +41 -0
- data/lib/rails_error_dashboard/plugin.rb +98 -0
- data/lib/rails_error_dashboard/plugin_registry.rb +88 -0
- data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +96 -0
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +122 -0
- data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +78 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +108 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +37 -0
- data/lib/rails_error_dashboard/queries/developer_insights.rb +277 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +66 -0
- data/lib/rails_error_dashboard/queries/errors_list_v2.rb +149 -0
- data/lib/rails_error_dashboard/queries/filter_options.rb +21 -0
- data/lib/rails_error_dashboard/services/platform_detector.rb +41 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +148 -0
- data/lib/rails_error_dashboard/version.rb +3 -0
- data/lib/rails_error_dashboard.rb +60 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +4 -0
- metadata +318 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module RailsErrorDashboard
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
include Pagy::Backend
|
|
4
|
+
|
|
5
|
+
layout "rails_error_dashboard"
|
|
6
|
+
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
|
|
9
|
+
# Make Pagy helpers available in views
|
|
10
|
+
helper Pagy::Frontend
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
class ErrorsController < ApplicationController
|
|
5
|
+
include Pagy::Backend
|
|
6
|
+
|
|
7
|
+
before_action :authenticate_dashboard_user!
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
# Use Query to get filtered errors
|
|
11
|
+
errors_query = Queries::ErrorsList.call(filter_params)
|
|
12
|
+
|
|
13
|
+
# Paginate with Pagy
|
|
14
|
+
@pagy, @errors = pagy(errors_query, items: params[:per_page] || 25)
|
|
15
|
+
|
|
16
|
+
# Get dashboard stats using Query
|
|
17
|
+
@stats = Queries::DashboardStats.call
|
|
18
|
+
|
|
19
|
+
# Get filter options using Query
|
|
20
|
+
filter_options = Queries::FilterOptions.call
|
|
21
|
+
@environments = filter_options[:environments]
|
|
22
|
+
@error_types = filter_options[:error_types]
|
|
23
|
+
@platforms = filter_options[:platforms]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show
|
|
27
|
+
@error = ErrorLog.find(params[:id])
|
|
28
|
+
@related_errors = @error.related_errors(limit: 5)
|
|
29
|
+
|
|
30
|
+
# Dispatch plugin event for error viewed
|
|
31
|
+
RailsErrorDashboard::PluginRegistry.dispatch(:on_error_viewed, @error)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve
|
|
35
|
+
# Use Command to resolve error
|
|
36
|
+
@error = Commands::ResolveError.call(
|
|
37
|
+
params[:id],
|
|
38
|
+
resolved_by_name: params[:resolved_by_name],
|
|
39
|
+
resolution_comment: params[:resolution_comment],
|
|
40
|
+
resolution_reference: params[:resolution_reference]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
redirect_to error_path(@error)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def analytics
|
|
47
|
+
days = (params[:days] || 30).to_i
|
|
48
|
+
@days = days
|
|
49
|
+
|
|
50
|
+
# Use Query to get analytics data
|
|
51
|
+
analytics = Queries::AnalyticsStats.call(days)
|
|
52
|
+
|
|
53
|
+
@error_stats = analytics[:error_stats]
|
|
54
|
+
@errors_over_time = analytics[:errors_over_time]
|
|
55
|
+
@errors_by_type = analytics[:errors_by_type]
|
|
56
|
+
@errors_by_platform = analytics[:errors_by_platform]
|
|
57
|
+
@errors_by_environment = analytics[:errors_by_environment]
|
|
58
|
+
@errors_by_hour = analytics[:errors_by_hour]
|
|
59
|
+
@top_users = analytics[:top_users]
|
|
60
|
+
@resolution_rate = analytics[:resolution_rate]
|
|
61
|
+
@mobile_errors = analytics[:mobile_errors]
|
|
62
|
+
@api_errors = analytics[:api_errors]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def batch_action
|
|
66
|
+
error_ids = params[:error_ids] || []
|
|
67
|
+
action_type = params[:action_type]
|
|
68
|
+
|
|
69
|
+
result = case action_type
|
|
70
|
+
when "resolve"
|
|
71
|
+
Commands::BatchResolveErrors.call(
|
|
72
|
+
error_ids,
|
|
73
|
+
resolved_by_name: params[:resolved_by_name],
|
|
74
|
+
resolution_comment: params[:resolution_comment]
|
|
75
|
+
)
|
|
76
|
+
when "delete"
|
|
77
|
+
Commands::BatchDeleteErrors.call(error_ids)
|
|
78
|
+
else
|
|
79
|
+
{ success: false, count: 0, errors: [ "Invalid action type" ] }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if result[:success]
|
|
83
|
+
flash[:notice] = "Successfully #{action_type}d #{result[:count]} error(s)"
|
|
84
|
+
else
|
|
85
|
+
flash[:alert] = "Batch operation failed: #{result[:errors].join(', ')}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
redirect_to errors_path
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def filter_params
|
|
94
|
+
{
|
|
95
|
+
environment: params[:environment],
|
|
96
|
+
error_type: params[:error_type],
|
|
97
|
+
unresolved: params[:unresolved],
|
|
98
|
+
platform: params[:platform],
|
|
99
|
+
search: params[:search]
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def authenticate_dashboard_user!
|
|
104
|
+
return if skip_authentication?
|
|
105
|
+
|
|
106
|
+
authenticate_or_request_with_http_basic do |username, password|
|
|
107
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
108
|
+
username,
|
|
109
|
+
RailsErrorDashboard.configuration.dashboard_username
|
|
110
|
+
) &
|
|
111
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
112
|
+
password,
|
|
113
|
+
RailsErrorDashboard.configuration.dashboard_password
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def skip_authentication?
|
|
119
|
+
!RailsErrorDashboard.configuration.require_authentication ||
|
|
120
|
+
(Rails.env.development? && !RailsErrorDashboard.configuration.require_authentication_in_development)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
# Job to send error notifications to Discord via webhook
|
|
7
|
+
class DiscordErrorNotificationJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
def perform(error_log_id)
|
|
11
|
+
error_log = ErrorLog.find(error_log_id)
|
|
12
|
+
webhook_url = RailsErrorDashboard.configuration.discord_webhook_url
|
|
13
|
+
|
|
14
|
+
return unless webhook_url.present?
|
|
15
|
+
|
|
16
|
+
payload = build_discord_payload(error_log)
|
|
17
|
+
|
|
18
|
+
HTTParty.post(
|
|
19
|
+
webhook_url,
|
|
20
|
+
body: payload.to_json,
|
|
21
|
+
headers: { "Content-Type" => "application/json" }
|
|
22
|
+
)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Rails.logger.error("Failed to send Discord notification: #{e.message}")
|
|
25
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_discord_payload(error_log)
|
|
31
|
+
{
|
|
32
|
+
embeds: [ {
|
|
33
|
+
title: "🚨 New Error: #{error_log.error_type}",
|
|
34
|
+
description: truncate_message(error_log.message),
|
|
35
|
+
color: severity_color(error_log),
|
|
36
|
+
fields: [
|
|
37
|
+
{
|
|
38
|
+
name: "Platform",
|
|
39
|
+
value: error_log.platform || "Unknown",
|
|
40
|
+
inline: true
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "Environment",
|
|
44
|
+
value: error_log.environment || "Unknown",
|
|
45
|
+
inline: true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "Occurrences",
|
|
49
|
+
value: error_log.occurrence_count.to_s,
|
|
50
|
+
inline: true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "Controller",
|
|
54
|
+
value: error_log.controller_name || "N/A",
|
|
55
|
+
inline: true
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "Action",
|
|
59
|
+
value: error_log.action_name || "N/A",
|
|
60
|
+
inline: true
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "First Seen",
|
|
64
|
+
value: format_time(error_log.first_seen_at),
|
|
65
|
+
inline: true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "Location",
|
|
69
|
+
value: extract_first_backtrace_line(error_log.backtrace),
|
|
70
|
+
inline: false
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
footer: {
|
|
74
|
+
text: "Rails Error Dashboard"
|
|
75
|
+
},
|
|
76
|
+
timestamp: error_log.occurred_at.iso8601
|
|
77
|
+
} ]
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def severity_color(error_log)
|
|
82
|
+
case error_log.severity
|
|
83
|
+
when :critical
|
|
84
|
+
16711680 # Red
|
|
85
|
+
when :high
|
|
86
|
+
16744192 # Orange
|
|
87
|
+
when :medium
|
|
88
|
+
16776960 # Yellow
|
|
89
|
+
else
|
|
90
|
+
8421504 # Gray
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def truncate_message(message, length = 200)
|
|
95
|
+
return "" if message.nil?
|
|
96
|
+
message.length > length ? "#{message[0...length]}..." : message
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_time(time)
|
|
100
|
+
return "N/A" if time.nil?
|
|
101
|
+
time.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_first_backtrace_line(backtrace)
|
|
105
|
+
return "N/A" if backtrace.nil?
|
|
106
|
+
|
|
107
|
+
lines = backtrace.is_a?(String) ? backtrace.lines : backtrace
|
|
108
|
+
first_line = lines.first&.strip
|
|
109
|
+
|
|
110
|
+
return "N/A" if first_line.nil?
|
|
111
|
+
|
|
112
|
+
# Truncate if too long
|
|
113
|
+
first_line.length > 100 ? "#{first_line[0...100]}..." : first_line
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
class EmailErrorNotificationJob < ApplicationJob
|
|
5
|
+
queue_as :error_notifications
|
|
6
|
+
|
|
7
|
+
def perform(error_log_id)
|
|
8
|
+
error_log = ErrorLog.find_by(id: error_log_id)
|
|
9
|
+
return unless error_log
|
|
10
|
+
|
|
11
|
+
recipients = RailsErrorDashboard.configuration.notification_email_recipients
|
|
12
|
+
return unless recipients.present?
|
|
13
|
+
|
|
14
|
+
ErrorNotificationMailer.error_alert(error_log, recipients).deliver_now
|
|
15
|
+
rescue => e
|
|
16
|
+
Rails.logger.error("Failed to send email notification: #{e.message}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
# Job to send critical error notifications to PagerDuty
|
|
7
|
+
# Only triggers for critical severity errors
|
|
8
|
+
class PagerdutyErrorNotificationJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
PAGERDUTY_EVENTS_API = "https://events.pagerduty.com/v2/enqueue"
|
|
12
|
+
|
|
13
|
+
def perform(error_log_id)
|
|
14
|
+
error_log = ErrorLog.find(error_log_id)
|
|
15
|
+
|
|
16
|
+
# Only trigger PagerDuty for critical errors
|
|
17
|
+
return unless error_log.critical?
|
|
18
|
+
|
|
19
|
+
routing_key = RailsErrorDashboard.configuration.pagerduty_integration_key
|
|
20
|
+
return unless routing_key.present?
|
|
21
|
+
|
|
22
|
+
payload = build_pagerduty_payload(error_log, routing_key)
|
|
23
|
+
|
|
24
|
+
response = HTTParty.post(
|
|
25
|
+
PAGERDUTY_EVENTS_API,
|
|
26
|
+
body: payload.to_json,
|
|
27
|
+
headers: { "Content-Type" => "application/json" }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
unless response.success?
|
|
31
|
+
Rails.logger.error("PagerDuty API error: #{response.code} - #{response.body}")
|
|
32
|
+
end
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Rails.logger.error("Failed to send PagerDuty notification: #{e.message}")
|
|
35
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_pagerduty_payload(error_log, routing_key)
|
|
41
|
+
{
|
|
42
|
+
routing_key: routing_key,
|
|
43
|
+
event_action: "trigger",
|
|
44
|
+
payload: {
|
|
45
|
+
summary: "Critical Error: #{error_log.error_type} in #{error_log.platform}",
|
|
46
|
+
severity: "critical",
|
|
47
|
+
source: error_source(error_log),
|
|
48
|
+
component: error_log.controller_name || "Unknown",
|
|
49
|
+
group: error_log.error_type,
|
|
50
|
+
class: error_log.error_type,
|
|
51
|
+
custom_details: {
|
|
52
|
+
message: error_log.message,
|
|
53
|
+
controller: error_log.controller_name,
|
|
54
|
+
action: error_log.action_name,
|
|
55
|
+
platform: error_log.platform,
|
|
56
|
+
environment: error_log.environment,
|
|
57
|
+
occurrences: error_log.occurrence_count,
|
|
58
|
+
first_seen_at: error_log.first_seen_at&.iso8601,
|
|
59
|
+
last_seen_at: error_log.last_seen_at&.iso8601,
|
|
60
|
+
request_url: error_log.request_url,
|
|
61
|
+
backtrace: extract_backtrace_summary(error_log.backtrace),
|
|
62
|
+
error_id: error_log.id
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
links: dashboard_links(error_log),
|
|
66
|
+
client: "Rails Error Dashboard",
|
|
67
|
+
client_url: dashboard_url(error_log)
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def error_source(error_log)
|
|
72
|
+
if error_log.controller_name && error_log.action_name
|
|
73
|
+
"#{error_log.controller_name}##{error_log.action_name}"
|
|
74
|
+
elsif error_log.request_url
|
|
75
|
+
error_log.request_url
|
|
76
|
+
else
|
|
77
|
+
error_log.platform || "Rails Application"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_backtrace_summary(backtrace)
|
|
82
|
+
return [] if backtrace.nil?
|
|
83
|
+
|
|
84
|
+
lines = backtrace.is_a?(String) ? backtrace.lines : backtrace
|
|
85
|
+
lines.first(10).map(&:strip)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def dashboard_links(error_log)
|
|
89
|
+
[
|
|
90
|
+
{
|
|
91
|
+
href: dashboard_url(error_log),
|
|
92
|
+
text: "View in Error Dashboard"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def dashboard_url(error_log)
|
|
98
|
+
# This will need to be configured per deployment
|
|
99
|
+
# For now, return a placeholder
|
|
100
|
+
config = RailsErrorDashboard.configuration
|
|
101
|
+
base_url = config.dashboard_base_url || "http://localhost:3000"
|
|
102
|
+
"#{base_url}/error_dashboard/errors/#{error_log.id}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
class SlackErrorNotificationJob < ApplicationJob
|
|
5
|
+
queue_as :error_notifications
|
|
6
|
+
|
|
7
|
+
def perform(error_log_id)
|
|
8
|
+
error_log = ErrorLog.find_by(id: error_log_id)
|
|
9
|
+
return unless error_log
|
|
10
|
+
|
|
11
|
+
webhook_url = RailsErrorDashboard.configuration.slack_webhook_url
|
|
12
|
+
return unless webhook_url.present?
|
|
13
|
+
|
|
14
|
+
send_slack_notification(error_log, webhook_url)
|
|
15
|
+
rescue => e
|
|
16
|
+
Rails.logger.error("Failed to send Slack notification: #{e.message}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def send_slack_notification(error_log, webhook_url)
|
|
22
|
+
require "net/http"
|
|
23
|
+
require "json"
|
|
24
|
+
|
|
25
|
+
uri = URI(webhook_url)
|
|
26
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
27
|
+
http.use_ssl = true
|
|
28
|
+
|
|
29
|
+
request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
|
|
30
|
+
request.body = slack_payload(error_log).to_json
|
|
31
|
+
|
|
32
|
+
response = http.request(request)
|
|
33
|
+
|
|
34
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
35
|
+
Rails.logger.error("Slack notification failed: #{response.code} - #{response.body}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def slack_payload(error_log)
|
|
40
|
+
{
|
|
41
|
+
text: "🚨 New Error in #{error_log.environment.titleize} Environment",
|
|
42
|
+
blocks: [
|
|
43
|
+
{
|
|
44
|
+
type: "header",
|
|
45
|
+
text: {
|
|
46
|
+
type: "plain_text",
|
|
47
|
+
text: "🚨 Error Alert",
|
|
48
|
+
emoji: true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
type: "section",
|
|
53
|
+
fields: [
|
|
54
|
+
{
|
|
55
|
+
type: "mrkdwn",
|
|
56
|
+
text: "*Error Type:*\n`#{error_log.error_type}`"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "mrkdwn",
|
|
60
|
+
text: "*Environment:*\n#{error_log.environment.titleize}"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: "mrkdwn",
|
|
64
|
+
text: "*Platform:*\n#{platform_emoji(error_log.platform)} #{error_log.platform || 'Unknown'}"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: "mrkdwn",
|
|
68
|
+
text: "*Occurred:*\n#{error_log.occurred_at.strftime('%B %d, %Y at %I:%M %p')}"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
type: "section",
|
|
74
|
+
text: {
|
|
75
|
+
type: "mrkdwn",
|
|
76
|
+
text: "*Message:*\n```#{truncate_message(error_log.message)}```"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
user_section(error_log),
|
|
80
|
+
request_section(error_log),
|
|
81
|
+
{
|
|
82
|
+
type: "actions",
|
|
83
|
+
elements: [
|
|
84
|
+
{
|
|
85
|
+
type: "button",
|
|
86
|
+
text: {
|
|
87
|
+
type: "plain_text",
|
|
88
|
+
text: "View Details",
|
|
89
|
+
emoji: true
|
|
90
|
+
},
|
|
91
|
+
url: dashboard_url(error_log),
|
|
92
|
+
style: "primary"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: "context",
|
|
98
|
+
elements: [
|
|
99
|
+
{
|
|
100
|
+
type: "mrkdwn",
|
|
101
|
+
text: "Error ID: #{error_log.id}"
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
].compact
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def user_section(error_log)
|
|
110
|
+
return nil unless error_log.user_id.present?
|
|
111
|
+
|
|
112
|
+
user_email = error_log.user&.email || "User ##{error_log.user_id}"
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
type: "section",
|
|
116
|
+
fields: [
|
|
117
|
+
{
|
|
118
|
+
type: "mrkdwn",
|
|
119
|
+
text: "*User:*\n#{user_email}"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: "mrkdwn",
|
|
123
|
+
text: "*IP Address:*\n#{error_log.ip_address || 'N/A'}"
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def request_section(error_log)
|
|
130
|
+
return nil unless error_log.request_url.present?
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
type: "section",
|
|
134
|
+
text: {
|
|
135
|
+
type: "mrkdwn",
|
|
136
|
+
text: "*Request URL:*\n`#{truncate_message(error_log.request_url, 200)}`"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def platform_emoji(platform)
|
|
142
|
+
case platform&.downcase
|
|
143
|
+
when "ios"
|
|
144
|
+
"📱"
|
|
145
|
+
when "android"
|
|
146
|
+
"🤖"
|
|
147
|
+
when "api"
|
|
148
|
+
"🔌"
|
|
149
|
+
else
|
|
150
|
+
"💻"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def truncate_message(message, length = 500)
|
|
155
|
+
return "" unless message
|
|
156
|
+
message.length > length ? "#{message[0...length]}..." : message
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def dashboard_url(error_log)
|
|
160
|
+
# Generate URL to error dashboard
|
|
161
|
+
# This will need to be configured based on your app's URL
|
|
162
|
+
base_url = RailsErrorDashboard.configuration.dashboard_base_url || "http://localhost:3000"
|
|
163
|
+
"#{base_url}/error_dashboard/errors/#{error_log.id}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
# Job to send error notifications to custom webhook URLs
|
|
7
|
+
# Supports multiple webhooks for different integrations
|
|
8
|
+
class WebhookErrorNotificationJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
def perform(error_log_id)
|
|
12
|
+
error_log = ErrorLog.find(error_log_id)
|
|
13
|
+
webhook_urls = RailsErrorDashboard.configuration.webhook_urls
|
|
14
|
+
|
|
15
|
+
return unless webhook_urls.present?
|
|
16
|
+
|
|
17
|
+
# Ensure webhook_urls is an array
|
|
18
|
+
urls = Array(webhook_urls)
|
|
19
|
+
|
|
20
|
+
payload = build_webhook_payload(error_log)
|
|
21
|
+
|
|
22
|
+
urls.each do |url|
|
|
23
|
+
send_webhook(url, payload, error_log)
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger.error("Failed to send webhook notification: #{e.message}")
|
|
27
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def send_webhook(url, payload, error_log)
|
|
33
|
+
response = HTTParty.post(
|
|
34
|
+
url,
|
|
35
|
+
body: payload.to_json,
|
|
36
|
+
headers: {
|
|
37
|
+
"Content-Type" => "application/json",
|
|
38
|
+
"User-Agent" => "RailsErrorDashboard/1.0",
|
|
39
|
+
"X-Error-Dashboard-Event" => "error.created",
|
|
40
|
+
"X-Error-Dashboard-ID" => error_log.id.to_s
|
|
41
|
+
},
|
|
42
|
+
timeout: 10 # 10 second timeout
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
unless response.success?
|
|
46
|
+
Rails.logger.warn("Webhook failed for #{url}: #{response.code}")
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Rails.logger.error("Webhook error for #{url}: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_webhook_payload(error_log)
|
|
53
|
+
{
|
|
54
|
+
event: "error.created",
|
|
55
|
+
timestamp: Time.current.iso8601,
|
|
56
|
+
error: {
|
|
57
|
+
id: error_log.id,
|
|
58
|
+
type: error_log.error_type,
|
|
59
|
+
message: error_log.message,
|
|
60
|
+
severity: error_log.severity.to_s,
|
|
61
|
+
platform: error_log.platform,
|
|
62
|
+
environment: error_log.environment,
|
|
63
|
+
controller: error_log.controller_name,
|
|
64
|
+
action: error_log.action_name,
|
|
65
|
+
occurrence_count: error_log.occurrence_count,
|
|
66
|
+
first_seen_at: error_log.first_seen_at&.iso8601,
|
|
67
|
+
last_seen_at: error_log.last_seen_at&.iso8601,
|
|
68
|
+
occurred_at: error_log.occurred_at.iso8601,
|
|
69
|
+
resolved: error_log.resolved,
|
|
70
|
+
request: {
|
|
71
|
+
url: error_log.request_url,
|
|
72
|
+
params: parse_request_params(error_log.request_params),
|
|
73
|
+
user_agent: error_log.user_agent,
|
|
74
|
+
ip_address: error_log.ip_address
|
|
75
|
+
},
|
|
76
|
+
user: {
|
|
77
|
+
id: error_log.user_id
|
|
78
|
+
},
|
|
79
|
+
backtrace: extract_backtrace(error_log.backtrace),
|
|
80
|
+
metadata: {
|
|
81
|
+
error_hash: error_log.error_hash,
|
|
82
|
+
dashboard_url: dashboard_url(error_log)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_request_params(params_json)
|
|
89
|
+
return {} if params_json.nil?
|
|
90
|
+
JSON.parse(params_json)
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
{}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_backtrace(backtrace)
|
|
96
|
+
return [] if backtrace.nil?
|
|
97
|
+
|
|
98
|
+
lines = backtrace.is_a?(String) ? backtrace.lines : backtrace
|
|
99
|
+
lines.first(20).map(&:strip)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def dashboard_url(error_log)
|
|
103
|
+
config = RailsErrorDashboard.configuration
|
|
104
|
+
base_url = config.dashboard_base_url || "http://localhost:3000"
|
|
105
|
+
"#{base_url}/error_dashboard/errors/#{error_log.id}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|