error_radar 0.7.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 +30 -0
- data/app/mailers/error_radar/error_mailer.rb +14 -7
- data/lib/error_radar/configuration.rb +10 -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 +12 -3
- metadata +2 -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,36 @@
|
|
|
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
|
+
|
|
5
35
|
## [0.7.0] - 2026-07-03
|
|
6
36
|
|
|
7
37
|
### Added
|
|
@@ -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,
|
|
@@ -69,6 +69,13 @@ 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
|
+
|
|
72
79
|
# Occurrences ─────────────────────────────────────────────────────────────
|
|
73
80
|
# When true, each individual error hit is stored in error_radar_occurrences.
|
|
74
81
|
# Requires running: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
|
|
@@ -156,6 +163,9 @@ module ErrorRadar
|
|
|
156
163
|
@app_host = nil
|
|
157
164
|
@error_callbacks = []
|
|
158
165
|
|
|
166
|
+
@spike_threshold = 10
|
|
167
|
+
@spike_window_minutes = 5
|
|
168
|
+
|
|
159
169
|
@track_occurrences = false
|
|
160
170
|
@max_occurrences_per_error = 200
|
|
161
171
|
|
|
@@ -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']
|
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
|
|
@@ -92,6 +92,7 @@ files:
|
|
|
92
92
|
- lib/error_radar/notifications/webhook.rb
|
|
93
93
|
- lib/error_radar/notifier.rb
|
|
94
94
|
- lib/error_radar/server_monitor.rb
|
|
95
|
+
- lib/error_radar/spike_detector.rb
|
|
95
96
|
- lib/error_radar/tracking.rb
|
|
96
97
|
- lib/error_radar/version.rb
|
|
97
98
|
- lib/generators/error_radar/install/install_generator.rb
|