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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ ErrorRadar::Engine.routes.draw do
4
+ root to: 'dashboard#index'
5
+ get 'errors/:id', to: 'dashboard#show', as: :error
6
+ patch 'errors/:id/status', to: 'dashboard#update_status', as: :error_status
7
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ VERSION = '0.1.0'
5
+ end