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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 131362a3ccb1bd3de4242f28690bb60059555bee0357ff488fbcce86f9b04f37
4
- data.tar.gz: 8f0218c89a83e54e514657dd1c7f9e25a28c39d0adfe284424efdb134bc58467
3
+ metadata.gz: 486749dc9fc6c6e2cc617340f184cf8dbeb4196199c3017eb7bf6f667718fe74
4
+ data.tar.gz: 13da87d1772c71901b66fb4cd6b4d29a809b539b6fccaee5613defae97c0a04d
5
5
  SHA512:
6
- metadata.gz: 43ade7e6e0d65e66af9cc34bfa3ee6768fdd9cbad0d79772df01ea6f355b7647f1e5c7a8d8be4667a80abfa5e1c383becc4c97651709c21df2e58fb65a2fede0
7
- data.tar.gz: ec4cf4f0863aa19ffa5639659b6a61642921d72462130caaff18a6c2bb2f9424594d9ebbab96dc65c974fab1e4b00b91db96573c3434949ec769ab7b46ef2918
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 = find_or_initialize_by(fingerprint: fp)
42
+ log = find_or_initialize_by(fingerprint: fp)
43
+ new_fingerprint = !log.persisted?
39
44
 
40
45
  if log.persisted?
41
46
  log.occurrences += 1
@@ -54,6 +59,7 @@ module ErrorRadar
54
59
  log.severity = severity if log.new_record? || severity_rank(severity) > severity_rank(log.severity)
55
60
  log.last_seen_at = now
56
61
  log.save!
62
+ log.instance_variable_set(:@new_fingerprint, new_fingerprint)
57
63
  log
58
64
  rescue StandardError => e
59
65
  ErrorRadar::Tracking.warn_internal("ErrorLog.record failed: #{e.class}: #{e.message}")
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ErrorRadar
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/error_radar.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'error_radar/version'
4
4
  require 'error_radar/configuration'
5
5
  require 'error_radar/tracking'
6
+ require 'error_radar/notifier'
6
7
  require 'error_radar/middleware'
7
8
  require 'error_radar/server_monitor'
8
9
  require 'error_radar/engine'
@@ -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.3.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