diff --git a/app/mailers/solid_errors/error_mailer.rb b/app/mailers/solid_errors/error_mailer.rb index 4c690b9..2d1f106 100644 --- a/app/mailers/solid_errors/error_mailer.rb +++ b/app/mailers/solid_errors/error_mailer.rb @@ -8,6 +8,8 @@ class ErrorMailer < (defined?(ActionMailer::Base) ? ActionMailer::Base : Object) Rails.error.report(exception, handled: true, source: "solid_errors.error_mailer") end end + + helper_method :occurrence_url end def error_occurred(occurrence) @@ -26,5 +28,14 @@ def error_occurred(occurrence) to: SolidErrors.email_to ) end + + private + + # Optional: an app without default_url_options[:host] still gets the email, just unlinked + def occurrence_url + public_send(SolidErrors.url_helper_name).error_url(@error, **SolidErrors.url_options, anchor: "occurrence_#{@occurrence.id}") + rescue ArgumentError, NoMethodError, ActionController::UrlGenerationError + nil + end end end diff --git a/app/views/solid_errors/error_mailer/error_occurred.html.erb b/app/views/solid_errors/error_mailer/error_occurred.html.erb index 8e63065..5d2bdf1 100644 --- a/app/views/solid_errors/error_mailer/error_occurred.html.erb +++ b/app/views/solid_errors/error_mailer/error_occurred.html.erb @@ -2,16 +2,169 @@ Solid Errors | <%= @error.severity_emoji %> <%= @error.exception_class %> <%= render "layouts/solid_errors/style" %> + - <%= render "solid_errors/errors/error", - error: @error, - show_actions: false %> -
-
+
+

+ <%= @error.severity_emoji %> + <%= @error.exception_class %> + from <%= @error.source %> +

+ <% if (url = occurrence_url) %> + View in Solid Errors → + <% end %> +
- <%= render "solid_errors/occurrences/occurrence", - occurrence: @occurrence %> +
<%= @error.message %>
+ + + + + + + + + + + + + + +
Severity<%= @error.severity_emoji %> <%= @error.severity %>
Status<%= @error.status_emoji %> <%= @error.status %>
Occurred at<%= @occurrence.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
+ + <% if @occurrence.context&.any? %> +
Context
+ + + + + + + + + <% @occurrence.context.each do |key, value| %> + + + + + <% end %> + +
KeyValue
<%= key %><%= value %>
+ <% end %> + +
Backtrace
+
+ <% @occurrence.parsed_backtrace.lines.each_with_index do |line, i| %> +
+ <% if line.filtered_file %> +<%= File.dirname(line.filtered_file) %>/<%= File.basename(line.filtered_file) %>:<%= line.filtered_number %> in <%= line.filtered_method %> + <% else %> +<%= line.unparsed_line %> + <% end %> +
+ <% end %> +
diff --git a/app/views/solid_errors/error_mailer/error_occurred.text.erb b/app/views/solid_errors/error_mailer/error_occurred.text.erb index e69de29..d887ab4 100644 --- a/app/views/solid_errors/error_mailer/error_occurred.text.erb +++ b/app/views/solid_errors/error_mailer/error_occurred.text.erb @@ -0,0 +1,32 @@ +<%= @error.severity_emoji %> <%= @error.exception_class %> from <%= @error.source %> +================================================================================ + +<%= @error.message %> + +<% if (url = occurrence_url) %> +View in Solid Errors: <%= url %> +<% end %> + +DETAILS +-------------------------------------------------------------------------------- +Severity: <%= @error.severity_emoji %> <%= @error.severity %> +Status: <%= @error.status_emoji %> <%= @error.status %> +Occurred at: <%= @occurrence.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %> + +<% if @occurrence.context&.any? %> +CONTEXT +-------------------------------------------------------------------------------- +<% @occurrence.context.each do |key, value| %> +<%= key.to_s.ljust(20) %> <%= value %> +<% end %> + +<% end %> +BACKTRACE +-------------------------------------------------------------------------------- +<% @occurrence.parsed_backtrace.lines.each_with_index do |line, i| %> +<% if line.filtered_file %> +<%= i.zero? ? '>>>' : ' ' %> <%= File.join(File.dirname(line.filtered_file), File.basename(line.filtered_file)) %>:<%= line.filtered_number %> in <%= line.filtered_method %> +<% else %> +<%= i.zero? ? '>>>' : ' ' %> <%= line.unparsed_line %> +<% end %> +<% end %> diff --git a/lib/solid_errors.rb b/lib/solid_errors.rb index 67bb954..7e9b4ff 100644 --- a/lib/solid_errors.rb +++ b/lib/solid_errors.rb @@ -18,6 +18,7 @@ module SolidErrors mattr_accessor :email_every_occurrence, default: true mattr_accessor :destroy_after mattr_accessor :ignored_errors, default: [] + mattr_writer :url_helper_name, :url_options class << self # use method instead of attr_accessor to ensure @@ -44,5 +45,30 @@ def without_emails ensure ActiveSupport::IsolatedExecutionState[:solid_errors_without_emails] = previous end + + def url_helper_name + @url_helper_name ||= detect_url_helper_name + end + + def url_options + @url_options ||= detect_url_options + end + + private + + def find_solid_errors_route + @solid_errors_route ||= Rails.application.routes.routes.find do |r| + r.name&.to_s&.end_with?("solid_errors") + end + end + + def detect_url_helper_name + find_solid_errors_route&.name&.to_sym || :solid_errors + end + + def detect_url_options + subdomain = find_solid_errors_route&.constraints&.dig(:subdomain) + subdomain.is_a?(String) ? {subdomain: subdomain} : {} + end end end diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb new file mode 100644 index 0000000..33e0063 --- /dev/null +++ b/test/dummy/config/environments/development.rb @@ -0,0 +1,68 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Configure Action Mailer for development + config.action_mailer.default_url_options = {host: "localhost", port: 3000} + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/test/mailers/previews/solid_errors/error_mailer_preview.rb b/test/mailers/previews/solid_errors/error_mailer_preview.rb new file mode 100644 index 0000000..f59eefb --- /dev/null +++ b/test/mailers/previews/solid_errors/error_mailer_preview.rb @@ -0,0 +1,47 @@ +module SolidErrors + class ErrorMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/solid_errors/error_mailer/error_occurred + def error_occurred + error = SolidErrors::Error.new( + id: 3, + exception_class: "Stack::Web::Api::Errors::MissingScope", + message: "missing_scope", + severity: "warning", + source: "application", + resolved_at: nil + ) + + occurrence = SolidErrors::Occurrence.new( + id: 123, + error: error, + backtrace: sample_backtrace, + context: { + "Job" => "#", + "Request ID" => "abc123-def456", + "User ID" => "42", + "Environment" => "production" + }, + created_at: 1.day.ago + ) + + SolidErrors::ErrorMailer.error_occurred(occurrence) + end + + private + + def sample_backtrace + [ + "[GEM_ROOT]/gems/slack-ruby-client-3.1.0/lib/slack/web/faraday/response/raise_error.rb:19:in `Slack::Web::Faraday::Response::RaiseError#on_complete'", + "/rails/app/services/sync_message_service.rb:17:in `SyncMessageService#call'", + "/rails/app/jobs/sync_message_service_channels_job.rb:5:in `SyncMessageServiceChannelsJob#perform'", + "[GEM_ROOT]/gems/activejob-7.0.4/lib/active_job/execution.rb:48:in `ActiveJob::Execution#perform_now'", + "[GEM_ROOT]/gems/activejob-7.0.4/lib/active_job/callbacks.rb:145:in `ActiveJob::Callbacks#perform_now'", + "/rails/app/controllers/webhooks_controller.rb:23:in `WebhooksController#create'", + "[GEM_ROOT]/gems/actionpack-7.0.4/lib/action_controller/metal/basic_implicit_render.rb:6:in `ActionController::Metal::BasicImplicitRender#send_action'", + "[GEM_ROOT]/gems/actionpack-7.0.4/lib/abstract_controller/base.rb:215:in `AbstractController::Base#process_action'", + "[GEM_ROOT]/gems/actionpack-7.0.4/lib/action_controller/metal/rendering.rb:53:in `ActionController::Metal::Rendering#process_action'", + "[GEM_ROOT]/gems/railties-7.0.4/lib/rails/engine.rb:531:in `Rails::Engine#call'" + ].join("\n") + end + end +end diff --git a/test/mailers/solid_errors/error_mailer_test.rb b/test/mailers/solid_errors/error_mailer_test.rb index 768a38f..13a5c51 100644 --- a/test/mailers/solid_errors/error_mailer_test.rb +++ b/test/mailers/solid_errors/error_mailer_test.rb @@ -22,6 +22,20 @@ def deliver!(_mail) assert_includes email.html_part.body.to_s, "oof" end + test "the error email links to the occurrence when the app has a mailer host" do + report_raised_error("oof") + occurrence = SolidErrors::Occurrence.last + ActionMailer::Base.default_url_options = {host: "errors.example.com"} + + email = SolidErrors::ErrorMailer.error_occurred(occurrence) + + url = "http://errors.example.com/solid_errors/#{occurrence.error_id}#occurrence_#{occurrence.id}" + assert_includes email.html_part.body.to_s, %(href="#{url}") + assert_includes email.text_part.body.to_s, url + ensure + ActionMailer::Base.default_url_options = {} + end + test "a failed delivery is recorded without sending another email" do with_failing_delivery do report_raised_error("oof")