Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions app/controllers/admin/errors_controller.rb
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions app/jobs/admin/error_capture_job.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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<String>] 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
36 changes: 36 additions & 0 deletions app/models/error_record.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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
134 changes: 134 additions & 0 deletions app/services/admin/errors/capture.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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
87 changes: 87 additions & 0 deletions app/services/admin/errors/subscriber.rb
Original file line number Diff line number Diff line change
@@ -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
Loading