error_radar 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/app/mailers/error_radar/error_mailer.rb +32 -0
- data/app/models/error_radar/error_log.rb +7 -1
- data/app/views/error_radar/error_mailer/new_error.html.erb +82 -0
- data/app/views/error_radar/error_mailer/new_error.text.erb +21 -0
- data/lib/error_radar/configuration.rb +45 -0
- data/lib/error_radar/notifications/discord.rb +61 -0
- data/lib/error_radar/notifications/email.rb +18 -0
- data/lib/error_radar/notifications/slack.rb +76 -0
- data/lib/error_radar/notifications/webhook.rb +48 -0
- data/lib/error_radar/notifier.rb +91 -0
- data/lib/error_radar/tracking.rb +6 -2
- data/lib/error_radar/version.rb +1 -1
- data/lib/error_radar.rb +1 -0
- data/lib/generators/error_radar/install/templates/initializer.rb +27 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 486749dc9fc6c6e2cc617340f184cf8dbeb4196199c3017eb7bf6f667718fe74
|
|
4
|
+
data.tar.gz: 13da87d1772c71901b66fb4cd6b4d29a809b539b6fccaee5613defae97c0a04d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a9fe7aa11d7668ca353e0c6331eac9a339c43263f93bc3650085bd72f2072968376006f7481661037c7d9f80fdca0057b8a28d9779a92050b931fb4eccd441f
|
|
7
|
+
data.tar.gz: f98150abaa5c7bd7695ddd38ba91d65db73bfe81ce7e739c9f91fac20c596b3303643fc282320b2366f9b1b6bd4da48c2bfca7bd8d182e1162c3a7786770384a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-07-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Slack notifications**: sends a Block Kit message with error details and a
|
|
9
|
+
deep-link button. Configure with `config.slack_webhook_url` and optional
|
|
10
|
+
`config.slack_channel`.
|
|
11
|
+
- **Discord notifications**: sends a rich embed to any Discord webhook.
|
|
12
|
+
Configure with `config.discord_webhook_url`.
|
|
13
|
+
- **Email notifications**: delivers an HTML + plain-text email via ActionMailer.
|
|
14
|
+
Configure `config.email_recipients` and `config.email_from`.
|
|
15
|
+
- **Generic webhook**: POSTs a JSON payload to any URL (PagerDuty, OpsGenie,
|
|
16
|
+
custom scripts). Configure with `config.webhook_urls = [url1, url2]`.
|
|
17
|
+
- **Custom callbacks**: `config.on_error { |log| ... }` for arbitrary alerting
|
|
18
|
+
logic (e.g. PagerDuty SDK, Telegram, SMS).
|
|
19
|
+
- **`notify_on` rule set**: controls when alerts fire — `:new_error` (default,
|
|
20
|
+
first occurrence per fingerprint), `:critical` (any critical severity, 1/hour
|
|
21
|
+
throttle), `:all` (every occurrence, 1/hour throttle per fingerprint).
|
|
22
|
+
- **In-memory throttle**: prevents notification storms for `:critical` and `:all`
|
|
23
|
+
rules — at most one alert per fingerprint per hour.
|
|
24
|
+
- **Deep-links in notifications**: set `config.app_host` to include a link to
|
|
25
|
+
the error detail page in every notification.
|
|
26
|
+
- **`ErrorLog#new_fingerprint?`**: transient predicate (not persisted) that is
|
|
27
|
+
`true` when the record was just created for the first time.
|
|
28
|
+
|
|
5
29
|
## [0.3.0] - 2026-07-03
|
|
6
30
|
|
|
7
31
|
### Added
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
class ErrorMailer < ActionMailer::Base
|
|
5
|
+
layout false
|
|
6
|
+
|
|
7
|
+
def new_error(error_log)
|
|
8
|
+
@error = error_log
|
|
9
|
+
@is_new = error_log.new_fingerprint?
|
|
10
|
+
@url = build_url
|
|
11
|
+
@app_name = ErrorRadar::Notifier.app_name
|
|
12
|
+
|
|
13
|
+
prefix = @is_new ? 'New' : 'Critical'
|
|
14
|
+
subject = "[#{@app_name}] #{prefix} #{@error.severity}: #{@error.error_class}"
|
|
15
|
+
|
|
16
|
+
mail(
|
|
17
|
+
to: ErrorRadar.config.email_recipients,
|
|
18
|
+
from: ErrorRadar.config.email_from || 'noreply@localhost',
|
|
19
|
+
subject: subject
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_url
|
|
26
|
+
host = ErrorRadar.config.app_host.to_s.chomp('/')
|
|
27
|
+
return nil if host.empty?
|
|
28
|
+
|
|
29
|
+
"#{host}/error_radar/errors/#{@error.id}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -29,13 +29,18 @@ module ErrorRadar
|
|
|
29
29
|
# Record (or roll-up) an error. Idempotent per fingerprint: identical errors
|
|
30
30
|
# increment `occurrences` and bump `last_seen_at` instead of creating a new
|
|
31
31
|
# row. NEVER raises — logging must not break the calling code path.
|
|
32
|
+
def new_fingerprint?
|
|
33
|
+
@new_fingerprint || false
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
def self.record(category:, message:, severity: :error, error_class: nil, source: nil,
|
|
33
37
|
backtrace: nil, context: {}, http_status: nil, request_url: nil,
|
|
34
38
|
api_code: nil, api_subcode: nil, fingerprint: nil)
|
|
35
39
|
now = Time.current
|
|
36
40
|
fp = presence(fingerprint) || build_fingerprint(category: category, error_class: error_class, source: source, message: message)
|
|
37
41
|
|
|
38
|
-
log
|
|
42
|
+
log = find_or_initialize_by(fingerprint: fp)
|
|
43
|
+
new_fingerprint = !log.persisted?
|
|
39
44
|
|
|
40
45
|
if log.persisted?
|
|
41
46
|
log.occurrences += 1
|
|
@@ -54,6 +59,7 @@ module ErrorRadar
|
|
|
54
59
|
log.severity = severity if log.new_record? || severity_rank(severity) > severity_rank(log.severity)
|
|
55
60
|
log.last_seen_at = now
|
|
56
61
|
log.save!
|
|
62
|
+
log.instance_variable_set(:@new_fingerprint, new_fingerprint)
|
|
57
63
|
log
|
|
58
64
|
rescue StandardError => e
|
|
59
65
|
ErrorRadar::Tracking.warn_internal("ErrorLog.record failed: #{e.class}: #{e.message}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<style>
|
|
7
|
+
body{margin:0;padding:20px;background:#f4f6f8;font-family:-apple-system,"Segoe UI",Roboto,Arial,sans-serif;color:#1f2933}
|
|
8
|
+
.container{max-width:600px;margin:0 auto}
|
|
9
|
+
.header{background:#1f2933;color:#fff;padding:24px;border-radius:10px 10px 0 0}
|
|
10
|
+
.header h1{margin:0 0 6px;font-size:18px}
|
|
11
|
+
.header .meta{color:#9ca3af;font-size:12px}
|
|
12
|
+
.body{background:#fff;padding:24px;border-radius:0 0 10px 10px}
|
|
13
|
+
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}
|
|
14
|
+
.field .label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;margin-bottom:3px}
|
|
15
|
+
.field .value{font-size:14px;color:#1f2933}
|
|
16
|
+
.badge{display:inline-block;padding:3px 10px;border-radius:10px;color:#fff;font-size:12px;font-weight:600}
|
|
17
|
+
.msg-box{background:#f8fafc;border-left:4px solid #dc3545;padding:12px 14px;border-radius:0 8px 8px 0;font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap;word-break:break-word;color:#374151;margin:16px 0}
|
|
18
|
+
.btn{display:inline-block;background:#2563eb;color:#fff!important;padding:11px 22px;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600;margin-top:8px}
|
|
19
|
+
.footer{margin-top:20px;font-size:11px;color:#9ca3af;text-align:center}
|
|
20
|
+
<%
|
|
21
|
+
sev_colors = { 'critical' => '#7b001c', 'error' => '#dc3545', 'warning' => '#fd7e14', 'info' => '#17a2b8' }
|
|
22
|
+
%>
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<div class="container">
|
|
27
|
+
<div class="header">
|
|
28
|
+
<h1><%= @is_new ? '🆕 New error detected' : '🔁 Critical error recurring' %></h1>
|
|
29
|
+
<div class="meta"><%= @app_name %> · <%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M UTC') %></div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="body">
|
|
33
|
+
<div class="field" style="margin-bottom:20px">
|
|
34
|
+
<div class="label">Error class</div>
|
|
35
|
+
<div class="value" style="font-family:ui-monospace,monospace;font-size:17px;font-weight:700"><%= @error.error_class %></div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="grid">
|
|
39
|
+
<div class="field">
|
|
40
|
+
<div class="label">Source</div>
|
|
41
|
+
<div class="value"><%= @error.source || 'unknown' %></div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="field">
|
|
44
|
+
<div class="label">Category</div>
|
|
45
|
+
<div class="value"><%= @error.category.to_s.humanize %></div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="field">
|
|
48
|
+
<div class="label">Severity</div>
|
|
49
|
+
<div class="value">
|
|
50
|
+
<span class="badge" style="background:<%= sev_colors[@error.severity] || '#6c757d' %>"><%= @error.severity %></span>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="field">
|
|
54
|
+
<div class="label">Occurrences</div>
|
|
55
|
+
<div class="value" style="font-weight:700;font-size:16px"><%= @error.occurrences %></div>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="field">
|
|
58
|
+
<div class="label">First seen</div>
|
|
59
|
+
<div class="value"><%= @error.first_seen_at&.strftime('%Y-%m-%d %H:%M') %></div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="field">
|
|
62
|
+
<div class="label">Last seen</div>
|
|
63
|
+
<div class="value"><%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M') %></div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="field">
|
|
68
|
+
<div class="label">Message</div>
|
|
69
|
+
<div class="msg-box"><%= @error.message.to_s.truncate(600) %></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<% if @url %>
|
|
73
|
+
<a href="<%= @url %>" class="btn">View in Error Radar →</a>
|
|
74
|
+
<% end %>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="footer">
|
|
78
|
+
Sent by <strong>Error Radar</strong> · <%= @app_name %>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</body>
|
|
82
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[<%= @app_name %>] <%= @is_new ? 'New error' : 'Critical error recurring' %>: <%= @error.error_class %>
|
|
2
|
+
<%= '=' * 60 %>
|
|
3
|
+
|
|
4
|
+
Error class: <%= @error.error_class %>
|
|
5
|
+
Source: <%= @error.source || 'unknown' %>
|
|
6
|
+
Category: <%= @error.category %>
|
|
7
|
+
Severity: <%= @error.severity %>
|
|
8
|
+
Occurrences: <%= @error.occurrences %>
|
|
9
|
+
First seen: <%= @error.first_seen_at&.strftime('%Y-%m-%d %H:%M UTC') %>
|
|
10
|
+
Last seen: <%= @error.last_seen_at&.strftime('%Y-%m-%d %H:%M UTC') %>
|
|
11
|
+
|
|
12
|
+
Message:
|
|
13
|
+
<%= @error.message.to_s.truncate(1000) %>
|
|
14
|
+
<% if @url %>
|
|
15
|
+
|
|
16
|
+
View in Error Radar:
|
|
17
|
+
<%= @url %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
--
|
|
21
|
+
Sent by Error Radar · <%= @app_name %>
|
|
@@ -35,6 +35,40 @@ module ErrorRadar
|
|
|
35
35
|
attr_accessor :install_middleware, :install_sidekiq, :install_rails_admin,
|
|
36
36
|
:install_active_job, :install_rake
|
|
37
37
|
|
|
38
|
+
# Notifications ─────────────────────────────────────────────────────────
|
|
39
|
+
# When to fire: :new_error (first time a fingerprint is seen),
|
|
40
|
+
# :critical (any critical-severity occurrence, throttled),
|
|
41
|
+
# :all (every occurrence, throttled to 1/hour/fingerprint)
|
|
42
|
+
attr_accessor :notify_on
|
|
43
|
+
|
|
44
|
+
# Slack incoming-webhook URL (https://hooks.slack.com/services/...)
|
|
45
|
+
attr_accessor :slack_webhook_url
|
|
46
|
+
# Override target channel. Leave nil to use webhook's default channel.
|
|
47
|
+
attr_accessor :slack_channel
|
|
48
|
+
|
|
49
|
+
# Discord incoming-webhook URL
|
|
50
|
+
attr_accessor :discord_webhook_url
|
|
51
|
+
|
|
52
|
+
# ActionMailer recipients. Requires ActionMailer to be configured in host app.
|
|
53
|
+
attr_accessor :email_recipients
|
|
54
|
+
# From address for notification emails.
|
|
55
|
+
attr_accessor :email_from
|
|
56
|
+
|
|
57
|
+
# One or more plain HTTPS URLs that receive a POST with JSON body on each alert.
|
|
58
|
+
attr_accessor :webhook_urls
|
|
59
|
+
|
|
60
|
+
# App name shown in notification subject/title. Defaults to Rails app name.
|
|
61
|
+
attr_accessor :app_name
|
|
62
|
+
# Base URL used to build deep-links (e.g. "https://myapp.com").
|
|
63
|
+
attr_accessor :app_host
|
|
64
|
+
|
|
65
|
+
# Custom callbacks: ->(error_log) { ... }. Called after built-in channels.
|
|
66
|
+
attr_reader :error_callbacks
|
|
67
|
+
|
|
68
|
+
def on_error(&block)
|
|
69
|
+
@error_callbacks << block
|
|
70
|
+
end
|
|
71
|
+
|
|
38
72
|
# Custom classification rules. Each is a callable `->(exception) { :category | nil }`.
|
|
39
73
|
# The first rule that returns a non-nil category wins; built-in rules run after.
|
|
40
74
|
attr_accessor :categorizers
|
|
@@ -79,6 +113,17 @@ module ErrorRadar
|
|
|
79
113
|
@install_rails_admin = true
|
|
80
114
|
@install_active_job = true
|
|
81
115
|
@install_rake = true
|
|
116
|
+
|
|
117
|
+
@notify_on = [:new_error]
|
|
118
|
+
@slack_webhook_url = nil
|
|
119
|
+
@slack_channel = nil
|
|
120
|
+
@discord_webhook_url = nil
|
|
121
|
+
@email_recipients = []
|
|
122
|
+
@email_from = nil
|
|
123
|
+
@webhook_urls = []
|
|
124
|
+
@app_name = nil
|
|
125
|
+
@app_host = nil
|
|
126
|
+
@error_callbacks = []
|
|
82
127
|
@categorizers = []
|
|
83
128
|
@detail_extractors = []
|
|
84
129
|
@expected_servers = []
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module ErrorRadar
|
|
8
|
+
module Notifications
|
|
9
|
+
module Discord
|
|
10
|
+
SEV_COLORS = {
|
|
11
|
+
'critical' => 0x7b001c,
|
|
12
|
+
'error' => 0xdc3545,
|
|
13
|
+
'warning' => 0xfd7e14,
|
|
14
|
+
'info' => 0x17a2b8
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.deliver(log)
|
|
18
|
+
url = URI(ErrorRadar.config.discord_webhook_url)
|
|
19
|
+
payload = build_payload(log)
|
|
20
|
+
|
|
21
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
22
|
+
http.use_ssl = url.scheme == 'https'
|
|
23
|
+
http.open_timeout = 5
|
|
24
|
+
http.read_timeout = 5
|
|
25
|
+
|
|
26
|
+
req = Net::HTTP::Post.new(url)
|
|
27
|
+
req['Content-Type'] = 'application/json'
|
|
28
|
+
req.body = payload.to_json
|
|
29
|
+
|
|
30
|
+
http.request(req)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
ErrorRadar::Tracking.warn_internal("Discord notification failed: #{e.message}")
|
|
33
|
+
end
|
|
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'
|
|
39
|
+
|
|
40
|
+
fields = [
|
|
41
|
+
{ name: 'Source', value: (log.source || 'unknown').truncate(100), inline: true },
|
|
42
|
+
{ name: 'Category', value: log.category.to_s, inline: true },
|
|
43
|
+
{ name: 'Severity', value: log.severity.to_s, inline: true },
|
|
44
|
+
{ name: 'Occurrences', value: log.occurrences.to_s, inline: true }
|
|
45
|
+
]
|
|
46
|
+
fields << { name: 'View', value: "[Error Radar](#{url})", inline: false } if url
|
|
47
|
+
|
|
48
|
+
embed = {
|
|
49
|
+
title: "#{prefix}: #{log.error_class}",
|
|
50
|
+
description: "```#{log.message.to_s.truncate(300)}```",
|
|
51
|
+
color: SEV_COLORS[log.severity] || 0x6c757d,
|
|
52
|
+
fields: fields,
|
|
53
|
+
footer: { text: "[#{app}] Error Radar" },
|
|
54
|
+
timestamp: log.last_seen_at&.iso8601
|
|
55
|
+
}.compact
|
|
56
|
+
|
|
57
|
+
{ embeds: [embed] }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
module Notifications
|
|
5
|
+
# Delivers notification emails via ActionMailer (must be configured in
|
|
6
|
+
# the host app). Uses deliver_later so it doesn't block the request cycle.
|
|
7
|
+
module Email
|
|
8
|
+
def self.deliver(log)
|
|
9
|
+
return unless defined?(::ActionMailer::Base)
|
|
10
|
+
|
|
11
|
+
require 'error_radar/mailers/error_mailer'
|
|
12
|
+
ErrorRadar::ErrorMailer.new_error(log).deliver_later
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
ErrorRadar::Tracking.warn_internal("Email notification failed: #{e.message}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module ErrorRadar
|
|
8
|
+
module Notifications
|
|
9
|
+
module Slack
|
|
10
|
+
SEV_EMOJI = {
|
|
11
|
+
'critical' => ':red_circle:',
|
|
12
|
+
'error' => ':large_orange_circle:',
|
|
13
|
+
'warning' => ':large_yellow_circle:',
|
|
14
|
+
'info' => ':large_blue_circle:'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.deliver(log)
|
|
18
|
+
url = URI(ErrorRadar.config.slack_webhook_url)
|
|
19
|
+
payload = build_payload(log)
|
|
20
|
+
|
|
21
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
22
|
+
http.use_ssl = url.scheme == 'https'
|
|
23
|
+
http.open_timeout = 5
|
|
24
|
+
http.read_timeout = 5
|
|
25
|
+
|
|
26
|
+
req = Net::HTTP::Post.new(url)
|
|
27
|
+
req['Content-Type'] = 'application/json'
|
|
28
|
+
req.body = payload.to_json
|
|
29
|
+
|
|
30
|
+
http.request(req)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
ErrorRadar::Tracking.warn_internal("Slack notification failed: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
|
|
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
|
|
41
|
+
|
|
42
|
+
blocks = [
|
|
43
|
+
{ type: 'section', text: { type: 'mrkdwn', text: "*[#{app}]* #{title}" } },
|
|
44
|
+
{
|
|
45
|
+
type: 'section',
|
|
46
|
+
fields: [
|
|
47
|
+
{ type: 'mrkdwn', text: "*Source*\n#{(log.source || 'unknown').truncate(80)}" },
|
|
48
|
+
{ type: 'mrkdwn', text: "*Category*\n#{log.category}" },
|
|
49
|
+
{ type: 'mrkdwn', text: "*Severity*\n#{log.severity}" },
|
|
50
|
+
{ type: 'mrkdwn', text: "*Occurrences*\n#{log.occurrences}" }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'section',
|
|
55
|
+
text: { type: 'mrkdwn', text: "*Message*\n```#{log.message.to_s.truncate(400)}```" }
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
if url
|
|
60
|
+
blocks << {
|
|
61
|
+
type: 'actions',
|
|
62
|
+
elements: [{
|
|
63
|
+
type: 'button',
|
|
64
|
+
text: { type: 'plain_text', text: 'View in Error Radar', emoji: true },
|
|
65
|
+
url: url
|
|
66
|
+
}]
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
payload = { blocks: blocks, text: "[#{app}] #{log.error_class}: #{log.message.to_s.truncate(200)}" }
|
|
71
|
+
payload[:channel] = channel if channel.to_s != ''
|
|
72
|
+
payload
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module ErrorRadar
|
|
8
|
+
module Notifications
|
|
9
|
+
# Generic outbound webhook. POSTs a JSON payload to any URL.
|
|
10
|
+
# Useful for PagerDuty, OpsGenie, custom scripts, etc.
|
|
11
|
+
module Webhook
|
|
12
|
+
def self.deliver(log, url:)
|
|
13
|
+
uri = URI(url)
|
|
14
|
+
|
|
15
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
16
|
+
http.use_ssl = uri.scheme == 'https'
|
|
17
|
+
http.open_timeout = 5
|
|
18
|
+
http.read_timeout = 5
|
|
19
|
+
|
|
20
|
+
req = Net::HTTP::Post.new(uri)
|
|
21
|
+
req['Content-Type'] = 'application/json'
|
|
22
|
+
req['User-Agent'] = "ErrorRadar/#{ErrorRadar::VERSION}"
|
|
23
|
+
req.body = build_payload(log).to_json
|
|
24
|
+
|
|
25
|
+
http.request(req)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
ErrorRadar::Tracking.warn_internal("Webhook (#{url}) notification failed: #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build_payload(log)
|
|
31
|
+
{
|
|
32
|
+
event: log.new_fingerprint? ? 'new_error' : 'recurring_error',
|
|
33
|
+
app: ErrorRadar::Notifier.app_name,
|
|
34
|
+
error_class: log.error_class,
|
|
35
|
+
source: log.source,
|
|
36
|
+
category: log.category,
|
|
37
|
+
severity: log.severity,
|
|
38
|
+
message: log.message.to_s.truncate(500),
|
|
39
|
+
occurrences: log.occurrences,
|
|
40
|
+
fingerprint: log.fingerprint,
|
|
41
|
+
first_seen_at: log.first_seen_at&.iso8601,
|
|
42
|
+
last_seen_at: log.last_seen_at&.iso8601,
|
|
43
|
+
url: ErrorRadar::Notifier.error_url(log)
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# Dispatches alert notifications (Slack, Discord, email, webhooks, custom
|
|
5
|
+
# callbacks) after an ErrorLog is persisted. Called from Tracking.capture and
|
|
6
|
+
# Tracking.notify. Never raises — a broken notification must not affect the
|
|
7
|
+
# host application.
|
|
8
|
+
module Notifier
|
|
9
|
+
THROTTLE_MUTEX = Mutex.new
|
|
10
|
+
# fingerprint => Time of last notification (in-memory, resets on restart)
|
|
11
|
+
THROTTLE = {}
|
|
12
|
+
THROTTLE_INTERVAL = 3600 # seconds between repeat alerts per fingerprint
|
|
13
|
+
|
|
14
|
+
def self.dispatch(log)
|
|
15
|
+
return unless ErrorRadar.config.enabled
|
|
16
|
+
return unless should_fire?(log)
|
|
17
|
+
|
|
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) }
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
ErrorRadar::Tracking.warn_internal("Notifier.dispatch failed: #{e.message}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ── 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
|
+
|
|
33
|
+
# :new_error — fire exactly once per fingerprint (no throttle needed)
|
|
34
|
+
return true if rules.include?(:new_error) && log.new_fingerprint?
|
|
35
|
+
|
|
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)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.throttle_ok?(fingerprint)
|
|
43
|
+
THROTTLE_MUTEX.synchronize do
|
|
44
|
+
last = THROTTLE[fingerprint]
|
|
45
|
+
ok = last.nil? || (Time.current - last) >= THROTTLE_INTERVAL
|
|
46
|
+
THROTTLE[fingerprint] = Time.current if ok
|
|
47
|
+
ok
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ── Channel dispatchers ────────────────────────────────────────────────
|
|
52
|
+
def self.send_slack(log)
|
|
53
|
+
require 'error_radar/notifications/slack'
|
|
54
|
+
Notifications::Slack.deliver(log)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.send_discord(log)
|
|
58
|
+
require 'error_radar/notifications/discord'
|
|
59
|
+
Notifications::Discord.deliver(log)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.send_email(log)
|
|
63
|
+
require 'error_radar/notifications/email'
|
|
64
|
+
Notifications::Email.deliver(log)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.send_webhook(log, url)
|
|
68
|
+
require 'error_radar/notifications/webhook'
|
|
69
|
+
Notifications::Webhook.deliver(log, url: url)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.safe_call(cb, log)
|
|
73
|
+
cb.call(log)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
ErrorRadar::Tracking.warn_internal("on_error callback failed: #{e.message}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Build a deep-link URL to the error detail page.
|
|
79
|
+
def self.error_url(log)
|
|
80
|
+
host = ErrorRadar.config.app_host.to_s.chomp('/')
|
|
81
|
+
return nil if host.empty?
|
|
82
|
+
|
|
83
|
+
"#{host}/error_radar/errors/#{log.id}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.app_name
|
|
87
|
+
ErrorRadar.config.app_name ||
|
|
88
|
+
(defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : 'App')
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/error_radar/tracking.rb
CHANGED
|
@@ -27,7 +27,9 @@ module ErrorRadar
|
|
|
27
27
|
attrs.merge!(extra.compact) if extra.is_a?(Hash)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
ErrorRadar::ErrorLog.record(**attrs)
|
|
30
|
+
log = ErrorRadar::ErrorLog.record(**attrs)
|
|
31
|
+
ErrorRadar::Notifier.dispatch(log) if log
|
|
32
|
+
log
|
|
31
33
|
rescue StandardError => e
|
|
32
34
|
warn_internal("capture failed: #{e.class}: #{e.message}")
|
|
33
35
|
nil
|
|
@@ -47,7 +49,9 @@ module ErrorRadar
|
|
|
47
49
|
def notify(message, category: :application, severity: :error, source: nil, context: {})
|
|
48
50
|
return nil unless ErrorRadar.config.enabled
|
|
49
51
|
|
|
50
|
-
ErrorRadar::ErrorLog.record(category: category, severity: severity, message: message, source: source, context: context)
|
|
52
|
+
log = ErrorRadar::ErrorLog.record(category: category, severity: severity, message: message, source: source, context: context)
|
|
53
|
+
ErrorRadar::Notifier.dispatch(log) if log
|
|
54
|
+
log
|
|
51
55
|
rescue StandardError => e
|
|
52
56
|
warn_internal("notify failed: #{e.class}: #{e.message}")
|
|
53
57
|
nil
|
data/lib/error_radar/version.rb
CHANGED
data/lib/error_radar.rb
CHANGED
|
@@ -14,6 +14,33 @@ ErrorRadar.configure do |config|
|
|
|
14
14
|
config.install_rake = true # capture Rake task failures (auto)
|
|
15
15
|
config.install_rails_admin = true # register the ErrorLog board (auto, if RailsAdmin present)
|
|
16
16
|
|
|
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)
|
|
21
|
+
config.notify_on = [:new_error]
|
|
22
|
+
|
|
23
|
+
# Slack incoming webhook (https://api.slack.com/messaging/webhooks)
|
|
24
|
+
# config.slack_webhook_url = ENV['SLACK_WEBHOOK_URL']
|
|
25
|
+
# config.slack_channel = '#errors' # optional override
|
|
26
|
+
|
|
27
|
+
# Discord incoming webhook
|
|
28
|
+
# config.discord_webhook_url = ENV['DISCORD_WEBHOOK_URL']
|
|
29
|
+
|
|
30
|
+
# Email (requires ActionMailer to be configured in the host app)
|
|
31
|
+
# config.email_recipients = ['dev@myapp.com', 'oncall@myapp.com']
|
|
32
|
+
# config.email_from = 'errors@myapp.com'
|
|
33
|
+
|
|
34
|
+
# Generic webhook — POST JSON to any URL (PagerDuty, OpsGenie, custom scripts)
|
|
35
|
+
# config.webhook_urls = [ENV['PAGERDUTY_WEBHOOK_URL']]
|
|
36
|
+
|
|
37
|
+
# Base URL used to generate deep-links in notifications
|
|
38
|
+
# config.app_host = 'https://myapp.com'
|
|
39
|
+
# config.app_name = 'MyApp' # shown in notification title/subject
|
|
40
|
+
|
|
41
|
+
# Custom callback — runs after all built-in channels
|
|
42
|
+
# config.on_error { |error_log| MyPager.create_incident(error_log) }
|
|
43
|
+
|
|
17
44
|
# --- Dashboard access control ---
|
|
18
45
|
# Run as a before_action; raise/redirect inside to deny. Example with Devise:
|
|
19
46
|
# config.authenticate = ->(controller) { controller.send(:authenticate_admin!) }
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- chienbn9x
|
|
@@ -60,10 +60,13 @@ files:
|
|
|
60
60
|
- app/controllers/error_radar/application_controller.rb
|
|
61
61
|
- app/controllers/error_radar/dashboard_controller.rb
|
|
62
62
|
- app/controllers/error_radar/errors_controller.rb
|
|
63
|
+
- app/mailers/error_radar/error_mailer.rb
|
|
63
64
|
- app/models/error_radar/application_record.rb
|
|
64
65
|
- app/models/error_radar/error_log.rb
|
|
65
66
|
- app/views/error_radar/dashboard/index.html.erb
|
|
66
67
|
- app/views/error_radar/dashboard/show.html.erb
|
|
68
|
+
- app/views/error_radar/error_mailer/new_error.html.erb
|
|
69
|
+
- app/views/error_radar/error_mailer/new_error.text.erb
|
|
67
70
|
- app/views/error_radar/errors/index.html.erb
|
|
68
71
|
- app/views/error_radar/errors/show.html.erb
|
|
69
72
|
- app/views/layouts/error_radar/application.html.erb
|
|
@@ -76,6 +79,11 @@ files:
|
|
|
76
79
|
- lib/error_radar/integrations/rake.rb
|
|
77
80
|
- lib/error_radar/integrations/sidekiq.rb
|
|
78
81
|
- lib/error_radar/middleware.rb
|
|
82
|
+
- lib/error_radar/notifications/discord.rb
|
|
83
|
+
- lib/error_radar/notifications/email.rb
|
|
84
|
+
- lib/error_radar/notifications/slack.rb
|
|
85
|
+
- lib/error_radar/notifications/webhook.rb
|
|
86
|
+
- lib/error_radar/notifier.rb
|
|
79
87
|
- lib/error_radar/server_monitor.rb
|
|
80
88
|
- lib/error_radar/tracking.rb
|
|
81
89
|
- lib/error_radar/version.rb
|