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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ Rails.error.handle(context: { user_id: user.id }, severity: :info) do
end
```


### What each occurrence records

Every occurrence stores the backtrace and the error's context. Rails adds the current request or job to that context, and Solid Errors records it as:

* **Requests:** the controller action (`UsersController#show`) and its params, with your `config.filter_parameters` applied.
* **Background jobs:** the job class, its id and queue, and its arguments, also filtered.

Any Active Record object in the context is stored as its [GlobalID](https://github.com/rails/globalid) (`gid://my-app/User/1`), so you can tell which record was involved. Add your own context with `Rails.error.set_context(...)` or the `context:` option shown above.

Errors raised in background jobs reach Solid Errors when your job backend runs jobs inside the Rails executor, as Solid Queue, Sidekiq and GoodJob do. You don't need any extra setup for this.
### Configuration

You can configure Solid Errors via the Rails configuration object, under the `solid_errors` key. Currently, the following configuration options are available:
Expand Down
13 changes: 13 additions & 0 deletions lib/solid_errors/sanitizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ def sanitize(data, depth = 0, stack = nil)
when String
sanitize_string(data)
else # all other objects
if (global_id = global_id(data))
return global_id
end

klass = data.class

begin
Expand All @@ -67,6 +71,15 @@ def sanitize(data, depth = 0, stack = nil)

attr_reader :max_depth

# A saved record is identified by its GlobalID (gid://app/User/1) instead of just its class
def global_id(object)
return unless object.respond_to?(:to_global_id)

object.to_global_id.to_s
rescue
nil
end

def basic_object?(object)
object.respond_to?(:to_s)
false
Expand Down
22 changes: 21 additions & 1 deletion lib/solid_errors/subscriber.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,31 @@ def report(error, handled:, severity:, context:, source: nil)
SolidErrors::Occurrence.create(
error_id: record.id,
backtrace: error.backtrace&.join("\n"),
context: s(context),
context: s(readable_context(context)),
opens_error: opens_error
)
end

# Rails puts the current controller or job in the context; keep what identifies the work, not the object
def readable_context(context)
context = context.dup
if defined?(ActionController::Metal) && (controller = context[:controller]).is_a?(ActionController::Metal)
context[:controller] = "#{controller.class.name}##{controller.action_name}"
context[:params] ||= controller.request.filtered_parameters.except("controller", "action")
end
if defined?(ActiveJob::Base) && (job = context[:job]).is_a?(ActiveJob::Base)
context[:job] = job.class.name
context[:job_id] ||= job.job_id
context[:queue] ||= job.queue_name
context[:arguments] ||= parameter_filter.filter(arguments: s(job.arguments))[:arguments]
end
context
end

def parameter_filter
ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
end

def s(data)
Sanitizer.sanitize(data)
end
Expand Down
6 changes: 6 additions & 0 deletions test/dummy/app/controllers/reports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class ReportsController < ApplicationController
def create
Rails.error.handle { raise "reported from a request" }
head :ok
end
end
5 changes: 5 additions & 0 deletions test/dummy/app/jobs/report_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class ReportJob < ActiveJob::Base
def perform(record, options)
Rails.error.handle { raise "reported from a job" }
end
end
2 changes: 2 additions & 0 deletions test/dummy/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class Application < Rails::Application
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]

config.filter_parameters += [:password]

# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
Expand Down
2 changes: 2 additions & 0 deletions test/dummy/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

mount SolidErrors::Engine, at: "/solid_errors"

post "reports" => "reports#create"

# Defines the root path route ("/")
root to: "rails/health#show"
end
43 changes: 43 additions & 0 deletions test/solid_errors/context_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require "test_helper"

class SolidErrors::ContextTest < ActionDispatch::IntegrationTest
test "an error in a request records the controller action and its filtered params" do
post "/reports", params: {name: "Ada", password: "secret"}

context = SolidErrors::Occurrence.last.context
assert_equal "ReportsController#create", context["controller"]
assert_equal({"name" => "Ada", "password" => "[FILTERED]"}, context["params"])
end

test "an error in a job records the job and its filtered arguments, with records as global ids" do
record = record_an_error

job = ReportJob.new(record, {"password" => "secret", "retries" => 3})
job.perform_now

context = SolidErrors::Occurrence.last.context
assert_equal ["ReportJob", job.job_id, "default"], context.values_at("job", "job_id", "queue")
assert_equal [record.to_global_id.to_s, {"password" => "[FILTERED]", "retries" => 3}], context["arguments"]
end

test "a record in the context is recorded as its global id" do
record = record_an_error

Rails.error.report(StandardError.new("with a record"), context: {error_record: record})

assert_equal record.to_global_id.to_s, SolidErrors::Occurrence.last.context["error_record"]
end

test "an unsaved record in the context is recorded by its class" do
Rails.error.report(StandardError.new("with a new record"), context: {error_record: SolidErrors::Error.new})

assert_equal "#<SolidErrors::Error>", SolidErrors::Occurrence.last.context["error_record"]
end

private

def record_an_error
Rails.error.report(StandardError.new("first"))
SolidErrors::Error.last
end
end
Loading