diff --git a/app/controllers/admin/errors_controller.rb b/app/controllers/admin/errors_controller.rb new file mode 100644 index 0000000..aa81036 --- /dev/null +++ b/app/controllers/admin/errors_controller.rb @@ -0,0 +1,73 @@ +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." + else + 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." + else + 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." + end + + # Bulk deletes all resolved error records. + # @return [void] + def destroy_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 = 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 ErrorRecord.resolved + when :all then ErrorRecord.all + else ErrorRecord.unresolved + end + end + 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..1503a81 --- /dev/null +++ b/app/jobs/admin/error_capture_job.rb @@ -0,0 +1,39 @@ +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) + end + + 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 + 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/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 new file mode 100644 index 0000000..438781d --- /dev/null +++ b/app/services/admin/errors/capture.rb @@ -0,0 +1,134 @@ +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 + Rails.logger.error("[ErrorCapture] Failed: #{e.message}") + 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 + + ErrorRecord.transaction do + record = 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 + + # 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[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/") } + .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 + + if json.bytesize > MAX_CONTEXT_SIZE + truncated = json[0...MAX_CONTEXT_SIZE] + { truncated: true, data: truncated } + else + sanitized + 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]" + 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/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 new file mode 100644 index 0000000..7ac8679 --- /dev/null +++ b/app/views/admin/errors/index.html.erb @@ -0,0 +1,93 @@ +
+

Application Errors

+ + <% if @tab == :resolved && 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/errors/show.html.erb b/app/views/admin/errors/show.html.erb new file mode 100644 index 0000000..351d2a2 --- /dev/null +++ b/app/views/admin/errors/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..d5fd4f3 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 == 'errors' ? '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/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/config/routes.rb b/config/routes.rb index 9bc0730..bf16d37 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, 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..cc628f6 --- /dev/null +++ b/db/migrate/20260128101750_create_error_records.rb @@ -0,0 +1,23 @@ +# 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 + 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..7f5c0f3 --- /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 = ErrorRecord.resolved + .where("resolved_at < ?", 30.days.ago) + .delete_all + puts "Deleted #{count} old resolved errors" + end +end 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