error_radar 0.6.0 → 0.8.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: bdfb13eb6566d150855376a07e28a5a76a758b23526d7cf31de5a6adc17e5d27
4
- data.tar.gz: 713dcf3fa8c7867b971b6e7da6dd7dbf56a6e4057ec284dbadb6a8818b83f6d6
3
+ metadata.gz: 1522c6f867cba16786c499703bf76e18d8d7134109f8c925387a628fd0b7f420
4
+ data.tar.gz: f9bfc6fe5a5de9e8394b3c81a4b41913b82ed22e5f56ef72980936dc9ecc59f9
5
5
  SHA512:
6
- metadata.gz: b71979e7ef0ca2737d789f3f047a024c9933d8b2149e83e5d88fc734144213226dfb1a160b4080ee008104dd0c19dd35a1da06ce0fbeb2ae61a0a65455a65d0f
7
- data.tar.gz: 1838775fea5865d76bc746c49bc51efb0a557d6f6caf0924bdcd489b081570ff8667715690e3ef1b8a32ca7af219d7a26a1fe312e17304c27e4a9025d0632bf4
6
+ metadata.gz: 13ae125f05a92939e320a4af5a55521c4f8bddde93fe702b44a2cc0919f2f47c754ed39f440787cb94040676e22d1efaa5a65195e306c955ca2be3cce70e4e8b
7
+ data.tar.gz: 782931704d0808b5c527a542ab2019b6f49726d9d8b7321ab63931c0788672edaa65eac04a5790f768a35c12296ae12233a38eb60dcfbb668c91535663c5e1cc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,67 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.8.0] - 2026-07-03
6
+
7
+ ### Added
8
+ - **Rate-based spike alerts**: add `:spike` to `config.notify_on` to fire a
9
+ notification when a single error exceeds `config.spike_threshold` (default: 10)
10
+ occurrences within `config.spike_window_minutes` (default: 5). Re-alerts once
11
+ per window after the first trigger.
12
+ - **`ErrorRadar::SpikeDetector`**: detects spikes using two strategies — if
13
+ `track_occurrences` is enabled it queries `error_radar_occurrences` for an
14
+ exact cross-process count; otherwise falls back to an in-memory ring buffer
15
+ (per-process, resets on restart). Memory is capped at 1 000 timestamps per
16
+ fingerprint.
17
+ - **`config.spike_threshold`** (default: 10) and **`config.spike_window_minutes`**
18
+ (default: 5) — tune the spike trigger.
19
+ - **Spike throttle**: a spike alert re-fires at most once per window
20
+ (e.g. every 5 minutes), using a separate throttle key from `:critical`/`:all`.
21
+ - **Event-aware notification channels**: all channels (Slack, Discord, email,
22
+ webhook) now receive the notification event (`:new_error`, `:spike`,
23
+ `:recurring`) and format accordingly:
24
+ - Slack: `:warning:` emoji, "danger" button colour, hit-count in title
25
+ - Discord: orange embed colour (`#ff6600`) for spikes
26
+ - Email subject: `[App] SPIKE ErrorClass: N hits in M min`
27
+ - Webhook payload: `event: "spike"` + `spike: { count:, window_minutes: }`
28
+
29
+ ### Changed
30
+ - `Notifier.dispatch` now determines a single *event type* before dispatching,
31
+ preventing a log from matching both `:spike` and `:critical` simultaneously.
32
+ - All channel `deliver` methods accept an optional `event` argument (default
33
+ `:recurring`) — backwards-compatible if called directly.
34
+
35
+ ## [0.7.0] - 2026-07-03
36
+
37
+ ### Added
38
+ - **Occurrence history**: every individual error hit can now be stored in a
39
+ separate `error_radar_occurrences` table. Enable with
40
+ `config.track_occurrences = true` after running the upgrade migration:
41
+ `bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate`.
42
+ - **`ErrorRadar::ErrorOccurrence` model**: columns `occurred_at`, `context`,
43
+ `backtrace`, `http_status`, `request_url`. Belongs to `ErrorLog` via
44
+ `error_log_id`. Indexed on `(error_log_id, occurred_at)` for fast retrieval.
45
+ - **`config.max_occurrences_per_error`** (default: 200): automatically prunes
46
+ the oldest occurrences for a given error on each new hit so the table stays
47
+ bounded without manual cleanup.
48
+ - **Occurrences panel on error detail page**: shows the 20 most recent
49
+ occurrences with timestamp, HTTP status badge, request URL, and expandable
50
+ "Context" / "Stack" toggles. Paginated at 20 per page.
51
+ - **`GET /api/errors/:id`** now includes `recent_occurrences` (last 10 hits
52
+ with `occurred_at`, `http_status`, `request_url`, `context`) when
53
+ `track_occurrences` is enabled.
54
+ - **`error_radar:upgrade_v060` generator**: generates the migration that creates
55
+ `error_radar_occurrences`.
56
+ - **`ErrorLog#occurrences`** association: `has_many :occurrences, dependent: :delete_all`
57
+ so destroying an error log also removes its occurrence history.
58
+
59
+ ### Notes
60
+ - `track_occurrences` defaults to `false` to avoid unexpected writes before the
61
+ migration is run. Set it to `true` after the migration has been applied.
62
+ - Occurrences are recorded via `ErrorLog.record`, which is called by both the
63
+ synchronous capture path and `CaptureJob` (async path) — no extra
64
+ configuration needed once enabled.
65
+
5
66
  ## [0.6.0] - 2026-07-03
6
67
 
7
68
  ### Added
@@ -99,6 +99,22 @@ module ErrorRadar
99
99
  context: log.context,
100
100
  backtrace: log.backtrace
101
101
  )
102
+
103
+ if ErrorRadar.config.track_occurrences
104
+ begin
105
+ data[:recent_occurrences] = log.occurrences.recent.limit(10).map do |occ|
106
+ {
107
+ id: occ.id,
108
+ occurred_at: occ.occurred_at&.iso8601,
109
+ http_status: occ.http_status,
110
+ request_url: occ.request_url,
111
+ context: occ.context
112
+ }
113
+ end
114
+ rescue ActiveRecord::StatementInvalid
115
+ data[:recent_occurrences] = []
116
+ end
117
+ end
102
118
  end
103
119
 
104
120
  if log.class.column_names.include?('github_issue_url')
@@ -22,7 +22,23 @@ module ErrorRadar
22
22
  @pages = pagination_pages(@page, @total_pages)
23
23
  end
24
24
 
25
- def show; end
25
+ def show
26
+ @occurrences = []
27
+ return unless ErrorRadar.config.track_occurrences
28
+
29
+ begin
30
+ @occ_page = [params[:occ_page].to_i, 1].max
31
+ occ_per_page = 20
32
+ @occ_total = @error.occurrences.count
33
+ @occ_total_pages = [(@occ_total.to_f / occ_per_page).ceil, 1].max
34
+ @occ_page = [@occ_page, @occ_total_pages].min
35
+ @occurrences = @error.occurrences.recent
36
+ .limit(occ_per_page)
37
+ .offset((@occ_page - 1) * occ_per_page)
38
+ rescue ActiveRecord::StatementInvalid
39
+ @occurrences = []
40
+ end
41
+ end
26
42
 
27
43
  def update_status
28
44
  new_status = params[:status].to_s
@@ -4,14 +4,21 @@ module ErrorRadar
4
4
  class ErrorMailer < ActionMailer::Base
5
5
  layout false
6
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
7
+ def new_error(error_log, event = :recurring)
8
+ @error = error_log
9
+ @event = event
10
+ @spike_data = error_log.instance_variable_get(:@spike_data)
11
+ @url = build_url
12
+ @app_name = ErrorRadar::Notifier.app_name
12
13
 
13
- prefix = @is_new ? 'New' : 'Critical'
14
- subject = "[#{@app_name}] #{prefix} #{@error.severity}: #{@error.error_class}"
14
+ subject = case event
15
+ when :spike
16
+ "[#{@app_name}] SPIKE #{@error.error_class}: #{@spike_data[:count]} hits in #{@spike_data[:window_minutes]} min"
17
+ when :new_error
18
+ "[#{@app_name}] New #{@error.severity}: #{@error.error_class}"
19
+ else
20
+ "[#{@app_name}] #{@error.severity.capitalize}: #{@error.error_class}"
21
+ end
15
22
 
16
23
  mail(
17
24
  to: ErrorRadar.config.email_recipients,
@@ -16,6 +16,10 @@ module ErrorRadar
16
16
 
17
17
  enum status: { open: 0, in_progress: 1, resolved: 2, ignored: 3 }, _prefix: :status
18
18
 
19
+ has_many :occurrences, class_name: 'ErrorRadar::ErrorOccurrence',
20
+ foreign_key: :error_log_id,
21
+ dependent: :delete_all
22
+
19
23
  validates :fingerprint, presence: true, uniqueness: true
20
24
  validates :first_seen_at, :last_seen_at, presence: true
21
25
 
@@ -60,6 +64,17 @@ module ErrorRadar
60
64
  log.last_seen_at = now
61
65
  log.save!
62
66
  log.instance_variable_set(:@new_fingerprint, new_fingerprint)
67
+
68
+ if ErrorRadar.config.track_occurrences
69
+ ErrorRadar::ErrorOccurrence.record_for(
70
+ log,
71
+ context: context,
72
+ backtrace: backtrace,
73
+ http_status: http_status,
74
+ request_url: request_url
75
+ )
76
+ end
77
+
63
78
  log
64
79
  rescue StandardError => e
65
80
  ErrorRadar::Tracking.warn_internal("ErrorLog.record failed: #{e.class}: #{e.message}")
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ # One row per individual error hit. Linked to an ErrorLog (the deduplicated
5
+ # aggregate). Only recorded when config.track_occurrences is true and the
6
+ # upgrade_v060 migration has been run.
7
+ class ErrorOccurrence < ApplicationRecord
8
+ belongs_to :error_log
9
+
10
+ scope :recent, -> { order(occurred_at: :desc) }
11
+
12
+ def self.record_for(log, context: {}, backtrace: nil, http_status: nil, request_url: nil)
13
+ max = ErrorRadar.config.max_occurrences_per_error
14
+
15
+ create!(
16
+ error_log: log,
17
+ occurred_at: Time.current,
18
+ context: context.presence,
19
+ backtrace: Array(backtrace).join("\n").presence,
20
+ http_status: http_status,
21
+ request_url: request_url
22
+ )
23
+
24
+ if max&.positive?
25
+ keep_ids = where(error_log_id: log.id).order(occurred_at: :desc).limit(max).pluck(:id)
26
+ where(error_log_id: log.id).where.not(id: keep_ids).delete_all if keep_ids.any?
27
+ end
28
+ rescue ActiveRecord::StatementInvalid
29
+ # Table not yet created — run: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
30
+ rescue StandardError => e
31
+ ErrorRadar::Tracking.warn_internal("ErrorOccurrence.record_for failed: #{e.class}: #{e.message}")
32
+ end
33
+
34
+ def context_pretty
35
+ return '{}' if context.blank?
36
+ JSON.pretty_generate(context.is_a?(Hash) ? context : JSON.parse(context.to_s))
37
+ rescue JSON::ParserError
38
+ context.to_s
39
+ end
40
+ end
41
+ end
@@ -104,6 +104,97 @@
104
104
  </div>
105
105
  </div>
106
106
 
107
+ <%# ── Occurrences History ── %>
108
+ <% if ErrorRadar.config.track_occurrences %>
109
+ <div class="panel" style="margin-top:16px" id="occurrences-panel">
110
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px">
111
+ <h2 style="margin:0">Occurrence History
112
+ <% if @occ_total.to_i > 0 %>
113
+ <span style="font-size:13px;font-weight:400;color:#6b7280">(<%= @occ_total %> total)</span>
114
+ <% end %>
115
+ </h2>
116
+ </div>
117
+
118
+ <% if @occurrences.empty? %>
119
+ <p style="font-size:13px;color:#9ca3af;margin:0">
120
+ <% if @occ_total.to_i == 0 %>
121
+ No occurrences recorded yet. New hits will appear here.
122
+ <% else %>
123
+ No occurrences on this page.
124
+ <% end %>
125
+ </p>
126
+ <% else %>
127
+ <table style="width:100%;border-collapse:collapse;font-size:13px">
128
+ <thead>
129
+ <tr style="border-bottom:2px solid #e5e7eb">
130
+ <th style="text-align:left;padding:6px 8px;color:#6b7280;width:170px">Time</th>
131
+ <th style="text-align:left;padding:6px 8px;color:#6b7280;width:70px">HTTP</th>
132
+ <th style="text-align:left;padding:6px 8px;color:#6b7280">Context / Request</th>
133
+ <th style="text-align:left;padding:6px 8px;color:#6b7280;width:80px">Backtrace</th>
134
+ </tr>
135
+ </thead>
136
+ <tbody>
137
+ <% @occurrences.each_with_index do |occ, i| %>
138
+ <tr style="border-bottom:1px solid #f3f4f6" class="occ-row">
139
+ <td style="padding:7px 8px;font-family:monospace;font-size:11px;white-space:nowrap">
140
+ <%= occ.occurred_at&.strftime('%Y-%m-%d %H:%M:%S') %>
141
+ </td>
142
+ <td style="padding:7px 8px">
143
+ <% if occ.http_status.present? %>
144
+ <span class="badge" style="background:<%= occ.http_status.to_i >= 500 ? '#dc3545' : '#fd7e14' %>">
145
+ <%= occ.http_status %>
146
+ </span>
147
+ <% end %>
148
+ </td>
149
+ <td style="padding:7px 8px;max-width:400px">
150
+ <% if occ.request_url.present? %>
151
+ <div style="font-size:11px;color:#6b7280;word-break:break-all;margin-bottom:4px"><%= occ.request_url.truncate(120) %></div>
152
+ <% end %>
153
+ <% unless (occ.context || {}).empty? %>
154
+ <button onclick="toggleOcc('ctx-<%= i %>')"
155
+ style="padding:2px 8px;font-size:11px;border:1px solid #d1d5db;border-radius:4px;background:#f9fafb;cursor:pointer">
156
+ Context ▾
157
+ </button>
158
+ <div id="ctx-<%= i %>" style="display:none;margin-top:6px">
159
+ <pre class="er-pre" style="max-height:200px;font-size:10px"><%= occ.context_pretty %></pre>
160
+ </div>
161
+ <% end %>
162
+ </td>
163
+ <td style="padding:7px 8px">
164
+ <% if occ.backtrace.present? %>
165
+ <button onclick="toggleOcc('bt-<%= i %>')"
166
+ style="padding:2px 8px;font-size:11px;border:1px solid #d1d5db;border-radius:4px;background:#f9fafb;cursor:pointer">
167
+ Stack ▾
168
+ </button>
169
+ <div id="bt-<%= i %>" style="display:none;position:absolute;z-index:10;background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:12px;width:600px;max-width:90vw;box-shadow:0 4px 16px rgba(0,0,0,.12)">
170
+ <pre class="er-pre" style="max-height:300px"><%= occ.backtrace %></pre>
171
+ <button onclick="toggleOcc('bt-<%= i %>')" style="margin-top:6px;font-size:11px;border:0;background:none;cursor:pointer;color:#6b7280">Close</button>
172
+ </div>
173
+ <% end %>
174
+ </td>
175
+ </tr>
176
+ <% end %>
177
+ </tbody>
178
+ </table>
179
+
180
+ <%# Pagination %>
181
+ <% if @occ_total_pages.to_i > 1 %>
182
+ <div style="margin-top:12px;display:flex;gap:6px;font-size:12px;align-items:center">
183
+ <% (1..@occ_total_pages).each do |p| %>
184
+ <% if p == @occ_page %>
185
+ <span style="padding:4px 10px;background:#2563eb;color:#fff;border-radius:6px"><%= p %></span>
186
+ <% else %>
187
+ <%= link_to p, error_path(@error, occ_page: p, anchor: 'occurrences-panel'),
188
+ style: 'padding:4px 10px;border:1px solid #d1d5db;border-radius:6px;text-decoration:none;color:#374151' %>
189
+ <% end %>
190
+ <% end %>
191
+ <span style="color:#9ca3af">of <%= @occ_total_pages %> pages</span>
192
+ </div>
193
+ <% end %>
194
+ <% end %>
195
+ </div>
196
+ <% end %>
197
+
107
198
  <%# ── GitHub Issue ── %>
108
199
  <% if ErrorRadar.config.github_token.present? && ErrorRadar.config.github_repo.present? %>
109
200
  <div class="panel" style="margin-top:16px">
@@ -130,6 +221,11 @@
130
221
  <% end %>
131
222
 
132
223
  <script>
224
+ window.toggleOcc = function (id) {
225
+ var el = document.getElementById(id);
226
+ if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
227
+ };
228
+
133
229
  (function () {
134
230
  var CSRF = document.querySelector('meta[name="csrf-token"]').content;
135
231
  var STATUS_URL = '<%= error_status_path(@error) %>';
@@ -69,6 +69,21 @@ module ErrorRadar
69
69
  @error_callbacks << block
70
70
  end
71
71
 
72
+ # Spike detection ─────────────────────────────────────────────────────────
73
+ # Number of occurrences within spike_window_minutes that triggers a spike alert.
74
+ # Add :spike to notify_on to enable: config.notify_on = [:new_error, :spike]
75
+ attr_accessor :spike_threshold
76
+ # Rolling time window in minutes for counting occurrences.
77
+ attr_accessor :spike_window_minutes
78
+
79
+ # Occurrences ─────────────────────────────────────────────────────────────
80
+ # When true, each individual error hit is stored in error_radar_occurrences.
81
+ # Requires running: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
82
+ attr_accessor :track_occurrences
83
+ # Maximum occurrences to keep per ErrorLog. Oldest are pruned on each new hit.
84
+ # nil = keep all (not recommended for high-volume apps).
85
+ attr_accessor :max_occurrences_per_error
86
+
72
87
  # Performance ─────────────────────────────────────────────────────────────
73
88
  # When true, ErrorLog.record is enqueued via ActiveJob (non-blocking).
74
89
  # Requires ActiveJob to be configured. Falls back to sync if ActiveJob is absent.
@@ -148,6 +163,12 @@ module ErrorRadar
148
163
  @app_host = nil
149
164
  @error_callbacks = []
150
165
 
166
+ @spike_threshold = 10
167
+ @spike_window_minutes = 5
168
+
169
+ @track_occurrences = false
170
+ @max_occurrences_per_error = 200
171
+
151
172
  @async_capture = false
152
173
  @capture_job_queue = :default
153
174
  @retention_days = nil
@@ -13,10 +13,10 @@ module ErrorRadar
13
13
  'warning' => 0xfd7e14,
14
14
  'info' => 0x17a2b8
15
15
  }.freeze
16
+ SPIKE_COLOR = 0xff6600
16
17
 
17
- def self.deliver(log)
18
- url = URI(ErrorRadar.config.discord_webhook_url)
19
- payload = build_payload(log)
18
+ def self.deliver(log, event = :recurring)
19
+ url = URI(ErrorRadar.config.discord_webhook_url)
20
20
 
21
21
  http = Net::HTTP.new(url.host, url.port)
22
22
  http.use_ssl = url.scheme == 'https'
@@ -25,17 +25,26 @@ module ErrorRadar
25
25
 
26
26
  req = Net::HTTP::Post.new(url)
27
27
  req['Content-Type'] = 'application/json'
28
- req.body = payload.to_json
28
+ req.body = build_payload(log, event).to_json
29
29
 
30
30
  http.request(req)
31
31
  rescue StandardError => e
32
32
  ErrorRadar::Tracking.warn_internal("Discord notification failed: #{e.message}")
33
33
  end
34
34
 
35
- def self.build_payload(log)
36
- app = ErrorRadar::Notifier.app_name
37
- url = ErrorRadar::Notifier.error_url(log)
38
- prefix = log.new_fingerprint? ? '🆕 New error' : '🔁 Critical error'
35
+ def self.build_payload(log, event)
36
+ spike_data = log.instance_variable_get(:@spike_data)
37
+ app = ErrorRadar::Notifier.app_name
38
+ link = ErrorRadar::Notifier.error_url(log)
39
+
40
+ prefix, color = case event
41
+ when :spike
42
+ ["⚠️ Spike (#{spike_data[:count]} hits / #{spike_data[:window_minutes]} min)", SPIKE_COLOR]
43
+ when :new_error
44
+ ['🆕 New error', SEV_COLORS[log.severity] || 0x6c757d]
45
+ else
46
+ ['🔁 Recurring error', SEV_COLORS[log.severity] || 0x6c757d]
47
+ end
39
48
 
40
49
  fields = [
41
50
  { name: 'Source', value: (log.source || 'unknown').truncate(100), inline: true },
@@ -43,12 +52,12 @@ module ErrorRadar
43
52
  { name: 'Severity', value: log.severity.to_s, inline: true },
44
53
  { name: 'Occurrences', value: log.occurrences.to_s, inline: true }
45
54
  ]
46
- fields << { name: 'View', value: "[Error Radar](#{url})", inline: false } if url
55
+ fields << { name: 'View', value: "[Error Radar](#{link})", inline: false } if link
47
56
 
48
57
  embed = {
49
58
  title: "#{prefix}: #{log.error_class}",
50
59
  description: "```#{log.message.to_s.truncate(300)}```",
51
- color: SEV_COLORS[log.severity] || 0x6c757d,
60
+ color: color,
52
61
  fields: fields,
53
62
  footer: { text: "[#{app}] Error Radar" },
54
63
  timestamp: log.last_seen_at&.iso8601
@@ -5,11 +5,11 @@ module ErrorRadar
5
5
  # Delivers notification emails via ActionMailer (must be configured in
6
6
  # the host app). Uses deliver_later so it doesn't block the request cycle.
7
7
  module Email
8
- def self.deliver(log)
8
+ def self.deliver(log, event = :recurring)
9
9
  return unless defined?(::ActionMailer::Base)
10
10
 
11
11
  require 'error_radar/mailers/error_mailer'
12
- ErrorRadar::ErrorMailer.new_error(log).deliver_later
12
+ ErrorRadar::ErrorMailer.new_error(log, event).deliver_later
13
13
  rescue StandardError => e
14
14
  ErrorRadar::Tracking.warn_internal("Email notification failed: #{e.message}")
15
15
  end
@@ -14,40 +14,47 @@ module ErrorRadar
14
14
  'info' => ':large_blue_circle:'
15
15
  }.freeze
16
16
 
17
- def self.deliver(log)
18
- url = URI(ErrorRadar.config.slack_webhook_url)
19
- payload = build_payload(log)
17
+ def self.deliver(log, event = :recurring)
18
+ url = URI(ErrorRadar.config.slack_webhook_url)
20
19
 
21
20
  http = Net::HTTP.new(url.host, url.port)
22
21
  http.use_ssl = url.scheme == 'https'
23
22
  http.open_timeout = 5
24
23
  http.read_timeout = 5
25
24
 
26
- req = Net::HTTP::Post.new(url)
27
- req['Content-Type'] = 'application/json'
28
- req.body = payload.to_json
25
+ req = Net::HTTP::Post.new(url)
26
+ req['Content-Type'] = 'application/json'
27
+ req.body = build_payload(log, event).to_json
29
28
 
30
29
  http.request(req)
31
30
  rescue StandardError => e
32
31
  ErrorRadar::Tracking.warn_internal("Slack notification failed: #{e.message}")
33
32
  end
34
33
 
35
- def self.build_payload(log)
36
- emoji = SEV_EMOJI[log.severity] || ':white_circle:'
37
- title = "#{emoji} #{log.new_fingerprint? ? 'New error' : 'Critical error'}: *#{log.error_class}*"
38
- url = ErrorRadar::Notifier.error_url(log)
39
- app = ErrorRadar::Notifier.app_name
40
- channel = ErrorRadar.config.slack_channel
34
+ def self.build_payload(log, event)
35
+ spike_data = log.instance_variable_get(:@spike_data)
36
+ app = ErrorRadar::Notifier.app_name
37
+ link = ErrorRadar::Notifier.error_url(log)
38
+ channel = ErrorRadar.config.slack_channel
39
+
40
+ emoji, title = case event
41
+ when :spike
42
+ [':warning:', "Spike — #{spike_data[:count]} hits in #{spike_data[:window_minutes]} min: *#{log.error_class}*"]
43
+ when :new_error
44
+ [SEV_EMOJI[log.severity] || ':white_circle:', "New error: *#{log.error_class}*"]
45
+ else
46
+ [SEV_EMOJI[log.severity] || ':white_circle:', "#{log.severity.capitalize} error: *#{log.error_class}*"]
47
+ end
41
48
 
42
49
  blocks = [
43
- { type: 'section', text: { type: 'mrkdwn', text: "*[#{app}]* #{title}" } },
50
+ { type: 'section', text: { type: 'mrkdwn', text: "#{emoji} *[#{app}]* #{title}" } },
44
51
  {
45
52
  type: 'section',
46
53
  fields: [
47
54
  { type: 'mrkdwn', text: "*Source*\n#{(log.source || 'unknown').truncate(80)}" },
48
55
  { type: 'mrkdwn', text: "*Category*\n#{log.category}" },
49
56
  { type: 'mrkdwn', text: "*Severity*\n#{log.severity}" },
50
- { type: 'mrkdwn', text: "*Occurrences*\n#{log.occurrences}" }
57
+ { type: 'mrkdwn', text: "*Total occurrences*\n#{log.occurrences}" }
51
58
  ]
52
59
  },
53
60
  {
@@ -56,13 +63,14 @@ module ErrorRadar
56
63
  }
57
64
  ]
58
65
 
59
- if url
66
+ if link
60
67
  blocks << {
61
68
  type: 'actions',
62
69
  elements: [{
63
70
  type: 'button',
64
- text: { type: 'plain_text', text: 'View in Error Radar', emoji: true },
65
- url: url
71
+ text: { type: 'plain_text', text: 'View in Error Radar', emoji: true },
72
+ url: link,
73
+ style: event == :spike ? 'danger' : 'primary'
66
74
  }]
67
75
  }
68
76
  end
@@ -9,7 +9,7 @@ module ErrorRadar
9
9
  # Generic outbound webhook. POSTs a JSON payload to any URL.
10
10
  # Useful for PagerDuty, OpsGenie, custom scripts, etc.
11
11
  module Webhook
12
- def self.deliver(log, url:)
12
+ def self.deliver(log, url:, event: :recurring)
13
13
  uri = URI(url)
14
14
 
15
15
  http = Net::HTTP.new(uri.host, uri.port)
@@ -20,16 +20,18 @@ module ErrorRadar
20
20
  req = Net::HTTP::Post.new(uri)
21
21
  req['Content-Type'] = 'application/json'
22
22
  req['User-Agent'] = "ErrorRadar/#{ErrorRadar::VERSION}"
23
- req.body = build_payload(log).to_json
23
+ req.body = build_payload(log, event).to_json
24
24
 
25
25
  http.request(req)
26
26
  rescue StandardError => e
27
27
  ErrorRadar::Tracking.warn_internal("Webhook (#{url}) notification failed: #{e.message}")
28
28
  end
29
29
 
30
- def self.build_payload(log)
31
- {
32
- event: log.new_fingerprint? ? 'new_error' : 'recurring_error',
30
+ def self.build_payload(log, event)
31
+ spike_data = log.instance_variable_get(:@spike_data)
32
+
33
+ payload = {
34
+ event: event.to_s,
33
35
  app: ErrorRadar::Notifier.app_name,
34
36
  error_class: log.error_class,
35
37
  source: log.source,
@@ -42,6 +44,15 @@ module ErrorRadar
42
44
  last_seen_at: log.last_seen_at&.iso8601,
43
45
  url: ErrorRadar::Notifier.error_url(log)
44
46
  }
47
+
48
+ if spike_data
49
+ payload[:spike] = {
50
+ count: spike_data[:count],
51
+ window_minutes: spike_data[:window_minutes]
52
+ }
53
+ end
54
+
55
+ payload
45
56
  end
46
57
  end
47
58
  end
@@ -5,68 +5,105 @@ module ErrorRadar
5
5
  # callbacks) after an ErrorLog is persisted. Called from Tracking.capture and
6
6
  # Tracking.notify. Never raises — a broken notification must not affect the
7
7
  # host application.
8
+ #
9
+ # Notification events:
10
+ # :new_error — first time a fingerprint is seen (no throttle)
11
+ # :spike — error rate exceeds config.spike_threshold in the window
12
+ # :recurring — matches :critical or :all rule, throttled to 1/hour
8
13
  module Notifier
9
- THROTTLE_MUTEX = Mutex.new
10
- # fingerprint => Time of last notification (in-memory, resets on restart)
11
- THROTTLE = {}
14
+ THROTTLE_MUTEX = Mutex.new
15
+ # key => Time of last notification (in-memory, resets on restart)
16
+ # Keys: fingerprint (for :recurring) or "spike:fingerprint" (for :spike)
17
+ THROTTLE = {}
12
18
  THROTTLE_INTERVAL = 3600 # seconds between repeat alerts per fingerprint
13
19
 
14
20
  def self.dispatch(log)
15
21
  return unless ErrorRadar.config.enabled
16
- return unless should_fire?(log)
17
22
 
18
- cfg = ErrorRadar.config
19
- send_slack(log) if cfg.slack_webhook_url.to_s.start_with?('http')
20
- send_discord(log) if cfg.discord_webhook_url.to_s.start_with?('http')
21
- send_email(log) if cfg.email_recipients.any?
22
- cfg.webhook_urls.each { |url| send_webhook(log, url) }
23
- cfg.error_callbacks.each { |cb| safe_call(cb, log) }
23
+ rules = Array(ErrorRadar.config.notify_on).map(&:to_sym)
24
+ return if rules.empty?
25
+
26
+ # Record the hit for in-memory spike tracking before we test the count.
27
+ if rules.include?(:spike)
28
+ require 'error_radar/spike_detector'
29
+ SpikeDetector.record_hit(log.fingerprint)
30
+ end
31
+
32
+ event = determine_event(log, rules)
33
+ return unless event
34
+
35
+ fire_all(log, event)
24
36
  rescue StandardError => e
25
37
  ErrorRadar::Tracking.warn_internal("Notifier.dispatch failed: #{e.message}")
26
38
  end
27
39
 
28
40
  # ── Fire decision ──────────────────────────────────────────────────────
29
- def self.should_fire?(log)
30
- rules = Array(ErrorRadar.config.notify_on).map(&:to_sym)
31
- return false if rules.empty?
32
41
 
33
- # :new_error — fire exactly once per fingerprint (no throttle needed)
34
- return true if rules.include?(:new_error) && log.new_fingerprint?
42
+ def self.determine_event(log, rules)
43
+ # :new_error fires exactly once per fingerprint, no throttle
44
+ return :new_error if rules.include?(:new_error) && log.new_fingerprint?
35
45
 
36
- # :critical / :all fire for recurring events, subject to throttle
37
- matches_severity = rules.include?(:all) ||
38
- (rules.include?(:critical) && log.severity_critical?)
39
- matches_severity && throttle_ok?(log.fingerprint)
46
+ # :spike rate-based alert when occurrences in window exceed threshold
47
+ if rules.include?(:spike)
48
+ spike_count = SpikeDetector.check(log)
49
+ if spike_count
50
+ window_secs = ErrorRadar.config.spike_window_minutes * 60
51
+ if throttle_ok?("spike:#{log.fingerprint}", window_secs)
52
+ log.instance_variable_set(:@spike_data, {
53
+ count: spike_count,
54
+ window_minutes: ErrorRadar.config.spike_window_minutes
55
+ })
56
+ return :spike
57
+ end
58
+ end
59
+ end
60
+
61
+ # :critical / :all — throttled recurring alerts
62
+ matches = rules.include?(:all) ||
63
+ (rules.include?(:critical) && log.severity_critical?)
64
+ return :recurring if matches && throttle_ok?(log.fingerprint, THROTTLE_INTERVAL)
65
+
66
+ nil
40
67
  end
41
68
 
42
- def self.throttle_ok?(fingerprint)
69
+ def self.throttle_ok?(key, interval = THROTTLE_INTERVAL)
43
70
  THROTTLE_MUTEX.synchronize do
44
- last = THROTTLE[fingerprint]
45
- ok = last.nil? || (Time.current - last) >= THROTTLE_INTERVAL
46
- THROTTLE[fingerprint] = Time.current if ok
71
+ last = THROTTLE[key]
72
+ ok = last.nil? || (Time.current - last) >= interval
73
+ THROTTLE[key] = Time.current if ok
47
74
  ok
48
75
  end
49
76
  end
50
77
 
51
78
  # ── Channel dispatchers ────────────────────────────────────────────────
52
- def self.send_slack(log)
79
+
80
+ def self.fire_all(log, event)
81
+ cfg = ErrorRadar.config
82
+ send_slack(log, event) if cfg.slack_webhook_url.to_s.start_with?('http')
83
+ send_discord(log, event) if cfg.discord_webhook_url.to_s.start_with?('http')
84
+ send_email(log, event) if cfg.email_recipients.any?
85
+ cfg.webhook_urls.each { |url| send_webhook(log, url, event) }
86
+ cfg.error_callbacks.each { |cb| safe_call(cb, log) }
87
+ end
88
+
89
+ def self.send_slack(log, event)
53
90
  require 'error_radar/notifications/slack'
54
- Notifications::Slack.deliver(log)
91
+ Notifications::Slack.deliver(log, event)
55
92
  end
56
93
 
57
- def self.send_discord(log)
94
+ def self.send_discord(log, event)
58
95
  require 'error_radar/notifications/discord'
59
- Notifications::Discord.deliver(log)
96
+ Notifications::Discord.deliver(log, event)
60
97
  end
61
98
 
62
- def self.send_email(log)
99
+ def self.send_email(log, event)
63
100
  require 'error_radar/notifications/email'
64
- Notifications::Email.deliver(log)
101
+ Notifications::Email.deliver(log, event)
65
102
  end
66
103
 
67
- def self.send_webhook(log, url)
104
+ def self.send_webhook(log, url, event)
68
105
  require 'error_radar/notifications/webhook'
69
- Notifications::Webhook.deliver(log, url: url)
106
+ Notifications::Webhook.deliver(log, url: url, event: event)
70
107
  end
71
108
 
72
109
  def self.safe_call(cb, log)
@@ -75,7 +112,8 @@ module ErrorRadar
75
112
  ErrorRadar::Tracking.warn_internal("on_error callback failed: #{e.message}")
76
113
  end
77
114
 
78
- # Build a deep-link URL to the error detail page.
115
+ # ── Helpers ───────────────────────────────────────────────────────────
116
+
79
117
  def self.error_url(log)
80
118
  host = ErrorRadar.config.app_host.to_s.chomp('/')
81
119
  return nil if host.empty?
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ErrorRadar
4
+ # Detects sudden error-rate spikes for a given fingerprint.
5
+ #
6
+ # Strategy (in order of preference):
7
+ # 1. If track_occurrences is on — query error_radar_occurrences for exact
8
+ # count in the window. Accurate across processes and survives restarts.
9
+ # 2. In-memory ring buffer per fingerprint. Fast, zero extra DB queries, but
10
+ # resets on process restart and is per-worker (not shared across Puma workers).
11
+ module SpikeDetector
12
+ MUTEX = Mutex.new
13
+ # fingerprint => Array<Time> of recent hit timestamps (in-memory fallback)
14
+ HITS = Hash.new { |h, k| h[k] = [] }
15
+ # Cap per-key buffer to avoid unbounded growth on very high-volume errors
16
+ MAX_HITS_PER_KEY = 1000
17
+
18
+ # Record a hit in the in-memory ring buffer.
19
+ # Called by Notifier before spike detection so the current hit is counted.
20
+ def self.record_hit(fingerprint)
21
+ MUTEX.synchronize do
22
+ arr = HITS[fingerprint]
23
+ arr << Time.current
24
+ arr.shift while arr.size > MAX_HITS_PER_KEY
25
+ end
26
+ end
27
+
28
+ # Returns the number of recent hits if >= config threshold, false otherwise.
29
+ def self.check(log)
30
+ threshold = ErrorRadar.config.spike_threshold.to_i
31
+ window = ErrorRadar.config.spike_window_minutes.to_i
32
+ return false unless threshold > 0 && window > 0
33
+
34
+ count = recent_count(log, window)
35
+ count >= threshold ? count : false
36
+ end
37
+
38
+ class << self
39
+ private
40
+
41
+ def recent_count(log, window_minutes)
42
+ if ErrorRadar.config.track_occurrences && defined?(ErrorRadar::ErrorOccurrence)
43
+ begin
44
+ return ErrorRadar::ErrorOccurrence
45
+ .where(error_log_id: log.id)
46
+ .where('occurred_at >= ?', window_minutes.minutes.ago)
47
+ .count
48
+ rescue ActiveRecord::StatementInvalid
49
+ # Table not yet created — fall through to in-memory
50
+ end
51
+ end
52
+
53
+ cutoff = window_minutes.minutes.ago
54
+ MUTEX.synchronize do
55
+ (HITS[log.fingerprint] || []).count { |t| t >= cutoff }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ErrorRadar
4
- VERSION = '0.6.0'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/error_radar.rb CHANGED
@@ -5,6 +5,7 @@ require 'error_radar/configuration'
5
5
  require 'error_radar/tracking'
6
6
  require 'error_radar/notifier'
7
7
  require 'error_radar/cleanup'
8
+ require 'error_radar/spike_detector'
8
9
  require 'error_radar/middleware'
9
10
  require 'error_radar/server_monitor'
10
11
  require 'error_radar/engine'
@@ -15,10 +15,19 @@ ErrorRadar.configure do |config|
15
15
  config.install_rails_admin = true # register the ErrorLog board (auto, if RailsAdmin present)
16
16
 
17
17
  # --- Notifications ---
18
- # When to fire: :new_error (first occurrence of a fingerprint, default),
19
- # :critical (any critical severity, throttled to 1/hour),
20
- # :all (every occurrence, throttled to 1/hour per fingerprint)
18
+ # When to fire:
19
+ # :new_error first occurrence of a fingerprint (default, no throttle)
20
+ # :critical — any critical-severity occurrence, throttled to 1/hour
21
+ # :all — every occurrence, throttled to 1/hour per fingerprint
22
+ # :spike — rate spike: N hits in M minutes (configure below)
21
23
  config.notify_on = [:new_error]
24
+ # config.notify_on = [:new_error, :spike]
25
+
26
+ # --- Spike detection (requires :spike in notify_on) ---
27
+ # Fire an alert when a single error hits >= spike_threshold times within
28
+ # spike_window_minutes. Re-alerts once per window after that.
29
+ # config.spike_threshold = 10 # occurrences …
30
+ # config.spike_window_minutes = 5 # … in this many minutes
22
31
 
23
32
  # Slack incoming webhook (https://api.slack.com/messaging/webhooks)
24
33
  # config.slack_webhook_url = ENV['SLACK_WEBHOOK_URL']
@@ -84,6 +93,12 @@ ErrorRadar.configure do |config|
84
93
  # config.github_token = ENV['GITHUB_TOKEN'] # PAT with repo scope
85
94
  # config.github_repo = 'myorg/myapp' # "owner/repo" format
86
95
 
96
+ # --- Occurrence History ---
97
+ # Store each individual error hit so you can see context/backtrace per occurrence.
98
+ # Requires running: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
99
+ # config.track_occurrences = true
100
+ # config.max_occurrences_per_error = 200 # keep last N per error (oldest pruned on each new hit)
101
+
87
102
  # --- Performance & Async Capture ---
88
103
  # Write ErrorLog records via ActiveJob so exceptions don't block the request.
89
104
  # Requires ActiveJob + a queue adapter (Sidekiq, Solid Queue, etc.).
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateErrorRadarOccurrences < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :error_radar_occurrences do |t|
6
+ t.references :error_log, null: false,
7
+ foreign_key: { to_table: :error_radar_error_logs },
8
+ index: false # covered by composite index below
9
+
10
+ t.datetime :occurred_at, null: false
11
+
12
+ t.json :context # structured payload snapshot for this hit
13
+ t.text :backtrace # backtrace at the moment of this occurrence
14
+ t.integer :http_status
15
+ t.string :request_url, limit: 2000
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ # Fast per-error chronological listing
21
+ add_index :error_radar_occurrences, %i[error_log_id occurred_at],
22
+ name: 'idx_er_occurrences_log_time'
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/active_record'
4
+
5
+ module ErrorRadar
6
+ module Generators
7
+ class UpgradeV060Generator < ActiveRecord::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ argument :name, type: :string, default: 'upgrade_v060'
11
+
12
+ desc 'Creates the error_radar_occurrences table for per-hit occurrence history (v0.7.0).'
13
+
14
+ def create_migration
15
+ migration_template 'create_error_radar_occurrences.rb.tt',
16
+ 'db/migrate/create_error_radar_occurrences.rb'
17
+ end
18
+ end
19
+ end
20
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: error_radar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chienbn9x
@@ -66,6 +66,7 @@ files:
66
66
  - app/mailers/error_radar/error_mailer.rb
67
67
  - app/models/error_radar/application_record.rb
68
68
  - app/models/error_radar/error_log.rb
69
+ - app/models/error_radar/error_occurrence.rb
69
70
  - app/views/error_radar/dashboard/index.html.erb
70
71
  - app/views/error_radar/dashboard/show.html.erb
71
72
  - app/views/error_radar/error_mailer/new_error.html.erb
@@ -91,6 +92,7 @@ files:
91
92
  - lib/error_radar/notifications/webhook.rb
92
93
  - lib/error_radar/notifier.rb
93
94
  - lib/error_radar/server_monitor.rb
95
+ - lib/error_radar/spike_detector.rb
94
96
  - lib/error_radar/tracking.rb
95
97
  - lib/error_radar/version.rb
96
98
  - lib/generators/error_radar/install/install_generator.rb
@@ -98,6 +100,8 @@ files:
98
100
  - lib/generators/error_radar/install/templates/initializer.rb
99
101
  - lib/generators/error_radar/upgrade_v050/templates/add_github_issue_to_error_radar_error_logs.rb.tt
100
102
  - lib/generators/error_radar/upgrade_v050/upgrade_v050_generator.rb
103
+ - lib/generators/error_radar/upgrade_v060/templates/create_error_radar_occurrences.rb.tt
104
+ - lib/generators/error_radar/upgrade_v060/upgrade_v060_generator.rb
101
105
  - lib/tasks/error_radar.rake
102
106
  homepage: https://github.com/chienbn9x/error_radar
103
107
  licenses: