rails_exception_log 1.0.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: f74b304353846663f7ce71910b4551a2ff998add94dcbfc1ec367cf8fd6a0cc1
4
+ data.tar.gz: f8f6dc75de6d73b215d47aa4dad59c07d0f91a2613b54c7f3d3bf050633b10fb
5
+ SHA512:
6
+ metadata.gz: ac73adab5b4b0f3bd6000c0054e468081bd305f9543f36e00d2047e1d051af1312c1f4dbc7d61b33c47d8a2da3a32ab0e7d894ec8bf23d182d495e8779b1bdfd
7
+ data.tar.gz: 6ce8da2534567b8a8308af48eb6fe4db59777a02f702417fafba96f6ac61c85ff565ded94e0e141b3f26abdb0c6133aca5931afe5143bb2fd40e93ea0066b12b
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # Rails Exception Log
2
+
3
+ A modern exception logging gem for Rails 7 and 8 with Tailwind UI, inspired by Honeybadger.
4
+
5
+ ## Features
6
+
7
+ - **Error Grouping**: Automatically groups similar exceptions using fingerprinting
8
+ - **Status Tracking**: Open, Resolved, Reopened, Ignored states
9
+ - **Occurrence Count**: Track how many times each error occurs
10
+ - **User Tracking**: Link exceptions to affected users
11
+ - **Comments**: Add notes to exceptions for team collaboration
12
+ - **Rate Limiting**: Prevent database flooding from repeated errors
13
+ - **Modern UI**: Beautiful Tailwind-styled dashboard
14
+ - **Stimulus JS**: Lightweight JavaScript interactions
15
+
16
+ ## Requirements
17
+
18
+ - Ruby >= 3.0
19
+ - Rails >= 7.0, < 9.0
20
+ - Bun (for CSS bundling)
21
+ - Tailwind CSS via css-bundling-rails
22
+
23
+ ## Installation
24
+
25
+ Add to your Gemfile:
26
+
27
+ ```ruby
28
+ gem "rails_exception_log"
29
+ ```
30
+
31
+ Run the install generator:
32
+
33
+ ```bash
34
+ rails generate rails_exception_log:install
35
+ ```
36
+
37
+ This will:
38
+ - Copy migrations to your app
39
+ - Add route to config/routes.rb
40
+
41
+ Then run migrations:
42
+
43
+ ```bash
44
+ rails db:migrate
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ ### Application Controller
50
+
51
+ ```ruby
52
+ class ApplicationController < ActionController::Base
53
+ include RailsExceptionLog::ExceptionLoggable
54
+ rescue_from Exception, with: :log_exception_handler
55
+ end
56
+ ```
57
+
58
+ ### Routes
59
+
60
+ The generator adds this route:
61
+
62
+ ```ruby
63
+ mount RailsExceptionLog::Engine => "/exceptions"
64
+ ```
65
+
66
+ ### Optional Configuration
67
+
68
+ Create an initializer `config/initializers/rails_exception_log.rb`:
69
+
70
+ ```ruby
71
+ RailsExceptionLog.configure do |config|
72
+ config.application_name = "My App"
73
+ config.max_requests_per_minute = 100
74
+ config.enable_user_tracking = true
75
+ config.before_log_exception = ->(controller) {
76
+ # Add custom authentication
77
+ true
78
+ }
79
+ config.after_log_exception = ->(exception) {
80
+ # Send notifications, etc.
81
+ }
82
+ end
83
+ ```
84
+
85
+ ### Custom Exception Data
86
+
87
+ ```ruby
88
+ class ApplicationController < ActionController::Base
89
+ # Add custom data to exceptions
90
+ self.exception_data = Proc.new { |controller|
91
+ { user_id: controller.current_user&.id }
92
+ }
93
+
94
+ # Or use a method
95
+ self.exception_data = :extra_exception_data
96
+
97
+ def extra_exception_data
98
+ { version: "1.0", environment: Rails.env }
99
+ end
100
+ end
101
+ ```
102
+
103
+ ### Authentication
104
+
105
+ In your environment config:
106
+
107
+ ```ruby
108
+ config.after_initialize do
109
+ RailsExceptionLog::LoggedExceptionsController.class_eval do
110
+ before_action :authenticate_admin!
111
+ end
112
+ end
113
+ ```
114
+
115
+ ## Usage
116
+
117
+ ### Dashboard
118
+
119
+ Visit `/exceptions` to view the exception dashboard.
120
+
121
+ ### Actions
122
+
123
+ - **View**: Click on an exception to see details
124
+ - **Resolve**: Mark an exception as resolved
125
+ - **Reopen**: Reopen a resolved exception
126
+ - **Ignore**: Ignore an exception
127
+ - **Delete**: Delete an exception
128
+ - **Export**: Export exceptions as CSV
129
+
130
+ ### Filtering
131
+
132
+ - By date range (Today, 7 days, 30 days)
133
+ - By status (Open, Resolved, Reopened, Ignored)
134
+ - By exception type
135
+ - By controller
136
+
137
+ ## JavaScript Setup
138
+
139
+ Ensure your app has Stimulus configured. The gem includes a dropdown controller.
140
+
141
+ In your application layout, include the bundled assets:
142
+
143
+ ```erb
144
+ <%= stylesheet_link_tag "rails_exception_log/application" %>
145
+ <%= javascript_include_tag "rails_exception_log/application" %>
146
+ ```
147
+
148
+ ## Rake Tasks
149
+
150
+ ```bash
151
+ # Cleanup exceptions older than 30 days
152
+ rails rails_exception_log:cleanup
153
+
154
+ # Cleanup with custom days
155
+ rails rails_exception_log:cleanup[60]
156
+ ```
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ # Install dependencies
162
+ bundle install
163
+
164
+ # Run tests
165
+ bundle exec rake test
166
+
167
+ # Build
168
+ bundle exec rake build
169
+ ```
170
+
171
+ ## License
172
+
173
+ MIT License
@@ -0,0 +1,34 @@
1
+ const defaultTheme = require("tailwindcss/defaultTheme");
2
+
3
+ module.exports = {
4
+ content: [
5
+ "./app/views/**/*.{erb,haml,html}",
6
+ "./app/helpers/**/*.rb",
7
+ "./app/controllers/**/*.rb",
8
+ ],
9
+ theme: {
10
+ extend: {
11
+ fontFamily: {
12
+ sans: ["Inter", ...defaultTheme.fontFamily.sans],
13
+ },
14
+ colors: {
15
+ primary: {
16
+ 50: "#EEF2FF",
17
+ 100: "#E0E7FF",
18
+ 200: "#C7D2FE",
19
+ 300: "#A5B4FC",
20
+ 400: "#818CF8",
21
+ 500: "#6366F1",
22
+ 600: "#4F46E5",
23
+ 700: "#4338CA",
24
+ 800: "#3730A3",
25
+ 900: "#312E81",
26
+ },
27
+ },
28
+ },
29
+ },
30
+ plugins: [],
31
+ corePlugins: {
32
+ container: false,
33
+ },
34
+ };
@@ -0,0 +1,12 @@
1
+ @import "tailwindcss/base";
2
+ @import "tailwindcss/components";
3
+ @import "tailwindcss/utilities";
4
+
5
+ /* Custom styles */
6
+ body {
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
8
+ }
9
+
10
+ .font-mono {
11
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
12
+ }
@@ -0,0 +1,17 @@
1
+ module RailsExceptionLog
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+
5
+ before_action :ensure_exception_log_enabled
6
+
7
+ private
8
+
9
+ def ensure_exception_log_enabled
10
+ return if Rails.env.development? || Rails.env.test?
11
+
12
+ return if RailsExceptionLog.before_log_exception.call(self)
13
+
14
+ render plain: 'Unauthorized', status: 401
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,158 @@
1
+ require 'csv'
2
+
3
+ module RailsExceptionLog
4
+ class LoggedExceptionsController < ApplicationController
5
+ before_action :load_exception, only: %i[show destroy resolve reopen ignore add_comment]
6
+
7
+ def index
8
+ @exceptions = filtered_exceptions
9
+ .select(:id, :exception_class, :message, :controller_name,
10
+ :action_name, :request_method, :request_path,
11
+ :environment, :created_at, :status, :occurrence_count,
12
+ :last_occurred_at, :resolved_at)
13
+ .order(last_occurred_at: :desc)
14
+ .page(params[:page])
15
+
16
+ @exception_classes = RailsExceptionLog::LoggedException
17
+ .select(:exception_class)
18
+ .distinct
19
+ .pluck(:exception_class)
20
+ .sort
21
+
22
+ @controllers = RailsExceptionLog::LoggedException
23
+ .select(:controller_name)
24
+ .distinct
25
+ .pluck(:controller_name)
26
+ .compact
27
+ .sort
28
+
29
+ @status_counts = {
30
+ open: RailsExceptionLog::LoggedException.status_open.count,
31
+ resolved: RailsExceptionLog::LoggedException.status_resolved.count,
32
+ reopened: RailsExceptionLog::LoggedException.status_reopened.count,
33
+ ignored: RailsExceptionLog::LoggedException.status_ignored.count
34
+ }
35
+
36
+ @stats = {
37
+ total: RailsExceptionLog::LoggedException.count,
38
+ unresolved: RailsExceptionLog::LoggedException.unresolved.count,
39
+ last_24h: RailsExceptionLog::LoggedException.where('created_at >= ?', 24.hours.ago).count
40
+ }
41
+
42
+ respond_to do |format|
43
+ format.html
44
+ format.json { render json: @exceptions }
45
+ end
46
+ end
47
+
48
+ def show
49
+ @exception_data = @exception.request_params_display
50
+ @session_data = @exception.session_data_display
51
+ @headers = @exception.request_headers || {}
52
+ @comments = @exception.comments.present? ? JSON.parse(@exception.comments) : []
53
+ end
54
+
55
+ def destroy
56
+ @exception.destroy
57
+ redirect_to railsexceptionlog_exceptions_path,
58
+ notice: 'Exception deleted successfully'
59
+ end
60
+
61
+ def resolve
62
+ @exception.mark_resolved!
63
+ redirect_to railsexceptionlog_exception_path(@exception),
64
+ notice: 'Exception marked as resolved'
65
+ end
66
+
67
+ def reopen
68
+ @exception.mark_open!
69
+ redirect_to railsexceptionlog_exception_path(@exception),
70
+ notice: 'Exception reopened'
71
+ end
72
+
73
+ def ignore
74
+ @exception.update!(status: :ignored)
75
+ redirect_to railsexceptionlog_exception_path(@exception),
76
+ notice: 'Exception ignored'
77
+ end
78
+
79
+ def add_comment
80
+ @exception.add_comment!(params[:comment], author: current_user_email)
81
+ redirect_to railsexceptionlog_exception_path(@exception),
82
+ notice: 'Comment added'
83
+ end
84
+
85
+ def destroy_all
86
+ if params[:status].present?
87
+ filtered_exceptions.destroy_all
88
+ else
89
+ RailsExceptionLog::LoggedException.delete_all
90
+ end
91
+ redirect_to railsexceptionlog_exceptions_path,
92
+ notice: 'Exceptions cleared'
93
+ end
94
+
95
+ def export
96
+ @exceptions = filtered_exceptions.order(last_occurred_at: :desc)
97
+ respond_to do |format|
98
+ format.csv { send_data export_to_csv(@exceptions), filename: "exceptions-#{Date.today}.csv" }
99
+ format.json { render json: @exceptions }
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def load_exception
106
+ @exception = RailsExceptionLog::LoggedException.find(params[:id])
107
+ end
108
+
109
+ def filtered_exceptions
110
+ exceptions = RailsExceptionLog::LoggedException.all
111
+
112
+ exceptions = exceptions.filter_by_date(params[:date_range]) if params[:date_range].present?
113
+
114
+ exceptions = exceptions.by_class(params[:exception_class]) if params[:exception_class].present?
115
+
116
+ exceptions = exceptions.by_controller(params[:controller]) if params[:controller].present?
117
+
118
+ exceptions = exceptions.by_status(params[:status]) if params[:status].present?
119
+
120
+ if params[:search].present?
121
+ search_term = "%#{params[:search]}%"
122
+ exceptions = exceptions.where(
123
+ 'exception_class LIKE ? OR message LIKE ?',
124
+ search_term, search_term
125
+ )
126
+ end
127
+
128
+ exceptions
129
+ end
130
+
131
+ def export_to_csv(exceptions)
132
+ CSV.generate(headers: true) do |csv|
133
+ csv << ['ID', 'Exception Class', 'Message', 'Controller', 'Action', 'Path', 'Method', 'Environment', 'Status',
134
+ 'Occurrences', 'Created At']
135
+ exceptions.each do |exc|
136
+ csv << [
137
+ exc.id,
138
+ exc.exception_class,
139
+ exc.message,
140
+ exc.controller_name,
141
+ exc.action_name,
142
+ exc.request_path,
143
+ exc.request_method,
144
+ exc.environment,
145
+ exc.status_label,
146
+ exc.occurrence_count,
147
+ exc.created_at
148
+ ]
149
+ end
150
+ end
151
+ end
152
+
153
+ def current_user_email
154
+ # Override this method to return the current user's email
155
+ 'anonymous'
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,52 @@
1
+ module RailsExceptionLog
2
+ module ApplicationHelper
3
+ def exception_status_class(exception)
4
+ case exception.environment
5
+ when 'production' then 'bg-red-100 text-red-800'
6
+ when 'staging' then 'bg-amber-100 text-amber-800'
7
+ else 'bg-slate-100 text-slate-800'
8
+ end
9
+ end
10
+
11
+ def exception_status_badge(exception)
12
+ case exception.status.to_s
13
+ when 'open' then 'bg-red-100 text-red-800'
14
+ when 'resolved' then 'bg-green-100 text-green-800'
15
+ when 'reopened' then 'bg-orange-100 text-orange-800'
16
+ when 'ignored' then 'bg-slate-100 text-slate-600'
17
+ else 'bg-slate-100 text-slate-800'
18
+ end
19
+ end
20
+
21
+ def severity_badge(severity)
22
+ case severity.to_i
23
+ when 3 then 'bg-red-600 text-white'
24
+ when 2 then 'bg-orange-500 text-white'
25
+ when 1 then 'bg-yellow-500 text-white'
26
+ else 'bg-blue-500 text-white'
27
+ end
28
+ end
29
+
30
+ def format_datetime(datetime)
31
+ return '-' unless datetime
32
+
33
+ datetime.strftime('%Y-%m-%d %H:%M:%S')
34
+ end
35
+
36
+ def format_datetime_relative(datetime)
37
+ return '-' unless datetime
38
+
39
+ time_ago_in_words(datetime)
40
+ end
41
+
42
+ def request_method_class(method)
43
+ case method&.upcase
44
+ when 'GET' then 'bg-green-100 text-green-800'
45
+ when 'POST' then 'bg-blue-100 text-blue-800'
46
+ when 'PUT', 'PATCH' then 'bg-amber-100 text-amber-800'
47
+ when 'DELETE' then 'bg-red-100 text-red-800'
48
+ else 'bg-slate-100 text-slate-800'
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,22 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class DropdownController extends Controller {
4
+ static targets = ["menu"]
5
+
6
+ connect() {
7
+ this.isOpen = false
8
+ }
9
+
10
+ toggle(event) {
11
+ event.preventDefault()
12
+ this.isOpen = !this.isOpen
13
+ this.menuTarget.classList.toggle("hidden", !this.isOpen)
14
+ }
15
+
16
+ close(event) {
17
+ if (!this.element.contains(event.target)) {
18
+ this.isOpen = false
19
+ this.menuTarget.classList.add("hidden")
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,4 @@
1
+ import "@hotwired/stimulus"
2
+ import DropdownController from "./controllers/dropdown_controller"
3
+
4
+ application.register("dropdown", DropdownController)
@@ -0,0 +1,149 @@
1
+ module RailsExceptionLog
2
+ class LoggedException < ::ActiveRecord::Base
3
+ self.table_name = 'rails_exception_log_logged_exceptions'
4
+
5
+ enum :status, { open: 0, resolved: 1, reopened: 2, ignored: 3 }, prefix: true
6
+ enum :severity, { info: 0, warning: 1, error: 2, critical: 3 }, prefix: true
7
+
8
+ validates :exception_class, presence: true
9
+
10
+ scope :recent, -> { order(last_occurred_at: :desc) }
11
+ scope :today, -> { where('created_at >= ?', Date.today) }
12
+ scope :last_days, ->(days) { where('created_at >= ?', days.days.ago) }
13
+ scope :by_class, ->(klass) { where(exception_class: klass) }
14
+ scope :by_controller, ->(controller) { where(controller_name: controller) }
15
+ scope :by_status, ->(status) { where(status: statuses[status]) }
16
+ scope :unresolved, -> { where(status: [statuses[:open], statuses[:reopened]]) }
17
+ scope :high_severity, -> { where('severity >= ?', severities[:error]) }
18
+
19
+ def self.filter_by_date(range)
20
+ case range
21
+ when 'today'
22
+ today
23
+ when '7days'
24
+ last_days(7)
25
+ when '30days'
26
+ last_days(30)
27
+ else
28
+ all
29
+ end
30
+ end
31
+
32
+ def self.generate_fingerprint(exception, request = nil)
33
+ fingerprint_parts = [
34
+ exception.class.name,
35
+ exception.message.to_s.split("\n").first[0..200],
36
+ request&.path
37
+ ].compact
38
+
39
+ Digest::SHA256.hexdigest(fingerprint_parts.join('|'))
40
+ end
41
+
42
+ def self.create_or_update_with_fingerprint!(exception, request = nil, user: nil)
43
+ fp = generate_fingerprint(exception, request)
44
+ existing = where(fingerprint: fp, status: [statuses[:open], statuses[:reopened]]).first
45
+
46
+ if existing
47
+ updates = {
48
+ occurrence_count: existing.occurrence_count + 1,
49
+ last_occurred_at: Time.current
50
+ }
51
+ updates[:backtrace] = exception.backtrace.join("\n") if exception.backtrace
52
+ existing.update!(updates)
53
+ existing
54
+ else
55
+ create!(
56
+ exception_class: exception.class.name,
57
+ message: exception.message,
58
+ backtrace: exception.backtrace&.join("\n"),
59
+ controller_name: request&.controller_name,
60
+ action_name: request&.action_name,
61
+ request_method: request&.method&.to_s,
62
+ request_path: request&.path,
63
+ request_params: request&.params,
64
+ request_headers: request_headers(request),
65
+ session_data: session_data(request),
66
+ environment: Rails.env,
67
+ fingerprint: fp,
68
+ user_id: user&.id,
69
+ user_email: user&.email,
70
+ last_occurred_at: Time.current
71
+ )
72
+ end
73
+ end
74
+
75
+ def self.request_headers(request)
76
+ return {} unless request&.headers
77
+
78
+ {
79
+ 'HTTP_HOST' => request.headers['HTTP_HOST'],
80
+ 'HTTP_USER_AGENT' => request.headers['HTTP_USER_AGENT'],
81
+ 'REMOTE_ADDR' => request.remote_ip
82
+ }.compact
83
+ end
84
+
85
+ def self.session_data(request)
86
+ return {} unless request&.session
87
+
88
+ begin
89
+ request.session.to_hash
90
+ rescue StandardError
91
+ {}
92
+ end
93
+ end
94
+
95
+ def formatted_backtrace
96
+ return [] unless backtrace
97
+
98
+ backtrace.split("\n").reject(&:blank?)
99
+ end
100
+
101
+ def request_params_display
102
+ return {} unless request_params
103
+
104
+ request_params.with_indifferent_access
105
+ end
106
+
107
+ def session_data_display
108
+ return {} unless session_data
109
+
110
+ session_data.with_indifferent_access
111
+ end
112
+
113
+ def mark_resolved!
114
+ update!(status: :resolved, resolved_at: Time.current)
115
+ end
116
+
117
+ def mark_open!
118
+ update!(status: :reopened, resolved_at: nil)
119
+ end
120
+
121
+ def add_comment!(comment, author: nil)
122
+ comment_data = {
123
+ body: comment,
124
+ author: author || 'System',
125
+ created_at: Time.current.iso8601
126
+ }
127
+
128
+ existing_comments = comments.present? ? JSON.parse(comments) : []
129
+ existing_comments << comment_data
130
+ update!(comments: existing_comments.to_json)
131
+ end
132
+
133
+ def reopen_if_needed!
134
+ return unless resolved? && last_occurred_at && last_occurred_at > resolved_at
135
+
136
+ update!(status: :reopened, resolved_at: nil)
137
+ end
138
+
139
+ def status_label
140
+ case status
141
+ when 'open' then 'Open'
142
+ when 'resolved' then 'Resolved'
143
+ when 'reopened' then 'Reopened'
144
+ when 'ignored' then 'Ignored'
145
+ else 'Unknown'
146
+ end
147
+ end
148
+ end
149
+ end