error_radar 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 486749dc9fc6c6e2cc617340f184cf8dbeb4196199c3017eb7bf6f667718fe74
4
- data.tar.gz: 13da87d1772c71901b66fb4cd6b4d29a809b539b6fccaee5613defae97c0a04d
3
+ metadata.gz: bdfb13eb6566d150855376a07e28a5a76a758b23526d7cf31de5a6adc17e5d27
4
+ data.tar.gz: 713dcf3fa8c7867b971b6e7da6dd7dbf56a6e4057ec284dbadb6a8818b83f6d6
5
5
  SHA512:
6
- metadata.gz: 6a9fe7aa11d7668ca353e0c6331eac9a339c43263f93bc3650085bd72f2072968376006f7481661037c7d9f80fdca0057b8a28d9779a92050b931fb4eccd441f
7
- data.tar.gz: f98150abaa5c7bd7695ddd38ba91d65db73bfe81ce7e739c9f91fac20c596b3303643fc282320b2366f9b1b6bd4da48c2bfca7bd8d182e1162c3a7786770384a
6
+ metadata.gz: b71979e7ef0ca2737d789f3f047a024c9933d8b2149e83e5d88fc734144213226dfb1a160b4080ee008104dd0c19dd35a1da06ce0fbeb2ae61a0a65455a65d0f
7
+ data.tar.gz: 1838775fea5865d76bc746c49bc51efb0a557d6f6caf0924bdcd489b081570ff8667715690e3ef1b8a32ca7af219d7a26a1fe312e17304c27e4a9025d0632bf4
data/CHANGELOG.md CHANGED
@@ -2,6 +2,67 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.6.0] - 2026-07-03
6
+
7
+ ### Added
8
+ - **Async capture** via ActiveJob: set `config.async_capture = true` to enqueue
9
+ error writes through `CaptureJob` so exceptions don't block the request/job
10
+ thread. Falls back to synchronous capture if ActiveJob is unavailable.
11
+ Configure the queue with `config.capture_job_queue` (default `:default`).
12
+ - **Age-based retention**: set `config.retention_days` to automatically prune
13
+ resolved/ignored records older than N days.
14
+ - **Count-based retention**: set `config.max_records` to cap the table size;
15
+ oldest resolved/ignored records are deleted first when the limit is exceeded.
16
+ - **`ErrorRadar::Cleanup.run`**: underlying cleanup logic, callable from Ruby or
17
+ Rake. Supports `older_than_days:` override and `dry_run: true` for previewing
18
+ without deleting.
19
+ - **Rake tasks**:
20
+ - `rake error_radar:cleanup` — apply `retention_days` + `max_records`
21
+ - `rake error_radar:cleanup:dry_run` — preview without deleting
22
+ - `rake error_radar:cleanup:older_than` — one-off: `DAYS=30 rake …`
23
+ - `rake error_radar:stats` — print table summary (total, by status, oldest)
24
+ - **Dashboard maintenance panel**: data summary (total / purgeable / open),
25
+ oldest record date, and an inline "Purge / Dry run" form with adjustable days.
26
+ - **`POST /maintenance/purge`** endpoint: called by the dashboard's Purge button;
27
+ accepts `days` and `dry_run` params, returns JSON `{ deleted:, dry_run: }`.
28
+ - **Dashboard stat query optimization**: 5 separate COUNT queries replaced by one
29
+ `group(:status).count` query.
30
+
31
+ ### Notes
32
+ - `async_capture` is `false` by default to avoid surprising behaviour for apps
33
+ that do not have a queue adapter configured.
34
+ - Cleanup only touches `resolved` and `ignored` records — open/in-progress
35
+ records are never auto-deleted.
36
+
37
+ ## [0.5.0] - 2026-07-03
38
+
39
+ ### Added
40
+ - **REST API** at `/api/*` for external integrations (CI/CD, dashboards, scripts):
41
+ - `GET /api/errors` — paginated list with the same filters as the web UI
42
+ (`status`, `severity`, `category`, `q`, `from`, `to`, `sort`, `order`, `page`)
43
+ - `GET /api/errors/:id` — full detail including context, backtrace, and
44
+ `github_issue_url` (if column is present)
45
+ - `PATCH /api/errors/:id` — update status; resolve accepts optional `note`
46
+ and `resolved_by` params
47
+ - `GET /api/stats` — summary counts by status, severity, and category
48
+ - **Bearer-token API auth**: set `config.api_token` to protect all `/api/*`
49
+ endpoints with `Authorization: Bearer <token>`.
50
+ - **GitHub Issue integration**: "Create GitHub Issue" button on the error detail
51
+ page opens a pre-filled issue with error class, source, message, backtrace,
52
+ and a deep-link back to Error Radar. Requires `config.github_token` and
53
+ `config.github_repo`.
54
+ - **`github_issue_url` column**: stored on the error row so the button becomes
55
+ "View GitHub Issue" once an issue exists. Requires running the upgrade
56
+ migration: `bin/rails generate error_radar:upgrade_v050 && bin/rails db:migrate`.
57
+ - **`error_radar:upgrade_v050` generator**: generates the migration that adds
58
+ `github_issue_url` to `error_radar_error_logs`.
59
+
60
+ ### Notes
61
+ - The GitHub column is optional — the integration is gracefully degraded when the
62
+ migration has not been run (button appears but URL is not persisted).
63
+ - The API controllers live in `ErrorRadar::Api::*` to avoid polluting the host
64
+ app's controller namespace.
65
+
5
66
  ## [0.4.0] - 2026-07-03
6
67
 
7
68
  ### 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
@@ -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
- @total = ErrorLog.count
14
- @open_count = ErrorLog.status_open.count
15
- @in_progress = ErrorLog.status_in_progress.count
16
- @resolved_count = ErrorLog.status_resolved.count
17
- @ignored_count = ErrorLog.status_ignored.count
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
@@ -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
@@ -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 &amp; 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,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
+ &#9651; View GitHub Issue
116
+ </a>
117
+ <% else %>
118
+ <button id="github-btn" class="er-btn" style="background:#24292e" onclick="createGithubIssue()">
119
+ &#9651; 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 &amp;&amp; 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,19 @@
3
3
  ErrorRadar::Engine.routes.draw do
4
4
  root to: 'dashboard#index'
5
5
 
6
- get 'errors', to: 'errors#index', as: :errors
7
- post 'errors/bulk', to: 'errors#bulk', as: :errors_bulk
8
- get 'errors/:id', to: 'errors#show', as: :error
9
- patch 'errors/:id/status', to: 'errors#update_status', as: :error_status
10
- delete 'errors/:id', to: 'errors#destroy'
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'
19
+
20
+ post 'maintenance/purge', to: 'dashboard#purge', as: :maintenance_purge
11
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,29 @@ module ErrorRadar
69
69
  @error_callbacks << block
70
70
  end
71
71
 
72
+ # Performance ─────────────────────────────────────────────────────────────
73
+ # When true, ErrorLog.record is enqueued via ActiveJob (non-blocking).
74
+ # Requires ActiveJob to be configured. Falls back to sync if ActiveJob is absent.
75
+ attr_accessor :async_capture
76
+ # Queue name for CaptureJob when async_capture is enabled.
77
+ attr_accessor :capture_job_queue
78
+
79
+ # Retention ───────────────────────────────────────────────────────────────
80
+ # Auto-delete resolved/ignored records older than this many days (nil = keep forever).
81
+ attr_accessor :retention_days
82
+ # Keep at most this many total records; deletes oldest resolved/ignored first (nil = unlimited).
83
+ attr_accessor :max_records
84
+
85
+ # REST API ────────────────────────────────────────────────────────────────
86
+ # Bearer token for /api/* endpoints. nil = unauthenticated (not for prod).
87
+ attr_accessor :api_token
88
+
89
+ # GitHub integration ──────────────────────────────────────────────────────
90
+ # Personal access token with repo scope.
91
+ attr_accessor :github_token
92
+ # "owner/repo" string, e.g. "myorg/myapp".
93
+ attr_accessor :github_repo
94
+
72
95
  # Custom classification rules. Each is a callable `->(exception) { :category | nil }`.
73
96
  # The first rule that returns a non-nil category wins; built-in rules run after.
74
97
  attr_accessor :categorizers
@@ -124,6 +147,15 @@ module ErrorRadar
124
147
  @app_name = nil
125
148
  @app_host = nil
126
149
  @error_callbacks = []
150
+
151
+ @async_capture = false
152
+ @capture_job_queue = :default
153
+ @retention_days = nil
154
+ @max_records = nil
155
+
156
+ @api_token = nil
157
+ @github_token = nil
158
+ @github_repo = nil
127
159
  @categorizers = []
128
160
  @detail_extractors = []
129
161
  @expected_servers = []
@@ -52,5 +52,9 @@ module ErrorRadar
52
52
  ErrorRadar::Integrations::RailsAdmin.install!
53
53
  end
54
54
  end
55
+
56
+ rake_tasks do
57
+ load File.expand_path('../tasks/error_radar.rake', __dir__)
58
+ end
55
59
  end
56
60
  end
@@ -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
@@ -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}")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ErrorRadar
4
- VERSION = '0.4.0'
4
+ VERSION = '0.6.0'
5
5
  end
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'
@@ -72,6 +72,36 @@ ErrorRadar.configure do |config|
72
72
  # config.expected_servers = [
73
73
  # { key: 'web', name: 'Web', tag: 'sidekiq_web', host: 'web', queue_hint: 'low' }
74
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
86
+
87
+ # --- Performance & Async Capture ---
88
+ # Write ErrorLog records via ActiveJob so exceptions don't block the request.
89
+ # Requires ActiveJob + a queue adapter (Sidekiq, Solid Queue, etc.).
90
+ # Falls back to synchronous capture if ActiveJob is unavailable.
91
+ # config.async_capture = true
92
+ # config.capture_job_queue = :default # queue name for CaptureJob
93
+
94
+ # --- Retention / Cleanup ---
95
+ # Auto-prune old resolved/ignored records to keep the table lean.
96
+ # Run `rake error_radar:cleanup` from a cron job or Heroku Scheduler.
97
+ # config.retention_days = 90 # delete resolved/ignored records older than 90 days
98
+ # config.max_records = 50000 # hard cap; oldest resolved/ignored purged first
99
+ #
100
+ # Rake tasks available:
101
+ # rake error_radar:cleanup # apply retention_days + max_records
102
+ # rake error_radar:cleanup:dry_run # preview without deleting
103
+ # rake error_radar:cleanup:older_than # DAYS=30 rake error_radar:cleanup:older_than
104
+ # rake error_radar:stats # print table summary
75
105
  end
76
106
 
77
107
  # ActiveJob is now captured automatically via install_active_job = true above.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddGithubIssueToErrorRadarErrorLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ add_column :error_radar_error_logs, :github_issue_url, :string, limit: 500
6
+ end
7
+ end
@@ -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
@@ -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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - chienbn9x
@@ -57,6 +57,9 @@ 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
@@ -72,9 +75,12 @@ files:
72
75
  - app/views/layouts/error_radar/application.html.erb
73
76
  - config/routes.rb
74
77
  - lib/error_radar.rb
78
+ - lib/error_radar/capture_job.rb
79
+ - lib/error_radar/cleanup.rb
75
80
  - lib/error_radar/configuration.rb
76
81
  - lib/error_radar/engine.rb
77
82
  - lib/error_radar/integrations/active_job.rb
83
+ - lib/error_radar/integrations/github.rb
78
84
  - lib/error_radar/integrations/rails_admin.rb
79
85
  - lib/error_radar/integrations/rake.rb
80
86
  - lib/error_radar/integrations/sidekiq.rb
@@ -90,6 +96,9 @@ files:
90
96
  - lib/generators/error_radar/install/install_generator.rb
91
97
  - lib/generators/error_radar/install/templates/create_error_radar_error_logs.rb.tt
92
98
  - lib/generators/error_radar/install/templates/initializer.rb
99
+ - lib/generators/error_radar/upgrade_v050/templates/add_github_issue_to_error_radar_error_logs.rb.tt
100
+ - lib/generators/error_radar/upgrade_v050/upgrade_v050_generator.rb
101
+ - lib/tasks/error_radar.rake
93
102
  homepage: https://github.com/chienbn9x/error_radar
94
103
  licenses:
95
104
  - MIT