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 +4 -4
- data/CHANGELOG.md +61 -0
- data/app/controllers/error_radar/api/errors_controller.rb +16 -0
- data/app/controllers/error_radar/errors_controller.rb +17 -1
- data/app/mailers/error_radar/error_mailer.rb +14 -7
- data/app/models/error_radar/error_log.rb +15 -0
- data/app/models/error_radar/error_occurrence.rb +41 -0
- data/app/views/error_radar/errors/show.html.erb +96 -0
- data/lib/error_radar/configuration.rb +21 -0
- data/lib/error_radar/notifications/discord.rb +19 -10
- data/lib/error_radar/notifications/email.rb +2 -2
- data/lib/error_radar/notifications/slack.rb +25 -17
- data/lib/error_radar/notifications/webhook.rb +16 -5
- data/lib/error_radar/notifier.rb +70 -32
- data/lib/error_radar/spike_detector.rb +60 -0
- data/lib/error_radar/version.rb +1 -1
- data/lib/error_radar.rb +1 -0
- data/lib/generators/error_radar/install/templates/initializer.rb +18 -3
- data/lib/generators/error_radar/upgrade_v060/templates/create_error_radar_occurrences.rb.tt +24 -0
- data/lib/generators/error_radar/upgrade_v060/upgrade_v060_generator.rb +20 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1522c6f867cba16786c499703bf76e18d8d7134109f8c925387a628fd0b7f420
|
|
4
|
+
data.tar.gz: f9bfc6fe5a5de9e8394b3c81a4b41913b82ed22e5f56ef72980936dc9ecc59f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
9
|
-
@
|
|
10
|
-
@
|
|
11
|
-
@
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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](#{
|
|
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:
|
|
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
|
|
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
|
|
27
|
-
req['Content-Type']
|
|
28
|
-
req.body
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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: "*
|
|
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
|
|
66
|
+
if link
|
|
60
67
|
blocks << {
|
|
61
68
|
type: 'actions',
|
|
62
69
|
elements: [{
|
|
63
70
|
type: 'button',
|
|
64
|
-
text:
|
|
65
|
-
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
|
-
|
|
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
|
data/lib/error_radar/notifier.rb
CHANGED
|
@@ -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
|
|
10
|
-
#
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
# :
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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?(
|
|
69
|
+
def self.throttle_ok?(key, interval = THROTTLE_INTERVAL)
|
|
43
70
|
THROTTLE_MUTEX.synchronize do
|
|
44
|
-
last = THROTTLE[
|
|
45
|
-
ok = last.nil? || (Time.current - last) >=
|
|
46
|
-
THROTTLE[
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/error_radar/version.rb
CHANGED
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:
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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.
|
|
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:
|