error_track 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b0aafcfb135d31aacec7b653a3f5a05862c08a3d7340ed2bcf23280943cabc8f
4
+ data.tar.gz: 613a310ad2fb7b96521225806fa9dc9a2703607823f1f182b51236e7e736eb1a
5
+ SHA512:
6
+ metadata.gz: c3346b12cd38a25d6006098646a1d27182986d3895fe5e289874c5b1a0975a519ad850fd9966a8133fedf1cec9302a2ecd23b0a036d8b1b634d4f5742f8726d2
7
+ data.tar.gz: 9ba6cbf434e9d11d1b26129de45156b630301578fd766eabbd0b156fa3bc127020dab56b890b79726b84dd630ebd1fdf281a1dba92389a1e41d29bdbec1a3986
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # ErrorTrack
2
+
3
+ A self-hosted, Sentry/Honeybadger-style error tracker for Rails — except
4
+ errors are logged to **your own app's database**, and shown at **`/errors`**.
5
+ No external service, no API keys, no extra infrastructure.
6
+
7
+ ## Features
8
+
9
+ - Automatically captures unhandled exceptions (Rack middleware) and errors
10
+ reported via Rails' built-in `Rails.error` reporter (retries, background
11
+ rescues, etc.)
12
+ - Manual reporting API: `ErrorTrack.notify(exception, context: {...})`
13
+ - Groups repeated occurrences of the same error together (like Sentry
14
+ "issues"), with a count, first-seen/last-seen timestamps
15
+ - Stores backtrace + request context (URL, method, params, user) per
16
+ occurrence
17
+ - `/errors` dashboard — a single self-contained page, hand-rolled with
18
+ vanilla JS and a Radix/Tailwind-inspired utility CSS. No Hotwire, no
19
+ React, no npm, no CDN, no build step. It talks to plain JSON endpoints
20
+ and polls lightly for live updates.
21
+ - **Works the same in full-stack Rails apps and API-only Rails apps**
22
+ (`--api`), and on older Rails versions that don't have Hotwire installed.
23
+ CSRF/session usage is auto-detected and skipped entirely when there's no
24
+ session store in the middleware stack, so it never blows up in an
25
+ API-only app.
26
+ - Zero required external services — just your existing database
27
+
28
+ ## Installation
29
+
30
+ Add to your Gemfile:
31
+
32
+ ```ruby
33
+ gem "error_track"
34
+ ```
35
+
36
+ Then:
37
+
38
+ ```bash
39
+ bundle install
40
+ rails generate error_track:install
41
+ rails db:migrate
42
+ ```
43
+
44
+ This will:
45
+ 1. Add a migration creating `error_track_error_events` and `error_track_occurrences`
46
+ 2. Add `config/initializers/error_track.rb`
47
+ 3. Mount the engine at `/errors` in `config/routes.rb`
48
+
49
+ Visit `/errors` in your running app — works identically whether your app
50
+ is a normal Rails app or `config.api_only = true`.
51
+
52
+ ### API-only apps + a separate frontend (e.g. Next.js)
53
+
54
+ If your Rails app is a pure JSON API and your actual product frontend
55
+ lives elsewhere (Next.js, etc.), `/errors` still works out of the box: it's
56
+ just an extra HTML route your Rails app happens to serve, independent of
57
+ your API routes and independent of your Next.js app. Nothing about your
58
+ API surface changes, and you don't need to build any dashboard UI into
59
+ your Next.js app - just visit `yourapi.example.com/errors` directly (behind
60
+ your own auth/VPN, see below).
61
+
62
+ If you'd rather it live *inside* your Next.js app instead, the JSON
63
+ endpoints (`GET /errors.json`, `GET /errors/:id.json`, etc. - see below)
64
+ are plain JSON and CORS-friendly, so you could point a custom Next.js page
65
+ at them instead of using the bundled HTML dashboard. That's more work than
66
+ just visiting `/errors` though, and isn't required.
67
+
68
+ ## Securing the dashboard
69
+
70
+ **This is important** — by default `/errors` has no auth and will show
71
+ backtraces, params, and any user info you configure. Restrict it in
72
+ `config/initializers/error_track.rb`, e.g.:
73
+
74
+ ```ruby
75
+ Rails.application.config.to_prepare do
76
+ ErrorTrack::ErrorsController.class_eval do
77
+ before_action :authenticate_admin! # your existing auth method
78
+ end
79
+ end
80
+ ```
81
+
82
+ or gate the route itself in `config/routes.rb`:
83
+
84
+ ```ruby
85
+ authenticate :user, ->(u) { u.admin? } do
86
+ mount ErrorTrack::Engine => "/errors"
87
+ end
88
+ ```
89
+
90
+ For an API-only app with no session-based auth at all, HTTP basic auth is
91
+ often simplest:
92
+
93
+ ```ruby
94
+ Rails.application.config.to_prepare do
95
+ ErrorTrack::ErrorsController.class_eval do
96
+ http_basic_authenticate_with name: Rails.application.credentials.dig(:error_track, :user),
97
+ password: Rails.application.credentials.dig(:error_track, :password)
98
+ end
99
+ end
100
+ ```
101
+
102
+ ## Manual reporting
103
+
104
+ ```ruby
105
+ begin
106
+ risky_call
107
+ rescue => e
108
+ ErrorTrack.notify(e, context: { user_id: current_user&.id, order_id: order.id })
109
+ # handle/re-raise as needed
110
+ end
111
+ ```
112
+
113
+ ## JSON API
114
+
115
+ The dashboard is powered by these endpoints (mounted relative to wherever
116
+ you mount the engine, e.g. `/errors`):
117
+
118
+ | Method | Path | Description |
119
+ |--------|-------------------------|-------------------------------------|
120
+ | GET | `/errors.json` | List, supports `?filter=unresolved\|resolved\|all` and `?page=` |
121
+ | GET | `/errors/:id.json` | Single error group + recent occurrences |
122
+ | POST | `/errors/:id/resolve` | Mark resolved |
123
+ | POST | `/errors/:id/reopen` | Reopen |
124
+ | DELETE | `/errors/:id` | Delete an error group and its occurrences |
125
+
126
+ ## Configuration
127
+
128
+ See the generated `config/initializers/error_track.rb` for all options:
129
+ enabling/disabling capture per environment, ignored exception classes,
130
+ a `current_user_resolver` for attaching user info to occurrences, and
131
+ retention settings.
132
+
133
+ ## How grouping works
134
+
135
+ Errors are fingerprinted from the exception class + the first backtrace
136
+ line inside your app (with line numbers normalized out), so the same bug
137
+ firing 500 times shows up as one row with a count of 500 — not 500 rows.
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,118 @@
1
+ module ErrorTrack
2
+ class ErrorsController < ActionController::Base
3
+ layout false
4
+
5
+ # CSRF protection (and the CSRF token/meta tags themselves) only work
6
+ # when a session store is actually in the middleware stack. API-only
7
+ # Rails apps strip out ActionDispatch::Session/Flash/Cookies by default,
8
+ # and touching the session without it raises. So we detect this once and
9
+ # skip CSRF entirely when there's no session backing it - this lets the
10
+ # same controller work in a full Rails app *and* an `--api` app with zero
11
+ # configuration.
12
+ SESSION_AVAILABLE = begin
13
+ Rails.application.middleware.any? { |m| m.klass.name.to_s.include?("Session") }
14
+ rescue
15
+ false
16
+ end
17
+
18
+ protect_from_forgery with: :exception if SESSION_AVAILABLE
19
+ helper_method :session_available? if respond_to?(:helper_method)
20
+
21
+ rescue_from ActiveRecord::RecordNotFound do
22
+ render json: { error: "not found" }, status: :not_found
23
+ end
24
+
25
+ # GET /errors (HTML shell)
26
+ # GET /errors.json (JSON list)
27
+ def index
28
+ scope = ErrorTrack::ErrorEvent.recent
29
+ scope = case params[:filter]
30
+ when "resolved" then scope.resolved
31
+ when "all" then scope
32
+ else scope.unresolved
33
+ end
34
+
35
+ page = [params[:page].to_i, 1].max
36
+ per_page = 25
37
+ total = scope.count
38
+ events = scope.offset((page - 1) * per_page).limit(per_page)
39
+
40
+ respond_to do |format|
41
+ format.html { render "error_track/errors/index" }
42
+ format.json do
43
+ render json: {
44
+ errors: events.map { |e| serialize_event(e) },
45
+ meta: {
46
+ page: page,
47
+ per_page: per_page,
48
+ total: total,
49
+ total_pages: (total.to_f / per_page).ceil
50
+ }
51
+ }
52
+ end
53
+ end
54
+ end
55
+
56
+ # GET /errors/:id.json
57
+ def show
58
+ event = ErrorTrack::ErrorEvent.find(params[:id])
59
+ occurrences = event.occurrences.recent.limit(20)
60
+
61
+ render json: serialize_event(event).merge(
62
+ occurrences: occurrences.map { |o| serialize_occurrence(o) }
63
+ )
64
+ end
65
+
66
+ # POST /errors/:id/resolve
67
+ def resolve
68
+ event = ErrorTrack::ErrorEvent.find(params[:id])
69
+ event.resolve!
70
+ render json: serialize_event(event)
71
+ end
72
+
73
+ # POST /errors/:id/reopen
74
+ def reopen
75
+ event = ErrorTrack::ErrorEvent.find(params[:id])
76
+ event.reopen!
77
+ render json: serialize_event(event)
78
+ end
79
+
80
+ # DELETE /errors/:id
81
+ def destroy
82
+ event = ErrorTrack::ErrorEvent.find(params[:id])
83
+ event.destroy
84
+ head :no_content
85
+ end
86
+
87
+ private
88
+
89
+ def session_available?
90
+ SESSION_AVAILABLE
91
+ end
92
+
93
+ def serialize_event(event)
94
+ {
95
+ id: event.id,
96
+ klass: event.klass,
97
+ message: event.message,
98
+ occurrences_count: event.occurrences_count,
99
+ resolved: event.resolved,
100
+ first_seen_at: event.first_seen_at,
101
+ last_seen_at: event.last_seen_at
102
+ }
103
+ end
104
+
105
+ def serialize_occurrence(occurrence)
106
+ {
107
+ id: occurrence.id,
108
+ occurred_at: occurrence.occurred_at,
109
+ backtrace: occurrence.backtrace,
110
+ environment: occurrence.environment,
111
+ url: occurrence.request_url,
112
+ method: occurrence.request_method,
113
+ params: occurrence.request_params,
114
+ user: occurrence.user_info
115
+ }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,5 @@
1
+ module ErrorTrack
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ module ErrorTrack
2
+ class ErrorEvent < ApplicationRecord
3
+ self.table_name = "error_track_error_events"
4
+
5
+ has_many :occurrences, class_name: "ErrorTrack::Occurrence", dependent: :destroy, inverse_of: :error_event
6
+
7
+ validates :fingerprint, presence: true, uniqueness: true
8
+ validates :klass, presence: true
9
+
10
+ scope :unresolved, -> { where(resolved: false) }
11
+ scope :resolved, -> { where(resolved: true) }
12
+ scope :recent, -> { order(last_seen_at: :desc) }
13
+
14
+ def resolve!
15
+ update!(resolved: true)
16
+ end
17
+
18
+ def reopen!
19
+ update!(resolved: false)
20
+ end
21
+
22
+ def latest_occurrence
23
+ occurrences.order(occurred_at: :desc).first
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,28 @@
1
+ module ErrorTrack
2
+ class Occurrence < ApplicationRecord
3
+ self.table_name = "error_track_occurrences"
4
+
5
+ belongs_to :error_event, class_name: "ErrorTrack::ErrorEvent", inverse_of: :occurrences
6
+
7
+ # `context` is a native :json column (see install generator migration),
8
+ # so Rails handles serialization automatically - no `serialize` needed.
9
+
10
+ scope :recent, -> { order(occurred_at: :desc) }
11
+
12
+ def request_url
13
+ context["url"] || context[:url]
14
+ end
15
+
16
+ def request_method
17
+ context["method"] || context[:method]
18
+ end
19
+
20
+ def request_params
21
+ context["params"] || context[:params]
22
+ end
23
+
24
+ def user_info
25
+ context["user"] || context[:user]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,414 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Errors</title>
7
+ <% if session_available? %>
8
+ <%= csrf_meta_tags %>
9
+ <% end %>
10
+ <style>
11
+ :root {
12
+ --gray-1:#fcfcfd; --gray-2:#f9f9fb; --gray-3:#f0f0f3; --gray-4:#e8e8ec;
13
+ --gray-5:#e0e1e6; --gray-6:#d9d9e0; --gray-7:#cdced6; --gray-8:#b9bbc6;
14
+ --gray-9:#8b8d98; --gray-10:#80828d; --gray-11:#60646c; --gray-12:#1c1f26;
15
+ --red-3:#feebec; --red-9:#e5484d; --red-11:#ce2c31;
16
+ --amber-3:#fff7d6; --amber-11:#a35200;
17
+ --green-3:#e6f6eb; --green-9:#30a46c; --green-11:#207c4f;
18
+ --accent-9:#3e63dd;
19
+ --radius-2:6px; --radius-3:10px;
20
+ --shadow-1:0 1px 2px rgba(0,0,0,.06);
21
+ --shadow-3:0 4px 16px rgba(0,0,0,.08),0 1px 3px rgba(0,0,0,.06);
22
+ --shadow-5:0 12px 36px rgba(0,0,0,.14),0 2px 6px rgba(0,0,0,.08);
23
+ --font: -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Helvetica,Arial,sans-serif;
24
+ --mono: ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ html,body { margin:0; padding:0; }
28
+ body {
29
+ font-family: var(--font);
30
+ background: var(--gray-2);
31
+ color: var(--gray-12);
32
+ font-size: 14px;
33
+ line-height: 1.5;
34
+ -webkit-font-smoothing: antialiased;
35
+ }
36
+ a { color: inherit; }
37
+ button { font-family: inherit; font-size: inherit; }
38
+
39
+ .app { max-width: 880px; margin: 0 auto; padding: 0 20px 80px; }
40
+
41
+ .topbar {
42
+ display:flex; align-items:center; justify-content:space-between;
43
+ padding: 18px 0 16px; border-bottom: 1px solid var(--gray-5); margin-bottom: 20px;
44
+ position: sticky; top: 0; background: var(--gray-2); z-index: 10;
45
+ }
46
+ .brand { display:flex; align-items:center; gap:8px; font-weight:600; font-size:15px; }
47
+ .brand-dot { width:8px; height:8px; border-radius:50%; background: var(--accent-9); }
48
+ .status-line { color: var(--gray-10); font-size: 12.5px; display:flex; align-items:center; gap:6px; }
49
+ .pulse { width:6px; height:6px; border-radius:50%; background: var(--green-9); animation: pulse 2s infinite; }
50
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
51
+
52
+ .tabs { display:flex; gap:4px; background: var(--gray-3); padding:3px; border-radius: var(--radius-3); width: fit-content; margin-bottom:16px; }
53
+ .tab {
54
+ border:none; background:transparent; padding:6px 14px; border-radius: var(--radius-2);
55
+ cursor:pointer; color: var(--gray-11); font-weight:500;
56
+ }
57
+ .tab[aria-selected="true"] { background:#fff; color: var(--gray-12); box-shadow: var(--shadow-1); }
58
+
59
+ .list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:8px; }
60
+ .row {
61
+ background:#fff; border:1px solid var(--gray-5); border-radius: var(--radius-3);
62
+ padding: 14px 16px; cursor:pointer; transition: box-shadow .12s, border-color .12s;
63
+ display:flex; justify-content:space-between; align-items:center; gap:12px;
64
+ }
65
+ .row:hover { box-shadow: var(--shadow-3); border-color: var(--gray-7); }
66
+ .row.resolved { opacity:.55; }
67
+ .row-main { min-width:0; }
68
+ .klass { font-family: var(--mono); font-size:12px; color: var(--red-11); background: var(--red-3); padding:1px 7px; border-radius:999px; display:inline-block; margin-bottom:5px; }
69
+ .msg { font-weight:500; color: var(--gray-12); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
70
+ .meta { color: var(--gray-9); font-size:12px; margin-top:3px; }
71
+ .row-side { display:flex; align-items:center; gap:8px; flex-shrink:0; }
72
+ .badge { font-size:11.5px; font-weight:600; padding:2px 9px; border-radius:999px; background: var(--amber-3); color: var(--amber-11); }
73
+ .badge.resolved { background: var(--green-3); color: var(--green-11); }
74
+
75
+ .empty { text-align:center; color: var(--gray-9); padding: 80px 20px; }
76
+ .empty-emoji { font-size:32px; margin-bottom:10px; }
77
+
78
+ .pager { display:flex; justify-content:center; gap:10px; align-items:center; margin-top:20px; color: var(--gray-10); font-size:13px; }
79
+ .pager button { border:1px solid var(--gray-6); background:#fff; border-radius: var(--radius-2); padding:5px 10px; cursor:pointer; }
80
+ .pager button:disabled { opacity:.4; cursor:default; }
81
+
82
+ .skeleton { background: linear-gradient(90deg,var(--gray-4) 25%,var(--gray-3) 37%,var(--gray-4) 63%); background-size:400% 100%; animation: sk 1.4s ease infinite; border-radius: var(--radius-3); height:58px; }
83
+ @keyframes sk { 0%{background-position:100% 50%} 100%{background-position:0 50%} }
84
+
85
+ .overlay { position:fixed; inset:0; background: rgba(20,20,25,.35); opacity:0; pointer-events:none; transition:opacity .15s; z-index:20; }
86
+ .overlay.open { opacity:1; pointer-events:auto; }
87
+
88
+ .panel {
89
+ position:fixed; top:0; right:0; bottom:0; width:100%; max-width:560px;
90
+ background:#fff; box-shadow: var(--shadow-5); transform: translateX(100%);
91
+ transition: transform .18s ease; z-index:21; display:flex; flex-direction:column;
92
+ }
93
+ .panel.open { transform: translateX(0); }
94
+ .panel-head { padding:18px 20px; border-bottom:1px solid var(--gray-5); display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
95
+ .panel-body { padding:20px; overflow-y:auto; flex:1; }
96
+ .panel-close { border:none; background: var(--gray-3); width:28px; height:28px; border-radius:50%; cursor:pointer; font-size:15px; color: var(--gray-11); flex-shrink:0; }
97
+ .panel-close:hover { background: var(--gray-4); }
98
+
99
+ .section-title { font-size:11.5px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color: var(--gray-9); margin: 20px 0 8px; }
100
+ .section-title:first-child { margin-top:0; }
101
+ .code {
102
+ background: var(--gray-12); color: #e5e7eb; font-family: var(--mono); font-size:12px;
103
+ padding:14px; border-radius: var(--radius-3); overflow-x:auto; white-space:pre; line-height:1.6;
104
+ }
105
+ .kv { display:grid; grid-template-columns:90px 1fr; gap:6px 10px; font-size:13px; }
106
+ .kv dt { color: var(--gray-9); }
107
+ .kv dd { margin:0; word-break:break-word; }
108
+
109
+ .btn { border:1px solid var(--gray-6); background:#fff; border-radius: var(--radius-2); padding:7px 13px; cursor:pointer; font-weight:500; }
110
+ .btn:hover { background: var(--gray-3); }
111
+ .btn.primary { background: var(--gray-12); border-color: var(--gray-12); color:#fff; }
112
+ .btn.primary:hover { background:#333; }
113
+ .btn.danger { color: var(--red-11); border-color: var(--red-9); }
114
+ .btn.danger:hover { background: var(--red-3); }
115
+ .actions { display:flex; gap:8px; margin-top:16px; }
116
+
117
+ table.occ { width:100%; border-collapse:collapse; font-size:12.5px; }
118
+ table.occ th { text-align:left; color: var(--gray-9); font-weight:600; padding:6px 8px; border-bottom:1px solid var(--gray-5); }
119
+ table.occ td { padding:6px 8px; border-bottom:1px solid var(--gray-4); font-family: var(--mono); }
120
+
121
+ .toast {
122
+ position:fixed; bottom:20px; left:50%; transform:translateX(-50%) translateY(10px);
123
+ background: var(--gray-12); color:#fff; padding:9px 16px; border-radius: var(--radius-3);
124
+ font-size:13px; opacity:0; pointer-events:none; transition:.15s; z-index:30; box-shadow: var(--shadow-3);
125
+ }
126
+ .toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <div class="app">
131
+ <div class="topbar">
132
+ <div class="brand"><span class="brand-dot"></span> Errors</div>
133
+ <div class="status-line"><span class="pulse"></span> <span id="last-refreshed">loading&hellip;</span></div>
134
+ </div>
135
+
136
+ <div class="tabs" role="tablist">
137
+ <button class="tab" data-filter="unresolved" role="tab">Unresolved</button>
138
+ <button class="tab" data-filter="resolved" role="tab">Resolved</button>
139
+ <button class="tab" data-filter="all" role="tab">All</button>
140
+ </div>
141
+
142
+ <ul class="list" id="list"></ul>
143
+ <div class="pager" id="pager" style="display:none"></div>
144
+ </div>
145
+
146
+ <div class="overlay" id="overlay"></div>
147
+ <aside class="panel" id="panel" aria-hidden="true">
148
+ <div class="panel-head">
149
+ <div>
150
+ <div class="klass" id="p-klass"></div>
151
+ <div style="font-weight:600; margin-top:6px;" id="p-message"></div>
152
+ <div class="meta" id="p-meta"></div>
153
+ </div>
154
+ <button class="panel-close" id="p-close" aria-label="Close">&times;</button>
155
+ </div>
156
+ <div class="panel-body">
157
+ <div class="actions" id="p-actions"></div>
158
+
159
+ <div class="section-title">Latest backtrace</div>
160
+ <div class="code" id="p-backtrace"></div>
161
+
162
+ <div id="p-request-wrap" style="display:none">
163
+ <div class="section-title">Request</div>
164
+ <dl class="kv" id="p-request"></dl>
165
+ </div>
166
+
167
+ <div class="section-title">Recent occurrences</div>
168
+ <table class="occ">
169
+ <thead><tr><th>When</th><th>Method</th><th>URL</th></tr></thead>
170
+ <tbody id="p-occurrences"></tbody>
171
+ </table>
172
+ </div>
173
+ </aside>
174
+
175
+ <div class="toast" id="toast"></div>
176
+
177
+ <script>
178
+ (function () {
179
+ "use strict";
180
+
181
+ var base = window.location.pathname.replace(/\/+$/, "");
182
+ var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content;
183
+
184
+ var state = { filter: "unresolved", page: 1, pollTimer: null, panelOpen: false };
185
+
186
+ var els = {
187
+ list: document.getElementById("list"),
188
+ pager: document.getElementById("pager"),
189
+ tabs: document.querySelectorAll(".tab"),
190
+ lastRefreshed: document.getElementById("last-refreshed"),
191
+ overlay: document.getElementById("overlay"),
192
+ panel: document.getElementById("panel"),
193
+ pClose: document.getElementById("p-close"),
194
+ pKlass: document.getElementById("p-klass"),
195
+ pMessage: document.getElementById("p-message"),
196
+ pMeta: document.getElementById("p-meta"),
197
+ pActions: document.getElementById("p-actions"),
198
+ pBacktrace: document.getElementById("p-backtrace"),
199
+ pRequestWrap: document.getElementById("p-request-wrap"),
200
+ pRequest: document.getElementById("p-request"),
201
+ pOccurrences: document.getElementById("p-occurrences"),
202
+ toast: document.getElementById("toast")
203
+ };
204
+
205
+ function api(path, opts) {
206
+ opts = opts || {};
207
+ var headers = Object.assign({ "Accept": "application/json" }, opts.headers || {});
208
+ if (opts.method && opts.method !== "GET" && csrfToken) {
209
+ headers["X-CSRF-Token"] = csrfToken;
210
+ }
211
+ return fetch(base + path, Object.assign({}, opts, { headers: headers }))
212
+ .then(function (res) {
213
+ if (!res.ok && res.status !== 204) throw new Error("Request failed: " + res.status);
214
+ if (res.status === 204) return null;
215
+ return res.json();
216
+ });
217
+ }
218
+
219
+ function escapeHtml(str) {
220
+ var div = document.createElement("div");
221
+ div.textContent = str == null ? "" : String(str);
222
+ return div.innerHTML;
223
+ }
224
+
225
+ function timeAgo(iso) {
226
+ if (!iso) return "\u2014";
227
+ var diff = (Date.now() - new Date(iso).getTime()) / 1000;
228
+ if (diff < 60) return Math.max(1, Math.floor(diff)) + "s ago";
229
+ if (diff < 3600) return Math.floor(diff / 60) + "m ago";
230
+ if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
231
+ return Math.floor(diff / 86400) + "d ago";
232
+ }
233
+
234
+ function showToast(msg) {
235
+ els.toast.textContent = msg;
236
+ els.toast.classList.add("show");
237
+ setTimeout(function () { els.toast.classList.remove("show"); }, 1800);
238
+ }
239
+
240
+ function renderTabs() {
241
+ els.tabs.forEach(function (tab) {
242
+ tab.setAttribute("aria-selected", tab.dataset.filter === state.filter ? "true" : "false");
243
+ });
244
+ }
245
+
246
+ function renderSkeleton() {
247
+ els.list.innerHTML = "";
248
+ for (var i = 0; i < 4; i++) {
249
+ var li = document.createElement("li");
250
+ li.className = "skeleton";
251
+ els.list.appendChild(li);
252
+ }
253
+ }
254
+
255
+ function renderList(data) {
256
+ els.list.innerHTML = "";
257
+
258
+ if (!data.errors.length) {
259
+ els.list.innerHTML =
260
+ '<div class="empty"><div class="empty-emoji">&#127881;</div>No ' +
261
+ (state.filter === "all" ? "" : escapeHtml(state.filter) + " ") +
262
+ "errors.</div>";
263
+ els.pager.style.display = "none";
264
+ return;
265
+ }
266
+
267
+ data.errors.forEach(function (e) {
268
+ var li = document.createElement("li");
269
+ li.className = "row" + (e.resolved ? " resolved" : "");
270
+ li.dataset.id = e.id;
271
+ li.innerHTML =
272
+ '<div class="row-main">' +
273
+ '<div class="klass">' + escapeHtml(e.klass) + "</div>" +
274
+ '<div class="msg">' + escapeHtml(e.message || "(no message)") + "</div>" +
275
+ '<div class="meta">last seen ' + timeAgo(e.last_seen_at) + " &middot; first seen " + timeAgo(e.first_seen_at) + "</div>" +
276
+ "</div>" +
277
+ '<div class="row-side">' +
278
+ (e.resolved ? '<span class="badge resolved">resolved</span>' : "") +
279
+ '<span class="badge">' + e.occurrences_count + "&times;</span>" +
280
+ "</div>";
281
+ li.addEventListener("click", function () { openPanel(e.id); });
282
+ els.list.appendChild(li);
283
+ });
284
+
285
+ renderPager(data.meta);
286
+ }
287
+
288
+ function renderPager(meta) {
289
+ if (meta.total_pages <= 1) { els.pager.style.display = "none"; return; }
290
+ els.pager.style.display = "flex";
291
+ els.pager.innerHTML =
292
+ '<button id="prev" ' + (meta.page <= 1 ? "disabled" : "") + ">&larr; Prev</button>" +
293
+ "<span>Page " + meta.page + " of " + meta.total_pages + "</span>" +
294
+ '<button id="next" ' + (meta.page >= meta.total_pages ? "disabled" : "") + ">Next &rarr;</button>";
295
+ var prev = document.getElementById("prev"), next = document.getElementById("next");
296
+ if (prev) prev.addEventListener("click", function () { state.page--; load(); });
297
+ if (next) next.addEventListener("click", function () { state.page++; load(); });
298
+ }
299
+
300
+ function load(opts) {
301
+ opts = opts || {};
302
+ if (!opts.silent) renderSkeleton();
303
+ return api("?filter=" + encodeURIComponent(state.filter) + "&page=" + state.page)
304
+ .then(function (data) {
305
+ renderList(data);
306
+ els.lastRefreshed.textContent = "updated " + new Date().toLocaleTimeString();
307
+ })
308
+ .catch(function () {
309
+ els.lastRefreshed.textContent = "couldn't refresh";
310
+ });
311
+ }
312
+
313
+ function openPanel(id) {
314
+ state.panelOpen = true;
315
+ els.overlay.classList.add("open");
316
+ els.panel.classList.add("open");
317
+ els.panel.setAttribute("aria-hidden", "false");
318
+ els.pBacktrace.textContent = "Loading\u2026";
319
+ els.pOccurrences.innerHTML = "";
320
+ els.pRequestWrap.style.display = "none";
321
+
322
+ api("/" + id).then(function (e) {
323
+ els.pKlass.textContent = e.klass;
324
+ els.pMessage.textContent = e.message || "(no message)";
325
+ els.pMeta.textContent = e.occurrences_count + " occurrences \u00b7 first seen " +
326
+ timeAgo(e.first_seen_at) + " \u00b7 last seen " + timeAgo(e.last_seen_at);
327
+
328
+ els.pActions.innerHTML = e.resolved
329
+ ? '<button class="btn primary" id="reopen-btn">Reopen</button>'
330
+ : '<button class="btn primary" id="resolve-btn">Resolve</button>';
331
+ els.pActions.innerHTML += '<button class="btn danger" id="delete-btn">Delete</button>';
332
+
333
+ var resolveBtn = document.getElementById("resolve-btn");
334
+ var reopenBtn = document.getElementById("reopen-btn");
335
+ var deleteBtn = document.getElementById("delete-btn");
336
+ if (resolveBtn) resolveBtn.addEventListener("click", function () { mutate(id, "resolve"); });
337
+ if (reopenBtn) reopenBtn.addEventListener("click", function () { mutate(id, "reopen"); });
338
+ if (deleteBtn) deleteBtn.addEventListener("click", function () { destroyError(id); });
339
+
340
+ var latest = e.occurrences[0];
341
+ els.pBacktrace.textContent = latest ? latest.backtrace : "(no occurrences recorded)";
342
+
343
+ if (latest && latest.url) {
344
+ els.pRequestWrap.style.display = "block";
345
+ els.pRequest.innerHTML =
346
+ "<dt>URL</dt><dd>" + escapeHtml(latest.url) + "</dd>" +
347
+ "<dt>Method</dt><dd>" + escapeHtml(latest.method) + "</dd>" +
348
+ (latest.user ? "<dt>User</dt><dd>" + escapeHtml(JSON.stringify(latest.user)) + "</dd>" : "");
349
+ }
350
+
351
+ els.pOccurrences.innerHTML = e.occurrences.map(function (o) {
352
+ return "<tr><td>" + escapeHtml(o.occurred_at) + "</td><td>" + escapeHtml(o.method) +
353
+ "</td><td>" + escapeHtml(o.url) + "</td></tr>";
354
+ }).join("");
355
+ });
356
+ }
357
+
358
+ function closePanel() {
359
+ state.panelOpen = false;
360
+ els.overlay.classList.remove("open");
361
+ els.panel.classList.remove("open");
362
+ els.panel.setAttribute("aria-hidden", "true");
363
+ }
364
+
365
+ function mutate(id, action) {
366
+ api("/" + id + "/" + action, { method: "POST" }).then(function () {
367
+ showToast(action === "resolve" ? "Marked as resolved" : "Reopened");
368
+ closePanel();
369
+ load({ silent: true });
370
+ });
371
+ }
372
+
373
+ function destroyError(id) {
374
+ if (!window.confirm("Delete this error and all its occurrences?")) return;
375
+ api("/" + id, { method: "DELETE" }).then(function () {
376
+ showToast("Deleted");
377
+ closePanel();
378
+ load({ silent: true });
379
+ });
380
+ }
381
+
382
+ els.tabs.forEach(function (tab) {
383
+ tab.addEventListener("click", function () {
384
+ state.filter = tab.dataset.filter;
385
+ state.page = 1;
386
+ renderTabs();
387
+ load();
388
+ });
389
+ });
390
+
391
+ els.pClose.addEventListener("click", closePanel);
392
+ els.overlay.addEventListener("click", closePanel);
393
+ document.addEventListener("keydown", function (ev) {
394
+ if (ev.key === "Escape" && state.panelOpen) closePanel();
395
+ });
396
+
397
+ // Lightweight polling instead of Hotwire/ActionCable - keeps this
398
+ // dependency-free and works the same for API-only Rails apps.
399
+ function startPolling() {
400
+ if (state.pollTimer) clearInterval(state.pollTimer);
401
+ state.pollTimer = setInterval(function () {
402
+ if (document.visibilityState === "visible" && !state.panelOpen) {
403
+ load({ silent: true });
404
+ }
405
+ }, 10000);
406
+ }
407
+
408
+ renderTabs();
409
+ load();
410
+ startPolling();
411
+ })();
412
+ </script>
413
+ </body>
414
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,15 @@
1
+ ErrorTrack::Engine.routes.draw do
2
+ # Mounted with path: "" so the engine's own root becomes the index,
3
+ # e.g. `mount ErrorTrack::Engine => "/errors"` gives you:
4
+ # GET /errors -> index (HTML shell, or JSON with .json / Accept: json)
5
+ # GET /errors/:id -> show (JSON)
6
+ # POST /errors/:id/resolve -> resolve (JSON)
7
+ # POST /errors/:id/reopen -> reopen (JSON)
8
+ # DELETE /errors/:id -> destroy (JSON)
9
+ resources :errors, only: [:index, :show, :destroy], path: "" do
10
+ member do
11
+ post :resolve
12
+ post :reopen
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,39 @@
1
+ module ErrorTrack
2
+ class Configuration
3
+ # Toggle capturing on/off (e.g. disable in test env)
4
+ attr_accessor :enabled
5
+
6
+ # Exception classes that should never be recorded
7
+ attr_accessor :ignored_exceptions
8
+
9
+ # Request param keys to scrub before storing (e.g. :password, :token)
10
+ attr_accessor :filtered_params
11
+
12
+ # Proc called with (record) -> current user info hash, e.g.
13
+ # config.current_user_resolver = ->(request) {
14
+ # user = request.env["warden"]&.user
15
+ # { id: user&.id, email: user&.email }
16
+ # }
17
+ attr_accessor :current_user_resolver
18
+
19
+ # How many days to keep resolved errors before they can be purged
20
+ # (purging itself is left to the host app via a rake task, this is just config)
21
+ attr_accessor :retention_days
22
+
23
+ def initialize
24
+ @enabled = true
25
+ @ignored_exceptions = [
26
+ "ActionController::RoutingError",
27
+ "ActiveRecord::RecordNotFound",
28
+ "ActionController::InvalidAuthenticityToken"
29
+ ]
30
+ @filtered_params = %w[password password_confirmation token secret api_key]
31
+ @current_user_resolver = nil
32
+ @retention_days = 30
33
+ end
34
+
35
+ def ignored?(exception)
36
+ ignored_exceptions.include?(exception.class.name)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ require "rails/engine"
2
+
3
+ module ErrorTrack
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ErrorTrack
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ # Catch anything that bubbles all the way up unhandled.
12
+ initializer "error_track.middleware" do |app|
13
+ app.middleware.insert_after ActionDispatch::ShowExceptions, ErrorTrack::Middleware
14
+ end
15
+
16
+ # Also subscribe to Rails' built-in error reporter (Rails 7+), so
17
+ # errors reported via `Rails.error.handle` / `Rails.error.record`
18
+ # (e.g. from ActiveJob retries, background rescues, etc.) get logged too.
19
+ initializer "error_track.rails_error_subscriber" do
20
+ ActiveSupport.on_load(:action_controller) do
21
+ if defined?(Rails.error) && Rails.error.respond_to?(:subscribe)
22
+ Rails.error.subscribe(ErrorTrack::RailsErrorSubscriber.new)
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ class RailsErrorSubscriber
29
+ def report(error, handled:, severity:, context:, source: nil)
30
+ ErrorTrack.notify(error, context: context.merge(handled: handled, severity: severity, source: source))
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,46 @@
1
+ module ErrorTrack
2
+ class Middleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ @app.call(env)
9
+ rescue Exception => e
10
+ request = ActionDispatch::Request.new(env)
11
+ ErrorTrack.notify(e, context: request_context(request))
12
+ raise
13
+ end
14
+
15
+ private
16
+
17
+ def request_context(request)
18
+ {
19
+ url: request.original_url,
20
+ method: request.request_method,
21
+ params: filtered_params(request),
22
+ user_agent: request.user_agent,
23
+ remote_ip: (request.remote_ip rescue nil),
24
+ user: current_user_info(request)
25
+ }
26
+ rescue
27
+ {}
28
+ end
29
+
30
+ def filtered_params(request)
31
+ params = request.filtered_parameters
32
+ params
33
+ rescue
34
+ {}
35
+ end
36
+
37
+ def current_user_info(request)
38
+ resolver = ErrorTrack.configuration.current_user_resolver
39
+ return nil unless resolver
40
+
41
+ resolver.call(request)
42
+ rescue
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ require "digest"
2
+
3
+ module ErrorTrack
4
+ class Notifier
5
+ def call(exception, context: {})
6
+ return unless ErrorTrack.configuration.enabled
7
+ return if ErrorTrack.configuration.ignored?(exception)
8
+
9
+ fingerprint = generate_fingerprint(exception)
10
+
11
+ event = ErrorTrack::ErrorEvent.transaction do
12
+ e = ErrorTrack::ErrorEvent.lock.find_or_initialize_by(fingerprint: fingerprint)
13
+ e.klass ||= exception.class.name
14
+ e.message = exception.message.to_s.truncate(1000)
15
+ e.first_seen_at ||= Time.current
16
+ e.last_seen_at = Time.current
17
+ e.occurrences_count = e.occurrences_count.to_i + 1
18
+ e.resolved = false if e.resolved?
19
+ e.save!
20
+ e
21
+ end
22
+
23
+ event.occurrences.create!(
24
+ backtrace: Array(exception.backtrace).first(50).join("\n"),
25
+ context: context.presence || {},
26
+ environment: defined?(Rails) ? Rails.env.to_s : nil,
27
+ occurred_at: Time.current
28
+ )
29
+
30
+ event
31
+ rescue => e
32
+ # Never let error tracking itself break the host app.
33
+ Rails.logger&.error("[ErrorTrack] failed to record exception: #{e.class} #{e.message}") if defined?(Rails)
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ # Group by exception class + the first "in-app-ish" backtrace line,
40
+ # normalized so line numbers don't fragment identical errors.
41
+ def generate_fingerprint(exception)
42
+ top_line = Array(exception.backtrace).first(5).find { |l| l.include?("/app/") } || Array(exception.backtrace).first
43
+ normalized_line = top_line.to_s.gsub(/:\d+:in/, ":in").gsub(/:\d+\z/, "")
44
+ Digest::SHA256.hexdigest("#{exception.class.name}|#{normalized_line}")
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module ErrorTrack
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,28 @@
1
+ require "error_track/version"
2
+ require "error_track/configuration"
3
+ require "error_track/notifier"
4
+ require "error_track/middleware"
5
+ require "error_track/engine"
6
+
7
+ module ErrorTrack
8
+ class << self
9
+ def configuration
10
+ @configuration ||= Configuration.new
11
+ end
12
+
13
+ def configure
14
+ yield(configuration)
15
+ end
16
+
17
+ # Public API for manually reporting a rescued exception.
18
+ #
19
+ # begin
20
+ # risky_call
21
+ # rescue => e
22
+ # ErrorTrack.notify(e, context: { user_id: current_user.id })
23
+ # end
24
+ def notify(exception, context: {})
25
+ Notifier.new.call(exception, context: context)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module ErrorTrack
5
+ module Generators
6
+ class InstallGenerator < ::Rails::Generators::Base
7
+ include ::Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Installs ErrorTrack: copies the migration, initializer, and mounts the engine."
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def copy_migration
18
+ migration_template "create_error_track_tables.rb.erb",
19
+ "db/migrate/create_error_track_tables.rb"
20
+ end
21
+
22
+ def copy_initializer
23
+ template "error_track.rb", "config/initializers/error_track.rb"
24
+ end
25
+
26
+ def mount_engine
27
+ route 'mount ErrorTrack::Engine => "/errors"'
28
+ end
29
+
30
+ def show_readme
31
+ say ""
32
+ say "ErrorTrack installed! Next steps:", :green
33
+ say " 1. rails db:migrate"
34
+ say " 2. Visit /errors in your app"
35
+ say " 3. (Recommended) Restrict access in config/initializers/error_track.rb"
36
+ say ""
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ class CreateErrorTrackTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :error_track_error_events do |t|
4
+ t.string :fingerprint, null: false
5
+ t.string :klass, null: false
6
+ t.text :message
7
+ t.integer :occurrences_count, null: false, default: 0
8
+ t.boolean :resolved, null: false, default: false
9
+ t.datetime :first_seen_at
10
+ t.datetime :last_seen_at
11
+
12
+ t.timestamps
13
+ end
14
+ add_index :error_track_error_events, :fingerprint, unique: true
15
+ add_index :error_track_error_events, :last_seen_at
16
+ add_index :error_track_error_events, :resolved
17
+
18
+ create_table :error_track_occurrences do |t|
19
+ t.references :error_event, null: false, foreign_key: { to_table: :error_track_error_events }
20
+ t.text :backtrace
21
+ t.json :context
22
+ t.string :environment
23
+ t.datetime :occurred_at
24
+
25
+ t.timestamps
26
+ end
27
+ add_index :error_track_occurrences, :occurred_at
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ ErrorTrack.configure do |config|
2
+ # Turn capturing off entirely (e.g. in test env)
3
+ # config.enabled = !Rails.env.test?
4
+
5
+ # Exception classes to never record
6
+ # config.ignored_exceptions += ["MyApp::ExpectedError"]
7
+
8
+ # Resolve the current user for context on each occurrence.
9
+ # Called with the ActionDispatch::Request.
10
+ # config.current_user_resolver = ->(request) {
11
+ # user = request.env["warden"]&.user
12
+ # { id: user&.id, email: user&.email } if user
13
+ # }
14
+
15
+ # Days to retain resolved errors (informational; wire up your own
16
+ # cleanup rake task/cron using this if desired)
17
+ # config.retention_days = 30
18
+ end
19
+
20
+ # Restrict access to the /errors dashboard - IMPORTANT for anything
21
+ # beyond local development. Example using an admin auth method:
22
+ #
23
+ # Rails.application.config.to_prepare do
24
+ # ErrorTrack::ErrorsController.class_eval do
25
+ # before_action :authenticate_admin!
26
+ # end
27
+ # end
28
+ #
29
+ # Or with HTTP basic auth:
30
+ #
31
+ # Rails.application.config.to_prepare do
32
+ # ErrorTrack::ErrorsController.class_eval do
33
+ # http_basic_authenticate_with name: Rails.application.credentials.dig(:error_track, :user),
34
+ # password: Rails.application.credentials.dig(:error_track, :password)
35
+ # end
36
+ # end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: error_track
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aditya Pandit
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: A mountable Rails engine that captures unhandled and manually-reported
55
+ exceptions, groups them like Sentry/Honeybadger, stores them in your app's own database,
56
+ and gives you a dependency-free dashboard at /errors (vanilla JS, no Hotwire/React/build
57
+ step required). Works in full-stack Rails apps and API-only apps alike.
58
+ email:
59
+ - adityapandit38@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - MIT-LICENSE
65
+ - README.md
66
+ - app/controllers/error_track/errors_controller.rb
67
+ - app/models/error_track/application_record.rb
68
+ - app/models/error_track/error_event.rb
69
+ - app/models/error_track/occurrence.rb
70
+ - app/views/error_track/errors/index.html.erb
71
+ - config/routes.rb
72
+ - lib/error_track.rb
73
+ - lib/error_track/configuration.rb
74
+ - lib/error_track/engine.rb
75
+ - lib/error_track/middleware.rb
76
+ - lib/error_track/notifier.rb
77
+ - lib/error_track/version.rb
78
+ - lib/generators/error_track/install/install_generator.rb
79
+ - lib/generators/error_track/install/templates/create_error_track_tables.rb.erb
80
+ - lib/generators/error_track/install/templates/error_track.rb
81
+ homepage: https://github.com/adityapandit17/error_track
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ homepage_uri: https://github.com/adityapandit17/error_track
86
+ source_code_uri: https://github.com/adityapandit17/error_track
87
+ allowed_push_host: https://rubygems.org
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 4.0.17
103
+ specification_version: 4
104
+ summary: 'Self-hosted error tracking for Rails: logs exceptions to your own DB and
105
+ shows them at /errors.'
106
+ test_files: []