Module: ErrorRadar::Notifier

Defined in:
lib/error_radar/notifier.rb

Overview

Dispatches alert notifications (Slack, Discord, email, webhooks, custom callbacks) after an ErrorLog is persisted. Called from Tracking.capture and Tracking.notify. Never raises — a broken notification must not affect the host application.

Notification events:

:new_error  — first time a fingerprint is seen (no throttle)
:spike      — error rate exceeds config.spike_threshold in the window
:recurring  — matches :critical or :all rule, throttled to 1/hour

Constant Summary collapse

THROTTLE_MUTEX =
Mutex.new
THROTTLE =

key => Time of last notification (in-memory, resets on restart) Keys: fingerprint (for :recurring) or "spike:fingerprint" (for :spike)

{}
THROTTLE_INTERVAL =

seconds between repeat alerts per fingerprint

3600

Class Method Summary collapse

Class Method Details

.app_nameObject



161
162
163
164
# File 'lib/error_radar/notifier.rb', line 161

def self.app_name
  ErrorRadar.config.app_name ||
    (defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : 'App')
end

.chatwork_db_settings(cfg) ⇒ Object

Returns [token, room_id, sources_array] — DB settings take priority over config.



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/error_radar/notifier.rb', line 126

def self.chatwork_db_settings(cfg)
  if defined?(ErrorRadar::Setting) && ErrorRadar::Setting.table_exists?
    token   = ErrorRadar::Setting.get('chatwork_api_token').to_s
    room_id = ErrorRadar::Setting.get('chatwork_room_id').to_s
    sources = Array(ErrorRadar::Setting.get('chatwork_notify_sources')).compact
    # Fall back to config when DB row is empty
    token   = cfg.chatwork_api_token.to_s   if token.empty?
    room_id = cfg.chatwork_room_id.to_s     if room_id.empty?
    sources = Array(cfg.chatwork_notify_sources).compact if sources.empty?
  else
    token   = cfg.chatwork_api_token.to_s
    room_id = cfg.chatwork_room_id.to_s
    sources = Array(cfg.chatwork_notify_sources).compact
  end
  [token, room_id, sources]
rescue StandardError
  [cfg.chatwork_api_token.to_s, cfg.chatwork_room_id.to_s,
   Array(cfg.chatwork_notify_sources).compact]
end

.chatwork_enabled?(cfg, log) ⇒ Boolean

Returns:

  • (Boolean)


115
116
117
118
119
120
121
122
123
# File 'lib/error_radar/notifier.rb', line 115

def self.chatwork_enabled?(cfg, log)
  token, room_id, sources = chatwork_db_settings(cfg)
  return false if token.empty? || room_id.empty?

  return true if sources.empty?

  source = log.source.to_s
  sources.any? { |filter| filter.is_a?(Regexp) ? filter.match?(source) : source.include?(filter.to_s) }
end

.determine_event(log, rules) ⇒ Object

── Fire decision ──────────────────────────────────────────────────────



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/error_radar/notifier.rb', line 42

def self.determine_event(log, rules)
  # :new_error fires exactly once per fingerprint, no throttle
  return :new_error if rules.include?(:new_error) && log.new_fingerprint?

  # :spike — rate-based alert when occurrences in window exceed threshold
  if rules.include?(:spike)
    spike_count = SpikeDetector.check(log)
    if spike_count
      window_secs = ErrorRadar.config.spike_window_minutes * 60
      if throttle_ok?("spike:#{log.fingerprint}", window_secs)
        log.instance_variable_set(:@spike_data, {
          count:          spike_count,
          window_minutes: ErrorRadar.config.spike_window_minutes
        })
        return :spike
      end
    end
  end

  # :critical / :all — throttled recurring alerts
  matches = rules.include?(:all) ||
            (rules.include?(:critical) && log.severity_critical?)
  return :recurring if matches && throttle_ok?(log.fingerprint, THROTTLE_INTERVAL)

  nil
end

.dispatch(log) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/error_radar/notifier.rb', line 20

def self.dispatch(log)
  return unless ErrorRadar.config.enabled

  rules = Array(ErrorRadar.config.notify_on).map(&:to_sym)
  return if rules.empty?

  # Record the hit for in-memory spike tracking before we test the count.
  if rules.include?(:spike)
    require 'error_radar/spike_detector'
    SpikeDetector.record_hit(log.fingerprint)
  end

  event = determine_event(log, rules)
  return unless event

  fire_all(log, event)
rescue StandardError => e
  ErrorRadar::Tracking.warn_internal("Notifier.dispatch failed: #{e.message}")
end

.error_url(log) ⇒ Object

── Helpers ───────────────────────────────────────────────────────────



154
155
156
157
158
159
# File 'lib/error_radar/notifier.rb', line 154

def self.error_url(log)
  host = ErrorRadar.config.app_host.to_s.chomp('/')
  return nil if host.empty?

  "#{host}/error_radar/errors/#{log.id}"
end

.fire_all(log, event) ⇒ Object

── Channel dispatchers ────────────────────────────────────────────────



80
81
82
83
84
85
86
87
88
# File 'lib/error_radar/notifier.rb', line 80

def self.fire_all(log, event)
  cfg = ErrorRadar.config
  send_slack(log, event)                     if cfg.slack_webhook_url.to_s.start_with?('http')
  send_discord(log, event)                   if cfg.discord_webhook_url.to_s.start_with?('http')
  send_email(log, event)                     if cfg.email_recipients.any?
  send_chatwork(log, event)                  if chatwork_enabled?(cfg, log)
  cfg.webhook_urls.each { |url| send_webhook(log, url, event) }
  cfg.error_callbacks.each { |cb| safe_call(cb, log) }
end

.safe_call(cb, log) ⇒ Object



146
147
148
149
150
# File 'lib/error_radar/notifier.rb', line 146

def self.safe_call(cb, log)
  cb.call(log)
rescue StandardError => e
  ErrorRadar::Tracking.warn_internal("on_error callback failed: #{e.message}")
end

.send_chatwork(log, event) ⇒ Object



110
111
112
113
# File 'lib/error_radar/notifier.rb', line 110

def self.send_chatwork(log, event)
  require 'error_radar/notifications/chatwork'
  Notifications::Chatwork.deliver(log, event)
end

.send_discord(log, event) ⇒ Object



95
96
97
98
# File 'lib/error_radar/notifier.rb', line 95

def self.send_discord(log, event)
  require 'error_radar/notifications/discord'
  Notifications::Discord.deliver(log, event)
end

.send_email(log, event) ⇒ Object



100
101
102
103
# File 'lib/error_radar/notifier.rb', line 100

def self.send_email(log, event)
  require 'error_radar/notifications/email'
  Notifications::Email.deliver(log, event)
end

.send_slack(log, event) ⇒ Object



90
91
92
93
# File 'lib/error_radar/notifier.rb', line 90

def self.send_slack(log, event)
  require 'error_radar/notifications/slack'
  Notifications::Slack.deliver(log, event)
end

.send_webhook(log, url, event) ⇒ Object



105
106
107
108
# File 'lib/error_radar/notifier.rb', line 105

def self.send_webhook(log, url, event)
  require 'error_radar/notifications/webhook'
  Notifications::Webhook.deliver(log, url: url, event: event)
end

.throttle_ok?(key, interval = THROTTLE_INTERVAL) ⇒ Boolean

Returns:

  • (Boolean)


69
70
71
72
73
74
75
76
# File 'lib/error_radar/notifier.rb', line 69

def self.throttle_ok?(key, interval = THROTTLE_INTERVAL)
  THROTTLE_MUTEX.synchronize do
    last = THROTTLE[key]
    ok   = last.nil? || (Time.current - last) >= interval
    THROTTLE[key] = Time.current if ok
    ok
  end
end