Skip to content
11 changes: 11 additions & 0 deletions app/mailers/solid_errors/error_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
167 changes: 160 additions & 7 deletions app/views/solid_errors/error_mailer/error_occurred.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,169 @@
<head>
<title>Solid Errors | <%= @error.severity_emoji %> <%= @error.exception_class %></title>
<%= render "layouts/solid_errors/style" %>
<style>
body {
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.email-header {
margin-bottom: 16px;
}
.email-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0 0 8px 0;
}
.email-header h1 a {
color: #1f2937;
text-decoration: none;
}
.email-header h1 a:hover {
text-decoration: underline;
}
.email-link {
font-size: 14px;
color: #2563eb;
text-decoration: none;
margin-bottom: 12px;
display: inline-block;
}
.error-message {
background: #f3f4f6;
padding: 12px;
border-radius: 4px;
margin: 12px 0;
font-family: monospace;
font-size: 13px;
white-space: pre-wrap;
}
.info-table {
width: auto;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.info-table td {
padding: 4px 8px;
border-bottom: 1px solid #e5e7eb;
}
.info-table td:first-child {
font-weight: 600;
white-space: nowrap;
vertical-align: top;
}
.backtrace {
background: #1e293b;
color: #fff;
padding: 12px;
border-radius: 4px;
margin: 12px 0;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
overflow-x: auto;
}
.backtrace-line {
margin: 2px 0;
}
.backtrace-highlight {
background: #374151;
margin-left: -12px;
margin-right: -12px;
padding-left: 12px;
padding-right: 12px;
}
.context-table {
width: auto;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.context-table th {
background: #f3f4f6;
padding: 6px 8px;
text-align: left;
font-weight: 600;
border: 1px solid #e5e7eb;
white-space: nowrap;
}
.context-table td {
padding: 6px 8px;
border: 1px solid #e5e7eb;
font-family: monospace;
font-size: 12px;
}
.context-table td:first-child {
white-space: nowrap;
}
.section-title {
font-weight: 700;
font-size: 14px;
margin: 16px 0 8px 0;
}
</style>
</head>
<body>
<%= render "solid_errors/errors/error",
error: @error,
show_actions: false %>

<br>
<br>
<div class="email-header">
<h1>
<%= @error.severity_emoji %>
<%= @error.exception_class %>
from <em><%= @error.source %></em>
</h1>
<% if (url = occurrence_url) %>
<a href="<%= url %>" class="email-link">View in Solid Errors &rarr;</a>
<% end %>
</div>

<%= render "solid_errors/occurrences/occurrence",
occurrence: @occurrence %>
<div class="error-message"><%= @error.message %></div>

<table class="info-table">
<tr>
<td>Severity</td>
<td><%= @error.severity_emoji %> <%= @error.severity %></td>
</tr>
<tr>
<td>Status</td>
<td><%= @error.status_emoji %> <%= @error.status %></td>
</tr>
<tr>
<td>Occurred at</td>
<td><%= @occurrence.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></td>
</tr>
</table>

<% if @occurrence.context&.any? %>
<div class="section-title">Context</div>
<table class="context-table">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<% @occurrence.context.each do |key, value| %>
<tr>
<td><%= key %></td>
<td><%= value %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

<div class="section-title">Backtrace</div>
<div class="backtrace">
<% @occurrence.parsed_backtrace.lines.each_with_index do |line, i| %>
<div class="backtrace-line <%= i.zero? ? 'backtrace-highlight' : '' %>">
<% if line.filtered_file %>
<span style="color: #9ca3af;"><%= File.dirname(line.filtered_file) %>/</span><span style="color: #60a5fa; font-weight: 500;"><%= File.basename(line.filtered_file) %></span>:<span style="color: #fff; font-weight: 500;"><%= line.filtered_number %></span> <span style="color: #9ca3af;">in</span> <span style="color: #34d399;"><%= line.filtered_method %></span>
<% else %>
<span style="color: #9ca3af;"><%= line.unparsed_line %></span>
<% end %>
</div>
<% end %>
</div>
</body>
</html>
32 changes: 32 additions & 0 deletions app/views/solid_errors/error_mailer/error_occurred.text.erb
Original file line number Diff line number Diff line change
@@ -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 %>
26 changes: 26 additions & 0 deletions lib/solid_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
68 changes: 68 additions & 0 deletions test/dummy/config/environments/development.rb
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions test/mailers/previews/solid_errors/error_mailer_preview.rb
Original file line number Diff line number Diff line change
@@ -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" => "#<SyncMessageServiceChannelsJob>",
"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
Loading
Loading