error_radar 0.2.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.
@@ -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.2.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'
@@ -8,9 +8,38 @@ ErrorRadar.configure do |config|
8
8
  config.enabled = !Rails.env.test?
9
9
 
10
10
  # --- Integrations (auto-detected; flip off if you don't want them) ---
11
- config.install_middleware = true # Rack: capture web-layer exceptions
12
- config.install_sidekiq = true # capture Sidekiq job failures (if Sidekiq present)
13
- config.install_rails_admin = true # register the ErrorLog board (if RailsAdmin present)
11
+ config.install_middleware = true # Rack: capture web-layer exceptions (auto)
12
+ config.install_sidekiq = true # capture Sidekiq job failures (auto, if Sidekiq present)
13
+ config.install_active_job = true # capture ActiveJob failures for any adapter (auto)
14
+ config.install_rake = true # capture Rake task failures (auto)
15
+ config.install_rails_admin = true # register the ErrorLog board (auto, if RailsAdmin present)
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) }
14
43
 
15
44
  # --- Dashboard access control ---
16
45
  # Run as a before_action; raise/redirect inside to deny. Example with Devise:
@@ -45,10 +74,6 @@ ErrorRadar.configure do |config|
45
74
  # ]
46
75
  end
47
76
 
48
- # --- Optional: capture ActiveJob failures across ALL queue adapters ---
49
- # Add to app/jobs/application_job.rb (skip if you only use Sidekiq, since the
50
- # Sidekiq integration above already covers it):
51
- #
52
- # class ApplicationJob < ActiveJob::Base
53
- # include ErrorRadar::Integrations::ActiveJob
54
- # end
77
+ # ActiveJob is now captured automatically via install_active_job = true above.
78
+ # No changes needed in ApplicationJob. Set install_active_job = false if you
79
+ # only use Sidekiq and want to avoid double-counting.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: error_radar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chienbn9x
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-07-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -59,10 +59,16 @@ files:
59
59
  - Rakefile
60
60
  - app/controllers/error_radar/application_controller.rb
61
61
  - app/controllers/error_radar/dashboard_controller.rb
62
+ - app/controllers/error_radar/errors_controller.rb
63
+ - app/mailers/error_radar/error_mailer.rb
62
64
  - app/models/error_radar/application_record.rb
63
65
  - app/models/error_radar/error_log.rb
64
66
  - app/views/error_radar/dashboard/index.html.erb
65
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
70
+ - app/views/error_radar/errors/index.html.erb
71
+ - app/views/error_radar/errors/show.html.erb
66
72
  - app/views/layouts/error_radar/application.html.erb
67
73
  - config/routes.rb
68
74
  - lib/error_radar.rb
@@ -70,8 +76,14 @@ files:
70
76
  - lib/error_radar/engine.rb
71
77
  - lib/error_radar/integrations/active_job.rb
72
78
  - lib/error_radar/integrations/rails_admin.rb
79
+ - lib/error_radar/integrations/rake.rb
73
80
  - lib/error_radar/integrations/sidekiq.rb
74
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
75
87
  - lib/error_radar/server_monitor.rb
76
88
  - lib/error_radar/tracking.rb
77
89
  - lib/error_radar/version.rb