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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +141 -0
- data/app/controllers/error_track/errors_controller.rb +118 -0
- data/app/models/error_track/application_record.rb +5 -0
- data/app/models/error_track/error_event.rb +26 -0
- data/app/models/error_track/occurrence.rb +28 -0
- data/app/views/error_track/errors/index.html.erb +414 -0
- data/config/routes.rb +15 -0
- data/lib/error_track/configuration.rb +39 -0
- data/lib/error_track/engine.rb +33 -0
- data/lib/error_track/middleware.rb +46 -0
- data/lib/error_track/notifier.rb +47 -0
- data/lib/error_track/version.rb +3 -0
- data/lib/error_track.rb +28 -0
- data/lib/generators/error_track/install/install_generator.rb +40 -0
- data/lib/generators/error_track/install/templates/create_error_track_tables.rb.erb +29 -0
- data/lib/generators/error_track/install/templates/error_track.rb +36 -0
- metadata +106 -0
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,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…</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">×</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">🎉</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) + " · 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 + "×</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" : "") + ">← Prev</button>" +
|
|
293
|
+
"<span>Page " + meta.page + " of " + meta.total_pages + "</span>" +
|
|
294
|
+
'<button id="next" ' + (meta.page >= meta.total_pages ? "disabled" : "") + ">Next →</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
|
data/lib/error_track.rb
ADDED
|
@@ -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: []
|