error_radar 0.5.0 → 0.7.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 +63 -0
- data/app/controllers/error_radar/api/errors_controller.rb +16 -0
- data/app/controllers/error_radar/dashboard_controller.rb +32 -5
- data/app/controllers/error_radar/errors_controller.rb +17 -1
- data/app/models/error_radar/error_log.rb +15 -0
- data/app/models/error_radar/error_occurrence.rb +41 -0
- data/app/views/error_radar/dashboard/index.html.erb +68 -0
- data/app/views/error_radar/errors/show.html.erb +96 -0
- data/config/routes.rb +2 -0
- data/lib/error_radar/capture_job.rb +18 -0
- data/lib/error_radar/cleanup.rb +43 -0
- data/lib/error_radar/configuration.rb +29 -0
- data/lib/error_radar/engine.rb +4 -0
- data/lib/error_radar/tracking.rb +17 -0
- data/lib/error_radar/version.rb +1 -1
- data/lib/error_radar.rb +1 -0
- data/lib/generators/error_radar/install/templates/initializer.rb +25 -0
- data/lib/generators/error_radar/upgrade_v060/templates/create_error_radar_occurrences.rb.tt +24 -0
- data/lib/generators/error_radar/upgrade_v060/upgrade_v060_generator.rb +20 -0
- data/lib/tasks/error_radar.rake +44 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 57b3fa6a7576ae08baeb699b180048363846c84ecbb489fc93e16ea537939898
|
|
4
|
+
data.tar.gz: 67008613bd52755dad89b7ed1abbc9a86eb92c138f95cae8005f5272e8336594
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2882f7be2a07a38bdafd4b44c8251774a470f37d44fcd3dc4403046cd2998eb1077ac576c97cd8aed6a43ab4a86153440598bafa5a6a47576f06bbc137a2528d
|
|
7
|
+
data.tar.gz: 8717757580e628dc988422df68c3a806bd7d9727f297439b5e7303cc049a48371f37035442dbfd12f7789cb8b480221b093720dda965f6ebeffc2c75ef3bcb57
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.7.0] - 2026-07-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Occurrence history**: every individual error hit can now be stored in a
|
|
9
|
+
separate `error_radar_occurrences` table. Enable with
|
|
10
|
+
`config.track_occurrences = true` after running the upgrade migration:
|
|
11
|
+
`bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate`.
|
|
12
|
+
- **`ErrorRadar::ErrorOccurrence` model**: columns `occurred_at`, `context`,
|
|
13
|
+
`backtrace`, `http_status`, `request_url`. Belongs to `ErrorLog` via
|
|
14
|
+
`error_log_id`. Indexed on `(error_log_id, occurred_at)` for fast retrieval.
|
|
15
|
+
- **`config.max_occurrences_per_error`** (default: 200): automatically prunes
|
|
16
|
+
the oldest occurrences for a given error on each new hit so the table stays
|
|
17
|
+
bounded without manual cleanup.
|
|
18
|
+
- **Occurrences panel on error detail page**: shows the 20 most recent
|
|
19
|
+
occurrences with timestamp, HTTP status badge, request URL, and expandable
|
|
20
|
+
"Context" / "Stack" toggles. Paginated at 20 per page.
|
|
21
|
+
- **`GET /api/errors/:id`** now includes `recent_occurrences` (last 10 hits
|
|
22
|
+
with `occurred_at`, `http_status`, `request_url`, `context`) when
|
|
23
|
+
`track_occurrences` is enabled.
|
|
24
|
+
- **`error_radar:upgrade_v060` generator**: generates the migration that creates
|
|
25
|
+
`error_radar_occurrences`.
|
|
26
|
+
- **`ErrorLog#occurrences`** association: `has_many :occurrences, dependent: :delete_all`
|
|
27
|
+
so destroying an error log also removes its occurrence history.
|
|
28
|
+
|
|
29
|
+
### Notes
|
|
30
|
+
- `track_occurrences` defaults to `false` to avoid unexpected writes before the
|
|
31
|
+
migration is run. Set it to `true` after the migration has been applied.
|
|
32
|
+
- Occurrences are recorded via `ErrorLog.record`, which is called by both the
|
|
33
|
+
synchronous capture path and `CaptureJob` (async path) — no extra
|
|
34
|
+
configuration needed once enabled.
|
|
35
|
+
|
|
36
|
+
## [0.6.0] - 2026-07-03
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- **Async capture** via ActiveJob: set `config.async_capture = true` to enqueue
|
|
40
|
+
error writes through `CaptureJob` so exceptions don't block the request/job
|
|
41
|
+
thread. Falls back to synchronous capture if ActiveJob is unavailable.
|
|
42
|
+
Configure the queue with `config.capture_job_queue` (default `:default`).
|
|
43
|
+
- **Age-based retention**: set `config.retention_days` to automatically prune
|
|
44
|
+
resolved/ignored records older than N days.
|
|
45
|
+
- **Count-based retention**: set `config.max_records` to cap the table size;
|
|
46
|
+
oldest resolved/ignored records are deleted first when the limit is exceeded.
|
|
47
|
+
- **`ErrorRadar::Cleanup.run`**: underlying cleanup logic, callable from Ruby or
|
|
48
|
+
Rake. Supports `older_than_days:` override and `dry_run: true` for previewing
|
|
49
|
+
without deleting.
|
|
50
|
+
- **Rake tasks**:
|
|
51
|
+
- `rake error_radar:cleanup` — apply `retention_days` + `max_records`
|
|
52
|
+
- `rake error_radar:cleanup:dry_run` — preview without deleting
|
|
53
|
+
- `rake error_radar:cleanup:older_than` — one-off: `DAYS=30 rake …`
|
|
54
|
+
- `rake error_radar:stats` — print table summary (total, by status, oldest)
|
|
55
|
+
- **Dashboard maintenance panel**: data summary (total / purgeable / open),
|
|
56
|
+
oldest record date, and an inline "Purge / Dry run" form with adjustable days.
|
|
57
|
+
- **`POST /maintenance/purge`** endpoint: called by the dashboard's Purge button;
|
|
58
|
+
accepts `days` and `dry_run` params, returns JSON `{ deleted:, dry_run: }`.
|
|
59
|
+
- **Dashboard stat query optimization**: 5 separate COUNT queries replaced by one
|
|
60
|
+
`group(:status).count` query.
|
|
61
|
+
|
|
62
|
+
### Notes
|
|
63
|
+
- `async_capture` is `false` by default to avoid surprising behaviour for apps
|
|
64
|
+
that do not have a queue adapter configured.
|
|
65
|
+
- Cleanup only touches `resolved` and `ignored` records — open/in-progress
|
|
66
|
+
records are never auto-deleted.
|
|
67
|
+
|
|
5
68
|
## [0.5.0] - 2026-07-03
|
|
6
69
|
|
|
7
70
|
### Added
|
|
@@ -99,6 +99,22 @@ module ErrorRadar
|
|
|
99
99
|
context: log.context,
|
|
100
100
|
backtrace: log.backtrace
|
|
101
101
|
)
|
|
102
|
+
|
|
103
|
+
if ErrorRadar.config.track_occurrences
|
|
104
|
+
begin
|
|
105
|
+
data[:recent_occurrences] = log.occurrences.recent.limit(10).map do |occ|
|
|
106
|
+
{
|
|
107
|
+
id: occ.id,
|
|
108
|
+
occurred_at: occ.occurred_at&.iso8601,
|
|
109
|
+
http_status: occ.http_status,
|
|
110
|
+
request_url: occ.request_url,
|
|
111
|
+
context: occ.context
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
rescue ActiveRecord::StatementInvalid
|
|
115
|
+
data[:recent_occurrences] = []
|
|
116
|
+
end
|
|
117
|
+
end
|
|
102
118
|
end
|
|
103
119
|
|
|
104
120
|
if log.class.column_names.include?('github_issue_url')
|
|
@@ -10,13 +10,18 @@ module ErrorRadar
|
|
|
10
10
|
SEVERITY_ORDER = { 'critical' => 0, 'error' => 1, 'warning' => 2, 'info' => 3 }.freeze
|
|
11
11
|
|
|
12
12
|
def index
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
17
|
-
@
|
|
13
|
+
counts_by_status = ErrorLog.group(:status).count
|
|
14
|
+
# statuses returns {"open"=>0, "in_progress"=>1, "resolved"=>2, "ignored"=>3}
|
|
15
|
+
inv = ErrorLog.statuses.invert
|
|
16
|
+
@open_count = counts_by_status[ErrorLog.statuses['open']] || 0
|
|
17
|
+
@in_progress = counts_by_status[ErrorLog.statuses['in_progress']] || 0
|
|
18
|
+
@resolved_count = counts_by_status[ErrorLog.statuses['resolved']] || 0
|
|
19
|
+
@ignored_count = counts_by_status[ErrorLog.statuses['ignored']] || 0
|
|
20
|
+
@total = counts_by_status.values.sum
|
|
18
21
|
@unresolved = @open_count + @in_progress
|
|
19
22
|
|
|
23
|
+
@oldest_record = ErrorLog.minimum(:first_seen_at)
|
|
24
|
+
|
|
20
25
|
@by_category = ErrorLog.unresolved.group(:category).count
|
|
21
26
|
@by_severity = ErrorLog.unresolved.group(:severity).count
|
|
22
27
|
|
|
@@ -46,6 +51,28 @@ module ErrorRadar
|
|
|
46
51
|
@external_links = build_external_links
|
|
47
52
|
end
|
|
48
53
|
|
|
54
|
+
def purge
|
|
55
|
+
days = params[:days].presence&.to_i
|
|
56
|
+
dry_run = params[:dry_run] == '1'
|
|
57
|
+
|
|
58
|
+
result = ErrorRadar::Cleanup.run(
|
|
59
|
+
older_than_days: (days if days&.positive?),
|
|
60
|
+
dry_run: dry_run
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
respond_to do |format|
|
|
64
|
+
format.json { render json: result }
|
|
65
|
+
format.html do
|
|
66
|
+
msg = if dry_run
|
|
67
|
+
"Dry run: would delete #{result[:deleted]} record(s)."
|
|
68
|
+
else
|
|
69
|
+
"Purged #{result[:deleted]} record(s)."
|
|
70
|
+
end
|
|
71
|
+
redirect_to root_path, notice: msg
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
49
76
|
private
|
|
50
77
|
|
|
51
78
|
def build_external_links
|
|
@@ -22,7 +22,23 @@ module ErrorRadar
|
|
|
22
22
|
@pages = pagination_pages(@page, @total_pages)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def show
|
|
25
|
+
def show
|
|
26
|
+
@occurrences = []
|
|
27
|
+
return unless ErrorRadar.config.track_occurrences
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
@occ_page = [params[:occ_page].to_i, 1].max
|
|
31
|
+
occ_per_page = 20
|
|
32
|
+
@occ_total = @error.occurrences.count
|
|
33
|
+
@occ_total_pages = [(@occ_total.to_f / occ_per_page).ceil, 1].max
|
|
34
|
+
@occ_page = [@occ_page, @occ_total_pages].min
|
|
35
|
+
@occurrences = @error.occurrences.recent
|
|
36
|
+
.limit(occ_per_page)
|
|
37
|
+
.offset((@occ_page - 1) * occ_per_page)
|
|
38
|
+
rescue ActiveRecord::StatementInvalid
|
|
39
|
+
@occurrences = []
|
|
40
|
+
end
|
|
41
|
+
end
|
|
26
42
|
|
|
27
43
|
def update_status
|
|
28
44
|
new_status = params[:status].to_s
|
|
@@ -16,6 +16,10 @@ module ErrorRadar
|
|
|
16
16
|
|
|
17
17
|
enum status: { open: 0, in_progress: 1, resolved: 2, ignored: 3 }, _prefix: :status
|
|
18
18
|
|
|
19
|
+
has_many :occurrences, class_name: 'ErrorRadar::ErrorOccurrence',
|
|
20
|
+
foreign_key: :error_log_id,
|
|
21
|
+
dependent: :delete_all
|
|
22
|
+
|
|
19
23
|
validates :fingerprint, presence: true, uniqueness: true
|
|
20
24
|
validates :first_seen_at, :last_seen_at, presence: true
|
|
21
25
|
|
|
@@ -60,6 +64,17 @@ module ErrorRadar
|
|
|
60
64
|
log.last_seen_at = now
|
|
61
65
|
log.save!
|
|
62
66
|
log.instance_variable_set(:@new_fingerprint, new_fingerprint)
|
|
67
|
+
|
|
68
|
+
if ErrorRadar.config.track_occurrences
|
|
69
|
+
ErrorRadar::ErrorOccurrence.record_for(
|
|
70
|
+
log,
|
|
71
|
+
context: context,
|
|
72
|
+
backtrace: backtrace,
|
|
73
|
+
http_status: http_status,
|
|
74
|
+
request_url: request_url
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
63
78
|
log
|
|
64
79
|
rescue StandardError => e
|
|
65
80
|
ErrorRadar::Tracking.warn_internal("ErrorLog.record failed: #{e.class}: #{e.message}")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# One row per individual error hit. Linked to an ErrorLog (the deduplicated
|
|
5
|
+
# aggregate). Only recorded when config.track_occurrences is true and the
|
|
6
|
+
# upgrade_v060 migration has been run.
|
|
7
|
+
class ErrorOccurrence < ApplicationRecord
|
|
8
|
+
belongs_to :error_log
|
|
9
|
+
|
|
10
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
11
|
+
|
|
12
|
+
def self.record_for(log, context: {}, backtrace: nil, http_status: nil, request_url: nil)
|
|
13
|
+
max = ErrorRadar.config.max_occurrences_per_error
|
|
14
|
+
|
|
15
|
+
create!(
|
|
16
|
+
error_log: log,
|
|
17
|
+
occurred_at: Time.current,
|
|
18
|
+
context: context.presence,
|
|
19
|
+
backtrace: Array(backtrace).join("\n").presence,
|
|
20
|
+
http_status: http_status,
|
|
21
|
+
request_url: request_url
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if max&.positive?
|
|
25
|
+
keep_ids = where(error_log_id: log.id).order(occurred_at: :desc).limit(max).pluck(:id)
|
|
26
|
+
where(error_log_id: log.id).where.not(id: keep_ids).delete_all if keep_ids.any?
|
|
27
|
+
end
|
|
28
|
+
rescue ActiveRecord::StatementInvalid
|
|
29
|
+
# Table not yet created — run: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
ErrorRadar::Tracking.warn_internal("ErrorOccurrence.record_for failed: #{e.class}: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def context_pretty
|
|
35
|
+
return '{}' if context.blank?
|
|
36
|
+
JSON.pretty_generate(context.is_a?(Hash) ? context : JSON.parse(context.to_s))
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
context.to_s
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -101,6 +101,52 @@
|
|
|
101
101
|
</div>
|
|
102
102
|
</div>
|
|
103
103
|
|
|
104
|
+
<div class="panel" style="margin-bottom:20px" id="maintenance-panel">
|
|
105
|
+
<h2>Maintenance & Data Retention</h2>
|
|
106
|
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;margin-bottom:16px">
|
|
107
|
+
<div style="background:#f9fafb;border-radius:8px;padding:12px;text-align:center">
|
|
108
|
+
<div style="font-size:22px;font-weight:700;color:#111827"><%= @total %></div>
|
|
109
|
+
<div style="font-size:11px;color:#6b7280">Total records</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div style="background:#f9fafb;border-radius:8px;padding:12px;text-align:center">
|
|
112
|
+
<div style="font-size:22px;font-weight:700;color:#28a745"><%= @resolved_count + @ignored_count %></div>
|
|
113
|
+
<div style="font-size:11px;color:#6b7280">Resolved / Ignored (purgeable)</div>
|
|
114
|
+
</div>
|
|
115
|
+
<div style="background:#f9fafb;border-radius:8px;padding:12px;text-align:center">
|
|
116
|
+
<div style="font-size:22px;font-weight:700;color:#fd7e14"><%= @unresolved %></div>
|
|
117
|
+
<div style="font-size:11px;color:#6b7280">Open / In progress</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div style="background:#f9fafb;border-radius:8px;padding:12px;text-align:center">
|
|
120
|
+
<div style="font-size:13px;font-weight:600;color:#111827">
|
|
121
|
+
<%= @oldest_record ? @oldest_record.strftime('%Y-%m-%d') : '—' %>
|
|
122
|
+
</div>
|
|
123
|
+
<div style="font-size:11px;color:#6b7280">Oldest record</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
127
|
+
<label style="font-size:13px;font-weight:500">Delete resolved/ignored older than</label>
|
|
128
|
+
<input id="purge-days" type="number" min="1" placeholder="days" value="90"
|
|
129
|
+
style="width:80px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;font-size:13px">
|
|
130
|
+
<span style="font-size:13px;color:#6b7280">days</span>
|
|
131
|
+
<button onclick="doPurge(false)"
|
|
132
|
+
style="padding:6px 14px;background:#dc3545;color:#fff;border:none;border-radius:6px;font-size:13px;cursor:pointer">
|
|
133
|
+
Purge
|
|
134
|
+
</button>
|
|
135
|
+
<button onclick="doPurge(true)"
|
|
136
|
+
style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;font-size:13px;cursor:pointer">
|
|
137
|
+
Dry run
|
|
138
|
+
</button>
|
|
139
|
+
<span id="purge-result" style="font-size:13px;color:#16a34a"></span>
|
|
140
|
+
</div>
|
|
141
|
+
<% if ErrorRadar.config.retention_days || ErrorRadar.config.max_records %>
|
|
142
|
+
<div style="margin-top:10px;font-size:12px;color:#6b7280">
|
|
143
|
+
Config: retention_days=<strong><%= ErrorRadar.config.retention_days || '—' %></strong>,
|
|
144
|
+
max_records=<strong><%= ErrorRadar.config.max_records || '—' %></strong>
|
|
145
|
+
(applies automatically when you run <code>rake error_radar:cleanup</code>)
|
|
146
|
+
</div>
|
|
147
|
+
<% end %>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
104
150
|
<div class="panel" style="margin-bottom:20px">
|
|
105
151
|
<h2>Kanban — drag a card to change status</h2>
|
|
106
152
|
<div class="kanban">
|
|
@@ -127,6 +173,28 @@
|
|
|
127
173
|
</div>
|
|
128
174
|
|
|
129
175
|
<script>
|
|
176
|
+
function doPurge(dryRun) {
|
|
177
|
+
var days = document.getElementById('purge-days').value;
|
|
178
|
+
var result = document.getElementById('purge-result');
|
|
179
|
+
var token = document.querySelector('meta[name="csrf-token"]').content;
|
|
180
|
+
result.textContent = 'Running…';
|
|
181
|
+
result.style.color = '#6b7280';
|
|
182
|
+
fetch('<%= maintenance_purge_path %>', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token, 'Accept': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ days: days, dry_run: dryRun ? '1' : '0' })
|
|
186
|
+
}).then(function(r) { return r.json(); })
|
|
187
|
+
.then(function(d) {
|
|
188
|
+
var label = dryRun ? 'Would delete' : 'Deleted';
|
|
189
|
+
result.textContent = label + ' ' + d.deleted + ' record(s).';
|
|
190
|
+
result.style.color = dryRun ? '#fd7e14' : '#16a34a';
|
|
191
|
+
})
|
|
192
|
+
.catch(function() {
|
|
193
|
+
result.textContent = 'Request failed.';
|
|
194
|
+
result.style.color = '#dc3545';
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
130
198
|
(function () {
|
|
131
199
|
var token = document.querySelector('meta[name="csrf-token"]').content;
|
|
132
200
|
|
|
@@ -104,6 +104,97 @@
|
|
|
104
104
|
</div>
|
|
105
105
|
</div>
|
|
106
106
|
|
|
107
|
+
<%# ── Occurrences History ── %>
|
|
108
|
+
<% if ErrorRadar.config.track_occurrences %>
|
|
109
|
+
<div class="panel" style="margin-top:16px" id="occurrences-panel">
|
|
110
|
+
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px">
|
|
111
|
+
<h2 style="margin:0">Occurrence History
|
|
112
|
+
<% if @occ_total.to_i > 0 %>
|
|
113
|
+
<span style="font-size:13px;font-weight:400;color:#6b7280">(<%= @occ_total %> total)</span>
|
|
114
|
+
<% end %>
|
|
115
|
+
</h2>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<% if @occurrences.empty? %>
|
|
119
|
+
<p style="font-size:13px;color:#9ca3af;margin:0">
|
|
120
|
+
<% if @occ_total.to_i == 0 %>
|
|
121
|
+
No occurrences recorded yet. New hits will appear here.
|
|
122
|
+
<% else %>
|
|
123
|
+
No occurrences on this page.
|
|
124
|
+
<% end %>
|
|
125
|
+
</p>
|
|
126
|
+
<% else %>
|
|
127
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
128
|
+
<thead>
|
|
129
|
+
<tr style="border-bottom:2px solid #e5e7eb">
|
|
130
|
+
<th style="text-align:left;padding:6px 8px;color:#6b7280;width:170px">Time</th>
|
|
131
|
+
<th style="text-align:left;padding:6px 8px;color:#6b7280;width:70px">HTTP</th>
|
|
132
|
+
<th style="text-align:left;padding:6px 8px;color:#6b7280">Context / Request</th>
|
|
133
|
+
<th style="text-align:left;padding:6px 8px;color:#6b7280;width:80px">Backtrace</th>
|
|
134
|
+
</tr>
|
|
135
|
+
</thead>
|
|
136
|
+
<tbody>
|
|
137
|
+
<% @occurrences.each_with_index do |occ, i| %>
|
|
138
|
+
<tr style="border-bottom:1px solid #f3f4f6" class="occ-row">
|
|
139
|
+
<td style="padding:7px 8px;font-family:monospace;font-size:11px;white-space:nowrap">
|
|
140
|
+
<%= occ.occurred_at&.strftime('%Y-%m-%d %H:%M:%S') %>
|
|
141
|
+
</td>
|
|
142
|
+
<td style="padding:7px 8px">
|
|
143
|
+
<% if occ.http_status.present? %>
|
|
144
|
+
<span class="badge" style="background:<%= occ.http_status.to_i >= 500 ? '#dc3545' : '#fd7e14' %>">
|
|
145
|
+
<%= occ.http_status %>
|
|
146
|
+
</span>
|
|
147
|
+
<% end %>
|
|
148
|
+
</td>
|
|
149
|
+
<td style="padding:7px 8px;max-width:400px">
|
|
150
|
+
<% if occ.request_url.present? %>
|
|
151
|
+
<div style="font-size:11px;color:#6b7280;word-break:break-all;margin-bottom:4px"><%= occ.request_url.truncate(120) %></div>
|
|
152
|
+
<% end %>
|
|
153
|
+
<% unless (occ.context || {}).empty? %>
|
|
154
|
+
<button onclick="toggleOcc('ctx-<%= i %>')"
|
|
155
|
+
style="padding:2px 8px;font-size:11px;border:1px solid #d1d5db;border-radius:4px;background:#f9fafb;cursor:pointer">
|
|
156
|
+
Context ▾
|
|
157
|
+
</button>
|
|
158
|
+
<div id="ctx-<%= i %>" style="display:none;margin-top:6px">
|
|
159
|
+
<pre class="er-pre" style="max-height:200px;font-size:10px"><%= occ.context_pretty %></pre>
|
|
160
|
+
</div>
|
|
161
|
+
<% end %>
|
|
162
|
+
</td>
|
|
163
|
+
<td style="padding:7px 8px">
|
|
164
|
+
<% if occ.backtrace.present? %>
|
|
165
|
+
<button onclick="toggleOcc('bt-<%= i %>')"
|
|
166
|
+
style="padding:2px 8px;font-size:11px;border:1px solid #d1d5db;border-radius:4px;background:#f9fafb;cursor:pointer">
|
|
167
|
+
Stack ▾
|
|
168
|
+
</button>
|
|
169
|
+
<div id="bt-<%= i %>" style="display:none;position:absolute;z-index:10;background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:12px;width:600px;max-width:90vw;box-shadow:0 4px 16px rgba(0,0,0,.12)">
|
|
170
|
+
<pre class="er-pre" style="max-height:300px"><%= occ.backtrace %></pre>
|
|
171
|
+
<button onclick="toggleOcc('bt-<%= i %>')" style="margin-top:6px;font-size:11px;border:0;background:none;cursor:pointer;color:#6b7280">Close</button>
|
|
172
|
+
</div>
|
|
173
|
+
<% end %>
|
|
174
|
+
</td>
|
|
175
|
+
</tr>
|
|
176
|
+
<% end %>
|
|
177
|
+
</tbody>
|
|
178
|
+
</table>
|
|
179
|
+
|
|
180
|
+
<%# Pagination %>
|
|
181
|
+
<% if @occ_total_pages.to_i > 1 %>
|
|
182
|
+
<div style="margin-top:12px;display:flex;gap:6px;font-size:12px;align-items:center">
|
|
183
|
+
<% (1..@occ_total_pages).each do |p| %>
|
|
184
|
+
<% if p == @occ_page %>
|
|
185
|
+
<span style="padding:4px 10px;background:#2563eb;color:#fff;border-radius:6px"><%= p %></span>
|
|
186
|
+
<% else %>
|
|
187
|
+
<%= link_to p, error_path(@error, occ_page: p, anchor: 'occurrences-panel'),
|
|
188
|
+
style: 'padding:4px 10px;border:1px solid #d1d5db;border-radius:6px;text-decoration:none;color:#374151' %>
|
|
189
|
+
<% end %>
|
|
190
|
+
<% end %>
|
|
191
|
+
<span style="color:#9ca3af">of <%= @occ_total_pages %> pages</span>
|
|
192
|
+
</div>
|
|
193
|
+
<% end %>
|
|
194
|
+
<% end %>
|
|
195
|
+
</div>
|
|
196
|
+
<% end %>
|
|
197
|
+
|
|
107
198
|
<%# ── GitHub Issue ── %>
|
|
108
199
|
<% if ErrorRadar.config.github_token.present? && ErrorRadar.config.github_repo.present? %>
|
|
109
200
|
<div class="panel" style="margin-top:16px">
|
|
@@ -130,6 +221,11 @@
|
|
|
130
221
|
<% end %>
|
|
131
222
|
|
|
132
223
|
<script>
|
|
224
|
+
window.toggleOcc = function (id) {
|
|
225
|
+
var el = document.getElementById(id);
|
|
226
|
+
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
227
|
+
};
|
|
228
|
+
|
|
133
229
|
(function () {
|
|
134
230
|
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
|
135
231
|
var STATUS_URL = '<%= error_status_path(@error) %>';
|
data/config/routes.rb
CHANGED
|
@@ -16,4 +16,6 @@ ErrorRadar::Engine.routes.draw do
|
|
|
16
16
|
get 'errors/:id', to: 'errors#show', as: :error
|
|
17
17
|
patch 'errors/:id/status', to: 'errors#update_status', as: :error_status
|
|
18
18
|
delete 'errors/:id', to: 'errors#destroy'
|
|
19
|
+
|
|
20
|
+
post 'maintenance/purge', to: 'dashboard#purge', as: :maintenance_purge
|
|
19
21
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# Background job for async exception capture. Used when config.async_capture
|
|
5
|
+
# is true. Failures are logged to Rails.logger and swallowed — a broken
|
|
6
|
+
# capture job must not cause retry storms or affect the application.
|
|
7
|
+
class CaptureJob < ActiveJob::Base
|
|
8
|
+
queue_as { ErrorRadar.config.capture_job_queue }
|
|
9
|
+
|
|
10
|
+
def perform(attrs_json)
|
|
11
|
+
attrs = JSON.parse(attrs_json, symbolize_names: true)
|
|
12
|
+
log = ErrorLog.record(**attrs)
|
|
13
|
+
Notifier.dispatch(log) if log
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
ErrorRadar::Tracking.warn_internal("CaptureJob failed: #{e.class}: #{e.message}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ErrorRadar
|
|
4
|
+
# Prunes stale ErrorLog records. Two independent strategies run in sequence:
|
|
5
|
+
#
|
|
6
|
+
# 1. Age-based: deletes resolved/ignored records whose last_seen_at is
|
|
7
|
+
# older than `older_than_days` (or config.retention_days).
|
|
8
|
+
# 2. Count-based: when the total exceeds config.max_records, deletes the
|
|
9
|
+
# oldest resolved/ignored records until the limit is satisfied.
|
|
10
|
+
#
|
|
11
|
+
# Open and in_progress records are never deleted automatically.
|
|
12
|
+
module Cleanup
|
|
13
|
+
def self.run(older_than_days: nil, dry_run: false)
|
|
14
|
+
deleted = 0
|
|
15
|
+
|
|
16
|
+
# ── Age-based pruning ───────────────────────────────────────────────
|
|
17
|
+
days = (older_than_days || ErrorRadar.config.retention_days)&.to_i
|
|
18
|
+
if days && days > 0
|
|
19
|
+
cutoff = days.days.ago
|
|
20
|
+
scope = stale_scope.where('last_seen_at < ?', cutoff)
|
|
21
|
+
deleted += dry_run ? scope.count : scope.delete_all
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ── Count-based pruning ────────────────────────────────────────────
|
|
25
|
+
max = ErrorRadar.config.max_records&.to_i
|
|
26
|
+
if max && max > 0
|
|
27
|
+
total = ErrorLog.count
|
|
28
|
+
if total > max
|
|
29
|
+
excess = total - max
|
|
30
|
+
ids = stale_scope.order(last_seen_at: :asc).limit(excess).pluck(:id)
|
|
31
|
+
deleted += dry_run ? ids.size : ErrorLog.where(id: ids).delete_all
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
{ deleted: deleted, dry_run: dry_run }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.stale_scope
|
|
39
|
+
ErrorLog.where(status: [ErrorLog.statuses[:resolved], ErrorLog.statuses[:ignored]])
|
|
40
|
+
end
|
|
41
|
+
private_class_method :stale_scope
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -69,6 +69,27 @@ module ErrorRadar
|
|
|
69
69
|
@error_callbacks << block
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Occurrences ─────────────────────────────────────────────────────────────
|
|
73
|
+
# When true, each individual error hit is stored in error_radar_occurrences.
|
|
74
|
+
# Requires running: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
|
|
75
|
+
attr_accessor :track_occurrences
|
|
76
|
+
# Maximum occurrences to keep per ErrorLog. Oldest are pruned on each new hit.
|
|
77
|
+
# nil = keep all (not recommended for high-volume apps).
|
|
78
|
+
attr_accessor :max_occurrences_per_error
|
|
79
|
+
|
|
80
|
+
# Performance ─────────────────────────────────────────────────────────────
|
|
81
|
+
# When true, ErrorLog.record is enqueued via ActiveJob (non-blocking).
|
|
82
|
+
# Requires ActiveJob to be configured. Falls back to sync if ActiveJob is absent.
|
|
83
|
+
attr_accessor :async_capture
|
|
84
|
+
# Queue name for CaptureJob when async_capture is enabled.
|
|
85
|
+
attr_accessor :capture_job_queue
|
|
86
|
+
|
|
87
|
+
# Retention ───────────────────────────────────────────────────────────────
|
|
88
|
+
# Auto-delete resolved/ignored records older than this many days (nil = keep forever).
|
|
89
|
+
attr_accessor :retention_days
|
|
90
|
+
# Keep at most this many total records; deletes oldest resolved/ignored first (nil = unlimited).
|
|
91
|
+
attr_accessor :max_records
|
|
92
|
+
|
|
72
93
|
# REST API ────────────────────────────────────────────────────────────────
|
|
73
94
|
# Bearer token for /api/* endpoints. nil = unauthenticated (not for prod).
|
|
74
95
|
attr_accessor :api_token
|
|
@@ -135,6 +156,14 @@ module ErrorRadar
|
|
|
135
156
|
@app_host = nil
|
|
136
157
|
@error_callbacks = []
|
|
137
158
|
|
|
159
|
+
@track_occurrences = false
|
|
160
|
+
@max_occurrences_per_error = 200
|
|
161
|
+
|
|
162
|
+
@async_capture = false
|
|
163
|
+
@capture_job_queue = :default
|
|
164
|
+
@retention_days = nil
|
|
165
|
+
@max_records = nil
|
|
166
|
+
|
|
138
167
|
@api_token = nil
|
|
139
168
|
@github_token = nil
|
|
140
169
|
@github_repo = nil
|
data/lib/error_radar/engine.rb
CHANGED
data/lib/error_radar/tracking.rb
CHANGED
|
@@ -27,6 +27,17 @@ module ErrorRadar
|
|
|
27
27
|
attrs.merge!(extra.compact) if extra.is_a?(Hash)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
if ErrorRadar.config.async_capture && defined?(::ActiveJob::Base)
|
|
31
|
+
require 'error_radar/capture_job'
|
|
32
|
+
job_attrs = attrs.merge(
|
|
33
|
+
category: attrs[:category].to_s,
|
|
34
|
+
severity: attrs[:severity].to_s,
|
|
35
|
+
context: safe_json(attrs[:context])
|
|
36
|
+
)
|
|
37
|
+
ErrorRadar::CaptureJob.perform_later(job_attrs.to_json)
|
|
38
|
+
return nil
|
|
39
|
+
end
|
|
40
|
+
|
|
30
41
|
log = ErrorRadar::ErrorLog.record(**attrs)
|
|
31
42
|
ErrorRadar::Notifier.dispatch(log) if log
|
|
32
43
|
log
|
|
@@ -112,6 +123,12 @@ module ErrorRadar
|
|
|
112
123
|
nil
|
|
113
124
|
end
|
|
114
125
|
|
|
126
|
+
def safe_json(value)
|
|
127
|
+
JSON.parse(value.to_json)
|
|
128
|
+
rescue StandardError
|
|
129
|
+
{}
|
|
130
|
+
end
|
|
131
|
+
|
|
115
132
|
def warn_internal(message)
|
|
116
133
|
logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
117
134
|
logger ? logger.error("[ErrorRadar] #{message}") : warn("[ErrorRadar] #{message}")
|
data/lib/error_radar/version.rb
CHANGED
data/lib/error_radar.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'error_radar/version'
|
|
|
4
4
|
require 'error_radar/configuration'
|
|
5
5
|
require 'error_radar/tracking'
|
|
6
6
|
require 'error_radar/notifier'
|
|
7
|
+
require 'error_radar/cleanup'
|
|
7
8
|
require 'error_radar/middleware'
|
|
8
9
|
require 'error_radar/server_monitor'
|
|
9
10
|
require 'error_radar/engine'
|
|
@@ -83,6 +83,31 @@ ErrorRadar.configure do |config|
|
|
|
83
83
|
# Requires running: bin/rails generate error_radar:upgrade_v050 && bin/rails db:migrate
|
|
84
84
|
# config.github_token = ENV['GITHUB_TOKEN'] # PAT with repo scope
|
|
85
85
|
# config.github_repo = 'myorg/myapp' # "owner/repo" format
|
|
86
|
+
|
|
87
|
+
# --- Occurrence History ---
|
|
88
|
+
# Store each individual error hit so you can see context/backtrace per occurrence.
|
|
89
|
+
# Requires running: bin/rails generate error_radar:upgrade_v060 && bin/rails db:migrate
|
|
90
|
+
# config.track_occurrences = true
|
|
91
|
+
# config.max_occurrences_per_error = 200 # keep last N per error (oldest pruned on each new hit)
|
|
92
|
+
|
|
93
|
+
# --- Performance & Async Capture ---
|
|
94
|
+
# Write ErrorLog records via ActiveJob so exceptions don't block the request.
|
|
95
|
+
# Requires ActiveJob + a queue adapter (Sidekiq, Solid Queue, etc.).
|
|
96
|
+
# Falls back to synchronous capture if ActiveJob is unavailable.
|
|
97
|
+
# config.async_capture = true
|
|
98
|
+
# config.capture_job_queue = :default # queue name for CaptureJob
|
|
99
|
+
|
|
100
|
+
# --- Retention / Cleanup ---
|
|
101
|
+
# Auto-prune old resolved/ignored records to keep the table lean.
|
|
102
|
+
# Run `rake error_radar:cleanup` from a cron job or Heroku Scheduler.
|
|
103
|
+
# config.retention_days = 90 # delete resolved/ignored records older than 90 days
|
|
104
|
+
# config.max_records = 50000 # hard cap; oldest resolved/ignored purged first
|
|
105
|
+
#
|
|
106
|
+
# Rake tasks available:
|
|
107
|
+
# rake error_radar:cleanup # apply retention_days + max_records
|
|
108
|
+
# rake error_radar:cleanup:dry_run # preview without deleting
|
|
109
|
+
# rake error_radar:cleanup:older_than # DAYS=30 rake error_radar:cleanup:older_than
|
|
110
|
+
# rake error_radar:stats # print table summary
|
|
86
111
|
end
|
|
87
112
|
|
|
88
113
|
# ActiveJob is now captured automatically via install_active_job = true above.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateErrorRadarOccurrences < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :error_radar_occurrences do |t|
|
|
6
|
+
t.references :error_log, null: false,
|
|
7
|
+
foreign_key: { to_table: :error_radar_error_logs },
|
|
8
|
+
index: false # covered by composite index below
|
|
9
|
+
|
|
10
|
+
t.datetime :occurred_at, null: false
|
|
11
|
+
|
|
12
|
+
t.json :context # structured payload snapshot for this hit
|
|
13
|
+
t.text :backtrace # backtrace at the moment of this occurrence
|
|
14
|
+
t.integer :http_status
|
|
15
|
+
t.string :request_url, limit: 2000
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fast per-error chronological listing
|
|
21
|
+
add_index :error_radar_occurrences, %i[error_log_id occurred_at],
|
|
22
|
+
name: 'idx_er_occurrences_log_time'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators/active_record'
|
|
4
|
+
|
|
5
|
+
module ErrorRadar
|
|
6
|
+
module Generators
|
|
7
|
+
class UpgradeV060Generator < ActiveRecord::Generators::Base
|
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
|
9
|
+
|
|
10
|
+
argument :name, type: :string, default: 'upgrade_v060'
|
|
11
|
+
|
|
12
|
+
desc 'Creates the error_radar_occurrences table for per-hit occurrence history (v0.7.0).'
|
|
13
|
+
|
|
14
|
+
def create_migration
|
|
15
|
+
migration_template 'create_error_radar_occurrences.rb.tt',
|
|
16
|
+
'db/migrate/create_error_radar_occurrences.rb'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :error_radar do
|
|
4
|
+
desc 'Delete old resolved/ignored ErrorLogs per config.retention_days and config.max_records'
|
|
5
|
+
task cleanup: :environment do
|
|
6
|
+
result = ErrorRadar::Cleanup.run
|
|
7
|
+
puts "[ErrorRadar] Cleanup complete — #{result[:deleted]} record(s) deleted."
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
namespace :cleanup do
|
|
11
|
+
desc 'Preview what error_radar:cleanup would delete without actually deleting'
|
|
12
|
+
task dry_run: :environment do
|
|
13
|
+
result = ErrorRadar::Cleanup.run(dry_run: true)
|
|
14
|
+
puts "[ErrorRadar] Dry run — would delete #{result[:deleted]} record(s)."
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'Delete resolved/ignored ErrorLogs older than DAYS (e.g. DAYS=30 rake error_radar:cleanup:older_than)'
|
|
18
|
+
task older_than: :environment do
|
|
19
|
+
days = ENV.fetch('DAYS', nil)&.to_i
|
|
20
|
+
abort '[ErrorRadar] Set DAYS env var (e.g. DAYS=30)' unless days&.positive?
|
|
21
|
+
|
|
22
|
+
result = ErrorRadar::Cleanup.run(older_than_days: days)
|
|
23
|
+
puts "[ErrorRadar] Deleted #{result[:deleted]} record(s) older than #{days} days."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc 'Print a summary of ErrorLog table stats'
|
|
28
|
+
task stats: :environment do
|
|
29
|
+
counts = ErrorRadar::ErrorLog.group(:status).count
|
|
30
|
+
.transform_keys { |k| ErrorRadar::ErrorLog.statuses.key(k) || k }
|
|
31
|
+
total = counts.values.sum
|
|
32
|
+
|
|
33
|
+
puts "\n[ErrorRadar] Error log summary"
|
|
34
|
+
puts " Total : #{total}"
|
|
35
|
+
puts " Open : #{counts['open'] || 0}"
|
|
36
|
+
puts " In progress: #{counts['in_progress'] || 0}"
|
|
37
|
+
puts " Resolved : #{counts['resolved'] || 0}"
|
|
38
|
+
puts " Ignored : #{counts['ignored'] || 0}"
|
|
39
|
+
|
|
40
|
+
oldest = ErrorRadar::ErrorLog.minimum(:first_seen_at)
|
|
41
|
+
puts " Oldest : #{oldest&.strftime('%Y-%m-%d') || '—'}"
|
|
42
|
+
puts ''
|
|
43
|
+
end
|
|
44
|
+
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- chienbn9x
|
|
@@ -66,6 +66,7 @@ files:
|
|
|
66
66
|
- app/mailers/error_radar/error_mailer.rb
|
|
67
67
|
- app/models/error_radar/application_record.rb
|
|
68
68
|
- app/models/error_radar/error_log.rb
|
|
69
|
+
- app/models/error_radar/error_occurrence.rb
|
|
69
70
|
- app/views/error_radar/dashboard/index.html.erb
|
|
70
71
|
- app/views/error_radar/dashboard/show.html.erb
|
|
71
72
|
- app/views/error_radar/error_mailer/new_error.html.erb
|
|
@@ -75,6 +76,8 @@ files:
|
|
|
75
76
|
- app/views/layouts/error_radar/application.html.erb
|
|
76
77
|
- config/routes.rb
|
|
77
78
|
- lib/error_radar.rb
|
|
79
|
+
- lib/error_radar/capture_job.rb
|
|
80
|
+
- lib/error_radar/cleanup.rb
|
|
78
81
|
- lib/error_radar/configuration.rb
|
|
79
82
|
- lib/error_radar/engine.rb
|
|
80
83
|
- lib/error_radar/integrations/active_job.rb
|
|
@@ -96,6 +99,9 @@ files:
|
|
|
96
99
|
- lib/generators/error_radar/install/templates/initializer.rb
|
|
97
100
|
- lib/generators/error_radar/upgrade_v050/templates/add_github_issue_to_error_radar_error_logs.rb.tt
|
|
98
101
|
- lib/generators/error_radar/upgrade_v050/upgrade_v050_generator.rb
|
|
102
|
+
- lib/generators/error_radar/upgrade_v060/templates/create_error_radar_occurrences.rb.tt
|
|
103
|
+
- lib/generators/error_radar/upgrade_v060/upgrade_v060_generator.rb
|
|
104
|
+
- lib/tasks/error_radar.rake
|
|
99
105
|
homepage: https://github.com/chienbn9x/error_radar
|
|
100
106
|
licenses:
|
|
101
107
|
- MIT
|