error_radar 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 017c3eb241a062270368e74bf4a38e52caaf303ddfdc1848c3e1f88d0ea8219a
4
- data.tar.gz: '08d6b0982d7cfae71bdde4c466948b0091e1c000091030a8c6c5507139c32136'
3
+ metadata.gz: 131362a3ccb1bd3de4242f28690bb60059555bee0357ff488fbcce86f9b04f37
4
+ data.tar.gz: 8f0218c89a83e54e514657dd1c7f9e25a28c39d0adfe284424efdb134bc58467
5
5
  SHA512:
6
- metadata.gz: 887a6d150b9e3bbcade9f208f80b0ef89755b229716cf1d2798bd72fb1feb4b13a8feb480178fda95bf36e9b291426895066e5043ea22a48ac830f86887d8de6
7
- data.tar.gz: 67e779feaef2bafc575a3aa6b52c1d8af20e907d22edc1b4e769a5cab6a88969984503b74c9a1f6df70b51367d98ed5d7cb254b2ea405741bef4c09600057179
6
+ metadata.gz: 43ade7e6e0d65e66af9cc34bfa3ee6768fdd9cbad0d79772df01ea6f355b7647f1e5c7a8d8be4667a80abfa5e1c383becc4c97651709c21df2e58fb65a2fede0
7
+ data.tar.gz: ec4cf4f0863aa19ffa5639659b6a61642921d72462130caaff18a6c2bb2f9424594d9ebbab96dc65c974fab1e4b00b91db96573c3434949ec769ab7b46ef2918
data/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-07-03
6
+
7
+ ### Added
8
+ - **Zero-config auto-capture for ActiveJob**: any ActiveJob subclass (regardless
9
+ of queue adapter) now has exceptions captured automatically via
10
+ `ActiveSupport.on_load(:active_job)`. No need to include the module manually.
11
+ Disable with `config.install_active_job = false` (e.g. when using Sidekiq only).
12
+ - **Zero-config auto-capture for Rake tasks**: patches `Rake::Task#execute` so
13
+ every task failure is recorded automatically. Disable with
14
+ `config.install_rake = false`.
15
+ - **Full error list page** at `/errors` with pagination (50 per page),
16
+ full-text search (message / error class / source), filter by status /
17
+ severity / category / date range, sortable columns, and bulk actions
18
+ (resolve / ignore / reopen / delete).
19
+ - **Bulk actions**: select individual rows or all rows, then resolve, ignore,
20
+ reopen, or delete in one click.
21
+ - **Quick-action buttons** on the list (✓ resolve, ✕ ignore) without leaving
22
+ the page.
23
+ - **Improved error detail page**: context and backtrace displayed side-by-side,
24
+ inline resolution note field, delete button with confirmation.
25
+ - **Navigation bar** across all dashboard pages linking Dashboard ↔ All Errors.
26
+
27
+ ### Changed
28
+ - `show` and `update_status` actions moved from `DashboardController` to the
29
+ new `ErrorsController`; route paths (`/errors/:id` etc.) are unchanged.
30
+ - Dashboard index h1 changed from "🛰 Error Radar" to "Dashboard" (branding
31
+ now lives in the shared nav bar).
32
+
33
+ ## [0.2.0] - 2026-07-02
34
+
35
+ ### Added
36
+ - Host-configurable error categories. The `category` enum is no longer fixed:
37
+ register app-specific categories with `config.register_category(:name, int)`
38
+ or replace the map with `config.categories = {...}` (built-in defaults are
39
+ merged in first). Stored integers are treated as a schema and must stay
40
+ stable once data exists.
41
+
42
+ ### Notes
43
+ - Backward compatible: with no configuration the six built-in categories
44
+ (`application`, `external_api`, `background_job`, `syntax`, `database`,
45
+ `network`) behave exactly as in 0.1.0.
46
+
5
47
  ## [0.1.0] - 2026-06-18
6
48
 
7
49
  ### Added
data/README.md CHANGED
@@ -65,6 +65,26 @@ ErrorRadar.configure do |config|
65
65
  end
66
66
  ```
67
67
 
68
+ ### Custom categories
69
+
70
+ The `category` enum ships with six built-ins — `application`, `external_api`,
71
+ `background_job`, `syntax`, `database`, `network` — but is **not fixed**. Register
72
+ your own without touching the gem:
73
+
74
+ ```ruby
75
+ ErrorRadar.configure do |config|
76
+ config.register_category(:instagram_api, 6) # add one
77
+ # or replace the whole map (defaults are merged in first):
78
+ config.categories = { instagram_api: 6, payments_api: 7 }
79
+ end
80
+ ```
81
+
82
+ The integer is the value stored in the `category` column, so treat it as a
83
+ schema: keep it stable once you have data, and don't reuse a number for two
84
+ names (a collision raises at boot). Custom categories work everywhere a built-in
85
+ does — `capture`/`notify` calls, the `categorize` rules, the dashboard charts
86
+ and the RailsAdmin board.
87
+
68
88
  ## Use
69
89
 
70
90
  ```ruby
@@ -5,7 +5,6 @@ module ErrorRadar
5
5
  # kanban board over ErrorLog statuses.
6
6
  class DashboardController < ApplicationController
7
7
  before_action :authenticate_request!
8
- before_action :set_error, only: %i[show update_status]
9
8
 
10
9
  KANBAN_LIMIT = 100
11
10
  SEVERITY_ORDER = { 'critical' => 0, 'error' => 1, 'warning' => 2, 'info' => 3 }.freeze
@@ -47,29 +46,8 @@ module ErrorRadar
47
46
  @external_links = build_external_links
48
47
  end
49
48
 
50
- def show; end
51
-
52
- def update_status
53
- new_status = params[:status].to_s
54
- unless ErrorLog.statuses.key?(new_status)
55
- return render json: { ok: false, error: 'invalid status' }, status: :unprocessable_entity
56
- end
57
-
58
- if new_status == 'resolved'
59
- @error.resolve!(by: error_radar_current_user)
60
- else
61
- @error.update!(status: new_status, resolved_at: nil)
62
- end
63
-
64
- render json: { ok: true, id: @error.id, status: @error.status }
65
- end
66
-
67
49
  private
68
50
 
69
- def set_error
70
- @error = ErrorLog.find(params[:id])
71
- end
72
-
73
51
  def build_external_links
74
52
  links = {}
75
53
  if defined?(::RailsAdmin)
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ class ErrorsController < ApplicationController
5
+ before_action :authenticate_request!
6
+ before_action :set_error, only: %i[show update_status destroy]
7
+
8
+ rescue_from ActiveRecord::RecordNotFound do
9
+ redirect_to errors_path, alert: 'Error not found.'
10
+ end
11
+
12
+ PER_PAGE = 50
13
+
14
+ def index
15
+ scope = build_scope
16
+ @total_count = scope.count
17
+ @page = [params[:page].to_i, 1].max
18
+ @total_pages = [(@total_count.to_f / PER_PAGE).ceil, 1].max
19
+ @page = [@page, @total_pages].min if @total_pages > 0
20
+ @errors = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
21
+ @filter_params = active_filter_params
22
+ @pages = pagination_pages(@page, @total_pages)
23
+ end
24
+
25
+ def show; end
26
+
27
+ def update_status
28
+ new_status = params[:status].to_s
29
+ unless ErrorLog.statuses.key?(new_status)
30
+ return render json: { ok: false, error: 'invalid status' }, status: :unprocessable_entity
31
+ end
32
+
33
+ if new_status == 'resolved'
34
+ @error.resolve!(by: error_radar_current_user, note: params[:note].presence)
35
+ else
36
+ @error.update!(status: new_status, resolved_at: nil)
37
+ end
38
+
39
+ render json: { ok: true, id: @error.id, status: @error.status }
40
+ end
41
+
42
+ def destroy
43
+ @error.destroy!
44
+ render json: { ok: true }
45
+ end
46
+
47
+ def bulk
48
+ ids = Array(params[:ids]).map(&:to_i).select(&:positive?)
49
+ action = params[:bulk_action].to_s
50
+
51
+ if ids.empty?
52
+ return render json: { ok: false, error: 'no ids selected' }, status: :unprocessable_entity
53
+ end
54
+ unless %w[resolve ignore reopen delete].include?(action)
55
+ return render json: { ok: false, error: 'unknown action' }, status: :unprocessable_entity
56
+ end
57
+
58
+ count = apply_bulk(ids, action)
59
+ render json: { ok: true, count: count, action: action }
60
+ end
61
+
62
+ private
63
+
64
+ def set_error
65
+ @error = ErrorLog.find(params[:id])
66
+ end
67
+
68
+ def build_scope
69
+ scope = ErrorLog.all
70
+
71
+ if params[:status].present? && ErrorLog.statuses.key?(params[:status])
72
+ scope = scope.where(status: params[:status])
73
+ end
74
+
75
+ if params[:severity].present? && ErrorLog.severities.key?(params[:severity])
76
+ scope = scope.where(severity: params[:severity])
77
+ end
78
+
79
+ if params[:category].present? && ErrorLog.categories.key?(params[:category])
80
+ scope = scope.where(category: params[:category])
81
+ end
82
+
83
+ if params[:q].present?
84
+ q = "%#{params[:q].downcase}%"
85
+ scope = scope.where(
86
+ 'lower(message) LIKE :q OR lower(error_class) LIKE :q OR lower(source) LIKE :q', q: q
87
+ )
88
+ end
89
+
90
+ from_date = params[:from].present? ? (Date.parse(params[:from]) rescue nil) : nil
91
+ to_date = params[:to].present? ? (Date.parse(params[:to]) rescue nil) : nil
92
+ scope = scope.where('last_seen_at >= ?', from_date) if from_date
93
+ scope = scope.where('last_seen_at <= ?', to_date.end_of_day) if to_date
94
+
95
+ sort_col = %w[last_seen_at first_seen_at occurrences].include?(params[:sort]) ? params[:sort] : 'last_seen_at'
96
+ sort_dir = params[:order] == 'asc' ? :asc : :desc
97
+ scope.order(sort_col => sort_dir)
98
+ end
99
+
100
+ def apply_bulk(ids, action)
101
+ case action
102
+ when 'resolve'
103
+ ErrorLog.where(id: ids).update_all(
104
+ status: ErrorLog.statuses[:resolved],
105
+ resolved_at: Time.current,
106
+ resolved_by: error_radar_current_user
107
+ )
108
+ when 'ignore'
109
+ ErrorLog.where(id: ids).update_all(status: ErrorLog.statuses[:ignored])
110
+ when 'reopen'
111
+ ErrorLog.where(id: ids).update_all(status: ErrorLog.statuses[:open], resolved_at: nil)
112
+ when 'delete'
113
+ ErrorLog.where(id: ids).delete_all
114
+ end
115
+ ids.size
116
+ end
117
+
118
+ def active_filter_params
119
+ params.permit(:q, :status, :severity, :category, :from, :to, :sort, :order).to_h
120
+ end
121
+
122
+ def pagination_pages(current, total)
123
+ return [] if total <= 1
124
+ return (1..total).to_a if total <= 9
125
+
126
+ visible = [1, total, *(current - 2..current + 2)]
127
+ .select { |p| p.between?(1, total) }
128
+ .sort.uniq
129
+ result = []
130
+ visible.each_with_index do |p, i|
131
+ result << '...' if i > 0 && p > visible[i - 1] + 1
132
+ result << p
133
+ end
134
+ result
135
+ end
136
+ end
137
+ end
@@ -6,14 +6,11 @@ module ErrorRadar
6
6
  # One row per distinct failure (collapsed by fingerprint). Doubles as a task
7
7
  # on the triage board. Table: error_radar_error_logs.
8
8
  class ErrorLog < ApplicationRecord
9
- enum category: {
10
- application: 0, # generic Ruby/Rails runtime error
11
- external_api: 1, # any 3rd-party API error
12
- background_job: 2, # uncategorised background-job failure
13
- syntax: 3, # SyntaxError / NameError / NoMethodError / ArgumentError / TypeError
14
- database: 4, # ActiveRecord / DB level
15
- network: 5 # timeouts, connection resets, DNS, ...
16
- }, _prefix: :category
9
+ # Category map is host-configurable (built-in defaults + anything the app
10
+ # registers via ErrorRadar.config). Read at class-load, which happens after
11
+ # the host's initializer has run `ErrorRadar.configure`, so custom
12
+ # categories are already merged in. See ErrorRadar::Configuration.
13
+ enum category: ErrorRadar.config.categories, _prefix: :category
17
14
 
18
15
  enum severity: { info: 0, warning: 1, error: 2, critical: 3 }, _prefix: :severity
19
16
 
@@ -3,9 +3,12 @@
3
3
  status_labels = { 'open' => 'Open', 'in_progress' => 'In progress', 'resolved' => 'Resolved', 'ignored' => 'Ignored' }
4
4
  %>
5
5
 
6
- <h1>🛰 Error Radar</h1>
6
+ <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:4px">
7
+ <h1>Dashboard</h1>
8
+ <%= link_to 'View All Errors →', errors_path, style: 'font-size:13px' %>
9
+ </div>
7
10
  <div class="sub">
8
- Track &amp; triage system errors
11
+ Error overview &amp; kanban board
9
12
  <% @external_links.each do |label, href| %>
10
13
  · <%= link_to(label.to_s.humanize, href) %>
11
14
  <% end %>
@@ -70,7 +73,10 @@
70
73
 
71
74
  <div class="grid2">
72
75
  <div class="panel">
73
- <h2>Top 10 errors (unresolved, by occurrences)</h2>
76
+ <div style="display:flex;justify-content:space-between;align-items:baseline">
77
+ <h2 style="margin:0 0 12px">Top 10 errors (unresolved, by occurrences)</h2>
78
+ <%= link_to 'See all →', errors_path(status: 'open'), style: 'font-size:12px' %>
79
+ </div>
74
80
  <table>
75
81
  <thead><tr><th>Severity</th><th>Source</th><th>Message</th><th>Count</th><th>Last seen</th></tr></thead>
76
82
  <tbody>
@@ -0,0 +1,297 @@
1
+ <%
2
+ sev_colors = { 'info' => '#17a2b8', 'warning' => '#fd7e14', 'error' => '#dc3545', 'critical' => '#7b001c' }
3
+ status_colors = { 'open' => '#dc3545', 'in_progress' => '#fd7e14', 'resolved' => '#28a745', 'ignored' => '#6c757d' }
4
+ fp = @filter_params
5
+ current_sort = fp['sort'] || 'last_seen_at'
6
+ current_order = fp['order'] || 'desc'
7
+
8
+ sort_url = ->(col) {
9
+ dir = (current_sort == col && current_order != 'asc') ? 'asc' : 'desc'
10
+ errors_path(fp.reject { |k, _| k == 'page' }.merge('sort' => col, 'order' => dir))
11
+ }
12
+ sort_arrow = ->(col) {
13
+ next '' unless current_sort == col
14
+ current_order == 'asc' ? ' ↑' : ' ↓'
15
+ }
16
+
17
+ offset_start = @total_count == 0 ? 0 : ((@page - 1) * 50) + 1
18
+ offset_end = [offset_start + @errors.size - 1, @total_count].min
19
+ filters_on = fp.values.any?(&:present?)
20
+ %>
21
+
22
+ <style>
23
+ .er-list-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
24
+ .er-list-header h1 { margin: 0; }
25
+ .er-filter-bar { margin-bottom: 12px; }
26
+ .er-filter-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
27
+ .er-filter-row input[type=text], .er-filter-row select, .er-filter-row input[type=date] {
28
+ border: 1px solid #d1d5db; border-radius: 7px; padding: 6px 10px; font-size: 13px;
29
+ background: #fff; color: #1f2933; height: 34px;
30
+ }
31
+ .er-filter-row input[type=text] { min-width: 260px; }
32
+ .er-filter-row select { min-width: 130px; }
33
+ .er-filter-row input[type=date] { min-width: 130px; }
34
+ .btn-filter { background: #2563eb; color: #fff; border: 0; border-radius: 7px; padding: 0 16px; height: 34px; font-size: 13px; cursor: pointer; }
35
+ .btn-filter:hover { background: #1d4ed8; }
36
+ .btn-clear { font-size: 13px; color: #6b7280; padding: 0 8px; }
37
+ .btn-clear:hover { color: #1f2933; }
38
+ .er-results-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; min-height: 36px; }
39
+ .er-results-count { font-size: 13px; color: #6b7280; }
40
+ .er-bulk-bar { display: none; align-items: center; gap: 8px; }
41
+ .btn-bulk { border: 0; border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer; color: #fff; }
42
+ .btn-bulk-resolve { background: #28a745; } .btn-bulk-ignore { background: #6c757d; }
43
+ .btn-bulk-reopen { background: #fd7e14; } .btn-bulk-delete { background: #dc3545; }
44
+ .btn-bulk:hover { opacity: .85; }
45
+ .er-table thead th { position: sticky; top: 0; background: #f9fafb; z-index: 1; white-space: nowrap; }
46
+ .er-table thead th a { color: #6b7280; font-size: 12px; }
47
+ .er-table thead th a:hover { color: #1f2933; }
48
+ .er-table tbody tr:hover { background: #f8fafc; }
49
+ .er-table tbody tr.row-checked { background: #eff6ff; }
50
+ .er-actions a { display: inline-block; padding: 2px 6px; border-radius: 5px; font-size: 12px; margin: 0 2px; }
51
+ .er-actions a:hover { background: #f3f4f6; }
52
+ .er-pagination { display: flex; gap: 4px; align-items: center; margin-top: 16px; justify-content: center; flex-wrap: wrap; }
53
+ .er-page-link { display: inline-block; padding: 5px 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; color: #374151; background: #fff; }
54
+ .er-page-link:hover { background: #f3f4f6; border-color: #9ca3af; }
55
+ .er-page-link.active { background: #2563eb; border-color: #2563eb; color: #fff; pointer-events: none; }
56
+ .er-page-ellipsis { padding: 5px 4px; color: #9ca3af; font-size: 13px; }
57
+ .mono { font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; font-size: 12px; }
58
+ </style>
59
+
60
+ <div class="er-list-header">
61
+ <h1>All Errors</h1>
62
+ <span class="sub" style="margin:0"><%= @total_count %> total</span>
63
+ </div>
64
+
65
+ <%# ─── Filter bar ─── %>
66
+ <div class="panel er-filter-bar">
67
+ <form action="<%= errors_path %>" method="GET" data-turbo="false">
68
+ <div class="er-filter-row">
69
+ <input type="text" name="q" value="<%= fp['q'] %>" placeholder="Search error class, message, source…">
70
+
71
+ <select name="status">
72
+ <option value="">All statuses</option>
73
+ <% ErrorLog.statuses.each_key do |s| %>
74
+ <option value="<%= s %>" <%= 'selected' if fp['status'] == s %>><%= s.humanize %></option>
75
+ <% end %>
76
+ </select>
77
+
78
+ <select name="severity">
79
+ <option value="">All severities</option>
80
+ <% ErrorLog.severities.each_key do |s| %>
81
+ <option value="<%= s %>" <%= 'selected' if fp['severity'] == s %>><%= s.capitalize %></option>
82
+ <% end %>
83
+ </select>
84
+
85
+ <select name="category">
86
+ <option value="">All categories</option>
87
+ <% ErrorLog.categories.each_key do |s| %>
88
+ <option value="<%= s %>" <%= 'selected' if fp['category'] == s.to_s %>><%= s.to_s.humanize %></option>
89
+ <% end %>
90
+ </select>
91
+
92
+ <input type="date" name="from" value="<%= fp['from'] %>" title="Last seen from">
93
+ <input type="date" name="to" value="<%= fp['to'] %>" title="Last seen to">
94
+
95
+ <button type="submit" class="btn-filter">Filter</button>
96
+ <% if filters_on %>
97
+ <a href="<%= errors_path %>" class="btn-clear">Clear</a>
98
+ <% end %>
99
+ </div>
100
+ </form>
101
+ </div>
102
+
103
+ <%# ─── Results bar + bulk actions ─── %>
104
+ <div class="er-results-bar">
105
+ <span class="er-results-count">
106
+ <% if @total_count == 0 %>
107
+ No errors found<% if filters_on %> matching your filters<% end %>
108
+ <% elsif @total_count <= 50 %>
109
+ <%= @total_count %> errors
110
+ <% else %>
111
+ <%= offset_start %>–<%= offset_end %> of <%= @total_count %> errors
112
+ <% end %>
113
+ </span>
114
+
115
+ <div class="er-bulk-bar" id="bulk-bar">
116
+ <span id="bulk-count" style="font-size:13px;color:#374151;font-weight:600"></span>
117
+ <button class="btn-bulk btn-bulk-resolve" onclick="bulkAction('resolve')">Resolve</button>
118
+ <button class="btn-bulk btn-bulk-ignore" onclick="bulkAction('ignore')">Ignore</button>
119
+ <button class="btn-bulk btn-bulk-reopen" onclick="bulkAction('reopen')">Reopen</button>
120
+ <button class="btn-bulk btn-bulk-delete" onclick="bulkAction('delete')">Delete</button>
121
+ </div>
122
+ </div>
123
+
124
+ <%# ─── Table ─── %>
125
+ <div class="panel" style="padding:0;overflow:hidden">
126
+ <% if @errors.empty? %>
127
+ <div style="padding:48px;text-align:center;color:#9ca3af;font-size:14px">
128
+ <div style="font-size:32px;margin-bottom:12px">✓</div>
129
+ No errors<% if filters_on %> match your filters<% else %> recorded<% end %>.
130
+ </div>
131
+ <% else %>
132
+ <div style="overflow-x:auto">
133
+ <table class="er-table">
134
+ <thead>
135
+ <tr>
136
+ <th style="width:36px;padding:10px 8px">
137
+ <input type="checkbox" id="select-all" onchange="toggleAll(this)" title="Select all">
138
+ </th>
139
+ <th><a href="<%= sort_url.call('severity') %>">Severity<%= sort_arrow.call('severity') %></a></th>
140
+ <th>Status</th>
141
+ <th>Error Class</th>
142
+ <th>Source</th>
143
+ <th style="min-width:240px">Message</th>
144
+ <th><a href="<%= sort_url.call('occurrences') %>">Count<%= sort_arrow.call('occurrences') %></a></th>
145
+ <th><a href="<%= sort_url.call('last_seen_at') %>">Last Seen<%= sort_arrow.call('last_seen_at') %></a></th>
146
+ <th style="width:80px">Actions</th>
147
+ </tr>
148
+ </thead>
149
+ <tbody>
150
+ <% @errors.each do |e| %>
151
+ <tr class="error-row" data-id="<%= e.id %>">
152
+ <td style="padding:8px"><input type="checkbox" class="row-cb" value="<%= e.id %>" onchange="onRowCheck(this)"></td>
153
+ <td><span class="badge" style="background:<%= sev_colors[e.severity] %>"><%= e.severity %></span></td>
154
+ <td><span class="badge" style="background:<%= status_colors[e.status] %>"><%= e.status.humanize %></span></td>
155
+ <td class="mono"><%= link_to e.error_class.to_s.truncate(40), error_path(e), title: e.error_class %></td>
156
+ <td style="font-size:12px;color:#6b7280;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="<%= e.source %>"><%= e.source %></td>
157
+ <td style="font-size:12px;color:#374151"><%= e.short_message %></td>
158
+ <td style="font-weight:600;color:<%= e.occurrences > 100 ? '#dc3545' : '#374151' %>"><%= e.occurrences %></td>
159
+ <td style="font-size:12px;color:#6b7280;white-space:nowrap"><%= e.last_seen_at&.strftime('%m/%d %H:%M') %></td>
160
+ <td class="er-actions" style="white-space:nowrap">
161
+ <% unless e.status_resolved? %>
162
+ <a href="#" class="quick-resolve" data-id="<%= e.id %>" data-url="<%= error_status_path(e) %>" title="Resolve">✓</a>
163
+ <% end %>
164
+ <% unless e.status_ignored? %>
165
+ <a href="#" class="quick-ignore" data-id="<%= e.id %>" data-url="<%= error_status_path(e) %>" title="Ignore">✕</a>
166
+ <% end %>
167
+ <%= link_to '→', error_path(e), title: 'View detail', style: 'color:#2563eb' %>
168
+ </td>
169
+ </tr>
170
+ <% end %>
171
+ </tbody>
172
+ </table>
173
+ </div>
174
+ <% end %>
175
+ </div>
176
+
177
+ <%# ─── Pagination ─── %>
178
+ <% if @pages.any? %>
179
+ <div class="er-pagination">
180
+ <% if @page > 1 %>
181
+ <%= link_to '← Prev', errors_path(fp.merge('page' => @page - 1)), class: 'er-page-link' %>
182
+ <% end %>
183
+
184
+ <% @pages.each do |p| %>
185
+ <% if p == '...' %>
186
+ <span class="er-page-ellipsis">…</span>
187
+ <% else %>
188
+ <%= link_to p, errors_path(fp.merge('page' => p)), class: "er-page-link #{p == @page ? 'active' : ''}" %>
189
+ <% end %>
190
+ <% end %>
191
+
192
+ <% if @page < @total_pages %>
193
+ <%= link_to 'Next →', errors_path(fp.merge('page' => @page + 1)), class: 'er-page-link' %>
194
+ <% end %>
195
+ </div>
196
+ <% end %>
197
+
198
+ <script>
199
+ (function () {
200
+ var BULK_URL = '<%= errors_bulk_path %>';
201
+ var CSRF = document.querySelector('meta[name="csrf-token"]').content;
202
+
203
+ // ── checkbox state ──
204
+ function selectedIds() {
205
+ return Array.from(document.querySelectorAll('.row-cb:checked')).map(function (c) { return c.value; });
206
+ }
207
+
208
+ window.onRowCheck = function (cb) {
209
+ var row = cb.closest('tr');
210
+ row.classList.toggle('row-checked', cb.checked);
211
+ syncBulkBar();
212
+ };
213
+
214
+ window.toggleAll = function (masterCb) {
215
+ document.querySelectorAll('.row-cb').forEach(function (c) {
216
+ c.checked = masterCb.checked;
217
+ c.closest('tr').classList.toggle('row-checked', masterCb.checked);
218
+ });
219
+ syncBulkBar();
220
+ };
221
+
222
+ function syncBulkBar() {
223
+ var ids = selectedIds();
224
+ var bar = document.getElementById('bulk-bar');
225
+ var cnt = document.getElementById('bulk-count');
226
+ if (ids.length > 0) {
227
+ bar.style.display = 'flex';
228
+ cnt.textContent = ids.length + ' selected';
229
+ } else {
230
+ bar.style.display = 'none';
231
+ }
232
+ }
233
+
234
+ // ── bulk actions ──
235
+ window.bulkAction = function (action) {
236
+ var ids = selectedIds();
237
+ if (!ids.length) return;
238
+ if (action === 'delete' && !confirm('Permanently delete ' + ids.length + ' error(s)? This cannot be undone.')) return;
239
+
240
+ fetch(BULK_URL, {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
243
+ body: JSON.stringify({ ids: ids, bulk_action: action })
244
+ })
245
+ .then(function (r) { return r.json(); })
246
+ .then(function (d) {
247
+ if (d.ok) {
248
+ toast(d.count + ' errors ' + action + 'd', true);
249
+ setTimeout(function () { location.reload(); }, 700);
250
+ } else {
251
+ toast('Error: ' + (d.error || 'failed'), false);
252
+ }
253
+ })
254
+ .catch(function () { toast('Network error', false); });
255
+ };
256
+
257
+ // ── quick row actions ──
258
+ document.querySelectorAll('.quick-resolve').forEach(function (a) {
259
+ a.addEventListener('click', function (e) {
260
+ e.preventDefault();
261
+ quickStatus(a.dataset.url, a.dataset.id, 'resolved');
262
+ });
263
+ });
264
+ document.querySelectorAll('.quick-ignore').forEach(function (a) {
265
+ a.addEventListener('click', function (e) {
266
+ e.preventDefault();
267
+ quickStatus(a.dataset.url, a.dataset.id, 'ignored');
268
+ });
269
+ });
270
+
271
+ function quickStatus(url, id, status) {
272
+ fetch(url, {
273
+ method: 'PATCH',
274
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
275
+ body: JSON.stringify({ status: status })
276
+ })
277
+ .then(function (r) { return r.json(); })
278
+ .then(function (d) {
279
+ if (d.ok) {
280
+ toast('#' + id + ' → ' + d.status, true);
281
+ setTimeout(function () { location.reload(); }, 500);
282
+ } else {
283
+ toast('Failed: ' + (d.error || ''), false);
284
+ }
285
+ });
286
+ }
287
+
288
+ // ── toast ──
289
+ function toast(msg, ok) {
290
+ var t = document.getElementById('toast');
291
+ t.textContent = msg;
292
+ t.style.background = ok ? '#16a34a' : '#dc2626';
293
+ t.classList.add('show');
294
+ setTimeout(function () { t.classList.remove('show'); }, 2200);
295
+ }
296
+ })();
297
+ </script>
@@ -0,0 +1,165 @@
1
+ <%
2
+ sev_colors = { 'info' => '#17a2b8', 'warning' => '#fd7e14', 'error' => '#dc3545', 'critical' => '#7b001c' }
3
+ status_colors = { 'open' => '#dc3545', 'in_progress' => '#fd7e14', 'resolved' => '#28a745', 'ignored' => '#6c757d' }
4
+ %>
5
+
6
+ <style>
7
+ .er-show-header { margin-bottom: 20px; }
8
+ .er-show-header h1 { font-size: 16px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
9
+ .er-show-meta { font-size: 13px; color: #6b7280; }
10
+ .er-show-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
11
+ .er-detail-table th { width: 150px; color: #6b7280; font-weight: 500; vertical-align: top; }
12
+ .er-detail-table td { word-break: break-word; }
13
+ .er-pre { white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px;
14
+ max-height: 360px; overflow: auto; font-size: 11px; font-family: ui-monospace, monospace; margin: 0; }
15
+ .er-status-actions { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
16
+ .er-btn { padding: 8px 16px; border: 0; border-radius: 8px; color: #fff; cursor: pointer; font-size: 13px; }
17
+ .er-btn:hover { opacity: .85; }
18
+ .er-btn-danger { background: #1f2933; }
19
+ .resolve-inline { margin-top: 10px; display: none; }
20
+ .resolve-inline textarea { width: 320px; padding: 7px 10px; border: 1px solid #d1d5db; border-radius: 7px;
21
+ font-size: 13px; resize: vertical; }
22
+ .resolve-inline button { margin-top: 6px; padding: 6px 14px; background: #28a745; color: #fff;
23
+ border: 0; border-radius: 6px; cursor: pointer; font-size: 13px; }
24
+ @media (max-width: 768px) { .er-show-grid { grid-template-columns: 1fr; } }
25
+ </style>
26
+
27
+ <div class="sub"><%= link_to '← All Errors', errors_path %></div>
28
+
29
+ <div class="er-show-header">
30
+ <h1>
31
+ <span class="badge" style="background:<%= status_colors[@error.status] %>"><%= @error.status.humanize %></span>
32
+ <span class="badge" style="background:<%= sev_colors[@error.severity] %>"><%= @error.severity %></span>
33
+ <span style="font-family:ui-monospace,monospace"><%= @error.error_class %></span>
34
+ </h1>
35
+ <div class="er-show-meta">
36
+ <%= @error.category.to_s.humanize %> ·
37
+ <strong>×<%= @error.occurrences %></strong> occurrences ·
38
+ last seen <strong><%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M') %></strong>
39
+ </div>
40
+ </div>
41
+
42
+ <%# ── Details ── %>
43
+ <div class="panel">
44
+ <h2>Details</h2>
45
+ <table class="er-detail-table">
46
+ <tr><th>Error class</th><td style="font-family:monospace"><%= @error.error_class %></td></tr>
47
+ <tr><th>Source</th><td><%= @error.source %></td></tr>
48
+ <tr><th>Category</th><td><%= @error.category.to_s.humanize %></td></tr>
49
+ <tr><th>Message</th><td style="white-space:pre-wrap"><%= @error.message %></td></tr>
50
+ <tr><th>Occurrences</th><td><strong><%= @error.occurrences %></strong></td></tr>
51
+ <tr><th>First seen</th><td><%= @error.first_seen_at&.strftime('%Y-%m-%d %H:%M:%S %Z') %></td></tr>
52
+ <tr><th>Last seen</th><td><%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M:%S %Z') %></td></tr>
53
+ <% if @error.http_status.present? || @error.api_code.present? %>
54
+ <tr><th>HTTP status</th><td><%= @error.http_status %></td></tr>
55
+ <% if @error.api_code.present? %>
56
+ <tr><th>API code / subcode</th><td><%= @error.api_code %> / <%= @error.api_subcode %></td></tr>
57
+ <% end %>
58
+ <% if @error.request_url.present? %>
59
+ <tr><th>Request URL</th><td style="word-break:break-all"><%= @error.request_url %></td></tr>
60
+ <% end %>
61
+ <% end %>
62
+ <% if @error.resolved_at.present? %>
63
+ <tr><th>Resolved at</th><td><%= @error.resolved_at&.strftime('%Y-%m-%d %H:%M:%S') %> by <%= @error.resolved_by %></td></tr>
64
+ <% if @error.resolution_note.present? %>
65
+ <tr><th>Resolution note</th><td><%= @error.resolution_note %></td></tr>
66
+ <% end %>
67
+ <% end %>
68
+ </table>
69
+ </div>
70
+
71
+ <%# ── Context + Backtrace side by side ── %>
72
+ <div class="er-show-grid">
73
+ <div class="panel">
74
+ <h2>Context</h2>
75
+ <pre class="er-pre"><%= JSON.pretty_generate(@error.context || {}) %></pre>
76
+ </div>
77
+ <div class="panel">
78
+ <h2>Backtrace</h2>
79
+ <pre class="er-pre"><%= @error.backtrace %></pre>
80
+ </div>
81
+ </div>
82
+
83
+ <%# ── Status change ── %>
84
+ <div class="panel" style="margin-top:16px">
85
+ <h2>Change status</h2>
86
+ <div class="er-status-actions">
87
+ <% %w[open in_progress ignored].each do |s| %>
88
+ <button class="er-btn status-btn" data-status="<%= s %>"
89
+ style="background:<%= status_colors[s] %>">
90
+ <%= s.humanize %>
91
+ </button>
92
+ <% end %>
93
+
94
+ <div>
95
+ <button class="er-btn" style="background:#28a745" onclick="toggleResolveBox(event)">Resolve</button>
96
+ <div class="resolve-inline" id="resolve-box">
97
+ <textarea id="resolve-note" rows="3" placeholder="Resolution note (optional)…"></textarea><br>
98
+ <button onclick="doResolve()">Confirm resolve</button>
99
+ </div>
100
+ </div>
101
+
102
+ <div style="flex:1"></div>
103
+ <button class="er-btn er-btn-danger" onclick="doDelete()">Delete</button>
104
+ </div>
105
+ </div>
106
+
107
+ <script>
108
+ (function () {
109
+ var CSRF = document.querySelector('meta[name="csrf-token"]').content;
110
+ var STATUS_URL = '<%= error_status_path(@error) %>';
111
+ var DELETE_URL = '<%= error_path(@error) %>';
112
+
113
+ document.querySelectorAll('.status-btn').forEach(function (btn) {
114
+ btn.addEventListener('click', function () { changeStatus(btn.dataset.status, null); });
115
+ });
116
+
117
+ window.toggleResolveBox = function (e) {
118
+ e.preventDefault();
119
+ var box = document.getElementById('resolve-box');
120
+ box.style.display = box.style.display === 'none' ? 'block' : 'none';
121
+ };
122
+
123
+ window.doResolve = function () {
124
+ changeStatus('resolved', document.getElementById('resolve-note').value.trim() || null);
125
+ };
126
+
127
+ function changeStatus(status, note) {
128
+ var body = { status: status };
129
+ if (note) body.note = note;
130
+ fetch(STATUS_URL, {
131
+ method: 'PATCH',
132
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
133
+ body: JSON.stringify(body)
134
+ })
135
+ .then(function (r) { return r.json(); })
136
+ .then(function (d) {
137
+ if (d.ok) {
138
+ showToast('→ ' + d.status, true);
139
+ setTimeout(function () { location.reload(); }, 700);
140
+ } else {
141
+ showToast('Error: ' + (d.error || 'failed'), false);
142
+ }
143
+ });
144
+ }
145
+
146
+ window.doDelete = function () {
147
+ if (!confirm('Permanently delete this error? This cannot be undone.')) return;
148
+ fetch(DELETE_URL, {
149
+ method: 'DELETE',
150
+ headers: { 'X-CSRF-Token': CSRF }
151
+ })
152
+ .then(function (r) {
153
+ if (r.ok) { window.location.href = '<%= errors_path %>'; }
154
+ });
155
+ };
156
+
157
+ function showToast(msg, ok) {
158
+ var t = document.getElementById('toast');
159
+ t.textContent = msg;
160
+ t.style.background = ok ? '#16a34a' : '#dc2626';
161
+ t.classList.add('show');
162
+ setTimeout(function () { t.classList.remove('show'); }, 2000);
163
+ }
164
+ })();
165
+ </script>
@@ -10,8 +10,8 @@
10
10
  <style>
11
11
  * { box-sizing: border-box; }
12
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; }
13
+ .wrap { max-width: 1400px; margin: 0 auto; padding: 20px; }
14
+ h1 { font-size: 20px; margin: 0 0 4px; }
15
15
  .sub { color: #6b7280; font-size: 13px; margin-bottom: 18px; }
16
16
  a { color: #2563eb; text-decoration: none; }
17
17
  .cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 20px; }
@@ -25,6 +25,7 @@
25
25
  .panel h2 { font-size: 14px; margin: 0 0 12px; color: #374151; }
26
26
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
27
27
  th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eef0f2; }
28
+ th { font-size: 12px; color: #6b7280; font-weight: 600; background: #f9fafb; }
28
29
  .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; color: #fff; font-size: 11px; }
29
30
  .kanban { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
30
31
  .col { background: #eceff1; border-radius: 10px; padding: 10px; min-height: 200px; }
@@ -37,11 +38,26 @@
37
38
  .ticket .msg { font-size: 12px; color: #4b5563; margin: 4px 0; max-height: 48px; overflow: hidden; }
38
39
  .ticket .meta { font-size: 11px; color: #9ca3af; display: flex; gap: 8px; flex-wrap: wrap; }
39
40
  .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 { position: fixed; bottom: 20px; right: 20px; background: #1f2933; color: #fff; padding: 10px 16px; border-radius: 8px; opacity: 0; transition: opacity .2s; z-index: 9999; }
41
42
  .toast.show { opacity: 1; }
43
+ /* Nav */
44
+ .er-nav { background: #1f2933; }
45
+ .er-nav .wrap { display: flex; align-items: center; gap: 4px; padding-top: 0; padding-bottom: 0; }
46
+ .er-nav .nav-logo { color: #f9fafb; font-weight: 700; font-size: 14px; padding: 14px 16px 14px 0; border-right: 1px solid #374151; margin-right: 8px; }
47
+ .er-nav .nav-logo:hover { color: #fff; }
48
+ .er-nav .nav-link { color: #9ca3af; font-size: 13px; padding: 14px 12px; display: inline-block; }
49
+ .er-nav .nav-link:hover { color: #f9fafb; }
50
+ .er-nav .nav-link.active { color: #fff; border-bottom: 2px solid #3b82f6; }
42
51
  </style>
43
52
  </head>
44
53
  <body>
54
+ <nav class="er-nav">
55
+ <div class="wrap">
56
+ <a href="<%= root_path %>" class="nav-logo">🛰 Error Radar</a>
57
+ <a href="<%= root_path %>" class="nav-link <%= 'active' if controller_name == 'dashboard' %>">Dashboard</a>
58
+ <a href="<%= errors_path %>" class="nav-link <%= 'active' if controller_name == 'errors' %>">All Errors</a>
59
+ </div>
60
+ </nav>
45
61
  <div class="wrap"><%= yield %></div>
46
62
  <div id="toast" class="toast"></div>
47
63
  </body>
data/config/routes.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ErrorRadar::Engine.routes.draw do
4
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
5
+
6
+ get 'errors', to: 'errors#index', as: :errors
7
+ post 'errors/bulk', to: 'errors#bulk', as: :errors_bulk
8
+ get 'errors/:id', to: 'errors#show', as: :error
9
+ patch 'errors/:id/status', to: 'errors#update_status', as: :error_status
10
+ delete 'errors/:id', to: 'errors#destroy'
7
11
  end
@@ -4,6 +4,17 @@ module ErrorRadar
4
4
  # Holds every host-app-specific decision so the engine stays decoupled.
5
5
  # Configure from an initializer (see the install generator's template).
6
6
  class Configuration
7
+ # Built-in categories (name => stored integer). Hosts can override the whole
8
+ # map (`config.categories = {...}`) or add their own (`register_category`).
9
+ DEFAULT_CATEGORIES = {
10
+ application: 0, # generic Ruby/Rails runtime error
11
+ external_api: 1, # any 3rd-party API error
12
+ background_job: 2, # uncategorised background-job failure
13
+ syntax: 3, # SyntaxError / NameError / NoMethodError / ArgumentError / TypeError
14
+ database: 4, # ActiveRecord / DB level
15
+ network: 5 # timeouts, connection resets, DNS, ...
16
+ }.freeze
17
+
7
18
  # Master switch — set false (e.g. in test env) to make capture a no-op.
8
19
  attr_accessor :enabled
9
20
 
@@ -21,7 +32,8 @@ module ErrorRadar
21
32
  attr_accessor :sensitive_params
22
33
 
23
34
  # Integration toggles.
24
- attr_accessor :install_middleware, :install_sidekiq, :install_rails_admin
35
+ attr_accessor :install_middleware, :install_sidekiq, :install_rails_admin,
36
+ :install_active_job, :install_rake
25
37
 
26
38
  # Custom classification rules. Each is a callable `->(exception) { :category | nil }`.
27
39
  # The first rule that returns a non-nil category wins; built-in rules run after.
@@ -43,6 +55,11 @@ module ErrorRadar
43
55
  # `->(controller) { "who@acted" }` — stamped onto resolved errors.
44
56
  attr_accessor :current_user
45
57
 
58
+ # The category name => integer map backing ErrorLog's `category` enum.
59
+ # Read-only accessor; mutate through `categories=` or `register_category`
60
+ # so the built-in defaults are always preserved.
61
+ attr_reader :categories
62
+
46
63
  def initialize
47
64
  @enabled = true
48
65
  @backtrace_lines = 30
@@ -57,14 +74,42 @@ module ErrorRadar
57
74
  ActionDispatch::Http::Parameters::ParseError
58
75
  ]
59
76
  @sensitive_params = %w[password password_confirmation token access_token authorization secret]
60
- @install_middleware = true
61
- @install_sidekiq = true
77
+ @install_middleware = true
78
+ @install_sidekiq = true
62
79
  @install_rails_admin = true
80
+ @install_active_job = true
81
+ @install_rake = true
63
82
  @categorizers = []
64
83
  @detail_extractors = []
65
84
  @expected_servers = []
66
85
  @authenticate = nil
67
86
  @current_user = nil
87
+ @categories = DEFAULT_CATEGORIES.dup
88
+ end
89
+
90
+ # Replace the category map. The built-in defaults are merged in first, so a
91
+ # host only needs to list what it adds or renumbers:
92
+ # config.categories = { instagram_api: 6, background_job: 7 }
93
+ # Stored integers must stay stable once data exists — treat them as a schema.
94
+ def categories=(hash)
95
+ merged = DEFAULT_CATEGORIES.merge(hash.transform_keys(&:to_sym))
96
+ assert_unique_values!(merged)
97
+ @categories = merged
98
+ end
99
+
100
+ # Add a single custom category without disturbing the rest:
101
+ # config.register_category(:instagram_api, 6)
102
+ def register_category(name, value)
103
+ name = name.to_sym
104
+ value = Integer(value)
105
+ if @categories[name] && @categories[name] != value
106
+ raise ArgumentError, "category #{name.inspect} already mapped to #{@categories[name]}"
107
+ end
108
+ existing = @categories.key(value)
109
+ if existing && existing != name
110
+ raise ArgumentError, "category value #{value} already used by #{existing.inspect}"
111
+ end
112
+ @categories = @categories.merge(name => value)
68
113
  end
69
114
 
70
115
  # Convenience DSL inside `configure`:
@@ -77,5 +122,14 @@ module ErrorRadar
77
122
  def extract_details(&block)
78
123
  @detail_extractors << block
79
124
  end
125
+
126
+ private
127
+
128
+ def assert_unique_values!(map)
129
+ dupes = map.values.tally.select { |_, n| n > 1 }.keys
130
+ return if dupes.empty?
131
+
132
+ raise ArgumentError, "category values must be unique; collisions on #{dupes.inspect}"
133
+ end
80
134
  end
81
135
  end
@@ -22,6 +22,29 @@ module ErrorRadar
22
22
  end
23
23
  end
24
24
 
25
+ # Auto-inject into every ActiveJob subclass (any queue adapter, not just Sidekiq).
26
+ # Disabled when only Sidekiq is in use to avoid double-counting.
27
+ initializer 'error_radar.active_job', after: :load_config_initializers do
28
+ if ErrorRadar.config.install_active_job && defined?(::ActiveJob)
29
+ require 'error_radar/integrations/active_job'
30
+ ActiveSupport.on_load(:active_job) do
31
+ include ErrorRadar::Integrations::ActiveJob
32
+ end
33
+ end
34
+ end
35
+
36
+ # Wrap every Rake task execution so failures are captured automatically.
37
+ initializer 'error_radar.rake', after: :load_config_initializers do |_app|
38
+ if ErrorRadar.config.install_rake
39
+ config.after_initialize do
40
+ if defined?(::Rake::Task)
41
+ require 'error_radar/integrations/rake'
42
+ ErrorRadar::Integrations::Rake.install!
43
+ end
44
+ end
45
+ end
46
+ end
47
+
25
48
  # Register the ErrorLog board + custom actions in RailsAdmin, if present.
26
49
  config.after_initialize do
27
50
  if ErrorRadar.config.install_rails_admin && defined?(::RailsAdmin)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ module Integrations
5
+ # Patches Rake::Task#execute so any exception raised inside a rake task is
6
+ # automatically captured as an ErrorLog, then re-raised so rake's normal
7
+ # failure handling (exit code, output) is unaffected.
8
+ module Rake
9
+ def self.install!
10
+ return if @installed
11
+
12
+ @installed = true
13
+
14
+ ::Rake::Task.class_eval do
15
+ alias_method :error_radar_original_execute, :execute
16
+
17
+ def execute(args = nil)
18
+ error_radar_original_execute(args)
19
+ rescue Exception => e # rubocop:disable Lint/RescueException
20
+ ErrorRadar.capture(
21
+ e,
22
+ source: "rake:#{name}",
23
+ category: :background_job,
24
+ context: { task: name }
25
+ )
26
+ raise
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ErrorRadar
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -8,15 +8,26 @@ ErrorRadar.configure do |config|
8
8
  config.enabled = !Rails.env.test?
9
9
 
10
10
  # --- Integrations (auto-detected; flip off if you don't want them) ---
11
- config.install_middleware = true # Rack: capture web-layer exceptions
12
- config.install_sidekiq = true # capture Sidekiq job failures (if Sidekiq present)
13
- config.install_rails_admin = true # register the ErrorLog board (if RailsAdmin present)
11
+ config.install_middleware = true # Rack: capture web-layer exceptions (auto)
12
+ config.install_sidekiq = true # capture Sidekiq job failures (auto, if Sidekiq present)
13
+ config.install_active_job = true # capture ActiveJob failures for any adapter (auto)
14
+ config.install_rake = true # capture Rake task failures (auto)
15
+ config.install_rails_admin = true # register the ErrorLog board (auto, if RailsAdmin present)
14
16
 
15
17
  # --- Dashboard access control ---
16
18
  # Run as a before_action; raise/redirect inside to deny. Example with Devise:
17
19
  # config.authenticate = ->(controller) { controller.send(:authenticate_admin!) }
18
20
  # config.current_user = ->(controller) { controller.current_admin&.email }
19
21
 
22
+ # --- Custom categories (optional) ---
23
+ # Built-in categories: application, external_api, background_job, syntax,
24
+ # database, network. Add your own app-specific ones here. The stored integer
25
+ # is a schema — keep it stable once you have data. Add lightly:
26
+ # config.register_category(:instagram_api, 6)
27
+ #
28
+ # Or replace the whole map at once (defaults are merged in first):
29
+ # config.categories = { instagram_api: 6, payments_api: 7 }
30
+
20
31
  # --- Custom classification ---
21
32
  # Map your own exception types to a category. First non-nil wins.
22
33
  # config.categorize { |e| :external_api if e.is_a?(MyApi::Error) }
@@ -36,10 +47,6 @@ ErrorRadar.configure do |config|
36
47
  # ]
37
48
  end
38
49
 
39
- # --- Optional: capture ActiveJob failures across ALL queue adapters ---
40
- # Add to app/jobs/application_job.rb (skip if you only use Sidekiq, since the
41
- # Sidekiq integration above already covers it):
42
- #
43
- # class ApplicationJob < ActiveJob::Base
44
- # include ErrorRadar::Integrations::ActiveJob
45
- # end
50
+ # ActiveJob is now captured automatically via install_active_job = true above.
51
+ # No changes needed in ApplicationJob. Set install_active_job = false if you
52
+ # only use Sidekiq and want to avoid double-counting.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: error_radar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chienbn9x
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-18 00:00:00.000000000 Z
11
+ date: 2026-07-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -59,10 +59,13 @@ files:
59
59
  - Rakefile
60
60
  - app/controllers/error_radar/application_controller.rb
61
61
  - app/controllers/error_radar/dashboard_controller.rb
62
+ - app/controllers/error_radar/errors_controller.rb
62
63
  - app/models/error_radar/application_record.rb
63
64
  - app/models/error_radar/error_log.rb
64
65
  - app/views/error_radar/dashboard/index.html.erb
65
66
  - app/views/error_radar/dashboard/show.html.erb
67
+ - app/views/error_radar/errors/index.html.erb
68
+ - app/views/error_radar/errors/show.html.erb
66
69
  - app/views/layouts/error_radar/application.html.erb
67
70
  - config/routes.rb
68
71
  - lib/error_radar.rb
@@ -70,6 +73,7 @@ files:
70
73
  - lib/error_radar/engine.rb
71
74
  - lib/error_radar/integrations/active_job.rb
72
75
  - lib/error_radar/integrations/rails_admin.rb
76
+ - lib/error_radar/integrations/rake.rb
73
77
  - lib/error_radar/integrations/sidekiq.rb
74
78
  - lib/error_radar/middleware.rb
75
79
  - lib/error_radar/server_monitor.rb