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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 106881790c16306716c7588f317da722c0e589db0a0a2bbb6e7ad838c9a8d118
4
- data.tar.gz: d4c9ccf1363b88637dc625418daf8553e15aad0796f42f79aadc6eafb3a4f554
3
+ metadata.gz: 57b3fa6a7576ae08baeb699b180048363846c84ecbb489fc93e16ea537939898
4
+ data.tar.gz: 67008613bd52755dad89b7ed1abbc9a86eb92c138f95cae8005f5272e8336594
5
5
  SHA512:
6
- metadata.gz: c880963d54ef38274dbc226b970f848f7a9ced038c6d88ababffe1b3eb71ef2d8490695a0a256775a500e707a72b9af2a017a066e8af1f66b17ab842a18ac726
7
- data.tar.gz: ba612e7cc8a547cd1bf7ffb8704347636bccc7f763fba3a73fbfe1919b86573421873b61c88f6c8b44da0ed661cf8cf73d4da62a54998828dfe1a767642580ad
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
- @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
@@ -22,7 +22,23 @@ module ErrorRadar
22
22
  @pages = pagination_pages(@page, @total_pages)
23
23
  end
24
24
 
25
- def show; end
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 &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,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
@@ -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
@@ -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.5.0'
4
+ VERSION = '0.7.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'
@@ -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.5.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