error_radar 0.3.0 → 0.5.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 +53 -0
- data/app/controllers/error_radar/api/base_controller.rb +20 -0
- data/app/controllers/error_radar/api/errors_controller.rb +112 -0
- data/app/controllers/error_radar/api/stats_controller.rb +22 -0
- data/app/controllers/error_radar/errors_controller.rb +34 -1
- 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/app/views/error_radar/errors/show.html.erb +55 -0
- data/config/routes.rb +13 -5
- data/lib/error_radar/configuration.rb +59 -0
- data/lib/error_radar/integrations/github.rb +74 -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 +38 -0
- data/lib/generators/error_radar/upgrade_v050/templates/add_github_issue_to_error_radar_error_logs.rb.tt +7 -0
- data/lib/generators/error_radar/upgrade_v050/upgrade_v050_generator.rb +27 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 106881790c16306716c7588f317da722c0e589db0a0a2bbb6e7ad838c9a8d118
|
|
4
|
+
data.tar.gz: d4c9ccf1363b88637dc625418daf8553e15aad0796f42f79aadc6eafb3a4f554
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c880963d54ef38274dbc226b970f848f7a9ced038c6d88ababffe1b3eb71ef2d8490695a0a256775a500e707a72b9af2a017a066e8af1f66b17ab842a18ac726
|
|
7
|
+
data.tar.gz: ba612e7cc8a547cd1bf7ffb8704347636bccc7f763fba3a73fbfe1919b86573421873b61c88f6c8b44da0ed661cf8cf73d4da62a54998828dfe1a767642580ad
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-07-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **REST API** at `/api/*` for external integrations (CI/CD, dashboards, scripts):
|
|
9
|
+
- `GET /api/errors` — paginated list with the same filters as the web UI
|
|
10
|
+
(`status`, `severity`, `category`, `q`, `from`, `to`, `sort`, `order`, `page`)
|
|
11
|
+
- `GET /api/errors/:id` — full detail including context, backtrace, and
|
|
12
|
+
`github_issue_url` (if column is present)
|
|
13
|
+
- `PATCH /api/errors/:id` — update status; resolve accepts optional `note`
|
|
14
|
+
and `resolved_by` params
|
|
15
|
+
- `GET /api/stats` — summary counts by status, severity, and category
|
|
16
|
+
- **Bearer-token API auth**: set `config.api_token` to protect all `/api/*`
|
|
17
|
+
endpoints with `Authorization: Bearer <token>`.
|
|
18
|
+
- **GitHub Issue integration**: "Create GitHub Issue" button on the error detail
|
|
19
|
+
page opens a pre-filled issue with error class, source, message, backtrace,
|
|
20
|
+
and a deep-link back to Error Radar. Requires `config.github_token` and
|
|
21
|
+
`config.github_repo`.
|
|
22
|
+
- **`github_issue_url` column**: stored on the error row so the button becomes
|
|
23
|
+
"View GitHub Issue" once an issue exists. Requires running the upgrade
|
|
24
|
+
migration: `bin/rails generate error_radar:upgrade_v050 && bin/rails db:migrate`.
|
|
25
|
+
- **`error_radar:upgrade_v050` generator**: generates the migration that adds
|
|
26
|
+
`github_issue_url` to `error_radar_error_logs`.
|
|
27
|
+
|
|
28
|
+
### Notes
|
|
29
|
+
- The GitHub column is optional — the integration is gracefully degraded when the
|
|
30
|
+
migration has not been run (button appears but URL is not persisted).
|
|
31
|
+
- The API controllers live in `ErrorRadar::Api::*` to avoid polluting the host
|
|
32
|
+
app's controller namespace.
|
|
33
|
+
|
|
34
|
+
## [0.4.0] - 2026-07-03
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- **Slack notifications**: sends a Block Kit message with error details and a
|
|
38
|
+
deep-link button. Configure with `config.slack_webhook_url` and optional
|
|
39
|
+
`config.slack_channel`.
|
|
40
|
+
- **Discord notifications**: sends a rich embed to any Discord webhook.
|
|
41
|
+
Configure with `config.discord_webhook_url`.
|
|
42
|
+
- **Email notifications**: delivers an HTML + plain-text email via ActionMailer.
|
|
43
|
+
Configure `config.email_recipients` and `config.email_from`.
|
|
44
|
+
- **Generic webhook**: POSTs a JSON payload to any URL (PagerDuty, OpsGenie,
|
|
45
|
+
custom scripts). Configure with `config.webhook_urls = [url1, url2]`.
|
|
46
|
+
- **Custom callbacks**: `config.on_error { |log| ... }` for arbitrary alerting
|
|
47
|
+
logic (e.g. PagerDuty SDK, Telegram, SMS).
|
|
48
|
+
- **`notify_on` rule set**: controls when alerts fire — `:new_error` (default,
|
|
49
|
+
first occurrence per fingerprint), `:critical` (any critical severity, 1/hour
|
|
50
|
+
throttle), `:all` (every occurrence, 1/hour throttle per fingerprint).
|
|
51
|
+
- **In-memory throttle**: prevents notification storms for `:critical` and `:all`
|
|
52
|
+
rules — at most one alert per fingerprint per hour.
|
|
53
|
+
- **Deep-links in notifications**: set `config.app_host` to include a link to
|
|
54
|
+
the error detail page in every notification.
|
|
55
|
+
- **`ErrorLog#new_fingerprint?`**: transient predicate (not persisted) that is
|
|
56
|
+
`true` when the record was just created for the first time.
|
|
57
|
+
|
|
5
58
|
## [0.3.0] - 2026-07-03
|
|
6
59
|
|
|
7
60
|
### Added
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
module Api
|
|
5
|
+
class BaseController < ActionController::Base
|
|
6
|
+
skip_before_action :verify_authenticity_token
|
|
7
|
+
before_action :authenticate_api!
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def authenticate_api!
|
|
12
|
+
token = ErrorRadar.config.api_token
|
|
13
|
+
return if token.nil?
|
|
14
|
+
|
|
15
|
+
provided = request.headers['Authorization'].to_s.delete_prefix('Bearer ').strip
|
|
16
|
+
render json: { error: 'Unauthorized' }, status: :unauthorized unless provided == token
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
module Api
|
|
5
|
+
class ErrorsController < BaseController
|
|
6
|
+
PER_PAGE = 50
|
|
7
|
+
|
|
8
|
+
# GET /api/errors
|
|
9
|
+
def index
|
|
10
|
+
scope = build_scope
|
|
11
|
+
total = scope.count
|
|
12
|
+
page = [params[:page].to_i, 1].max
|
|
13
|
+
total_pages = [(total.to_f / PER_PAGE).ceil, 1].max
|
|
14
|
+
page = [page, total_pages].min
|
|
15
|
+
|
|
16
|
+
errors = scope.limit(PER_PAGE).offset((page - 1) * PER_PAGE)
|
|
17
|
+
|
|
18
|
+
render json: {
|
|
19
|
+
data: errors.map { |e| serialize(e) },
|
|
20
|
+
meta: { total: total, page: page, per_page: PER_PAGE, total_pages: total_pages }
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# GET /api/errors/:id
|
|
25
|
+
def show
|
|
26
|
+
log = ErrorLog.find(params[:id])
|
|
27
|
+
render json: { data: serialize(log, detail: true) }
|
|
28
|
+
rescue ActiveRecord::RecordNotFound
|
|
29
|
+
render json: { error: 'Not found' }, status: :not_found
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# PATCH /api/errors/:id
|
|
33
|
+
def update
|
|
34
|
+
log = ErrorLog.find(params[:id])
|
|
35
|
+
new_status = params[:status].to_s
|
|
36
|
+
|
|
37
|
+
unless ErrorLog.statuses.key?(new_status)
|
|
38
|
+
return render json: { error: 'invalid status' }, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if new_status == 'resolved'
|
|
42
|
+
log.resolve!(by: params[:resolved_by].presence, note: params[:note].presence)
|
|
43
|
+
else
|
|
44
|
+
log.update!(status: new_status, resolved_at: nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
render json: { data: serialize(log) }
|
|
48
|
+
rescue ActiveRecord::RecordNotFound
|
|
49
|
+
render json: { error: 'Not found' }, status: :not_found
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_scope
|
|
55
|
+
scope = ErrorLog.all
|
|
56
|
+
scope = scope.where(status: params[:status]) if params[:status].present? && ErrorLog.statuses.key?(params[:status])
|
|
57
|
+
scope = scope.where(severity: params[:severity]) if params[:severity].present? && ErrorLog.severities.key?(params[:severity])
|
|
58
|
+
scope = scope.where(category: params[:category]) if params[:category].present? && ErrorLog.categories.key?(params[:category])
|
|
59
|
+
|
|
60
|
+
if params[:q].present?
|
|
61
|
+
q = "%#{params[:q].downcase}%"
|
|
62
|
+
scope = scope.where('lower(message) LIKE :q OR lower(error_class) LIKE :q OR lower(source) LIKE :q', q: q)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
from = params[:from].present? ? (Date.parse(params[:from]) rescue nil) : nil
|
|
66
|
+
to = params[:to].present? ? (Date.parse(params[:to]) rescue nil) : nil
|
|
67
|
+
scope = scope.where('last_seen_at >= ?', from) if from
|
|
68
|
+
scope = scope.where('last_seen_at <= ?', to.end_of_day) if to
|
|
69
|
+
|
|
70
|
+
sort = %w[last_seen_at first_seen_at occurrences].include?(params[:sort]) ? params[:sort] : 'last_seen_at'
|
|
71
|
+
dir = params[:order] == 'asc' ? :asc : :desc
|
|
72
|
+
scope.order(sort => dir)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def serialize(log, detail: false)
|
|
76
|
+
data = {
|
|
77
|
+
id: log.id,
|
|
78
|
+
error_class: log.error_class,
|
|
79
|
+
source: log.source,
|
|
80
|
+
message: log.message,
|
|
81
|
+
category: log.category,
|
|
82
|
+
severity: log.severity,
|
|
83
|
+
status: log.status,
|
|
84
|
+
occurrences: log.occurrences,
|
|
85
|
+
first_seen_at: log.first_seen_at&.iso8601,
|
|
86
|
+
last_seen_at: log.last_seen_at&.iso8601,
|
|
87
|
+
resolved_at: log.resolved_at&.iso8601,
|
|
88
|
+
resolved_by: log.resolved_by
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if detail
|
|
92
|
+
data.merge!(
|
|
93
|
+
fingerprint: log.fingerprint,
|
|
94
|
+
resolution_note: log.resolution_note,
|
|
95
|
+
http_status: log.http_status,
|
|
96
|
+
request_url: log.request_url,
|
|
97
|
+
api_code: log.api_code,
|
|
98
|
+
api_subcode: log.api_subcode,
|
|
99
|
+
context: log.context,
|
|
100
|
+
backtrace: log.backtrace
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if log.class.column_names.include?('github_issue_url')
|
|
105
|
+
data[:github_issue_url] = log.github_issue_url
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
data
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
module Api
|
|
5
|
+
# GET /api/stats — summary counts for dashboards, CI gates, uptime monitors.
|
|
6
|
+
class StatsController < BaseController
|
|
7
|
+
def show
|
|
8
|
+
render json: {
|
|
9
|
+
total: ErrorLog.count,
|
|
10
|
+
open: ErrorLog.status_open.count,
|
|
11
|
+
in_progress: ErrorLog.status_in_progress.count,
|
|
12
|
+
resolved: ErrorLog.status_resolved.count,
|
|
13
|
+
ignored: ErrorLog.status_ignored.count,
|
|
14
|
+
unresolved: ErrorLog.unresolved.count,
|
|
15
|
+
by_severity: ErrorLog.group(:severity).count,
|
|
16
|
+
by_category: ErrorLog.group(:category).count,
|
|
17
|
+
generated_at: Time.current.iso8601
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ErrorRadar
|
|
4
4
|
class ErrorsController < ApplicationController
|
|
5
5
|
before_action :authenticate_request!
|
|
6
|
-
before_action :set_error, only: %i[show update_status destroy]
|
|
6
|
+
before_action :set_error, only: %i[show update_status destroy create_github_issue]
|
|
7
7
|
|
|
8
8
|
rescue_from ActiveRecord::RecordNotFound do
|
|
9
9
|
redirect_to errors_path, alert: 'Error not found.'
|
|
@@ -44,6 +44,35 @@ module ErrorRadar
|
|
|
44
44
|
render json: { ok: true }
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def create_github_issue
|
|
48
|
+
unless github_configured?
|
|
49
|
+
return render json: { ok: false, error: 'GitHub not configured (set config.github_token and config.github_repo)' },
|
|
50
|
+
status: :unprocessable_entity
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if ErrorLog.column_names.include?('github_issue_url') && @error.github_issue_url.present?
|
|
54
|
+
return render json: { ok: false, error: 'Issue already created', url: @error.github_issue_url },
|
|
55
|
+
status: :unprocessable_entity
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
require 'error_radar/integrations/github'
|
|
59
|
+
result = ErrorRadar::Integrations::Github.create_issue(
|
|
60
|
+
@error,
|
|
61
|
+
token: ErrorRadar.config.github_token,
|
|
62
|
+
repo: ErrorRadar.config.github_repo
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if result['html_url']
|
|
66
|
+
@error.update!(github_issue_url: result['html_url']) if ErrorLog.column_names.include?('github_issue_url')
|
|
67
|
+
render json: { ok: true, url: result['html_url'], number: result['number'] }
|
|
68
|
+
else
|
|
69
|
+
render json: { ok: false, error: result['message'] || 'GitHub API error' },
|
|
70
|
+
status: :unprocessable_entity
|
|
71
|
+
end
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
render json: { ok: false, error: e.message }, status: :internal_server_error
|
|
74
|
+
end
|
|
75
|
+
|
|
47
76
|
def bulk
|
|
48
77
|
ids = Array(params[:ids]).map(&:to_i).select(&:positive?)
|
|
49
78
|
action = params[:bulk_action].to_s
|
|
@@ -115,6 +144,10 @@ module ErrorRadar
|
|
|
115
144
|
ids.size
|
|
116
145
|
end
|
|
117
146
|
|
|
147
|
+
def github_configured?
|
|
148
|
+
ErrorRadar.config.github_token.present? && ErrorRadar.config.github_repo.present?
|
|
149
|
+
end
|
|
150
|
+
|
|
118
151
|
def active_filter_params
|
|
119
152
|
params.permit(:q, :status, :severity, :category, :from, :to, :sort, :order).to_h
|
|
120
153
|
end
|
|
@@ -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 %>
|
|
@@ -104,11 +104,37 @@
|
|
|
104
104
|
</div>
|
|
105
105
|
</div>
|
|
106
106
|
|
|
107
|
+
<%# ── GitHub Issue ── %>
|
|
108
|
+
<% if ErrorRadar.config.github_token.present? && ErrorRadar.config.github_repo.present? %>
|
|
109
|
+
<div class="panel" style="margin-top:16px">
|
|
110
|
+
<h2>GitHub</h2>
|
|
111
|
+
<% has_col = @error.class.column_names.include?('github_issue_url') %>
|
|
112
|
+
<% if has_col && @error.github_issue_url.present? %>
|
|
113
|
+
<a href="<%= @error.github_issue_url %>" target="_blank" rel="noopener"
|
|
114
|
+
class="er-btn" style="background:#24292e;display:inline-block;text-decoration:none">
|
|
115
|
+
△ View GitHub Issue
|
|
116
|
+
</a>
|
|
117
|
+
<% else %>
|
|
118
|
+
<button id="github-btn" class="er-btn" style="background:#24292e" onclick="createGithubIssue()">
|
|
119
|
+
△ Create GitHub Issue
|
|
120
|
+
</button>
|
|
121
|
+
<span id="github-status" style="font-size:13px;color:#6b7280;margin-left:10px"></span>
|
|
122
|
+
<% unless has_col %>
|
|
123
|
+
<p style="font-size:12px;color:#9ca3af;margin:10px 0 0">
|
|
124
|
+
Run <code style="background:#f3f4f6;padding:2px 6px;border-radius:4px">bin/rails generate error_radar:upgrade_v050 && bin/rails db:migrate</code>
|
|
125
|
+
to enable issue tracking.
|
|
126
|
+
</p>
|
|
127
|
+
<% end %>
|
|
128
|
+
<% end %>
|
|
129
|
+
</div>
|
|
130
|
+
<% end %>
|
|
131
|
+
|
|
107
132
|
<script>
|
|
108
133
|
(function () {
|
|
109
134
|
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
|
110
135
|
var STATUS_URL = '<%= error_status_path(@error) %>';
|
|
111
136
|
var DELETE_URL = '<%= error_path(@error) %>';
|
|
137
|
+
var GITHUB_URL = '<%= error_github_issue_path(@error) %>';
|
|
112
138
|
|
|
113
139
|
document.querySelectorAll('.status-btn').forEach(function (btn) {
|
|
114
140
|
btn.addEventListener('click', function () { changeStatus(btn.dataset.status, null); });
|
|
@@ -154,6 +180,35 @@
|
|
|
154
180
|
});
|
|
155
181
|
};
|
|
156
182
|
|
|
183
|
+
window.createGithubIssue = function () {
|
|
184
|
+
var btn = document.getElementById('github-btn');
|
|
185
|
+
var status = document.getElementById('github-status');
|
|
186
|
+
if (!btn) return;
|
|
187
|
+
btn.disabled = true;
|
|
188
|
+
status.textContent = 'Creating issue…';
|
|
189
|
+
|
|
190
|
+
fetch(GITHUB_URL, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF }
|
|
193
|
+
})
|
|
194
|
+
.then(function (r) { return r.json(); })
|
|
195
|
+
.then(function (d) {
|
|
196
|
+
if (d.ok) {
|
|
197
|
+
status.innerHTML = '✓ Created: <a href="' + d.url + '" target="_blank">#' + d.number + '</a>';
|
|
198
|
+
btn.style.display = 'none';
|
|
199
|
+
showToast('GitHub issue #' + d.number + ' created', true);
|
|
200
|
+
} else {
|
|
201
|
+
status.textContent = 'Error: ' + (d.error || 'failed');
|
|
202
|
+
btn.disabled = false;
|
|
203
|
+
showToast(d.error || 'GitHub error', false);
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
.catch(function () {
|
|
207
|
+
status.textContent = 'Network error';
|
|
208
|
+
btn.disabled = false;
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
157
212
|
function showToast(msg, ok) {
|
|
158
213
|
var t = document.getElementById('toast');
|
|
159
214
|
t.textContent = msg;
|
data/config/routes.rb
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
ErrorRadar::Engine.routes.draw do
|
|
4
4
|
root to: 'dashboard#index'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
# REST API — JSON endpoints for CI/CD, external dashboards, scripts
|
|
7
|
+
namespace :api, defaults: { format: :json } do
|
|
8
|
+
resources :errors, only: %i[index show update]
|
|
9
|
+
resource :stats, only: [:show]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Web UI
|
|
13
|
+
get 'errors', to: 'errors#index', as: :errors
|
|
14
|
+
post 'errors/bulk', to: 'errors#bulk', as: :errors_bulk
|
|
15
|
+
post 'errors/:id/github_issue', to: 'errors#create_github_issue', as: :error_github_issue
|
|
16
|
+
get 'errors/:id', to: 'errors#show', as: :error
|
|
17
|
+
patch 'errors/:id/status', to: 'errors#update_status', as: :error_status
|
|
18
|
+
delete 'errors/:id', to: 'errors#destroy'
|
|
11
19
|
end
|
|
@@ -35,6 +35,50 @@ 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
|
+
|
|
72
|
+
# REST API ────────────────────────────────────────────────────────────────
|
|
73
|
+
# Bearer token for /api/* endpoints. nil = unauthenticated (not for prod).
|
|
74
|
+
attr_accessor :api_token
|
|
75
|
+
|
|
76
|
+
# GitHub integration ──────────────────────────────────────────────────────
|
|
77
|
+
# Personal access token with repo scope.
|
|
78
|
+
attr_accessor :github_token
|
|
79
|
+
# "owner/repo" string, e.g. "myorg/myapp".
|
|
80
|
+
attr_accessor :github_repo
|
|
81
|
+
|
|
38
82
|
# Custom classification rules. Each is a callable `->(exception) { :category | nil }`.
|
|
39
83
|
# The first rule that returns a non-nil category wins; built-in rules run after.
|
|
40
84
|
attr_accessor :categorizers
|
|
@@ -79,6 +123,21 @@ module ErrorRadar
|
|
|
79
123
|
@install_rails_admin = true
|
|
80
124
|
@install_active_job = true
|
|
81
125
|
@install_rake = true
|
|
126
|
+
|
|
127
|
+
@notify_on = [:new_error]
|
|
128
|
+
@slack_webhook_url = nil
|
|
129
|
+
@slack_channel = nil
|
|
130
|
+
@discord_webhook_url = nil
|
|
131
|
+
@email_recipients = []
|
|
132
|
+
@email_from = nil
|
|
133
|
+
@webhook_urls = []
|
|
134
|
+
@app_name = nil
|
|
135
|
+
@app_host = nil
|
|
136
|
+
@error_callbacks = []
|
|
137
|
+
|
|
138
|
+
@api_token = nil
|
|
139
|
+
@github_token = nil
|
|
140
|
+
@github_repo = nil
|
|
82
141
|
@categorizers = []
|
|
83
142
|
@detail_extractors = []
|
|
84
143
|
@expected_servers = []
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module ErrorRadar
|
|
8
|
+
module Integrations
|
|
9
|
+
# Creates GitHub issues from ErrorLog records via the GitHub REST API.
|
|
10
|
+
# Requires a personal access token with the `repo` scope and a repo in
|
|
11
|
+
# "owner/repo" format.
|
|
12
|
+
module Github
|
|
13
|
+
API_BASE = 'https://api.github.com'
|
|
14
|
+
|
|
15
|
+
# Returns the parsed JSON response from GitHub.
|
|
16
|
+
# Raises StandardError on network/API failure.
|
|
17
|
+
def self.create_issue(error_log, token:, repo:)
|
|
18
|
+
owner, repo_name = repo.split('/', 2)
|
|
19
|
+
uri = URI("#{API_BASE}/repos/#{owner}/#{repo_name}/issues")
|
|
20
|
+
|
|
21
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
22
|
+
http.use_ssl = true
|
|
23
|
+
http.open_timeout = 10
|
|
24
|
+
http.read_timeout = 10
|
|
25
|
+
|
|
26
|
+
req = Net::HTTP::Post.new(uri)
|
|
27
|
+
req['Authorization'] = "Bearer #{token}"
|
|
28
|
+
req['Content-Type'] = 'application/json'
|
|
29
|
+
req['Accept'] = 'application/vnd.github+json'
|
|
30
|
+
req['X-GitHub-Api-Version'] = '2022-11-28'
|
|
31
|
+
req.body = {
|
|
32
|
+
title: issue_title(error_log),
|
|
33
|
+
body: issue_body(error_log),
|
|
34
|
+
labels: ['bug', 'error-radar']
|
|
35
|
+
}.to_json
|
|
36
|
+
|
|
37
|
+
response = http.request(req)
|
|
38
|
+
JSON.parse(response.body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.issue_title(log)
|
|
42
|
+
short_msg = log.message.to_s.truncate(80).gsub(/\n/, ' ')
|
|
43
|
+
"[Error Radar] #{log.error_class}: #{short_msg}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.issue_body(log)
|
|
47
|
+
url = ErrorRadar::Notifier.error_url(log)
|
|
48
|
+
backtrace = log.backtrace.to_s.split("\n").first(20).join("\n")
|
|
49
|
+
|
|
50
|
+
parts = []
|
|
51
|
+
parts << "## Error Details\n"
|
|
52
|
+
parts << "| Field | Value |"
|
|
53
|
+
parts << "|-------|-------|"
|
|
54
|
+
parts << "| **Error Class** | `#{log.error_class}` |"
|
|
55
|
+
parts << "| **Source** | #{log.source || 'unknown'} |"
|
|
56
|
+
parts << "| **Category** | #{log.category} |"
|
|
57
|
+
parts << "| **Severity** | #{log.severity} |"
|
|
58
|
+
parts << "| **Occurrences** | #{log.occurrences} |"
|
|
59
|
+
parts << "| **First seen** | #{log.first_seen_at&.strftime('%Y-%m-%d %H:%M UTC')} |"
|
|
60
|
+
parts << "| **Last seen** | #{log.last_seen_at&.strftime('%Y-%m-%d %H:%M UTC')} |"
|
|
61
|
+
parts << "\n## Message\n\n```\n#{log.message.to_s.truncate(1000)}\n```"
|
|
62
|
+
|
|
63
|
+
unless backtrace.empty?
|
|
64
|
+
parts << "\n## Backtrace\n\n```\n#{backtrace}\n```"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
parts << "\n## Link\n\n[View in Error Radar](#{url})" if url
|
|
68
|
+
|
|
69
|
+
parts << "\n---\n*Created automatically by [Error Radar](https://github.com/chienbn9x/error_radar)*"
|
|
70
|
+
parts.join("\n")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -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!) }
|
|
@@ -45,6 +72,17 @@ ErrorRadar.configure do |config|
|
|
|
45
72
|
# config.expected_servers = [
|
|
46
73
|
# { key: 'web', name: 'Web', tag: 'sidekiq_web', host: 'web', queue_hint: 'low' }
|
|
47
74
|
# ]
|
|
75
|
+
|
|
76
|
+
# --- REST API ---
|
|
77
|
+
# Protect /api/* endpoints with a Bearer token.
|
|
78
|
+
# config.api_token = ENV['ERROR_RADAR_API_TOKEN']
|
|
79
|
+
# curl -H "Authorization: Bearer $TOKEN" https://myapp.com/error_radar/api/stats
|
|
80
|
+
|
|
81
|
+
# --- GitHub Integration ---
|
|
82
|
+
# Creates GitHub issues directly from the error detail page.
|
|
83
|
+
# Requires running: bin/rails generate error_radar:upgrade_v050 && bin/rails db:migrate
|
|
84
|
+
# config.github_token = ENV['GITHUB_TOKEN'] # PAT with repo scope
|
|
85
|
+
# config.github_repo = 'myorg/myapp' # "owner/repo" format
|
|
48
86
|
end
|
|
49
87
|
|
|
50
88
|
# ActiveJob is now captured automatically via install_active_job = true above.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module ErrorRadar
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradeV050Generator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
|
12
|
+
|
|
13
|
+
desc 'Generates the migration for Error Radar v0.5.0 (adds github_issue_url column).'
|
|
14
|
+
|
|
15
|
+
def self.next_migration_number(dirname)
|
|
16
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_migration_file
|
|
20
|
+
migration_template(
|
|
21
|
+
'add_github_issue_to_error_radar_error_logs.rb.tt',
|
|
22
|
+
'db/migrate/add_github_issue_to_error_radar_error_logs.rb'
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- chienbn9x
|
|
@@ -57,13 +57,19 @@ files:
|
|
|
57
57
|
- MIT-LICENSE
|
|
58
58
|
- README.md
|
|
59
59
|
- Rakefile
|
|
60
|
+
- app/controllers/error_radar/api/base_controller.rb
|
|
61
|
+
- app/controllers/error_radar/api/errors_controller.rb
|
|
62
|
+
- app/controllers/error_radar/api/stats_controller.rb
|
|
60
63
|
- app/controllers/error_radar/application_controller.rb
|
|
61
64
|
- app/controllers/error_radar/dashboard_controller.rb
|
|
62
65
|
- app/controllers/error_radar/errors_controller.rb
|
|
66
|
+
- app/mailers/error_radar/error_mailer.rb
|
|
63
67
|
- app/models/error_radar/application_record.rb
|
|
64
68
|
- app/models/error_radar/error_log.rb
|
|
65
69
|
- app/views/error_radar/dashboard/index.html.erb
|
|
66
70
|
- app/views/error_radar/dashboard/show.html.erb
|
|
71
|
+
- app/views/error_radar/error_mailer/new_error.html.erb
|
|
72
|
+
- app/views/error_radar/error_mailer/new_error.text.erb
|
|
67
73
|
- app/views/error_radar/errors/index.html.erb
|
|
68
74
|
- app/views/error_radar/errors/show.html.erb
|
|
69
75
|
- app/views/layouts/error_radar/application.html.erb
|
|
@@ -72,16 +78,24 @@ files:
|
|
|
72
78
|
- lib/error_radar/configuration.rb
|
|
73
79
|
- lib/error_radar/engine.rb
|
|
74
80
|
- lib/error_radar/integrations/active_job.rb
|
|
81
|
+
- lib/error_radar/integrations/github.rb
|
|
75
82
|
- lib/error_radar/integrations/rails_admin.rb
|
|
76
83
|
- lib/error_radar/integrations/rake.rb
|
|
77
84
|
- lib/error_radar/integrations/sidekiq.rb
|
|
78
85
|
- lib/error_radar/middleware.rb
|
|
86
|
+
- lib/error_radar/notifications/discord.rb
|
|
87
|
+
- lib/error_radar/notifications/email.rb
|
|
88
|
+
- lib/error_radar/notifications/slack.rb
|
|
89
|
+
- lib/error_radar/notifications/webhook.rb
|
|
90
|
+
- lib/error_radar/notifier.rb
|
|
79
91
|
- lib/error_radar/server_monitor.rb
|
|
80
92
|
- lib/error_radar/tracking.rb
|
|
81
93
|
- lib/error_radar/version.rb
|
|
82
94
|
- lib/generators/error_radar/install/install_generator.rb
|
|
83
95
|
- lib/generators/error_radar/install/templates/create_error_radar_error_logs.rb.tt
|
|
84
96
|
- lib/generators/error_radar/install/templates/initializer.rb
|
|
97
|
+
- lib/generators/error_radar/upgrade_v050/templates/add_github_issue_to_error_radar_error_logs.rb.tt
|
|
98
|
+
- lib/generators/error_radar/upgrade_v050/upgrade_v050_generator.rb
|
|
85
99
|
homepage: https://github.com/chienbn9x/error_radar
|
|
86
100
|
licenses:
|
|
87
101
|
- MIT
|