error_radar 0.2.0 → 0.4.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: 2914a57eb370a3b003c69d8ab3e9899826dade79b35f62bffc97e8fed40748bf
4
- data.tar.gz: f0f0772a121489558626b8780c1d10f3d1d08fb1e71b067c0a422c5da5eab709
3
+ metadata.gz: 486749dc9fc6c6e2cc617340f184cf8dbeb4196199c3017eb7bf6f667718fe74
4
+ data.tar.gz: 13da87d1772c71901b66fb4cd6b4d29a809b539b6fccaee5613defae97c0a04d
5
5
  SHA512:
6
- metadata.gz: e9ddda42dc56d8512da27db0fa371639b08925af149200fd4a016904eb2fdc2ee8fc7d4febb5b008db2b1f3e3b689acda1c068c1883d55f38fa833201346cb12
7
- data.tar.gz: e76130935b55369dbb16d839a3d4e29fbe6c2cb28390915ffc43cee00bd8bb9c5ebc1585cecbbd656cfaf2a22d91465eb41e8529ee17ba91fae86023cf89563d
6
+ metadata.gz: 6a9fe7aa11d7668ca353e0c6331eac9a339c43263f93bc3650085bd72f2072968376006f7481661037c7d9f80fdca0057b8a28d9779a92050b931fb4eccd441f
7
+ data.tar.gz: f98150abaa5c7bd7695ddd38ba91d65db73bfe81ce7e739c9f91fac20c596b3303643fc282320b2366f9b1b6bd4da48c2bfca7bd8d182e1162c3a7786770384a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.0] - 2026-07-03
6
+
7
+ ### Added
8
+ - **Slack notifications**: sends a Block Kit message with error details and a
9
+ deep-link button. Configure with `config.slack_webhook_url` and optional
10
+ `config.slack_channel`.
11
+ - **Discord notifications**: sends a rich embed to any Discord webhook.
12
+ Configure with `config.discord_webhook_url`.
13
+ - **Email notifications**: delivers an HTML + plain-text email via ActionMailer.
14
+ Configure `config.email_recipients` and `config.email_from`.
15
+ - **Generic webhook**: POSTs a JSON payload to any URL (PagerDuty, OpsGenie,
16
+ custom scripts). Configure with `config.webhook_urls = [url1, url2]`.
17
+ - **Custom callbacks**: `config.on_error { |log| ... }` for arbitrary alerting
18
+ logic (e.g. PagerDuty SDK, Telegram, SMS).
19
+ - **`notify_on` rule set**: controls when alerts fire — `:new_error` (default,
20
+ first occurrence per fingerprint), `:critical` (any critical severity, 1/hour
21
+ throttle), `:all` (every occurrence, 1/hour throttle per fingerprint).
22
+ - **In-memory throttle**: prevents notification storms for `:critical` and `:all`
23
+ rules — at most one alert per fingerprint per hour.
24
+ - **Deep-links in notifications**: set `config.app_host` to include a link to
25
+ the error detail page in every notification.
26
+ - **`ErrorLog#new_fingerprint?`**: transient predicate (not persisted) that is
27
+ `true` when the record was just created for the first time.
28
+
29
+ ## [0.3.0] - 2026-07-03
30
+
31
+ ### Added
32
+ - **Zero-config auto-capture for ActiveJob**: any ActiveJob subclass (regardless
33
+ of queue adapter) now has exceptions captured automatically via
34
+ `ActiveSupport.on_load(:active_job)`. No need to include the module manually.
35
+ Disable with `config.install_active_job = false` (e.g. when using Sidekiq only).
36
+ - **Zero-config auto-capture for Rake tasks**: patches `Rake::Task#execute` so
37
+ every task failure is recorded automatically. Disable with
38
+ `config.install_rake = false`.
39
+ - **Full error list page** at `/errors` with pagination (50 per page),
40
+ full-text search (message / error class / source), filter by status /
41
+ severity / category / date range, sortable columns, and bulk actions
42
+ (resolve / ignore / reopen / delete).
43
+ - **Bulk actions**: select individual rows or all rows, then resolve, ignore,
44
+ reopen, or delete in one click.
45
+ - **Quick-action buttons** on the list (✓ resolve, ✕ ignore) without leaving
46
+ the page.
47
+ - **Improved error detail page**: context and backtrace displayed side-by-side,
48
+ inline resolution note field, delete button with confirmation.
49
+ - **Navigation bar** across all dashboard pages linking Dashboard ↔ All Errors.
50
+
51
+ ### Changed
52
+ - `show` and `update_status` actions moved from `DashboardController` to the
53
+ new `ErrorsController`; route paths (`/errors/:id` etc.) are unchanged.
54
+ - Dashboard index h1 changed from "🛰 Error Radar" to "Dashboard" (branding
55
+ now lives in the shared nav bar).
56
+
5
57
  ## [0.2.0] - 2026-07-02
6
58
 
7
59
  ### Added
@@ -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
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ class ErrorMailer < ActionMailer::Base
5
+ layout false
6
+
7
+ def new_error(error_log)
8
+ @error = error_log
9
+ @is_new = error_log.new_fingerprint?
10
+ @url = build_url
11
+ @app_name = ErrorRadar::Notifier.app_name
12
+
13
+ prefix = @is_new ? 'New' : 'Critical'
14
+ subject = "[#{@app_name}] #{prefix} #{@error.severity}: #{@error.error_class}"
15
+
16
+ mail(
17
+ to: ErrorRadar.config.email_recipients,
18
+ from: ErrorRadar.config.email_from || 'noreply@localhost',
19
+ subject: subject
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def build_url
26
+ host = ErrorRadar.config.app_host.to_s.chomp('/')
27
+ return nil if host.empty?
28
+
29
+ "#{host}/error_radar/errors/#{@error.id}"
30
+ end
31
+ end
32
+ end
@@ -29,13 +29,18 @@ module ErrorRadar
29
29
  # Record (or roll-up) an error. Idempotent per fingerprint: identical errors
30
30
  # increment `occurrences` and bump `last_seen_at` instead of creating a new
31
31
  # row. NEVER raises — logging must not break the calling code path.
32
+ def new_fingerprint?
33
+ @new_fingerprint || false
34
+ end
35
+
32
36
  def self.record(category:, message:, severity: :error, error_class: nil, source: nil,
33
37
  backtrace: nil, context: {}, http_status: nil, request_url: nil,
34
38
  api_code: nil, api_subcode: nil, fingerprint: nil)
35
39
  now = Time.current
36
40
  fp = presence(fingerprint) || build_fingerprint(category: category, error_class: error_class, source: source, message: message)
37
41
 
38
- log = find_or_initialize_by(fingerprint: fp)
42
+ log = find_or_initialize_by(fingerprint: fp)
43
+ new_fingerprint = !log.persisted?
39
44
 
40
45
  if log.persisted?
41
46
  log.occurrences += 1
@@ -54,6 +59,7 @@ module ErrorRadar
54
59
  log.severity = severity if log.new_record? || severity_rank(severity) > severity_rank(log.severity)
55
60
  log.last_seen_at = now
56
61
  log.save!
62
+ log.instance_variable_set(:@new_fingerprint, new_fingerprint)
57
63
  log
58
64
  rescue StandardError => e
59
65
  ErrorRadar::Tracking.warn_internal("ErrorLog.record failed: #{e.class}: #{e.message}")
@@ -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,82 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <style>
7
+ body{margin:0;padding:20px;background:#f4f6f8;font-family:-apple-system,"Segoe UI",Roboto,Arial,sans-serif;color:#1f2933}
8
+ .container{max-width:600px;margin:0 auto}
9
+ .header{background:#1f2933;color:#fff;padding:24px;border-radius:10px 10px 0 0}
10
+ .header h1{margin:0 0 6px;font-size:18px}
11
+ .header .meta{color:#9ca3af;font-size:12px}
12
+ .body{background:#fff;padding:24px;border-radius:0 0 10px 10px}
13
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}
14
+ .field .label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;margin-bottom:3px}
15
+ .field .value{font-size:14px;color:#1f2933}
16
+ .badge{display:inline-block;padding:3px 10px;border-radius:10px;color:#fff;font-size:12px;font-weight:600}
17
+ .msg-box{background:#f8fafc;border-left:4px solid #dc3545;padding:12px 14px;border-radius:0 8px 8px 0;font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap;word-break:break-word;color:#374151;margin:16px 0}
18
+ .btn{display:inline-block;background:#2563eb;color:#fff!important;padding:11px 22px;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600;margin-top:8px}
19
+ .footer{margin-top:20px;font-size:11px;color:#9ca3af;text-align:center}
20
+ <%
21
+ sev_colors = { 'critical' => '#7b001c', 'error' => '#dc3545', 'warning' => '#fd7e14', 'info' => '#17a2b8' }
22
+ %>
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div class="container">
27
+ <div class="header">
28
+ <h1><%= @is_new ? '🆕 New error detected' : '🔁 Critical error recurring' %></h1>
29
+ <div class="meta"><%= @app_name %> · <%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M UTC') %></div>
30
+ </div>
31
+
32
+ <div class="body">
33
+ <div class="field" style="margin-bottom:20px">
34
+ <div class="label">Error class</div>
35
+ <div class="value" style="font-family:ui-monospace,monospace;font-size:17px;font-weight:700"><%= @error.error_class %></div>
36
+ </div>
37
+
38
+ <div class="grid">
39
+ <div class="field">
40
+ <div class="label">Source</div>
41
+ <div class="value"><%= @error.source || 'unknown' %></div>
42
+ </div>
43
+ <div class="field">
44
+ <div class="label">Category</div>
45
+ <div class="value"><%= @error.category.to_s.humanize %></div>
46
+ </div>
47
+ <div class="field">
48
+ <div class="label">Severity</div>
49
+ <div class="value">
50
+ <span class="badge" style="background:<%= sev_colors[@error.severity] || '#6c757d' %>"><%= @error.severity %></span>
51
+ </div>
52
+ </div>
53
+ <div class="field">
54
+ <div class="label">Occurrences</div>
55
+ <div class="value" style="font-weight:700;font-size:16px"><%= @error.occurrences %></div>
56
+ </div>
57
+ <div class="field">
58
+ <div class="label">First seen</div>
59
+ <div class="value"><%= @error.first_seen_at&.strftime('%Y-%m-%d %H:%M') %></div>
60
+ </div>
61
+ <div class="field">
62
+ <div class="label">Last seen</div>
63
+ <div class="value"><%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M') %></div>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="field">
68
+ <div class="label">Message</div>
69
+ <div class="msg-box"><%= @error.message.to_s.truncate(600) %></div>
70
+ </div>
71
+
72
+ <% if @url %>
73
+ <a href="<%= @url %>" class="btn">View in Error Radar →</a>
74
+ <% end %>
75
+ </div>
76
+
77
+ <div class="footer">
78
+ Sent by <strong>Error Radar</strong> · <%= @app_name %>
79
+ </div>
80
+ </div>
81
+ </body>
82
+ </html>
@@ -0,0 +1,21 @@
1
+ [<%= @app_name %>] <%= @is_new ? 'New error' : 'Critical error recurring' %>: <%= @error.error_class %>
2
+ <%= '=' * 60 %>
3
+
4
+ Error class: <%= @error.error_class %>
5
+ Source: <%= @error.source || 'unknown' %>
6
+ Category: <%= @error.category %>
7
+ Severity: <%= @error.severity %>
8
+ Occurrences: <%= @error.occurrences %>
9
+ First seen: <%= @error.first_seen_at&.strftime('%Y-%m-%d %H:%M UTC') %>
10
+ Last seen: <%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M UTC') %>
11
+
12
+ Message:
13
+ <%= @error.message.to_s.truncate(1000) %>
14
+ <% if @url %>
15
+
16
+ View in Error Radar:
17
+ <%= @url %>
18
+ <% end %>
19
+
20
+ --
21
+ Sent by Error Radar · <%= @app_name %>