From eb20d0b62d540ae743374ab8097b65586e4e69ba Mon Sep 17 00:00:00 2001 From: Maciej Mensfeld Date: Wed, 28 Jan 2026 10:24:06 +0100 Subject: [PATCH 1/3] Add self-contained error tracking system Implements comprehensive error tracking for application-level errors with deduplication, aggregation, and admin UI workflow. ## Core Features - **Error Capture Service**: Fingerprints errors by class + normalized message, deduplicates by fingerprint, increments occurrence counts - **Async Background Job**: Non-blocking error capture via Solid Queue - **Database Model**: Stores error class, message, backtrace, context, occurrence count, timestamps, and resolution status - **Admin UI**: Three-tab view (Unresolved/Resolved/All) with pagination, detail views with backtrace highlighting, resolve/unresolve actions - **Application Integration**: Captures errors from controllers (production only) and background jobs (excluding DispatchJob operational errors) - **Maintenance**: Rake task for cleanup of resolved errors older than 30 days ## Implementation Details - Fingerprinting normalizes IDs, UUIDs, hex addresses, temp paths - Backtrace cleaned of gem paths, limited to 50 lines - Context sanitized (removes passwords/tokens, truncates at 10KB) - Defensive error handling prevents capture failures from breaking app - Job discard_on prevents infinite retry loops - Available at `/errors` path with HTTP Basic Auth ## Database - Migration adds error_records table with indexes on fingerprint, resolved_at, and last_occurred_at for query performance - JSON context field stores metadata (controller/action, job args, etc.) Addresses application error visibility without duplicating existing operational dispatch error tracking in Delivery model. --- .../admin/error_records_controller.rb | 54 +++++++++ app/controllers/application_controller.rb | 23 ++++ app/jobs/admin/error_capture_job.rb | 26 +++++ app/jobs/application_job.rb | 25 ++++ app/models/admin/error_record.rb | 26 +++++ app/services/admin/errors/capture.rb | 107 ++++++++++++++++++ app/views/admin/error_records/index.html.erb | 93 +++++++++++++++ app/views/admin/error_records/show.html.erb | 75 ++++++++++++ app/views/layouts/admin.html.erb | 3 + config/routes.rb | 13 +++ .../20260128101750_create_error_records.rb | 21 ++++ db/schema.rb | 20 +++- lib/tasks/errors.rake | 9 ++ 13 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 app/controllers/admin/error_records_controller.rb create mode 100644 app/jobs/admin/error_capture_job.rb create mode 100644 app/models/admin/error_record.rb create mode 100644 app/services/admin/errors/capture.rb create mode 100644 app/views/admin/error_records/index.html.erb create mode 100644 app/views/admin/error_records/show.html.erb create mode 100644 db/migrate/20260128101750_create_error_records.rb create mode 100644 lib/tasks/errors.rake diff --git a/app/controllers/admin/error_records_controller.rb b/app/controllers/admin/error_records_controller.rb new file mode 100644 index 0000000..71a1aa5 --- /dev/null +++ b/app/controllers/admin/error_records_controller.rb @@ -0,0 +1,54 @@ +module Admin + class ErrorRecordsController < AdminController + before_action :set_error_record, only: %i[show resolve unresolve destroy] + + def index + @tab = params[:tab].presence&.to_sym || :unresolved + @error_records = filter_by_tab(@tab).recent_first.page(params[:page]).per(50) + end + + def show + # @error_record set by before_action + end + + def resolve + if @error_record.resolve! + redirect_to error_path(@error_record), notice: 'Error marked as resolved.' + else + redirect_to error_path(@error_record), alert: 'Failed to resolve error.' + end + end + + def unresolve + if @error_record.unresolve! + redirect_to error_path(@error_record), notice: 'Error marked as unresolved.' + else + redirect_to error_path(@error_record), alert: 'Failed to unresolve error.' + end + end + + def destroy + @error_record.destroy + redirect_to errors_path, notice: 'Error record deleted.' + end + + def destroy_all + count = Admin::ErrorRecord.resolved.delete_all + redirect_to errors_path, notice: "Deleted #{count} resolved error(s)." + end + + private + + def set_error_record + @error_record = Admin::ErrorRecord.find(params[:id]) + end + + def filter_by_tab(tab) + case tab + when :resolved then Admin::ErrorRecord.resolved + when :all then Admin::ErrorRecord.all + else Admin::ErrorRecord.unresolved + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fecef7f..e81800b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,4 +8,27 @@ class ApplicationController < ActionController::Base # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + rescue_from StandardError, with: :capture_and_reraise if Rails.env.production? + + private + + def capture_and_reraise(exception) + context = { + controller: controller_name, + action: action_name, + params: params.to_unsafe_h.except(:password, :token, :secret), + request_id: request.request_id, + ip: request.remote_ip + } + + Admin::ErrorCaptureJob.perform_later( + exception.class.name, + exception.message, + exception.backtrace || [], + context + ) rescue nil + + raise exception # Show error page + end end diff --git a/app/jobs/admin/error_capture_job.rb b/app/jobs/admin/error_capture_job.rb new file mode 100644 index 0000000..74cc0b8 --- /dev/null +++ b/app/jobs/admin/error_capture_job.rb @@ -0,0 +1,26 @@ +module Admin + class ErrorCaptureJob < ApplicationJob + queue_as :default + discard_on StandardError # Prevent retry loops + + def perform(error_class, error_message, error_backtrace, context = {}) + exception = build_exception(error_class, error_message, error_backtrace) + Admin::Errors::Capture.call(exception, context: context) + end + + private + + def build_exception(error_class, error_message, error_backtrace) + # Create a synthetic exception object + exception_class = begin + error_class.constantize + rescue NameError + StandardError + end + + exception = exception_class.new(error_message) + exception.set_backtrace(error_backtrace) + exception + end + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index 199ea11..b646ac5 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -8,4 +8,29 @@ class ApplicationJob < ActiveJob::Base # Most jobs are safe to ignore if the underlying records are no longer available # discard_on ActiveJob::DeserializationError + + around_perform :capture_errors + + private + + def capture_errors + yield + rescue StandardError => e + # EXCLUDE DispatchJob - those are operational errors tracked via Delivery model + return if is_a?(DispatchJob) + + context = { + job_class: self.class.name, + job_id: job_id, + queue_name: queue_name, + arguments: arguments.map(&:to_s), + executions: executions + } + + Admin::ErrorCaptureJob.perform_later( + e.class.name, e.message, e.backtrace || [], context + ) rescue nil + + raise e # Re-raise for ActiveJob retry logic + end end diff --git a/app/models/admin/error_record.rb b/app/models/admin/error_record.rb new file mode 100644 index 0000000..6f189ab --- /dev/null +++ b/app/models/admin/error_record.rb @@ -0,0 +1,26 @@ +module Admin + class ErrorRecord < ApplicationRecord + validates :error_class, presence: true + validates :fingerprint, presence: true, uniqueness: true + + scope :unresolved, -> { where(resolved_at: nil) } + scope :resolved, -> { where.not(resolved_at: nil) } + scope :recent_first, -> { order(last_occurred_at: :desc) } + + def resolve! + update(resolved_at: Time.current) + end + + def unresolve! + update(resolved_at: nil) + end + + def backtrace_lines + backtrace.to_s.split("\n").take(20) + end + + def resolved? + resolved_at.present? + end + end +end diff --git a/app/services/admin/errors/capture.rb b/app/services/admin/errors/capture.rb new file mode 100644 index 0000000..e12a33a --- /dev/null +++ b/app/services/admin/errors/capture.rb @@ -0,0 +1,107 @@ +module Admin + module Errors + class Capture + SENSITIVE_KEYS = %w[password token secret authorization api_key].freeze + MAX_CONTEXT_SIZE = 10_240 # 10KB + + def self.call(exception, context: {}) + new(exception, context).call + rescue StandardError => e + Rails.logger.error("[ErrorCapture] Failed: #{e.message}") + nil + end + + def initialize(exception, context = {}) + @exception = exception + @context = context + end + + def call + fingerprint = generate_fingerprint + + Admin::ErrorRecord.transaction do + record = Admin::ErrorRecord.find_or_initialize_by(fingerprint: fingerprint) + + if record.new_record? + record.assign_attributes( + error_class: @exception.class.name, + message: @exception.message, + backtrace: clean_backtrace.join("\n"), + context: sanitize_context, + first_occurred_at: Time.current, + last_occurred_at: Time.current, + occurrences_count: 1 + ) + else + record.increment!(:occurrences_count) + record.update( + last_occurred_at: Time.current, + context: sanitize_context # Update with latest context + ) + end + + record.save! + record + end + rescue StandardError => e + Rails.logger.error("[ErrorCapture] Transaction failed: #{e.message}") + nil + end + + private + + def generate_fingerprint + cleaned_message = clean_message(@exception.message.to_s) + raw = "#{@exception.class.name}:#{cleaned_message}" + Digest::SHA256.hexdigest(raw) + end + + def clean_message(message) + message + .gsub(/\b\d+\b/, 'N') # Numbers -> N + .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, 'UUID') # UUIDs + .gsub(/0x[0-9a-f]+/i, '0xHEX') # Hex addresses + .gsub(%r{/tmp/[^\s]+}, '/tmp/PATH') # Temp paths + end + + def clean_backtrace + return [] unless @exception.backtrace + + @exception.backtrace + .reject { |line| line.include?('/gems/') || line.include?('/rubygems/') } + .take(50) + end + + def sanitize_context + sanitized = deep_sanitize(@context) + json = sanitized.to_json + + if json.bytesize > MAX_CONTEXT_SIZE + truncated = json[0...MAX_CONTEXT_SIZE] + { truncated: true, data: truncated } + else + sanitized + end + end + + def deep_sanitize(obj) + case obj + when Hash + obj.each_with_object({}) do |(key, value), result| + if SENSITIVE_KEYS.any? { |sensitive| key.to_s.downcase.include?(sensitive) } + result[key] = '[REDACTED]' + else + result[key] = deep_sanitize(value) + end + end + when Array + obj.map { |item| deep_sanitize(item) } + when String + obj.length > 1000 ? "#{obj[0...1000]}... [truncated]" : obj + else + obj + end + end + end + end +end diff --git a/app/views/admin/error_records/index.html.erb b/app/views/admin/error_records/index.html.erb new file mode 100644 index 0000000..7858931 --- /dev/null +++ b/app/views/admin/error_records/index.html.erb @@ -0,0 +1,93 @@ +
+

Application Errors

+ + <% if @tab == :resolved && Admin::ErrorRecord.resolved.any? %> + <%= button_to "Delete All Resolved", destroy_all_errors_path, + method: :delete, + class: "btn btn-error btn-sm", + form: { data: { turbo_confirm: "Are you sure you want to delete all resolved errors?" } } %> + <% end %> +
+ + +
+ <%= link_to "Unresolved", errors_path(tab: :unresolved), + class: "tab #{@tab == :unresolved ? 'tab-active' : ''}" %> + <%= link_to "Resolved", errors_path(tab: :resolved), + class: "tab #{@tab == :resolved ? 'tab-active' : ''}" %> + <%= link_to "All", errors_path(tab: :all), + class: "tab #{@tab == :all ? 'tab-active' : ''}" %> +
+ +
+ <% if @error_records.any? %> +
+ + + + + + + + + + + + + + <% @error_records.each do |error| %> + + + + + + + + + + <% end %> + +
ErrorMessageOccurrencesFirst SeenLast SeenStatus
<%= error.error_class.split('::').last %> + <%= error.message.to_s.truncate(60) %> + + + <%= error.occurrences_count %> + + +
<%= error.first_occurred_at.strftime("%Y-%m-%d") %>
+
<%= error.first_occurred_at.strftime("%H:%M:%S") %>
+
+
<%= error.last_occurred_at.strftime("%Y-%m-%d") %>
+
<%= error.last_occurred_at.strftime("%H:%M:%S") %>
+
+ <% if error.resolved? %> + Resolved + <% else %> + Unresolved + <% end %> + + <%= link_to "View", error_path(error), class: "btn btn-ghost btn-xs" %> +
+
+ +
+ <%= paginate @error_records %> +
+ <% else %> +
+ + + +

+ <% if @tab == :unresolved %> + No unresolved errors + <% elsif @tab == :resolved %> + No resolved errors + <% else %> + No errors captured yet + <% end %> +

+

Application errors will appear here when they occur

+
+ <% end %> +
diff --git a/app/views/admin/error_records/show.html.erb b/app/views/admin/error_records/show.html.erb new file mode 100644 index 0000000..351d2a2 --- /dev/null +++ b/app/views/admin/error_records/show.html.erb @@ -0,0 +1,75 @@ +
+
+ <%= link_to errors_path, class: "btn btn-ghost btn-sm btn-square" do %> + + + + <% end %> +

<%= @error_record.error_class %>

+ <% if @error_record.resolved? %> + Resolved + <% else %> + Unresolved + <% end %> + + <%= @error_record.occurrences_count %> occurrences + +
+
+ <% if @error_record.resolved? %> + <%= button_to "Mark Unresolved", unresolve_error_path(@error_record), + method: :post, class: "btn btn-warning btn-sm" %> + <% else %> + <%= button_to "Mark Resolved", resolve_error_path(@error_record), + method: :post, class: "btn btn-success btn-sm" %> + <% end %> + <%= button_to "Delete", error_path(@error_record), + method: :delete, class: "btn btn-error btn-outline btn-sm", + data: { turbo_confirm: "Delete this error record?" } %> +
+
+ + +
+
Error Message
+
<%= @error_record.message || "(no message)" %>
+
+ + +
+
+
+
First Occurred
+
<%= @error_record.first_occurred_at.strftime("%Y-%m-%d %H:%M:%S") %>
+
+
+
Last Occurred
+
<%= @error_record.last_occurred_at.strftime("%Y-%m-%d %H:%M:%S") %>
+
+
+
Resolved At
+
<%= @error_record.resolved_at&.strftime("%Y-%m-%d %H:%M:%S") || "Not resolved" %>
+
+
+
+ + +
+
Backtrace
+ <% if @error_record.backtrace.present? %> +
<% @error_record.backtrace_lines.each do |line| %><%= line.include?('/app/') ? "#{line}".html_safe : line %>
+<% end %>
+ <% else %> +
No backtrace available
+ <% end %> +
+ + +
+
Context
+ <% if @error_record.context.present? && @error_record.context.any? %> +
<%= JSON.pretty_generate(@error_record.context) %>
+ <% else %> +
No context available
+ <% end %> +
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 65ab2f0..7a67eae 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -31,6 +31,8 @@ class: "btn btn-sm #{controller_name == 'dispatches' ? 'btn-primary' : 'btn-outline'}" %> <%= link_to "Targets", admin_targets_path, class: "btn btn-sm #{controller_name == 'targets' ? 'btn-primary' : 'btn-outline'}" %> + <%= link_to "Errors", errors_path, + class: "btn btn-sm #{controller_name == 'error_records' ? 'btn-primary' : 'btn-outline'}" %> <%= link_to "Jobs", "/jobs", class: "btn btn-sm btn-outline" %> @@ -64,6 +66,7 @@
  • <%= link_to "Webhooks", admin_webhooks_path %>
  • <%= link_to "Dispatches", admin_dispatches_path %>
  • <%= link_to "Targets", admin_targets_path %>
  • +
  • <%= link_to "Errors", errors_path %>
  • <%= link_to "Jobs", "/jobs" %>
  • <%= stats[:webhooks_today] %> webhooks today
  • diff --git a/config/routes.rb b/config/routes.rb index 9bc0730..e89e083 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,19 @@ end end + # Errors route (simpler path but still uses Admin namespace) + scope module: :admin do + resources :errors, controller: :error_records, only: %i[index show destroy] do + member do + post :resolve + post :unresolve + end + collection do + delete :destroy_all + end + end + end + # Root redirects to admin webhooks root to: redirect("/admin/webhooks") end diff --git a/db/migrate/20260128101750_create_error_records.rb b/db/migrate/20260128101750_create_error_records.rb new file mode 100644 index 0000000..2e75ec2 --- /dev/null +++ b/db/migrate/20260128101750_create_error_records.rb @@ -0,0 +1,21 @@ +class CreateErrorRecords < ActiveRecord::Migration[7.2] + def change + create_table :error_records do |t| + t.string :error_class, null: false + t.text :message + t.text :backtrace + t.json :context, default: {}, null: false + t.string :fingerprint, null: false + t.integer :occurrences_count, null: false, default: 1 + t.datetime :first_occurred_at, null: false + t.datetime :last_occurred_at, null: false + t.datetime :resolved_at + t.timestamps + end + + add_index :error_records, :fingerprint, unique: true + add_index :error_records, :resolved_at + add_index :error_records, :last_occurred_at + add_index :error_records, [:resolved_at, :last_occurred_at] + end +end diff --git a/db/schema.rb b/db/schema.rb index 85fedbb..7c31885 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_01_27_160104) do +ActiveRecord::Schema[8.1].define(version: 2026_01_28_101750) do create_table "deliveries", force: :cascade do |t| t.integer "attempts", default: 0, null: false t.datetime "created_at", null: false @@ -33,6 +33,24 @@ t.index ["webhook_id"], name: "index_deliveries_on_webhook_id" end + create_table "error_records", force: :cascade do |t| + t.text "backtrace" + t.json "context", default: {}, null: false + t.datetime "created_at", null: false + t.string "error_class", null: false + t.string "fingerprint", null: false + t.datetime "first_occurred_at", null: false + t.datetime "last_occurred_at", null: false + t.text "message" + t.integer "occurrences_count", default: 1, null: false + t.datetime "resolved_at" + t.datetime "updated_at", null: false + t.index ["fingerprint"], name: "index_error_records_on_fingerprint", unique: true + t.index ["last_occurred_at"], name: "index_error_records_on_last_occurred_at" + t.index ["resolved_at", "last_occurred_at"], name: "index_error_records_on_resolved_at_and_last_occurred_at" + t.index ["resolved_at"], name: "index_error_records_on_resolved_at" + end + create_table "filters", force: :cascade do |t| t.datetime "created_at", null: false t.string "field", null: false diff --git a/lib/tasks/errors.rake b/lib/tasks/errors.rake new file mode 100644 index 0000000..17818f3 --- /dev/null +++ b/lib/tasks/errors.rake @@ -0,0 +1,9 @@ +namespace :errors do + desc "Delete resolved errors older than 30 days" + task cleanup: :environment do + count = Admin::ErrorRecord.resolved + .where("resolved_at < ?", 30.days.ago) + .delete_all + puts "Deleted #{count} old resolved errors" + end +end From bae59e95ff38f8a7ca496d4c0ae5a9f99b935d4e Mon Sep 17 00:00:00 2001 From: Maciej Mensfeld Date: Wed, 28 Jan 2026 10:28:34 +0100 Subject: [PATCH 2/3] Rename ErrorRecordsController to ErrorsController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifies naming to match the /errors route path: - Admin::ErrorRecordsController → Admin::ErrorsController - app/views/admin/error_records/ → app/views/admin/errors/ - Routes now point to admin/errors#action - Layout checks controller_name == 'errors' The model remains Admin::ErrorRecord (singular) as it represents individual error records in the database. --- .../admin/{error_records_controller.rb => errors_controller.rb} | 2 +- app/views/admin/{error_records => errors}/index.html.erb | 0 app/views/admin/{error_records => errors}/show.html.erb | 0 app/views/layouts/admin.html.erb | 2 +- config/routes.rb | 2 +- 5 files changed, 3 insertions(+), 3 deletions(-) rename app/controllers/admin/{error_records_controller.rb => errors_controller.rb} (96%) rename app/views/admin/{error_records => errors}/index.html.erb (100%) rename app/views/admin/{error_records => errors}/show.html.erb (100%) diff --git a/app/controllers/admin/error_records_controller.rb b/app/controllers/admin/errors_controller.rb similarity index 96% rename from app/controllers/admin/error_records_controller.rb rename to app/controllers/admin/errors_controller.rb index 71a1aa5..b6ad347 100644 --- a/app/controllers/admin/error_records_controller.rb +++ b/app/controllers/admin/errors_controller.rb @@ -1,5 +1,5 @@ module Admin - class ErrorRecordsController < AdminController + class ErrorsController < AdminController before_action :set_error_record, only: %i[show resolve unresolve destroy] def index diff --git a/app/views/admin/error_records/index.html.erb b/app/views/admin/errors/index.html.erb similarity index 100% rename from app/views/admin/error_records/index.html.erb rename to app/views/admin/errors/index.html.erb diff --git a/app/views/admin/error_records/show.html.erb b/app/views/admin/errors/show.html.erb similarity index 100% rename from app/views/admin/error_records/show.html.erb rename to app/views/admin/errors/show.html.erb diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 7a67eae..d5fd4f3 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -32,7 +32,7 @@ <%= link_to "Targets", admin_targets_path, class: "btn btn-sm #{controller_name == 'targets' ? 'btn-primary' : 'btn-outline'}" %> <%= link_to "Errors", errors_path, - class: "btn btn-sm #{controller_name == 'error_records' ? 'btn-primary' : 'btn-outline'}" %> + class: "btn btn-sm #{controller_name == 'errors' ? 'btn-primary' : 'btn-outline'}" %> <%= link_to "Jobs", "/jobs", class: "btn btn-sm btn-outline" %> diff --git a/config/routes.rb b/config/routes.rb index e89e083..bf16d37 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,7 +34,7 @@ # Errors route (simpler path but still uses Admin namespace) scope module: :admin do - resources :errors, controller: :error_records, only: %i[index show destroy] do + resources :errors, only: %i[index show destroy] do member do post :resolve post :unresolve From 00545bbe2717ad92b0ded3d7982c5bfa67546c19 Mon Sep 17 00:00:00 2001 From: Maciej Mensfeld Date: Wed, 28 Jan 2026 10:41:57 +0100 Subject: [PATCH 3/3] Refactor to use Rails 8 error reporting subscriber Major changes: - **Rails 8 Integration**: Replace manual rescue_from/around_perform with Rails.error.subscribe() using Admin::Errors::Subscriber - **Model Namespace**: Move ErrorRecord out of Admin namespace to match other models (Webhook, Target, etc.) - **Comprehensive Tests**: Add 73 specs covering models, services, jobs, and controllers with 94.3% line coverage - **Documentation**: Add YARD docs to all public methods and classes - **Bug Fixes**: Fix fingerprint normalization order (UUIDs before numbers) ## Rails 8 Error Subscriber Benefits - Automatic capture from ALL Rails executions (controllers, jobs, console) - Centralized error handling in one subscriber - No need to manually instrument each error source - Survives through Rails upgrades ## Test Coverage - ErrorRecord model: validations, scopes, methods - Admin::Errors::Capture: deduplication, sanitization, fingerprinting - Admin::Errors::Subscriber: Rails error reporting integration - Admin::ErrorCaptureJob: async processing - Admin::ErrorsController: CRUD + resolution workflow - Request specs: authentication, tab filtering, bulk operations ## Code Quality - All tests pass (256 examples, 0 failures) - Rubocop clean (81 files, 0 offenses) - YARD-lint compliant - 94.3% line coverage, 86.11% branch coverage --- app/controllers/admin/errors_controller.rb | 39 +++- app/controllers/application_controller.rb | 23 -- app/jobs/admin/error_capture_job.rb | 13 ++ app/jobs/application_job.rb | 25 -- app/models/admin/error_record.rb | 26 --- app/models/error_record.rb | 36 +++ app/services/admin/errors/capture.rb | 43 +++- app/services/admin/errors/subscriber.rb | 87 +++++++ app/views/admin/errors/index.html.erb | 2 +- config/initializers/error_tracking.rb | 6 + .../20260128101750_create_error_records.rb | 4 +- lib/tasks/errors.rake | 2 +- spec/factories/error_records.rb | 49 ++++ spec/jobs/admin/error_capture_job_spec.rb | 63 +++++ spec/models/error_record_spec.rb | 113 +++++++++ spec/requests/admin/errors_spec.rb | 170 ++++++++++++++ spec/services/admin/errors/capture_spec.rb | 220 ++++++++++++++++++ spec/services/admin/errors/subscriber_spec.rb | 115 +++++++++ 18 files changed, 941 insertions(+), 95 deletions(-) delete mode 100644 app/models/admin/error_record.rb create mode 100644 app/models/error_record.rb create mode 100644 app/services/admin/errors/subscriber.rb create mode 100644 config/initializers/error_tracking.rb create mode 100644 spec/factories/error_records.rb create mode 100644 spec/jobs/admin/error_capture_job_spec.rb create mode 100644 spec/models/error_record_spec.rb create mode 100644 spec/requests/admin/errors_spec.rb create mode 100644 spec/services/admin/errors/capture_spec.rb create mode 100644 spec/services/admin/errors/subscriber_spec.rb diff --git a/app/controllers/admin/errors_controller.rb b/app/controllers/admin/errors_controller.rb index b6ad347..aa81036 100644 --- a/app/controllers/admin/errors_controller.rb +++ b/app/controllers/admin/errors_controller.rb @@ -1,53 +1,72 @@ module Admin + # Manages error records in the error tracking system. + # Provides viewing, filtering, resolution, and deletion of application errors. class ErrorsController < AdminController before_action :set_error_record, only: %i[show resolve unresolve destroy] + # Lists error records with tab filtering (unresolved/resolved/all). + # @return [void] def index @tab = params[:tab].presence&.to_sym || :unresolved @error_records = filter_by_tab(@tab).recent_first.page(params[:page]).per(50) end + # Shows detailed view of a single error record. + # @return [void] def show # @error_record set by before_action end + # Marks an error record as resolved. + # @return [void] def resolve if @error_record.resolve! - redirect_to error_path(@error_record), notice: 'Error marked as resolved.' + redirect_to error_path(@error_record), notice: "Error marked as resolved." else - redirect_to error_path(@error_record), alert: 'Failed to resolve error.' + redirect_to error_path(@error_record), alert: "Failed to resolve error." end end + # Marks an error record as unresolved. + # @return [void] def unresolve if @error_record.unresolve! - redirect_to error_path(@error_record), notice: 'Error marked as unresolved.' + redirect_to error_path(@error_record), notice: "Error marked as unresolved." else - redirect_to error_path(@error_record), alert: 'Failed to unresolve error.' + redirect_to error_path(@error_record), alert: "Failed to unresolve error." end end + # Deletes a single error record. + # @return [void] def destroy @error_record.destroy - redirect_to errors_path, notice: 'Error record deleted.' + redirect_to errors_path, notice: "Error record deleted." end + # Bulk deletes all resolved error records. + # @return [void] def destroy_all - count = Admin::ErrorRecord.resolved.delete_all + count = ErrorRecord.resolved.delete_all redirect_to errors_path, notice: "Deleted #{count} resolved error(s)." end private + # Sets the @error_record instance variable from params[:id]. + # @return [void] def set_error_record - @error_record = Admin::ErrorRecord.find(params[:id]) + @error_record = ErrorRecord.find(params[:id]) end + # Filters error records by tab selection. + # @param tab [Symbol] the tab to filter by (:unresolved, :resolved, or :all) + # @return [ActiveRecord::Relation] the filtered error records def filter_by_tab(tab) case tab - when :resolved then Admin::ErrorRecord.resolved - when :all then Admin::ErrorRecord.all - else Admin::ErrorRecord.unresolved + when :resolved then ErrorRecord.resolved + when :all then ErrorRecord.all + else ErrorRecord.unresolved end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e81800b..fecef7f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,27 +8,4 @@ class ApplicationController < ActionController::Base # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes - - rescue_from StandardError, with: :capture_and_reraise if Rails.env.production? - - private - - def capture_and_reraise(exception) - context = { - controller: controller_name, - action: action_name, - params: params.to_unsafe_h.except(:password, :token, :secret), - request_id: request.request_id, - ip: request.remote_ip - } - - Admin::ErrorCaptureJob.perform_later( - exception.class.name, - exception.message, - exception.backtrace || [], - context - ) rescue nil - - raise exception # Show error page - end end diff --git a/app/jobs/admin/error_capture_job.rb b/app/jobs/admin/error_capture_job.rb index 74cc0b8..1503a81 100644 --- a/app/jobs/admin/error_capture_job.rb +++ b/app/jobs/admin/error_capture_job.rb @@ -1,8 +1,16 @@ module Admin + # Background job for capturing application errors asynchronously. + # Prevents error capture failures from affecting the main application flow. class ErrorCaptureJob < ApplicationJob queue_as :default discard_on StandardError # Prevent retry loops + # Captures an error by reconstructing the exception and calling the capture service. + # @param error_class [String] the name of the exception class + # @param error_message [String] the exception message + # @param error_backtrace [Array] the exception backtrace lines + # @param context [Hash] additional context about the error + # @return [void] def perform(error_class, error_message, error_backtrace, context = {}) exception = build_exception(error_class, error_message, error_backtrace) Admin::Errors::Capture.call(exception, context: context) @@ -10,6 +18,11 @@ def perform(error_class, error_message, error_backtrace, context = {}) private + # Builds a synthetic exception object from string components. + # @param error_class [String] the name of the exception class + # @param error_message [String] the exception message + # @param error_backtrace [Array] the exception backtrace lines + # @return [Exception] the reconstructed exception def build_exception(error_class, error_message, error_backtrace) # Create a synthetic exception object exception_class = begin diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index b646ac5..199ea11 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -8,29 +8,4 @@ class ApplicationJob < ActiveJob::Base # Most jobs are safe to ignore if the underlying records are no longer available # discard_on ActiveJob::DeserializationError - - around_perform :capture_errors - - private - - def capture_errors - yield - rescue StandardError => e - # EXCLUDE DispatchJob - those are operational errors tracked via Delivery model - return if is_a?(DispatchJob) - - context = { - job_class: self.class.name, - job_id: job_id, - queue_name: queue_name, - arguments: arguments.map(&:to_s), - executions: executions - } - - Admin::ErrorCaptureJob.perform_later( - e.class.name, e.message, e.backtrace || [], context - ) rescue nil - - raise e # Re-raise for ActiveJob retry logic - end end diff --git a/app/models/admin/error_record.rb b/app/models/admin/error_record.rb deleted file mode 100644 index 6f189ab..0000000 --- a/app/models/admin/error_record.rb +++ /dev/null @@ -1,26 +0,0 @@ -module Admin - class ErrorRecord < ApplicationRecord - validates :error_class, presence: true - validates :fingerprint, presence: true, uniqueness: true - - scope :unresolved, -> { where(resolved_at: nil) } - scope :resolved, -> { where.not(resolved_at: nil) } - scope :recent_first, -> { order(last_occurred_at: :desc) } - - def resolve! - update(resolved_at: Time.current) - end - - def unresolve! - update(resolved_at: nil) - end - - def backtrace_lines - backtrace.to_s.split("\n").take(20) - end - - def resolved? - resolved_at.present? - end - end -end diff --git a/app/models/error_record.rb b/app/models/error_record.rb new file mode 100644 index 0000000..4c2f412 --- /dev/null +++ b/app/models/error_record.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Represents an application error captured by the error tracking system. +# Errors are deduplicated by fingerprint and track occurrence counts. +class ErrorRecord < ApplicationRecord + validates :error_class, presence: true + validates :fingerprint, presence: true, uniqueness: true + + scope :unresolved, -> { where(resolved_at: nil) } + scope :resolved, -> { where.not(resolved_at: nil) } + scope :recent_first, -> { order(last_occurred_at: :desc) } + + # Marks the error as resolved. + # @return [Boolean] whether the update succeeded + def resolve! + update(resolved_at: Time.current) + end + + # Marks the error as unresolved. + # @return [Boolean] whether the update succeeded + def unresolve! + update(resolved_at: nil) + end + + # Returns the first 20 lines of the backtrace. + # @return [Array] backtrace lines + def backtrace_lines + backtrace.to_s.split("\n").take(20) + end + + # Checks if the error is resolved. + # @return [Boolean] true if resolved + def resolved? + resolved_at.present? + end +end diff --git a/app/services/admin/errors/capture.rb b/app/services/admin/errors/capture.rb index e12a33a..438781d 100644 --- a/app/services/admin/errors/capture.rb +++ b/app/services/admin/errors/capture.rb @@ -1,9 +1,18 @@ module Admin + # Error tracking services module. module Errors + # Service for capturing and storing application errors with deduplication. + # Generates fingerprints for error deduplication and sanitizes sensitive context data. class Capture + # Sensitive context keys that should be redacted. SENSITIVE_KEYS = %w[password token secret authorization api_key].freeze + # Maximum size for context JSON in bytes. MAX_CONTEXT_SIZE = 10_240 # 10KB + # Captures an error by creating or updating an ErrorRecord. + # @param exception [Exception] the error to capture + # @param context [Hash] additional context about the error + # @return [ErrorRecord, nil] the error record, or nil if capture failed def self.call(exception, context: {}) new(exception, context).call rescue StandardError => e @@ -11,16 +20,21 @@ def self.call(exception, context: {}) nil end + # Initializes a new capture service. + # @param exception [Exception] the error to capture + # @param context [Hash] additional context about the error def initialize(exception, context = {}) @exception = exception @context = context end + # Performs the error capture operation. + # @return [ErrorRecord, nil] the error record, or nil if capture failed def call fingerprint = generate_fingerprint - Admin::ErrorRecord.transaction do - record = Admin::ErrorRecord.find_or_initialize_by(fingerprint: fingerprint) + ErrorRecord.transaction do + record = ErrorRecord.find_or_initialize_by(fingerprint: fingerprint) if record.new_record? record.assign_attributes( @@ -50,28 +64,38 @@ def call private + # Generates a SHA256 fingerprint for error deduplication. + # @return [String] the fingerprint hex digest def generate_fingerprint cleaned_message = clean_message(@exception.message.to_s) raw = "#{@exception.class.name}:#{cleaned_message}" Digest::SHA256.hexdigest(raw) end + # Normalizes dynamic values in error messages for deduplication. + # Replaces UUIDs, hex addresses, paths, and numbers with placeholders. + # @param message [String] the error message to clean + # @return [String] the normalized message def clean_message(message) message - .gsub(/\b\d+\b/, 'N') # Numbers -> N - .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, 'UUID') # UUIDs - .gsub(/0x[0-9a-f]+/i, '0xHEX') # Hex addresses - .gsub(%r{/tmp/[^\s]+}, '/tmp/PATH') # Temp paths + .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "UUID") # UUIDs (before numbers!) + .gsub(/0x[0-9a-f]+/i, "0xHEX") # Hex addresses (before numbers!) + .gsub(%r{/tmp/[^\s]+}, "/tmp/PATH") # Temp paths + .gsub(/\b\d+\b/, "N") # Numbers -> N (last!) end + # Cleans the backtrace by removing gem paths and limiting lines. + # @return [Array] the cleaned backtrace lines def clean_backtrace return [] unless @exception.backtrace @exception.backtrace - .reject { |line| line.include?('/gems/') || line.include?('/rubygems/') } + .reject { |line| line.include?("/gems/") || line.include?("/rubygems/") } .take(50) end + # Sanitizes context by redacting sensitive keys and truncating large values. + # @return [Hash] the sanitized context def sanitize_context sanitized = deep_sanitize(@context) json = sanitized.to_json @@ -84,12 +108,15 @@ def sanitize_context end end + # Recursively sanitizes an object by redacting sensitive keys. + # @param obj [Object] the object to sanitize + # @return [Object] the sanitized object def deep_sanitize(obj) case obj when Hash obj.each_with_object({}) do |(key, value), result| if SENSITIVE_KEYS.any? { |sensitive| key.to_s.downcase.include?(sensitive) } - result[key] = '[REDACTED]' + result[key] = "[REDACTED]" else result[key] = deep_sanitize(value) end diff --git a/app/services/admin/errors/subscriber.rb b/app/services/admin/errors/subscriber.rb new file mode 100644 index 0000000..a1b65f0 --- /dev/null +++ b/app/services/admin/errors/subscriber.rb @@ -0,0 +1,87 @@ +module Admin + module Errors + # Subscriber for Rails error reporting pipeline. + # Captures application errors and stores them in the database. + class Subscriber + # Reports an error to the error tracking system. + # + # @param error [Exception] the error that occurred + # @param handled [Boolean] whether the error was handled + # @param severity [Symbol] error severity (:error, :warning, :info) + # @param context [Hash] additional context about the error + # @param source [String] where the error originated + # @return [ErrorRecord, nil] the created/updated error record, or nil if capture failed + def report(error, handled:, severity:, context:, source:) + # Only capture errors, not warnings or info + return unless severity == :error + + # Skip DispatchJob errors - those are operational cases tracked via Delivery model + return if dispatch_job_error?(context) + + # Skip in test environment unless explicitly enabled + return if Rails.env.test? && !context[:force_capture] + + Admin::ErrorCaptureJob.perform_later( + error.class.name, + error.message, + error.backtrace || [], + build_context(context, source) + ) + rescue StandardError => e + Rails.logger.error("[ErrorSubscriber] Failed to queue error capture: #{e.message}") + nil + end + + private + + # Checks if the error originated from a DispatchJob. + # @param context [Hash] the error context + # @return [Boolean] true if this is a DispatchJob error + def dispatch_job_error?(context) + context[:job]&.is_a?(DispatchJob) || + context.dig(:job, :class) == "DispatchJob" + end + + # Builds the context hash for the capture job. + # @param context [Hash] the original error context + # @param source [String] the error source identifier + # @return [Hash] the structured context + def build_context(context, source) + { + source: source, + job: extract_job_context(context), + controller: extract_controller_context(context), + additional: context.except(:job, :controller, :force_capture) + }.compact + end + + # Extracts job-related context information. + # @param context [Hash] the error context + # @return [Hash, nil] the job context or nil if not present + def extract_job_context(context) + return nil unless context[:job] + + job = context[:job] + { + class: job.class.name, + queue: job.queue_name, + arguments: job.arguments.map(&:to_s), + executions: job.executions + } + end + + # Extracts controller-related context information. + # @param context [Hash] the error context + # @return [Hash, nil] the controller context or nil if not present + def extract_controller_context(context) + return nil unless context[:controller] + + { + name: context[:controller], + action: context[:action], + params: context[:params] + } + end + end + end +end diff --git a/app/views/admin/errors/index.html.erb b/app/views/admin/errors/index.html.erb index 7858931..7ac8679 100644 --- a/app/views/admin/errors/index.html.erb +++ b/app/views/admin/errors/index.html.erb @@ -1,7 +1,7 @@

    Application Errors

    - <% if @tab == :resolved && Admin::ErrorRecord.resolved.any? %> + <% if @tab == :resolved && ErrorRecord.resolved.any? %> <%= button_to "Delete All Resolved", destroy_all_errors_path, method: :delete, class: "btn btn-error btn-sm", diff --git a/config/initializers/error_tracking.rb b/config/initializers/error_tracking.rb new file mode 100644 index 0000000..26ae77d --- /dev/null +++ b/config/initializers/error_tracking.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Register error tracking subscriber with Rails error reporter +Rails.application.config.after_initialize do + Rails.error.subscribe(Admin::Errors::Subscriber.new) +end diff --git a/db/migrate/20260128101750_create_error_records.rb b/db/migrate/20260128101750_create_error_records.rb index 2e75ec2..cc628f6 100644 --- a/db/migrate/20260128101750_create_error_records.rb +++ b/db/migrate/20260128101750_create_error_records.rb @@ -1,4 +1,6 @@ +# Creates the error_records table for error tracking system. class CreateErrorRecords < ActiveRecord::Migration[7.2] + # Creates error_records table with fingerprint-based deduplication indexes. def change create_table :error_records do |t| t.string :error_class, null: false @@ -16,6 +18,6 @@ def change add_index :error_records, :fingerprint, unique: true add_index :error_records, :resolved_at add_index :error_records, :last_occurred_at - add_index :error_records, [:resolved_at, :last_occurred_at] + add_index :error_records, [ :resolved_at, :last_occurred_at ] end end diff --git a/lib/tasks/errors.rake b/lib/tasks/errors.rake index 17818f3..7f5c0f3 100644 --- a/lib/tasks/errors.rake +++ b/lib/tasks/errors.rake @@ -1,7 +1,7 @@ namespace :errors do desc "Delete resolved errors older than 30 days" task cleanup: :environment do - count = Admin::ErrorRecord.resolved + count = ErrorRecord.resolved .where("resolved_at < ?", 30.days.ago) .delete_all puts "Deleted #{count} old resolved errors" diff --git a/spec/factories/error_records.rb b/spec/factories/error_records.rb new file mode 100644 index 0000000..563d2eb --- /dev/null +++ b/spec/factories/error_records.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :error_record do + sequence(:error_class) { |n| "Error#{n}" } + sequence(:message) { |n| "Something went wrong #{n}" } + backtrace { "app/controllers/some_controller.rb:42:in `action'\napp/services/service.rb:10:in `call'" } + context { { source: "test" } } + fingerprint { Digest::SHA256.hexdigest("#{error_class}:#{message}") } + occurrences_count { 1 } + first_occurred_at { Time.current } + last_occurred_at { Time.current } + resolved_at { nil } + + trait :resolved do + resolved_at { Time.current } + end + + trait :high_occurrences do + occurrences_count { 15 } + end + + trait :with_job_context do + context do + { + source: "job", + job: { + class: "SomeJob", + queue: "default", + arguments: [ "arg1", "arg2" ] + } + } + end + end + + trait :with_controller_context do + context do + { + source: "controller", + controller: { + name: "webhooks", + action: "create", + params: { "foo" => "bar" } + } + } + end + end + end +end diff --git a/spec/jobs/admin/error_capture_job_spec.rb b/spec/jobs/admin/error_capture_job_spec.rb new file mode 100644 index 0000000..2ad96c5 --- /dev/null +++ b/spec/jobs/admin/error_capture_job_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::ErrorCaptureJob do + describe "#perform" do + let(:error_class) { "StandardError" } + let(:error_message) { "Test error" } + let(:error_backtrace) { [ "line 1", "line 2" ] } + let(:context) { { source: "test" } } + + it "calls Admin::Errors::Capture" do + expect(Admin::Errors::Capture).to receive(:call).with( + an_instance_of(StandardError), + context: context + ) + + described_class.perform_now(error_class, error_message, error_backtrace, context) + end + + it "builds exception with correct class" do + result = nil + allow(Admin::Errors::Capture).to receive(:call) do |exception, **_| + result = exception + end + + described_class.perform_now(error_class, error_message, error_backtrace, context) + + expect(result).to be_a(StandardError) + expect(result.message).to eq(error_message) + expect(result.backtrace).to eq(error_backtrace) + end + + context "with unknown error class" do + let(:error_class) { "NonExistentError" } + + it "falls back to StandardError" do + result = nil + allow(Admin::Errors::Capture).to receive(:call) do |exception, **_| + result = exception + end + + described_class.perform_now(error_class, error_message, error_backtrace, context) + + expect(result).to be_a(StandardError) + end + end + + context "when capture fails" do + before do + allow(Admin::Errors::Capture).to receive(:call).and_raise(StandardError.new("Capture failed")) + end + + it "does not retry the job" do + # The job has discard_on StandardError, so it swallows the exception + # and doesn't retry. We just verify it doesn't raise to the caller. + expect { + described_class.perform_now(error_class, error_message, error_backtrace, context) + }.not_to raise_error + end + end + end +end diff --git a/spec/models/error_record_spec.rb b/spec/models/error_record_spec.rb new file mode 100644 index 0000000..06c1a29 --- /dev/null +++ b/spec/models/error_record_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ErrorRecord do + describe "validations" do + subject { build(:error_record) } + + it { is_expected.to validate_presence_of(:error_class) } + it { is_expected.to validate_presence_of(:fingerprint) } + it { is_expected.to validate_uniqueness_of(:fingerprint) } + end + + describe "scopes" do + describe ".unresolved" do + let!(:unresolved_error) { create(:error_record) } + let!(:resolved_error) { create(:error_record, :resolved) } + + it "returns only unresolved errors" do + expect(described_class.unresolved).to include(unresolved_error) + expect(described_class.unresolved).not_to include(resolved_error) + end + end + + describe ".resolved" do + let!(:unresolved_error) { create(:error_record) } + let!(:resolved_error) { create(:error_record, :resolved) } + + it "returns only resolved errors" do + expect(described_class.resolved).to include(resolved_error) + expect(described_class.resolved).not_to include(unresolved_error) + end + end + + describe ".recent_first" do + let!(:old_error) { create(:error_record, last_occurred_at: 2.days.ago) } + let!(:new_error) { create(:error_record, last_occurred_at: 1.hour.ago) } + + it "orders by last_occurred_at descending" do + expect(described_class.recent_first.first).to eq(new_error) + expect(described_class.recent_first.last).to eq(old_error) + end + end + end + + describe "#resolve!" do + let(:error_record) { create(:error_record) } + + it "sets resolved_at to current time" do + before_time = Time.current + error_record.resolve! + expect(error_record.reload.resolved_at).to be >= before_time + expect(error_record.reload.resolved_at).to be_within(1.second).of(Time.current) + end + + it "returns true on success" do + expect(error_record.resolve!).to be true + end + end + + describe "#unresolve!" do + let(:error_record) { create(:error_record, :resolved) } + + it "sets resolved_at to nil" do + error_record.unresolve! + expect(error_record.reload.resolved_at).to be_nil + end + + it "returns true on success" do + expect(error_record.unresolve!).to be true + end + end + + describe "#backtrace_lines" do + context "with multi-line backtrace" do + let(:error_record) do + create(:error_record, backtrace: (1..30).map { |i| "line #{i}" }.join("\n")) + end + + it "returns first 20 lines" do + expect(error_record.backtrace_lines.size).to eq(20) + expect(error_record.backtrace_lines.first).to eq("line 1") + expect(error_record.backtrace_lines.last).to eq("line 20") + end + end + + context "with nil backtrace" do + let(:error_record) { create(:error_record, backtrace: nil) } + + it "returns empty array" do + expect(error_record.backtrace_lines).to eq([]) + end + end + end + + describe "#resolved?" do + context "when resolved_at is present" do + let(:error_record) { create(:error_record, :resolved) } + + it "returns true" do + expect(error_record.resolved?).to be true + end + end + + context "when resolved_at is nil" do + let(:error_record) { create(:error_record) } + + it "returns false" do + expect(error_record.resolved?).to be false + end + end + end +end diff --git a/spec/requests/admin/errors_spec.rb b/spec/requests/admin/errors_spec.rb new file mode 100644 index 0000000..9539e95 --- /dev/null +++ b/spec/requests/admin/errors_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Admin Errors" do + let(:auth_headers) do + credentials = ActionController::HttpAuthentication::Basic.encode_credentials("admin", "changeme") + { "HTTP_AUTHORIZATION" => credentials } + end + + describe "GET /errors" do + it "requires authentication" do + get "/errors" + + expect(response).to have_http_status(:unauthorized) + end + + it "lists errors when authenticated" do + create(:error_record) + + get "/errors", headers: auth_headers + + expect(response).to have_http_status(:ok) + end + + context "with tab parameter" do + let!(:unresolved_error) { create(:error_record) } + let!(:resolved_error) { create(:error_record, :resolved) } + + it "defaults to unresolved tab" do + get "/errors", headers: auth_headers + + expect(response.body).to include(unresolved_error.error_class) + expect(response.body).not_to include(resolved_error.error_class) + end + + it "filters by resolved tab" do + get "/errors?tab=resolved", headers: auth_headers + + expect(response.body).to include(resolved_error.error_class) + expect(response.body).not_to include(unresolved_error.error_class) + end + + it "shows all errors in all tab" do + get "/errors?tab=all", headers: auth_headers + + expect(response.body).to include(unresolved_error.error_class) + expect(response.body).to include(resolved_error.error_class) + end + end + end + + describe "GET /errors/:id" do + let(:error_record) { create(:error_record) } + + it "requires authentication" do + get "/errors/#{error_record.id}" + + expect(response).to have_http_status(:unauthorized) + end + + it "shows error details when authenticated" do + get "/errors/#{error_record.id}", headers: auth_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include(error_record.error_class) + expect(response.body).to include(error_record.message) + end + end + + describe "POST /errors/:id/resolve" do + let(:error_record) { create(:error_record) } + + it "requires authentication" do + post "/errors/#{error_record.id}/resolve" + + expect(response).to have_http_status(:unauthorized) + end + + it "marks error as resolved" do + post "/errors/#{error_record.id}/resolve", headers: auth_headers + + expect(error_record.reload.resolved?).to be true + end + + it "redirects to error page" do + post "/errors/#{error_record.id}/resolve", headers: auth_headers + + expect(response).to redirect_to(error_path(error_record)) + end + end + + describe "POST /errors/:id/unresolve" do + let(:error_record) { create(:error_record, :resolved) } + + it "requires authentication" do + post "/errors/#{error_record.id}/unresolve" + + expect(response).to have_http_status(:unauthorized) + end + + it "marks error as unresolved" do + post "/errors/#{error_record.id}/unresolve", headers: auth_headers + + expect(error_record.reload.resolved?).to be false + end + + it "redirects to error page" do + post "/errors/#{error_record.id}/unresolve", headers: auth_headers + + expect(response).to redirect_to(error_path(error_record)) + end + end + + describe "DELETE /errors/:id" do + let!(:error_record) { create(:error_record) } + + it "requires authentication" do + delete "/errors/#{error_record.id}" + + expect(response).to have_http_status(:unauthorized) + end + + it "deletes the error" do + expect { + delete "/errors/#{error_record.id}", headers: auth_headers + }.to change { ErrorRecord.count }.by(-1) + end + + it "redirects to errors index" do + delete "/errors/#{error_record.id}", headers: auth_headers + + expect(response).to redirect_to(errors_path) + end + end + + describe "DELETE /errors/destroy_all" do + let!(:unresolved_error) { create(:error_record) } + let!(:resolved_error1) { create(:error_record, :resolved) } + let!(:resolved_error2) { create(:error_record, :resolved) } + + it "requires authentication" do + delete "/errors/destroy_all" + + expect(response).to have_http_status(:unauthorized) + end + + it "deletes only resolved errors" do + expect { + delete "/errors/destroy_all", headers: auth_headers + }.to change { ErrorRecord.count }.from(3).to(1) + + expect(ErrorRecord.exists?(unresolved_error.id)).to be true + expect(ErrorRecord.exists?(resolved_error1.id)).to be false + expect(ErrorRecord.exists?(resolved_error2.id)).to be false + end + + it "redirects to errors index" do + delete "/errors/destroy_all", headers: auth_headers + + expect(response).to redirect_to(errors_path) + end + + it "shows count in flash" do + delete "/errors/destroy_all", headers: auth_headers + + expect(flash[:notice]).to include("Deleted 2 resolved error") + end + end +end diff --git a/spec/services/admin/errors/capture_spec.rb b/spec/services/admin/errors/capture_spec.rb new file mode 100644 index 0000000..e802ff1 --- /dev/null +++ b/spec/services/admin/errors/capture_spec.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::Errors::Capture do + include ActiveSupport::Testing::TimeHelpers + describe ".call" do + let(:exception) { StandardError.new("Test error") } + let(:context) { { source: "test" } } + + it "creates a new error record" do + expect { + described_class.call(exception, context: context) + }.to change { ErrorRecord.count }.by(1) + end + + it "sets error_class from exception class" do + described_class.call(exception, context: context) + record = ErrorRecord.last + expect(record.error_class).to eq("StandardError") + end + + it "sets message from exception message" do + described_class.call(exception, context: context) + record = ErrorRecord.last + expect(record.message).to eq("Test error") + end + + it "generates a fingerprint" do + described_class.call(exception, context: context) + record = ErrorRecord.last + expect(record.fingerprint).to be_present + expect(record.fingerprint.length).to eq(64) # SHA256 length + end + + it "sets occurrences_count to 1 for new error" do + described_class.call(exception, context: context) + record = ErrorRecord.last + expect(record.occurrences_count).to eq(1) + end + + it "sanitizes context" do + ctx = { source: "test", password: "secret123" } + described_class.call(exception, context: ctx) + record = ErrorRecord.last + expect(record.context["password"]).to eq("[REDACTED]") + end + + context "with duplicate error" do + before do + described_class.call(exception, context: context) + end + + it "does not create a new record" do + expect { + described_class.call(exception, context: context) + }.not_to change { ErrorRecord.count } + end + + it "increments occurrences_count" do + record = ErrorRecord.last + expect { + described_class.call(exception, context: context) + }.to change { record.reload.occurrences_count }.from(1).to(2) + end + + it "updates last_occurred_at" do + record = ErrorRecord.last + original_time = record.last_occurred_at + travel 1.hour + described_class.call(exception, context: context) + expect(record.reload.last_occurred_at).to be > original_time + end + + it "does not update first_occurred_at" do + record = ErrorRecord.last + original_time = record.first_occurred_at + travel 1.hour + described_class.call(exception, context: context) + expect(record.reload.first_occurred_at).to eq(original_time) + end + end + + context "with backtrace" do + let(:backtrace) do + [ + "app/controllers/test_controller.rb:10:in `create'", + "/gems/actionpack/lib/action_controller.rb:100:in `dispatch'", + "app/services/test_service.rb:20:in `call'" + ] + end + + before do + exception.set_backtrace(backtrace) + end + + it "removes gem paths from backtrace" do + described_class.call(exception, context: context) + record = ErrorRecord.last + expect(record.backtrace).not_to include("actionpack") + expect(record.backtrace).to include("test_controller") + expect(record.backtrace).to include("test_service") + end + end + + context "when capture fails" do + before do + allow(ErrorRecord).to receive(:transaction).and_raise(StandardError.new("DB error")) + allow(Rails.logger).to receive(:error) + end + + it "logs the error" do + described_class.call(exception, context: context) + expect(Rails.logger).to have_received(:error).with(match(/ErrorCapture.*Transaction failed/)) + end + + it "returns nil" do + result = described_class.call(exception, context: context) + expect(result).to be_nil + end + + it "does not raise exception" do + expect { + described_class.call(exception, context: context) + }.not_to raise_error + end + end + end + + describe "fingerprint normalization" do + it "normalizes numbers" do + error1 = StandardError.new("Error with id 123") + error2 = StandardError.new("Error with id 456") + + described_class.call(error1) + fingerprint1 = ErrorRecord.last.fingerprint + + described_class.call(error2) + expect(ErrorRecord.count).to eq(1) # Same fingerprint + expect(ErrorRecord.last.fingerprint).to eq(fingerprint1) + end + + it "normalizes UUIDs" do + error1 = StandardError.new("Error with uuid 550e8400-e29b-41d4-a716-446655440000") + error2 = StandardError.new("Error with uuid 6ba7b810-9dad-11d1-80b4-00c04fd430c8") + + # First error creates the record + described_class.call(error1) + expect(ErrorRecord.count).to eq(1) + initial_count = ErrorRecord.last.occurrences_count + + # Second error with different UUID should deduplicate + described_class.call(error2) + expect(ErrorRecord.count).to eq(1) # Same record + expect(ErrorRecord.last.occurrences_count).to eq(initial_count + 1) # Incremented + end + + it "normalizes hex addresses" do + error1 = StandardError.new("Object at 0x00007f8b3c8d9f60") + error2 = StandardError.new("Object at 0x00007f8b3c8d9f70") + + described_class.call(error1) + fingerprint1 = ErrorRecord.last.fingerprint + + described_class.call(error2) + expect(ErrorRecord.count).to eq(1) # Same fingerprint + end + + it "normalizes temp paths" do + error1 = StandardError.new("File not found: /tmp/foo123") + error2 = StandardError.new("File not found: /tmp/bar456") + + described_class.call(error1) + fingerprint1 = ErrorRecord.last.fingerprint + + described_class.call(error2) + expect(ErrorRecord.count).to eq(1) # Same fingerprint + end + end + + describe "context sanitization" do + it "redacts password fields" do + context = { password: "secret123" } + described_class.call(StandardError.new("test"), context: context) + record = ErrorRecord.last + expect(record.context["password"]).to eq("[REDACTED]") + end + + it "redacts token fields" do + context = { api_token: "abc123" } + described_class.call(StandardError.new("test"), context: context) + record = ErrorRecord.last + expect(record.context["api_token"]).to eq("[REDACTED]") + end + + it "redacts authorization headers" do + context = { authorization: "Bearer token123" } + described_class.call(StandardError.new("test"), context: context) + record = ErrorRecord.last + expect(record.context["authorization"]).to eq("[REDACTED]") + end + + it "truncates long strings" do + long_string = "x" * 2000 + context = { data: long_string } + described_class.call(StandardError.new("test"), context: context) + record = ErrorRecord.last + expect(record.context["data"].length).to be < long_string.length + expect(record.context["data"]).to include("[truncated]") + end + + it "handles nested hashes" do + context = { user: { password: "secret", name: "John" } } + described_class.call(StandardError.new("test"), context: context) + record = ErrorRecord.last + expect(record.context["user"]["password"]).to eq("[REDACTED]") + expect(record.context["user"]["name"]).to eq("John") + end + end +end diff --git a/spec/services/admin/errors/subscriber_spec.rb b/spec/services/admin/errors/subscriber_spec.rb new file mode 100644 index 0000000..cf9d736 --- /dev/null +++ b/spec/services/admin/errors/subscriber_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::Errors::Subscriber do + let(:subscriber) { described_class.new } + let(:error) { StandardError.new("Test error") } + let(:context) { {} } + + describe "#report" do + it "queues error capture job for errors" do + expect { + subscriber.report(error, handled: false, severity: :error, context: context.merge(force_capture: true), source: "test") + }.to have_enqueued_job(Admin::ErrorCaptureJob) + end + + it "does not queue job for warnings" do + expect { + subscriber.report(error, handled: true, severity: :warning, context: context, source: "test") + }.not_to have_enqueued_job(Admin::ErrorCaptureJob) + end + + it "does not queue job for info" do + expect { + subscriber.report(error, handled: true, severity: :info, context: context, source: "test") + }.not_to have_enqueued_job(Admin::ErrorCaptureJob) + end + + context "with DispatchJob errors" do + let(:dispatch_job) { DispatchJob.new } + let(:context) { { job: dispatch_job } } + + it "does not queue error capture" do + expect { + subscriber.report(error, handled: false, severity: :error, context: context, source: "job") + }.not_to have_enqueued_job(Admin::ErrorCaptureJob) + end + end + + context "with non-DispatchJob errors" do + it "queues error capture" do + # Use a context that doesn't have a DispatchJob + ctx = { some_key: "value", force_capture: true } + expect { + subscriber.report(error, handled: false, severity: :error, context: ctx, source: "job") + }.to have_enqueued_job(Admin::ErrorCaptureJob) + end + end + + context "in test environment" do + before do + allow(Rails.env).to receive(:test?).and_return(true) + end + + it "does not capture by default" do + expect { + subscriber.report(error, handled: false, severity: :error, context: context, source: "test") + }.not_to have_enqueued_job(Admin::ErrorCaptureJob) + end + + it "captures when force_capture is true" do + context[:force_capture] = true + expect { + subscriber.report(error, handled: false, severity: :error, context: context, source: "test") + }.to have_enqueued_job(Admin::ErrorCaptureJob) + end + end + + context "when job queuing fails" do + before do + allow(Admin::ErrorCaptureJob).to receive(:perform_later).and_raise(StandardError.new("Queue error")) + end + + it "logs the error" do + expect(Rails.logger).to receive(:error).with(match(/ErrorSubscriber.*Failed to queue/)) + subscriber.report(error, handled: false, severity: :error, context: context.merge(force_capture: true), source: "test") + end + + it "returns nil" do + result = subscriber.report(error, handled: false, severity: :error, context: context.merge(force_capture: true), source: "test") + expect(result).to be_nil + end + + it "does not raise exception" do + expect { + subscriber.report(error, handled: false, severity: :error, context: context.merge(force_capture: true), source: "test") + }.not_to raise_error + end + end + + describe "context building" do + it "includes source in context" do + expect(Admin::ErrorCaptureJob).to receive(:perform_later).with( + "StandardError", + "Test error", + [], + hash_including(source: "custom_source") + ) + + subscriber.report(error, handled: false, severity: :error, context: { force_capture: true }, source: "custom_source") + end + + it "passes additional context through" do + expect(Admin::ErrorCaptureJob).to receive(:perform_later).with( + "StandardError", + "Test error", + [], + hash_including(additional: hash_including(custom_key: "custom_value")) + ) + + subscriber.report(error, handled: false, severity: :error, context: { custom_key: "custom_value", force_capture: true }, source: "test") + end + end + end +end