error_radar 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/CHANGELOG.md +11 -0
- data/MIT-LICENSE +20 -0
- data/README.md +106 -0
- data/Rakefile +5 -0
- data/app/controllers/error_radar/application_controller.rb +23 -0
- data/app/controllers/error_radar/dashboard_controller.rb +82 -0
- data/app/models/error_radar/application_record.rb +7 -0
- data/app/models/error_radar/error_log.rb +95 -0
- data/app/views/error_radar/dashboard/index.html.erb +185 -0
- data/app/views/error_radar/dashboard/show.html.erb +72 -0
- data/app/views/layouts/error_radar/application.html.erb +48 -0
- data/config/routes.rb +7 -0
- data/lib/error_radar/configuration.rb +81 -0
- data/lib/error_radar/engine.rb +33 -0
- data/lib/error_radar/integrations/active_job.rb +32 -0
- data/lib/error_radar/integrations/rails_admin.rb +145 -0
- data/lib/error_radar/integrations/sidekiq.rb +35 -0
- data/lib/error_radar/middleware.rb +53 -0
- data/lib/error_radar/server_monitor.rb +94 -0
- data/lib/error_radar/tracking.rb +116 -0
- data/lib/error_radar/version.rb +5 -0
- data/lib/error_radar.rb +47 -0
- data/lib/generators/error_radar/install/install_generator.rb +38 -0
- data/lib/generators/error_radar/install/templates/create_error_radar_error_logs.rb.tt +45 -0
- data/lib/generators/error_radar/install/templates/initializer.rb +45 -0
- metadata +108 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Error Radar</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<!-- Chart.js (charts) + SortableJS (kanban drag-drop), loaded from CDN -->
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js"></script>
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
|
10
|
+
<style>
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
body { margin: 0; font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f4f6f8; color: #1f2933; }
|
|
13
|
+
.wrap { max-width: 1360px; margin: 0 auto; padding: 20px; }
|
|
14
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
15
|
+
.sub { color: #6b7280; font-size: 13px; margin-bottom: 18px; }
|
|
16
|
+
a { color: #2563eb; text-decoration: none; }
|
|
17
|
+
.cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 20px; }
|
|
18
|
+
.card { background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
|
19
|
+
.card .n { font-size: 28px; font-weight: 700; }
|
|
20
|
+
.card .l { font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; }
|
|
21
|
+
.card.open .n { color: #dc3545; } .card.prog .n { color: #fd7e14; }
|
|
22
|
+
.card.res .n { color: #28a745; } .card.ign .n { color: #6c757d; } .card.tot .n { color: #1f2933; }
|
|
23
|
+
.grid2 { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; margin-bottom: 20px; }
|
|
24
|
+
.panel { background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
|
25
|
+
.panel h2 { font-size: 14px; margin: 0 0 12px; color: #374151; }
|
|
26
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
27
|
+
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eef0f2; }
|
|
28
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; color: #fff; font-size: 11px; }
|
|
29
|
+
.kanban { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
|
30
|
+
.col { background: #eceff1; border-radius: 10px; padding: 10px; min-height: 200px; }
|
|
31
|
+
.col h3 { font-size: 13px; margin: 0 0 10px; display: flex; justify-content: space-between; }
|
|
32
|
+
.col .count { background: #cfd8dc; border-radius: 8px; padding: 0 8px; font-size: 12px; }
|
|
33
|
+
.ticket { background: #fff; border-radius: 8px; padding: 10px; margin-bottom: 8px; box-shadow: 0 1px 2px rgba(0,0,0,.1); cursor: grab; border-left: 4px solid #9ca3af; }
|
|
34
|
+
.ticket.sev-critical { border-left-color: #7b001c; } .ticket.sev-error { border-left-color: #dc3545; }
|
|
35
|
+
.ticket.sev-warning { border-left-color: #fd7e14; } .ticket.sev-info { border-left-color: #17a2b8; }
|
|
36
|
+
.ticket .src { font-weight: 600; font-size: 12px; word-break: break-all; }
|
|
37
|
+
.ticket .msg { font-size: 12px; color: #4b5563; margin: 4px 0; max-height: 48px; overflow: hidden; }
|
|
38
|
+
.ticket .meta { font-size: 11px; color: #9ca3af; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
39
|
+
.ticket .occ { background: #fee2e2; color: #b91c1c; border-radius: 8px; padding: 0 6px; }
|
|
40
|
+
.toast { position: fixed; bottom: 20px; right: 20px; background: #1f2933; color: #fff; padding: 10px 16px; border-radius: 8px; opacity: 0; transition: opacity .2s; }
|
|
41
|
+
.toast.show { opacity: 1; }
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<div class="wrap"><%= yield %></div>
|
|
46
|
+
<div id="toast" class="toast"></div>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# Holds every host-app-specific decision so the engine stays decoupled.
|
|
5
|
+
# Configure from an initializer (see the install generator's template).
|
|
6
|
+
class Configuration
|
|
7
|
+
# Master switch — set false (e.g. in test env) to make capture a no-op.
|
|
8
|
+
attr_accessor :enabled
|
|
9
|
+
|
|
10
|
+
# How many backtrace lines to persist.
|
|
11
|
+
attr_accessor :backtrace_lines
|
|
12
|
+
|
|
13
|
+
# Truncate stored messages to this many chars.
|
|
14
|
+
attr_accessor :max_message_length
|
|
15
|
+
|
|
16
|
+
# Exception class names the Rack middleware must NOT log (expected client
|
|
17
|
+
# outcomes: routing errors, 404s, bad requests, ...).
|
|
18
|
+
attr_accessor :ignored_exceptions
|
|
19
|
+
|
|
20
|
+
# Request param keys to scrub before persisting them in `context`.
|
|
21
|
+
attr_accessor :sensitive_params
|
|
22
|
+
|
|
23
|
+
# Integration toggles.
|
|
24
|
+
attr_accessor :install_middleware, :install_sidekiq, :install_rails_admin
|
|
25
|
+
|
|
26
|
+
# Custom classification rules. Each is a callable `->(exception) { :category | nil }`.
|
|
27
|
+
# The first rule that returns a non-nil category wins; built-in rules run after.
|
|
28
|
+
attr_accessor :categorizers
|
|
29
|
+
|
|
30
|
+
# Extract extra structured columns from custom exception types. Each is a
|
|
31
|
+
# callable `->(exception) { { http_status:, request_url:, api_code:, api_subcode: } | nil }`.
|
|
32
|
+
attr_accessor :detail_extractors
|
|
33
|
+
|
|
34
|
+
# Optional Sidekiq process expectations for the dashboard's server panel.
|
|
35
|
+
# Each entry: { key:, name:, tag:, host:, queue_hint: }. Empty => the panel
|
|
36
|
+
# simply lists whatever live processes exist.
|
|
37
|
+
attr_accessor :expected_servers
|
|
38
|
+
|
|
39
|
+
# Dashboard auth: `->(controller) { ... }` run as a before_action. Raise /
|
|
40
|
+
# redirect inside it to deny access. nil => no auth (NOT recommended in prod).
|
|
41
|
+
attr_accessor :authenticate
|
|
42
|
+
|
|
43
|
+
# `->(controller) { "who@acted" }` — stamped onto resolved errors.
|
|
44
|
+
attr_accessor :current_user
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@enabled = true
|
|
48
|
+
@backtrace_lines = 30
|
|
49
|
+
@max_message_length = 4_000
|
|
50
|
+
@ignored_exceptions = %w[
|
|
51
|
+
ActionController::RoutingError
|
|
52
|
+
ActiveRecord::RecordNotFound
|
|
53
|
+
ActionController::ParameterMissing
|
|
54
|
+
ActionController::UnknownFormat
|
|
55
|
+
ActionController::BadRequest
|
|
56
|
+
ActionController::InvalidAuthenticityToken
|
|
57
|
+
ActionDispatch::Http::Parameters::ParseError
|
|
58
|
+
]
|
|
59
|
+
@sensitive_params = %w[password password_confirmation token access_token authorization secret]
|
|
60
|
+
@install_middleware = true
|
|
61
|
+
@install_sidekiq = true
|
|
62
|
+
@install_rails_admin = true
|
|
63
|
+
@categorizers = []
|
|
64
|
+
@detail_extractors = []
|
|
65
|
+
@expected_servers = []
|
|
66
|
+
@authenticate = nil
|
|
67
|
+
@current_user = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Convenience DSL inside `configure`:
|
|
71
|
+
# c.categorize { |e| :external_api if e.is_a?(MyApi::Error) }
|
|
72
|
+
def categorize(&block)
|
|
73
|
+
@categorizers << block
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# c.extract_details { |e| { http_status: e.status } if e.is_a?(MyApi::Error) }
|
|
77
|
+
def extract_details(&block)
|
|
78
|
+
@detail_extractors << block
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/engine'
|
|
4
|
+
|
|
5
|
+
module ErrorRadar
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace ErrorRadar
|
|
8
|
+
|
|
9
|
+
# Capture all unhandled web-layer exceptions. Run after the host's
|
|
10
|
+
# config/initializers so ErrorRadar.configure has already executed.
|
|
11
|
+
initializer 'error_radar.middleware', after: :load_config_initializers do |app|
|
|
12
|
+
if ErrorRadar.config.install_middleware
|
|
13
|
+
app.middleware.insert_after ActionDispatch::DebugExceptions, ErrorRadar::Middleware
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Capture every Sidekiq job failure (incl. retries) as an ErrorLog task.
|
|
18
|
+
initializer 'error_radar.sidekiq', after: :load_config_initializers do
|
|
19
|
+
if ErrorRadar.config.install_sidekiq && defined?(::Sidekiq)
|
|
20
|
+
require 'error_radar/integrations/sidekiq'
|
|
21
|
+
ErrorRadar::Integrations::Sidekiq.install!
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Register the ErrorLog board + custom actions in RailsAdmin, if present.
|
|
26
|
+
config.after_initialize do
|
|
27
|
+
if ErrorRadar.config.install_rails_admin && defined?(::RailsAdmin)
|
|
28
|
+
require 'error_radar/integrations/rails_admin'
|
|
29
|
+
ErrorRadar::Integrations::RailsAdmin.install!
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
module Integrations
|
|
5
|
+
# Opt-in ActiveJob capture. Include into your ApplicationJob to catch
|
|
6
|
+
# exceptions from any queue adapter (not just Sidekiq), then re-raise so the
|
|
7
|
+
# adapter's retry/failure handling is unaffected:
|
|
8
|
+
#
|
|
9
|
+
# class ApplicationJob < ActiveJob::Base
|
|
10
|
+
# include ErrorRadar::Integrations::ActiveJob
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# NOTE: if you also enable the Sidekiq integration, Sidekiq-backed jobs will
|
|
14
|
+
# be captured by both — keep only one if you want to avoid double counting.
|
|
15
|
+
module ActiveJob
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
around_perform do |job, block|
|
|
20
|
+
block.call
|
|
21
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
22
|
+
ErrorRadar.capture(
|
|
23
|
+
e,
|
|
24
|
+
source: job.class.name,
|
|
25
|
+
context: { args: job.arguments, job_id: job.job_id, queue: job.queue_name }
|
|
26
|
+
)
|
|
27
|
+
raise
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
module Integrations
|
|
5
|
+
# Registers ErrorRadar::ErrorLog as a first-class RailsAdmin model (a triage
|
|
6
|
+
# board) plus four member actions: start / resolve / ignore / reopen.
|
|
7
|
+
# Everything is guarded so a host without RailsAdmin is unaffected.
|
|
8
|
+
module RailsAdmin
|
|
9
|
+
MODEL = 'ErrorRadar::ErrorLog'
|
|
10
|
+
|
|
11
|
+
# status -> { update attrs, flash verb, icon }
|
|
12
|
+
ACTIONS = {
|
|
13
|
+
start_error_log: { icon: 'fa-solid fa-person-digging', verb: 'marked in progress', update: { status: :in_progress } },
|
|
14
|
+
ignore_error_log: { icon: 'fa-solid fa-ban', verb: 'ignored', update: { status: :ignored } },
|
|
15
|
+
reopen_error_log: { icon: 'fa-solid fa-rotate-left', verb: 'reopened', method: :reopen! },
|
|
16
|
+
resolve_error_log: { icon: 'fa-solid fa-circle-check', verb: 'marked resolved', method: :resolve! }
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def self.install!
|
|
20
|
+
define_actions!
|
|
21
|
+
configure_model!
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
ErrorRadar::Tracking.warn_internal("RailsAdmin integration failed: #{e.class}: #{e.message}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.define_actions!
|
|
27
|
+
require 'rails_admin/config/actions'
|
|
28
|
+
require 'rails_admin/config/actions/base'
|
|
29
|
+
|
|
30
|
+
ACTIONS.each do |name, spec|
|
|
31
|
+
next if ::RailsAdmin::Config::Actions.find(name)
|
|
32
|
+
|
|
33
|
+
klass = Class.new(::RailsAdmin::Config::Actions::Base) do
|
|
34
|
+
register_instance_option(:only) { MODEL }
|
|
35
|
+
register_instance_option(:member) { true }
|
|
36
|
+
register_instance_option(:http_methods) { %i[get put] }
|
|
37
|
+
register_instance_option(:link_icon) { spec[:icon] }
|
|
38
|
+
register_instance_option(:pjax?) { false }
|
|
39
|
+
register_instance_option(:turbo?) { false }
|
|
40
|
+
register_instance_option(:controller) do
|
|
41
|
+
proc do
|
|
42
|
+
actor = (_current_user.try(:email) rescue nil)
|
|
43
|
+
if spec[:method] == :resolve!
|
|
44
|
+
@object.resolve!(by: actor)
|
|
45
|
+
elsif spec[:method]
|
|
46
|
+
@object.public_send(spec[:method])
|
|
47
|
+
else
|
|
48
|
+
@object.update!(spec[:update])
|
|
49
|
+
end
|
|
50
|
+
flash[:notice] = "Error ##{@object.id} #{spec[:verb]}."
|
|
51
|
+
redirect_to back_or_index
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
const_name = name.to_s.split('_').map(&:capitalize).join
|
|
57
|
+
ErrorRadar::Integrations::RailsAdmin.const_set(const_name, klass) unless const_defined?(const_name)
|
|
58
|
+
::RailsAdmin::Config::Actions.register(name, klass)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.configure_model!
|
|
63
|
+
::RailsAdmin.config do |config|
|
|
64
|
+
config.model MODEL do
|
|
65
|
+
navigation_label 'Monitoring'
|
|
66
|
+
navigation_icon 'fa-solid fa-triangle-exclamation'
|
|
67
|
+
label 'Error / Task'
|
|
68
|
+
label_plural 'Errors / Tasks'
|
|
69
|
+
weight(-100)
|
|
70
|
+
|
|
71
|
+
list do
|
|
72
|
+
scopes %i[unresolved] + [nil] + %i[open in_progress resolved ignored]
|
|
73
|
+
sort_by :last_seen_at
|
|
74
|
+
items_per_page 50
|
|
75
|
+
|
|
76
|
+
field :id
|
|
77
|
+
field :status do
|
|
78
|
+
pretty_value do
|
|
79
|
+
colors = { 'open' => '#dc3545', 'in_progress' => '#fd7e14', 'resolved' => '#28a745', 'ignored' => '#6c757d' }
|
|
80
|
+
v = bindings[:object].status
|
|
81
|
+
%(<span class="label" style="padding:2px 8px;border-radius:10px;color:#fff;background:#{colors[v] || '#6c757d'}">#{v}</span>).html_safe
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
field :severity do
|
|
85
|
+
pretty_value do
|
|
86
|
+
colors = { 'info' => '#17a2b8', 'warning' => '#fd7e14', 'error' => '#dc3545', 'critical' => '#7b001c' }
|
|
87
|
+
v = bindings[:object].severity
|
|
88
|
+
%(<span class="label" style="padding:2px 8px;border-radius:10px;color:#fff;background:#{colors[v] || '#6c757d'}">#{v}</span>).html_safe
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
field :category
|
|
92
|
+
field :source
|
|
93
|
+
field :error_class
|
|
94
|
+
field :message do
|
|
95
|
+
formatted_value { bindings[:object].short_message }
|
|
96
|
+
end
|
|
97
|
+
field :occurrences
|
|
98
|
+
field :http_status
|
|
99
|
+
field :last_seen_at
|
|
100
|
+
field :first_seen_at
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
show do
|
|
104
|
+
field :id
|
|
105
|
+
field :status
|
|
106
|
+
field :severity
|
|
107
|
+
field :category
|
|
108
|
+
field :source
|
|
109
|
+
field :error_class
|
|
110
|
+
field :message
|
|
111
|
+
field :occurrences
|
|
112
|
+
field :first_seen_at
|
|
113
|
+
field :last_seen_at
|
|
114
|
+
field :http_status
|
|
115
|
+
field :api_code
|
|
116
|
+
field :api_subcode
|
|
117
|
+
field :request_url
|
|
118
|
+
field :context do
|
|
119
|
+
pretty_value do
|
|
120
|
+
%(<pre style="white-space:pre-wrap;max-height:400px;overflow:auto">#{JSON.pretty_generate(bindings[:object].context || {})}</pre>).html_safe
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
field :backtrace do
|
|
124
|
+
pretty_value do
|
|
125
|
+
%(<pre style="white-space:pre-wrap;max-height:500px;overflow:auto">#{ERB::Util.html_escape(bindings[:object].backtrace)}</pre>).html_safe
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
field :resolved_at
|
|
129
|
+
field :resolved_by
|
|
130
|
+
field :resolution_note
|
|
131
|
+
field :created_at
|
|
132
|
+
field :updated_at
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
edit do
|
|
136
|
+
field :status
|
|
137
|
+
field :severity
|
|
138
|
+
field :resolution_note
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
module Integrations
|
|
5
|
+
# Hooks ErrorRadar.capture into Sidekiq's server-side error handler so every
|
|
6
|
+
# background-job failure becomes an ErrorLog task. Idempotent.
|
|
7
|
+
module Sidekiq
|
|
8
|
+
HANDLER = lambda do |exception, ctx, _config = nil|
|
|
9
|
+
job = (ctx && ctx[:job]) || {}
|
|
10
|
+
ErrorRadar.capture(
|
|
11
|
+
exception,
|
|
12
|
+
source: job['class'] || (ctx && ctx[:context]) || 'Sidekiq',
|
|
13
|
+
context: {
|
|
14
|
+
jid: job['jid'],
|
|
15
|
+
queue: job['queue'],
|
|
16
|
+
args: job['args'],
|
|
17
|
+
retry_count: job['retry_count'],
|
|
18
|
+
failed_at: job['failed_at']
|
|
19
|
+
}.compact
|
|
20
|
+
)
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
ErrorRadar::Tracking.warn_internal("Sidekiq error_handler failed: #{e.class}: #{e.message}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.install!
|
|
26
|
+
::Sidekiq.configure_server do |config|
|
|
27
|
+
handlers = config.error_handlers
|
|
28
|
+
handlers << HANDLER unless handlers.include?(HANDLER)
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
ErrorRadar::Tracking.warn_internal("Sidekiq integration failed: #{e.class}: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# Rack middleware that captures every unhandled web-layer exception as an
|
|
5
|
+
# ErrorLog, then re-raises so Rails' normal exception rendering still happens.
|
|
6
|
+
# Inserted just below ActionDispatch::DebugExceptions.
|
|
7
|
+
class Middleware
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
@app.call(env)
|
|
14
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
15
|
+
capture(e, env) unless ignored?(e)
|
|
16
|
+
raise
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def ignored?(exception)
|
|
22
|
+
ErrorRadar.config.ignored_exceptions.include?(exception.class.name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def capture(exception, env)
|
|
26
|
+
request = ActionDispatch::Request.new(env)
|
|
27
|
+
ErrorRadar.capture(
|
|
28
|
+
exception,
|
|
29
|
+
source: "#{request.request_method} #{request.path}",
|
|
30
|
+
context: {
|
|
31
|
+
controller: env['action_dispatch.request.path_parameters']&.dig(:controller),
|
|
32
|
+
action: env['action_dispatch.request.path_parameters']&.dig(:action),
|
|
33
|
+
path: request.fullpath,
|
|
34
|
+
method: request.request_method,
|
|
35
|
+
ip: request.remote_ip,
|
|
36
|
+
params: filtered_params(request)
|
|
37
|
+
}.compact
|
|
38
|
+
)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
ErrorRadar::Tracking.warn_internal("middleware capture failed: #{e.class}: #{e.message}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def filtered_params(request)
|
|
44
|
+
request.filtered_parameters.except('controller', 'action').deep_transform_values do |v|
|
|
45
|
+
v.is_a?(String) && v.length > 500 ? "#{v[0, 500]}…" : v
|
|
46
|
+
end.tap do |p|
|
|
47
|
+
ErrorRadar.config.sensitive_params.each { |k| p[k] = '[FILTERED]' if p.key?(k) }
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError
|
|
50
|
+
{}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# Reads the live Sidekiq process registry from Redis and reports whether each
|
|
5
|
+
# EXPECTED process (configured via ErrorRadar.config.expected_servers) is
|
|
6
|
+
# currently alive. With no expectations configured it simply surfaces every
|
|
7
|
+
# live process so the dashboard still shows something useful.
|
|
8
|
+
#
|
|
9
|
+
# Safe when Sidekiq is absent — every method degrades to empty results.
|
|
10
|
+
class ServerMonitor
|
|
11
|
+
DEAD_AFTER = 60 # seconds without a heartbeat => process considered gone
|
|
12
|
+
|
|
13
|
+
Status = Struct.new(:key, :name, :up, :processes, :last_beat_ago, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def self.statuses
|
|
16
|
+
new.statuses
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def expected
|
|
20
|
+
ErrorRadar.config.expected_servers
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def statuses
|
|
24
|
+
live = live_processes
|
|
25
|
+
|
|
26
|
+
if expected.empty?
|
|
27
|
+
return live.map do |p|
|
|
28
|
+
Status.new(
|
|
29
|
+
key: p[:identity],
|
|
30
|
+
name: "#{p[:hostname]} #{p[:tag]}".strip,
|
|
31
|
+
up: p[:beat_ago] && p[:beat_ago] <= DEAD_AFTER,
|
|
32
|
+
processes: [p],
|
|
33
|
+
last_beat_ago: p[:beat_ago]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
expected.map do |exp|
|
|
39
|
+
matched = live.select { |p| matches?(p, exp) }
|
|
40
|
+
Status.new(
|
|
41
|
+
key: exp[:key],
|
|
42
|
+
name: exp[:name],
|
|
43
|
+
up: matched.any? { |p| p[:beat_ago] && p[:beat_ago] <= DEAD_AFTER },
|
|
44
|
+
processes: matched,
|
|
45
|
+
last_beat_ago: matched.map { |p| p[:beat_ago] }.compact.min
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unexpected_processes
|
|
51
|
+
return [] if expected.empty?
|
|
52
|
+
|
|
53
|
+
live_processes.reject { |p| expected.any? { |exp| matches?(p, exp) } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def live_processes
|
|
57
|
+
return [] unless defined?(::Sidekiq)
|
|
58
|
+
|
|
59
|
+
require 'sidekiq/api'
|
|
60
|
+
now = Time.now.to_f
|
|
61
|
+
::Sidekiq::ProcessSet.new.map do |p|
|
|
62
|
+
{
|
|
63
|
+
identity: p['identity'],
|
|
64
|
+
hostname: p['hostname'],
|
|
65
|
+
tag: p['tag'],
|
|
66
|
+
queues: Array(p['queues']),
|
|
67
|
+
concurrency: p['concurrency'],
|
|
68
|
+
busy: p['busy'],
|
|
69
|
+
rss: p['rss'],
|
|
70
|
+
quiet: p['quiet'] == 'true' || p['quiet'] == true,
|
|
71
|
+
started_at: p['started_at'] && Time.at(p['started_at']),
|
|
72
|
+
beat_ago: p['beat'] ? (now - p['beat']).round : nil
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
ErrorRadar::Tracking.warn_internal("ServerMonitor.live_processes failed: #{e.class}: #{e.message}")
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def matches?(process, exp)
|
|
83
|
+
return true if present?(exp[:tag]) && process[:tag].to_s == exp[:tag]
|
|
84
|
+
return true if present?(exp[:host]) && process[:hostname].to_s.include?(exp[:host])
|
|
85
|
+
|
|
86
|
+
present?(exp[:queue_hint]) && process[:queues].include?(exp[:queue_hint]) &&
|
|
87
|
+
expected.none? { |o| o != exp && (process[:tag].to_s == o[:tag] || (present?(o[:host]) && process[:hostname].to_s.include?(o[:host].to_s))) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def present?(value)
|
|
91
|
+
!value.nil? && value != ''
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# The capture facade. Auto-classifies common exception types, lets the host
|
|
5
|
+
# app plug in custom rules, then persists/rolls-up an ErrorLog. Never raises.
|
|
6
|
+
module Tracking
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def capture(exception, source: nil, category: nil, severity: nil, context: {})
|
|
10
|
+
return nil unless ErrorRadar.config.enabled
|
|
11
|
+
|
|
12
|
+
category ||= categorize(exception)
|
|
13
|
+
severity ||= default_severity(exception, category)
|
|
14
|
+
|
|
15
|
+
attrs = {
|
|
16
|
+
category: category,
|
|
17
|
+
severity: severity,
|
|
18
|
+
error_class: exception.class.name,
|
|
19
|
+
source: source || infer_source(exception),
|
|
20
|
+
message: exception.message,
|
|
21
|
+
backtrace: Array(exception.backtrace).first(ErrorRadar.config.backtrace_lines),
|
|
22
|
+
context: context
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ErrorRadar.config.detail_extractors.each do |extractor|
|
|
26
|
+
extra = safe_call(extractor, exception)
|
|
27
|
+
attrs.merge!(extra.compact) if extra.is_a?(Hash)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ErrorRadar::ErrorLog.record(**attrs)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
warn_internal("capture failed: #{e.class}: #{e.message}")
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Wrap a block (rake task, cron script, manual maintenance) so any exception
|
|
37
|
+
# is captured and then re-raised. Use at boundaries the web/Sidekiq handlers
|
|
38
|
+
# don't cover.
|
|
39
|
+
def monitor(source, category: nil, severity: nil, context: {})
|
|
40
|
+
yield
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
capture(e, source: source, category: category, severity: severity, context: context)
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Log a problem without an exception object.
|
|
47
|
+
def notify(message, category: :application, severity: :error, source: nil, context: {})
|
|
48
|
+
return nil unless ErrorRadar.config.enabled
|
|
49
|
+
|
|
50
|
+
ErrorRadar::ErrorLog.record(category: category, severity: severity, message: message, source: source, context: context)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
warn_internal("notify failed: #{e.class}: #{e.message}")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def categorize(exception)
|
|
57
|
+
ErrorRadar.config.categorizers.each do |rule|
|
|
58
|
+
cat = safe_call(rule, exception)
|
|
59
|
+
return cat if cat
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if defined?(ActiveRecord::ActiveRecordError) && exception.is_a?(ActiveRecord::ActiveRecordError)
|
|
63
|
+
:database
|
|
64
|
+
elsif exception.is_a?(SyntaxError) || exception.is_a?(NameError) ||
|
|
65
|
+
exception.is_a?(ArgumentError) || exception.is_a?(TypeError)
|
|
66
|
+
:syntax
|
|
67
|
+
elsif network_error?(exception)
|
|
68
|
+
:network
|
|
69
|
+
else
|
|
70
|
+
:application
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def network_error?(exception)
|
|
75
|
+
names = %w[
|
|
76
|
+
Net::OpenTimeout Net::ReadTimeout Errno::ECONNREFUSED Errno::ECONNRESET
|
|
77
|
+
Errno::EHOSTUNREACH Errno::ETIMEDOUT SocketError Timeout::Error
|
|
78
|
+
OpenSSL::SSL::SSLError EOFError Faraday::ConnectionFailed Faraday::TimeoutError
|
|
79
|
+
]
|
|
80
|
+
klass = exception.class
|
|
81
|
+
while klass
|
|
82
|
+
return true if names.include?(klass.name)
|
|
83
|
+
|
|
84
|
+
klass = klass.superclass
|
|
85
|
+
end
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def default_severity(_exception, category)
|
|
90
|
+
case category.to_sym
|
|
91
|
+
when :syntax, :database then :critical
|
|
92
|
+
when :network, :external_api then :warning
|
|
93
|
+
else :error
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def infer_source(exception)
|
|
98
|
+
line = Array(exception.backtrace).find { |l| l.include?('/app/') } || Array(exception.backtrace).first
|
|
99
|
+
return 'unknown' if line.nil? || line.empty?
|
|
100
|
+
|
|
101
|
+
line.split(':').first.to_s.split('/app/').last || line
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def safe_call(callable, *args)
|
|
105
|
+
callable.call(*args)
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
warn_internal("custom rule failed: #{e.class}: #{e.message}")
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def warn_internal(message)
|
|
112
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
113
|
+
logger ? logger.error("[ErrorRadar] #{message}") : warn("[ErrorRadar] #{message}")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|