solid_errors 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 877768652877df99eb678441601fa65e3329ad898ae4a163c2a260e5ab88d40b
4
- data.tar.gz: dc5cd7fcb81f7548ea8657f74670971682f530c6149114b97bad9d943c282764
3
+ metadata.gz: 11cd9651413ea528780669e5c01abec456118a34a70c93042a85a471c0d1fd07
4
+ data.tar.gz: 39f16c51d5bb62144c8b1b138c18fc6fa4dff4e601c635ed3046f9d37f1d960a
5
5
  SHA512:
6
- metadata.gz: 4482cfe25a500becec052384f224214e9946d97f1f034a03ea4d52f2549814863e00632da29b659bb78f9b0b58667a4773020141597f2e517be7dd08fbccb76c
7
- data.tar.gz: bf4e567dcc81fe59ab1aac561abfd3110c69b83db080c317401caa19206f5e36c0e6b12a6afd6e232aeb883cfd85de822fa7fc802672348b01b3ca6aaa2ae32e
6
+ metadata.gz: 39341060f4ee0e9bfdc009fedd2176fb9bb7ae6c60aaddc175f8e4449d2274f7014f6df71b683865a94eb6c779700c4b0356a0addf2c435a40162027a45b5bec
7
+ data.tar.gz: 87f876f3bdcdf11dcdeac4ded4c6ae873c3f5a53c87aebe454ce925416a66ad19dc37d4984b9a6c326fe79e2c1bab16f6c20f6feb9f79381a70965e35d058030
data/README.md CHANGED
@@ -24,13 +24,8 @@
24
24
  </a>
25
25
  </p>
26
26
 
27
-
28
27
  Solid Errors is a DB-based, app-internal exception tracker for Rails applications, designed with simplicity and performance in mind. It uses the new [Rails error reporting API](https://guides.rubyonrails.org/error_reporting.html) to store uncaught exceptions in the database, and provides a simple UI for viewing and managing exceptions.
29
28
 
30
- > [!WARNING]
31
- > The current point release of Rails (7.1.3.2) has a bug which severely limits the utility of Solid Errors. Exceptions raised during a web request *are not* reported to Rails' error reporter. There is a fix in the `main` branch, but it has not been released in a new point release. As such, Solid Errors is **not** production-ready unless you are running Rails from the `main` branch or until a new point version is released and you upgrade.
32
- > The original bug report can be found [here](https://github.com/rails/rails/issues/51002) and the pull request making the fix is [here](https://github.com/rails/rails/pull/51050). I will try to backport the fix into the gem directly, but I haven't quite figured it out yet.
33
-
34
29
 
35
30
  ## Installation
36
31
 
@@ -80,7 +75,7 @@ production:
80
75
  ```
81
76
 
82
77
  > [!NOTE]
83
- > Calling `bin/rails solid_errors:install` will automatically add `config.solid_errors.connects_to = { database: { writing: :errors } }` to `config/environments/production.rb`, so no additional configuration is needed there (although you must make sure that you use the `errors` name in `database.yml` for this to match!). But if you want to use Solid Errors in a different environment (like staging or even development), you'll have to manually add that `config.solid_errors.connects_to` line to the respective environment file. And, as always, make sure that the name you're using for the database in `config/database.yml` matches the name you use in `config.solid_errors.connects_to`.
78
+ > Calling `bin/rails generate solid_errors:install` will automatically add `config.solid_errors.connects_to = { database: { writing: :errors } }` to `config/environments/production.rb`, so no additional configuration is needed there (although you must make sure that you use the `errors` name in `database.yml` for this to match!). But if you want to use Solid Errors in a different environment (like staging or even development), you'll have to manually add that `config.solid_errors.connects_to` line to the respective environment file. And, as always, make sure that the name you're using for the database in `config/database.yml` matches the name you use in `config.solid_errors.connects_to`.
84
79
 
85
80
  Then run `db:prepare` in production to ensure the database is created and the schema is loaded.
86
81
 
@@ -158,17 +153,20 @@ end
158
153
 
159
154
  ### Configuration
160
155
 
161
- You can configure Solid Errors via the Rails configuration object, under the `solid_errors` key. Currently, 6 configuration options are available:
156
+ You can configure Solid Errors via the Rails configuration object, under the `solid_errors` key. Currently, the following configuration options are available:
162
157
 
158
+ * `enabled` - Enable or disable error tracking. Defaults to `true`. See [Disabling error tracking](#disabling-error-tracking) for more information.
163
159
  * `connects_to` - The database configuration to use for the Solid Errors database. See [Database Configuration](#database-configuration) for more information.
164
160
  * `username` - The username to use for HTTP authentication. See [Authentication](#authentication) for more information.
165
161
  * `password` - The password to use for HTTP authentication. See [Authentication](#authentication) for more information.
166
162
  * `sends_email` - Whether or not to send emails when an error occurs. See [Email notifications](#email-notifications) for more information.
167
163
  * `email_from` - The email address to send a notification from. See [Email notifications](#email-notifications) for more information.
168
164
  * `email_to` - The email address(es) to send a notification to. See [Email notifications](#email-notifications) for more information.
165
+ * `email_every_occurrence` - Whether to email every occurrence (the default), or only new errors and resolved errors that occur again. See [Email notifications](#email-notifications) for more information.
169
166
  * `email_subject_prefix` - Prefix added to the subject line for email notifications. See [Email notifications](#email-notifications) for more information.
170
167
  * `base_controller_class` - Specify a different controller as the base class for the Solid Errors controller. See [Authentication](#authentication) for more information.
171
168
  * `destroy_after` - If set, Solid Errors will periodically destroy resolved records that are older than the value specified. See [Automatically destroying old records](#automatically-destroying-old-records) for more information.
169
+ * `ignored_errors` - Error classes, or their names, to never record, in addition to the built-in list in `SolidErrors::Subscriber::IGNORED_ERRORS`. For example, `config.solid_errors.ignored_errors = ["ActiveStorage::FileNotFoundError"]`.
172
170
 
173
171
  ### Database Configuration
174
172
 
@@ -196,6 +194,42 @@ Running Solid Errors in a separate database is recommended, but it's also possib
196
194
 
197
195
  You won't have multiple databases, so `database.yml` doesn't need to have the errors database configuration.
198
196
 
197
+ #### Apps that use `schema_format = :sql`
198
+
199
+ The installer writes `db/errors_schema.rb`, but an app with `config.active_record.schema_format = :sql` looks for `db/errors_structure.sql` for the errors database.
200
+
201
+ On Rails 8.1 and later, keep the Ruby schema for the errors database only by setting `schema_format` on it in `config/database.yml`:
202
+
203
+ ```yaml
204
+ production:
205
+ errors:
206
+ <<: *default
207
+ database: storage/production_errors.sqlite3
208
+ migrations_paths: db/errors_migrate
209
+ schema_format: ruby
210
+ ```
211
+
212
+ On earlier Rails versions, create the errors database, load the Ruby schema into it, and dump it as SQL for your database adapter:
213
+
214
+ ```sh
215
+ bin/rails db:create:errors
216
+ bin/rails runner 'ActiveRecord::Tasks::DatabaseTasks.load_schema(ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: "errors"), :ruby, "db/errors_schema.rb")'
217
+ bin/rails db:schema:dump:errors
218
+ ```
219
+
220
+ This writes `db/errors_structure.sql`, which you commit in place of `db/errors_schema.rb`.
221
+
222
+ #### Disabling error tracking
223
+
224
+ Solid Errors is enabled by default. If you want to disable error tracking in a specific environment, you can set `enabled` to `false`:
225
+
226
+ ```ruby
227
+ # config/environments/development.rb
228
+ config.solid_errors.enabled = false
229
+ ```
230
+
231
+ When disabled, Solid Errors will not subscribe to the Rails error reporter and no errors will be recorded.
232
+
199
233
  #### Authentication
200
234
 
201
235
  Solid Errors does not restrict access out of the box. You must secure the dashboard yourself. However, it does provide basic HTTP authentication that can be used with basic authentication or Devise. All you need to do is setup a username and password.
@@ -257,6 +291,31 @@ config.solid_errors.email_subject_prefix = "[#{Rails.application.name}][#{Rails.
257
291
 
258
292
  If you have set `send_emails` to `true` and have set an `email_to` address, Solid Errors will send an email notification whenever an error occurs. If you have not set `send_emails` to `true` or have not set an `email_to` address, Solid Errors will not send any email notifications.
259
293
 
294
+ To be emailed only when an error is new, or when a resolved error occurs again, rather than for every occurrence:
295
+
296
+ ```ruby
297
+ config.solid_errors.email_every_occurrence = false # defaults to true
298
+ ```
299
+
300
+ #### Lifecycle events
301
+
302
+ Solid Errors publishes [Active Support notifications](https://guides.rubyonrails.org/active_support_instrumentation.html) once each change is committed, so you can send errors to Slack, a webhook or anywhere else:
303
+
304
+ | Event | When | Payload |
305
+ | --- | --- | --- |
306
+ | `error_created.solid_errors` | An error is recorded for the first time | `:error`, `:occurrence` |
307
+ | `error_reopened.solid_errors` | A resolved error occurs again | `:error`, `:occurrence` |
308
+ | `error_resolved.solid_errors` | An error is marked as resolved | `:error` |
309
+ | `occurrence_created.solid_errors` | Any occurrence is recorded, including those above | `:error`, `:occurrence` |
310
+
311
+ ```ruby
312
+ # config/initializers/solid_errors.rb
313
+ ActiveSupport::Notifications.subscribe(/\Aerror_(created|reopened)\.solid_errors\z/) do |event|
314
+ error = event.payload[:error]
315
+ SlackNotifier.post("#{error.exception_class}: #{error.message}")
316
+ end
317
+ ```
318
+
260
319
  #### Automatically destroying old records
261
320
 
262
321
  Setting `destroy_after` to a duration will allow Solid Errors to be self-maintaining by peridically destroying **resolved** records that are older than that value. The value provided must respond to `.ago`.
@@ -381,4 +440,3 @@ The gem is available as open source under the terms of the [MIT License](https:/
381
440
  ## Code of Conduct
382
441
 
383
442
  Everyone interacting in the SolidErrors project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/fractaledmind/solid_errors/blob/main/CODE_OF_CONDUCT.md).
384
-
@@ -1,6 +1,17 @@
1
1
  module SolidErrors
2
2
  # adapted from: https://github.com/codergeek121/email_error_reporter/blob/main/lib/email_error_reporter/error_mailer.rb
3
3
  class ErrorMailer < (defined?(ActionMailer::Base) ? ActionMailer::Base : Object)
4
+ if defined?(ActionMailer::Base)
5
+ # A failure here reported as usual would email again and fail again, without end
6
+ rescue_from StandardError do |exception|
7
+ SolidErrors.without_emails do
8
+ Rails.error.report(exception, handled: true, source: "solid_errors.error_mailer")
9
+ end
10
+ end
11
+
12
+ helper_method :occurrence_url
13
+ end
14
+
4
15
  def error_occurred(occurrence)
5
16
  unless defined?(ActionMailer::Base)
6
17
  raise "ActionMailer is not available. Make sure that you require \"action_mailer/railtie\" in application.rb"
@@ -17,5 +28,14 @@ module SolidErrors
17
28
  to: SolidErrors.email_to
18
29
  )
19
30
  end
31
+
32
+ private
33
+
34
+ # Optional: an app without default_url_options[:host] still gets the email, just unlinked
35
+ def occurrence_url
36
+ public_send(SolidErrors.url_helper_name).error_url(@error, **SolidErrors.url_options, anchor: "occurrence_#{@occurrence.id}")
37
+ rescue ArgumentError, NoMethodError, ActionController::UrlGenerationError
38
+ nil
39
+ end
20
40
  end
21
41
  end
@@ -35,7 +35,7 @@ module SolidErrors
35
35
  end
36
36
 
37
37
  def to_s
38
- lines.map(&:to_s).join("\n")
38
+ lines.join("\n")
39
39
  end
40
40
 
41
41
  def inspect
@@ -30,6 +30,8 @@ module SolidErrors
30
30
  scope :resolved, -> { where.not(resolved_at: nil) }
31
31
  scope :unresolved, -> { where(resolved_at: nil) }
32
32
 
33
+ after_update_commit :publish_resolved, if: -> { saved_change_to_resolved_at? && resolved? }
34
+
33
35
  def severity_emoji
34
36
  SEVERITY_TO_EMOJI[severity.to_sym]
35
37
  end
@@ -53,5 +55,46 @@ module SolidErrors
53
55
  def resolved?
54
56
  resolved_at.present?
55
57
  end
58
+
59
+ def to_markdown
60
+ md = ""
61
+ md << "## Exception: #{exception_class}\n"
62
+ md << "**Severity:** #{severity} | **Source:** #{source}\n\n"
63
+ md << "### Message\n#{message}\n"
64
+
65
+ occurrence = occurrences.order(created_at: :desc).first
66
+ return md unless occurrence
67
+
68
+ backtrace = occurrence.parsed_backtrace
69
+
70
+ md << "\n### Backtrace\n"
71
+ backtrace.lines.each do |line|
72
+ md << (line.filtered_method ? line.to_s : line.unparsed_line) << "\n"
73
+ end
74
+
75
+ app_lines = backtrace.application_lines
76
+ if app_lines.any?
77
+ md << "\n### Source Context\n"
78
+ app_lines.each do |line|
79
+ md << "#### #{line}\n"
80
+ md << "```ruby\n"
81
+ line.source.each { |n, code| md << "#{n}: #{code.chomp}\n" }
82
+ md << "```\n\n"
83
+ end
84
+ end
85
+
86
+ if occurrence.context.present?
87
+ md << "### Request Context\n"
88
+ occurrence.context.each { |k, v| md << "- #{k}: #{v}\n" }
89
+ end
90
+
91
+ md
92
+ end
93
+
94
+ private
95
+
96
+ def publish_resolved
97
+ ActiveSupport::Notifications.instrument("error_resolved.solid_errors", error: self)
98
+ end
56
99
  end
57
100
  end
@@ -2,7 +2,14 @@ module SolidErrors
2
2
  class Occurrence < Record
3
3
  belongs_to :error, class_name: "SolidErrors::Error"
4
4
 
5
- after_create_commit :send_email, if: -> { SolidErrors.send_emails? && SolidErrors.email_to.present? }
5
+ # Adapters that report the json column as text (MariaDB) would otherwise store Hash#inspect
6
+ attribute :context, :json
7
+
8
+ # :created or :reopened, set by the subscriber when this occurrence opens its error
9
+ attr_accessor :opens_error
10
+
11
+ after_create_commit :send_email, if: -> { SolidErrors.send_emails? && (SolidErrors.email_every_occurrence || opens_error) }
12
+ after_create_commit :publish_events
6
13
  after_create_commit :clear_resolved_errors, if: :should_clear_resolved_errors?
7
14
 
8
15
  # The parsed exception backtrace. Lines in this backtrace that are from installed gems
@@ -12,7 +19,7 @@ module SolidErrors
12
19
  def parsed_backtrace
13
20
  return @parsed_backtrace if defined? @parsed_backtrace
14
21
 
15
- @parsed_backtrace = parse_backtrace(backtrace.split("\n"))
22
+ @parsed_backtrace = parse_backtrace(backtrace&.split("\n"))
16
23
  end
17
24
 
18
25
  private
@@ -21,6 +28,12 @@ module SolidErrors
21
28
  Backtrace.parse(backtrace)
22
29
  end
23
30
 
31
+ def publish_events
32
+ payload = {error: error, occurrence: self}
33
+ ActiveSupport::Notifications.instrument("error_#{opens_error}.solid_errors", payload) if opens_error
34
+ ActiveSupport::Notifications.instrument("occurrence_created.solid_errors", payload)
35
+ end
36
+
24
37
  def send_email
25
38
  ErrorMailer.error_occurred(self).deliver_later
26
39
  end
@@ -1,4 +1,4 @@
1
- <style>
1
+ <style<% if local_assigns[:nonce] %> nonce="<%= nonce %>"<% end %>>
2
2
  /*
3
3
  ! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
4
4
  */
@@ -5,10 +5,13 @@
5
5
  <%= csrf_meta_tags %>
6
6
  <%= csp_meta_tag %>
7
7
 
8
- <%= render "layouts/solid_errors/style" %>
8
+ <%= render "layouts/solid_errors/style", nonce: content_security_policy_nonce %>
9
9
  </head>
10
10
  <body class="h-full flex flex-col pb-4">
11
11
  <main class="grow container mx-auto mt-4">
12
+ <% if defined?(main_app.root_path) %>
13
+ <%= link_to "← Back to main app", main_app.root_path, class: "text-sm text-blue-500 hover:underline decoration-blue-500" %>
14
+ <% end %>
12
15
  <%= content_for?(:content) ? yield(:content) : yield %>
13
16
  </main>
14
17
 
@@ -51,6 +54,87 @@
51
54
  document.querySelectorAll('[data-controller="fade"]').forEach(element => {
52
55
  fadeOut(element);
53
56
  });
57
+
58
+ // Backtrace filter toggle functionality
59
+ (function() {
60
+ const STORAGE_KEY = 'solid_errors_backtrace_filter';
61
+
62
+ function toggleBacktrace(button) {
63
+ const filter = button.getAttribute('data-backtrace-toggle');
64
+ const occurrenceId = button.getAttribute('data-occurrence-id');
65
+
66
+ // Toggle visibility
67
+ const appTrace = document.getElementById(`backtrace-application-${occurrenceId}`);
68
+ const fullTrace = document.getElementById(`backtrace-full-${occurrenceId}`);
69
+
70
+ if (filter === 'application') {
71
+ appTrace.removeAttribute('hidden');
72
+ fullTrace.setAttribute('hidden', '');
73
+ } else {
74
+ appTrace.setAttribute('hidden', '');
75
+ fullTrace.removeAttribute('hidden');
76
+ }
77
+
78
+ // Update button states for this occurrence
79
+ const occurrenceButtons = document.querySelectorAll(`[data-occurrence-id="${occurrenceId}"][data-backtrace-toggle]`);
80
+ occurrenceButtons.forEach(btn => {
81
+ if (btn === button) {
82
+ btn.classList.add('border-blue-500', 'text-blue-500');
83
+ btn.classList.remove('border-transparent', 'text-gray-500');
84
+ } else {
85
+ btn.classList.remove('border-blue-500', 'text-blue-500');
86
+ btn.classList.add('border-transparent', 'text-gray-500');
87
+ }
88
+ });
89
+
90
+ // Remember the choice; storage can be unavailable (private browsing)
91
+ try { sessionStorage.setItem(STORAGE_KEY, filter); } catch (e) {}
92
+ }
93
+
94
+ // Initialize on page load
95
+ let savedFilter = 'application';
96
+ try { savedFilter = sessionStorage.getItem(STORAGE_KEY) || savedFilter; } catch (e) {}
97
+
98
+ // Apply saved filter to all occurrences
99
+ if (savedFilter === 'full') {
100
+ document.querySelectorAll('[id^="backtrace-application-"]').forEach(el => el.setAttribute('hidden', ''));
101
+ document.querySelectorAll('[id^="backtrace-full-"]').forEach(el => el.removeAttribute('hidden'));
102
+
103
+ // Update button states
104
+ document.querySelectorAll('[data-backtrace-toggle]').forEach(btn => {
105
+ const filter = btn.getAttribute('data-backtrace-toggle');
106
+ if (filter === savedFilter) {
107
+ btn.classList.add('border-blue-500', 'text-blue-500');
108
+ btn.classList.remove('border-transparent', 'text-gray-500');
109
+ } else {
110
+ btn.classList.remove('border-blue-500', 'text-blue-500');
111
+ btn.classList.add('border-transparent', 'text-gray-500');
112
+ }
113
+ });
114
+ }
115
+
116
+ // Attach event listeners
117
+ document.querySelectorAll('[data-backtrace-toggle]').forEach(button => {
118
+ button.addEventListener('click', function() {
119
+ toggleBacktrace(this);
120
+ });
121
+ });
122
+ })();
123
+
124
+ var copyBtn = document.getElementById('copy-markdown');
125
+ if (copyBtn) {
126
+ copyBtn.addEventListener('click', function() {
127
+ var text = document.getElementById('error-markdown').content.textContent;
128
+ var span = copyBtn.querySelector('span');
129
+ var originalText = span.textContent;
130
+ navigator.clipboard.writeText(text).then(function() {
131
+ span.textContent = 'Copied!';
132
+ setTimeout(function() { span.textContent = originalText; }, 2000);
133
+ }).catch(function(err) {
134
+ alert('Failed to copy: ' + err);
135
+ });
136
+ });
137
+ }
54
138
  </script>
55
139
  </body>
56
140
  </html>
@@ -2,16 +2,169 @@
2
2
  <head>
3
3
  <title>Solid Errors | <%= @error.severity_emoji %> <%= @error.exception_class %></title>
4
4
  <%= render "layouts/solid_errors/style" %>
5
+ <style>
6
+ body {
7
+ padding: 20px;
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
9
+ }
10
+ .email-header {
11
+ margin-bottom: 16px;
12
+ }
13
+ .email-header h1 {
14
+ font-size: 20px;
15
+ font-weight: 700;
16
+ margin: 0 0 8px 0;
17
+ }
18
+ .email-header h1 a {
19
+ color: #1f2937;
20
+ text-decoration: none;
21
+ }
22
+ .email-header h1 a:hover {
23
+ text-decoration: underline;
24
+ }
25
+ .email-link {
26
+ font-size: 14px;
27
+ color: #2563eb;
28
+ text-decoration: none;
29
+ margin-bottom: 12px;
30
+ display: inline-block;
31
+ }
32
+ .error-message {
33
+ background: #f3f4f6;
34
+ padding: 12px;
35
+ border-radius: 4px;
36
+ margin: 12px 0;
37
+ font-family: monospace;
38
+ font-size: 13px;
39
+ white-space: pre-wrap;
40
+ }
41
+ .info-table {
42
+ width: auto;
43
+ border-collapse: collapse;
44
+ margin: 12px 0;
45
+ font-size: 13px;
46
+ }
47
+ .info-table td {
48
+ padding: 4px 8px;
49
+ border-bottom: 1px solid #e5e7eb;
50
+ }
51
+ .info-table td:first-child {
52
+ font-weight: 600;
53
+ white-space: nowrap;
54
+ vertical-align: top;
55
+ }
56
+ .backtrace {
57
+ background: #1e293b;
58
+ color: #fff;
59
+ padding: 12px;
60
+ border-radius: 4px;
61
+ margin: 12px 0;
62
+ font-family: 'Monaco', 'Courier New', monospace;
63
+ font-size: 12px;
64
+ line-height: 1.4;
65
+ overflow-x: auto;
66
+ }
67
+ .backtrace-line {
68
+ margin: 2px 0;
69
+ }
70
+ .backtrace-highlight {
71
+ background: #374151;
72
+ margin-left: -12px;
73
+ margin-right: -12px;
74
+ padding-left: 12px;
75
+ padding-right: 12px;
76
+ }
77
+ .context-table {
78
+ width: auto;
79
+ border-collapse: collapse;
80
+ margin: 12px 0;
81
+ font-size: 13px;
82
+ }
83
+ .context-table th {
84
+ background: #f3f4f6;
85
+ padding: 6px 8px;
86
+ text-align: left;
87
+ font-weight: 600;
88
+ border: 1px solid #e5e7eb;
89
+ white-space: nowrap;
90
+ }
91
+ .context-table td {
92
+ padding: 6px 8px;
93
+ border: 1px solid #e5e7eb;
94
+ font-family: monospace;
95
+ font-size: 12px;
96
+ }
97
+ .context-table td:first-child {
98
+ white-space: nowrap;
99
+ }
100
+ .section-title {
101
+ font-weight: 700;
102
+ font-size: 14px;
103
+ margin: 16px 0 8px 0;
104
+ }
105
+ </style>
5
106
  </head>
6
107
  <body>
7
- <%= render "solid_errors/errors/error",
8
- error: @error,
9
- show_actions: false %>
10
108
 
11
- <br>
12
- <br>
109
+ <div class="email-header">
110
+ <h1>
111
+ <%= @error.severity_emoji %>
112
+ <%= @error.exception_class %>
113
+ from <em><%= @error.source %></em>
114
+ </h1>
115
+ <% if (url = occurrence_url) %>
116
+ <a href="<%= url %>" class="email-link">View in Solid Errors &rarr;</a>
117
+ <% end %>
118
+ </div>
13
119
 
14
- <%= render "solid_errors/occurrences/occurrence",
15
- occurrence: @occurrence %>
120
+ <div class="error-message"><%= @error.message %></div>
121
+
122
+ <table class="info-table">
123
+ <tr>
124
+ <td>Severity</td>
125
+ <td><%= @error.severity_emoji %> <%= @error.severity %></td>
126
+ </tr>
127
+ <tr>
128
+ <td>Status</td>
129
+ <td><%= @error.status_emoji %> <%= @error.status %></td>
130
+ </tr>
131
+ <tr>
132
+ <td>Occurred at</td>
133
+ <td><%= @occurrence.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></td>
134
+ </tr>
135
+ </table>
136
+
137
+ <% if @occurrence.context&.any? %>
138
+ <div class="section-title">Context</div>
139
+ <table class="context-table">
140
+ <thead>
141
+ <tr>
142
+ <th>Key</th>
143
+ <th>Value</th>
144
+ </tr>
145
+ </thead>
146
+ <tbody>
147
+ <% @occurrence.context.each do |key, value| %>
148
+ <tr>
149
+ <td><%= key %></td>
150
+ <td><%= value %></td>
151
+ </tr>
152
+ <% end %>
153
+ </tbody>
154
+ </table>
155
+ <% end %>
156
+
157
+ <div class="section-title">Backtrace</div>
158
+ <div class="backtrace">
159
+ <% @occurrence.parsed_backtrace.lines.each_with_index do |line, i| %>
160
+ <div class="backtrace-line <%= i.zero? ? 'backtrace-highlight' : '' %>">
161
+ <% if line.filtered_file %>
162
+ <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>
163
+ <% else %>
164
+ <span style="color: #9ca3af;"><%= line.unparsed_line %></span>
165
+ <% end %>
166
+ </div>
167
+ <% end %>
168
+ </div>
16
169
  </body>
17
170
  </html>
@@ -0,0 +1,32 @@
1
+ <%= @error.severity_emoji %> <%= @error.exception_class %> from <%= @error.source %>
2
+ ================================================================================
3
+
4
+ <%= @error.message %>
5
+
6
+ <% if (url = occurrence_url) %>
7
+ View in Solid Errors: <%= url %>
8
+ <% end %>
9
+
10
+ DETAILS
11
+ --------------------------------------------------------------------------------
12
+ Severity: <%= @error.severity_emoji %> <%= @error.severity %>
13
+ Status: <%= @error.status_emoji %> <%= @error.status %>
14
+ Occurred at: <%= @occurrence.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
15
+
16
+ <% if @occurrence.context&.any? %>
17
+ CONTEXT
18
+ --------------------------------------------------------------------------------
19
+ <% @occurrence.context.each do |key, value| %>
20
+ <%= key.to_s.ljust(20) %> <%= value %>
21
+ <% end %>
22
+
23
+ <% end %>
24
+ BACKTRACE
25
+ --------------------------------------------------------------------------------
26
+ <% @occurrence.parsed_backtrace.lines.each_with_index do |line, i| %>
27
+ <% if line.filtered_file %>
28
+ <%= i.zero? ? '>>>' : ' ' %> <%= File.join(File.dirname(line.filtered_file), File.basename(line.filtered_file)) %>:<%= line.filtered_number %> in <%= line.filtered_method %>
29
+ <% else %>
30
+ <%= i.zero? ? '>>>' : ' ' %> <%= line.unparsed_line %>
31
+ <% end %>
32
+ <% end %>
@@ -5,6 +5,15 @@
5
5
  </svg>
6
6
  <span>Back to errors</span>
7
7
  <% end %>
8
+ <template id="error-markdown"><%= error.to_markdown %></template>
9
+ <button id="copy-markdown"
10
+ class="inline-flex items-center justify-center gap-2 font-medium cursor-pointer border rounded-lg py-3 px-5 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-8">
11
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
12
+ <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
13
+ <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
14
+ </svg>
15
+ <span>Copy Markdown</span>
16
+ </button>
8
17
  <% if error.resolved? %>
9
18
  <%= render 'solid_errors/errors/delete_button', error: error %>
10
19
  <% else %>
@@ -0,0 +1,14 @@
1
+ <%= tag.details open: open do %>
2
+ <summary class="hover:bg-gray-50 px-2 py-1 rounded cursor-pointer">
3
+ <% if line.filtered_file %>
4
+ <span class="text-gray-500"><%= File.dirname(line.filtered_file) %>/</span><span class="text-blue-500 font-medium"><%= File.basename(line.filtered_file) %></span>:<span class="text-gray-900 font-medium"><%= line.filtered_number %></span>
5
+ <span class="text-gray-500">in</span>
6
+ <code class="text-green-500 font-medium"><%= line.filtered_method %></code>
7
+ <% else %>
8
+ <span class="text-gray-500"><%= line.unparsed_line %></span>
9
+ <% end %>
10
+ </summary>
11
+ <div><pre class="flex overflow-auto rounded-b-lg bg-slate-800 p-4 text-sm leading-normal text-white sm:rounded-t-lg"><code class="flex flex-1 flex-col min-h-full min-w-content px-0"><% line.source.each do |n, code| %>
12
+ <div class="line <%= n == line.filtered_number.to_i ? 'bg-gray-600/50' : '' %>"><span class="mr-2 text-right select-none text-gray-600"><%= n %></span><span><%= code %></span></div>
13
+ <% end %></code></pre></div>
14
+ <% end %>
@@ -20,25 +20,25 @@
20
20
 
21
21
  <nav aria-label="Pages">
22
22
  <% if (first_page = page.first) %>
23
- <a href="<%= url_for(params.to_unsafe_hash.merge(page: first_page)) %>" aria-label="First" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&laquo;</a>
23
+ <a href="<%= url_for(page: first_page, items: params[:items]) %>" aria-label="First" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&laquo;</a>
24
24
  <% else %>
25
25
  <a role="link" aria-disabled="true" aria-label="First" class="border text-gray-500 bg-white py-1 px-2">&laquo;</a>
26
26
  <% end %>
27
27
 
28
28
  <% if (prev_page = page.prev) %>
29
- <a href="<%= url_for(params.to_unsafe_hash.merge(page: prev_page)) %>" aria-label="Previous" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&lsaquo;</a>
29
+ <a href="<%= url_for(page: prev_page, items: params[:items]) %>" aria-label="Previous" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&lsaquo;</a>
30
30
  <% else %>
31
31
  <a role="link" aria-disabled="true" aria-label="Previous" class="border text-gray-500 bg-white py-1 px-2">&lsaquo;</a>
32
32
  <% end %>
33
33
 
34
34
  <% if (next_page = page.next) %>
35
- <a href="<%= url_for(params.to_unsafe_hash.merge(page: next_page)) %>" aria-label="Next" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&rsaquo;</a>
35
+ <a href="<%= url_for(page: next_page, items: params[:items]) %>" aria-label="Next" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&rsaquo;</a>
36
36
  <% else %>
37
37
  <a role="link" aria-disabled="true" aria-label="Next" class="border text-gray-500 bg-white py-1 px-2">&rsaquo;</a>
38
38
  <% end %>
39
39
 
40
40
  <% if (last_page = page.last) %>
41
- <a href="<%= url_for(params.to_unsafe_hash.merge(page: last_page)) %>" aria-label="Last" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&raquo;</a>
41
+ <a href="<%= url_for(page: last_page, items: params[:items]) %>" aria-label="Last" class="border border-black text-black bg-white py-1 px-2 cursor-pointer hover:bg-black hover:text-white">&raquo;</a>
42
42
  <% else %>
43
43
  <a role="link" aria-disabled="true" aria-label="Last" class="border text-gray-500 bg-white py-1 px-2">&raquo;</a>
44
44
  <% end %>
@@ -25,22 +25,39 @@
25
25
  <%= SolidErrors::Occurrence.human_attribute_name(:backtrace) %>
26
26
  </dt>
27
27
  <dd class="">
28
- <% backtrace.lines.each_with_index do |line, i| %>
29
- <%= tag.details open: line.application? || i.zero? do %>
30
- <summary class="hover:bg-gray-50 px-2 py-1 rounded cursor-pointer">
31
- <% if line.filtered_file %>
32
- <span class="text-gray-500"><%= File.dirname(line.filtered_file) %>/</span><span class="text-blue-500 font-medium"><%= File.basename(line.filtered_file) %></span>:<span class="text-gray-900 font-medium"><%= line.filtered_number %></span>
33
- <span class="text-gray-500">in</span>
34
- <code class="text-green-500 font-medium"><%= line.filtered_method %></code>
35
- <% else %>
36
- <span class="text-gray-500"><%= line.unparsed_line %></span>
37
- <% end %>
38
- </summary>
39
- <div><pre class="flex overflow-auto rounded-b-lg bg-slate-800 p-4 text-sm leading-normal text-white sm:rounded-t-lg"><code class="flex flex-1 flex-col min-h-full min-w-content px-0"><% line.source.each do |n, code| %>
40
- <div class="line <%= n == line.filtered_number.to_i ? 'bg-gray-600/50' : '' %>"><span class="mr-2 text-right select-none text-gray-600"><%= n %></span><span><%= code %></span></div>
41
- <% end %></code></pre></div>
28
+ <div class="flex gap-2 mb-3">
29
+ <button type="button"
30
+ data-backtrace-toggle="application"
31
+ data-occurrence-id="<%= occurrence.id %>"
32
+ class="backtrace-toggle inline-flex items-center border-b-2 px-3 py-2 text-sm font-medium cursor-pointer border-blue-500 text-blue-500">
33
+ Application
34
+ </button>
35
+ <button type="button"
36
+ data-backtrace-toggle="full"
37
+ data-occurrence-id="<%= occurrence.id %>"
38
+ class="backtrace-toggle inline-flex items-center border-b-2 px-3 py-2 text-sm font-medium cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700">
39
+ Full
40
+ </button>
41
+ </div>
42
+
43
+ <div id="backtrace-application-<%= occurrence.id %>">
44
+ <% if backtrace.application_lines.empty? %>
45
+ <p class="text-gray-500 text-sm">
46
+ <em>No application code in backtrace.</em>
47
+ <button type="button" data-backtrace-toggle="full" data-occurrence-id="<%= occurrence.id %>" class="text-blue-500 hover:underline cursor-pointer">
48
+ View full trace
49
+ </button>
50
+ </p>
51
+ <% else %>
52
+ <%= render partial: "solid_errors/occurrences/backtrace_line", collection: backtrace.application_lines, as: :line, locals: {open: true} %>
53
+ <% end %>
54
+ </div>
55
+
56
+ <div id="backtrace-full-<%= occurrence.id %>" hidden>
57
+ <% backtrace.lines.each_with_index do |line, i| %>
58
+ <%= render "solid_errors/occurrences/backtrace_line", line: line, open: line.application? || i.zero? %>
42
59
  <% end %>
43
- <% end %>
60
+ </div>
44
61
  </dd>
45
62
  </div>
46
63
  </dl>
@@ -18,7 +18,7 @@ module SolidErrors
18
18
  end
19
19
 
20
20
  initializer "solid_errors.active_record.error_subscriber" do
21
- Rails.error.subscribe(SolidErrors::Subscriber.new)
21
+ Rails.error.subscribe(SolidErrors::Subscriber.new) if SolidErrors.enabled
22
22
  end
23
23
  end
24
24
  end
@@ -32,15 +32,18 @@ module SolidErrors
32
32
  }
33
33
  fingerprint = Digest::SHA256.hexdigest(error_attributes.values.join)
34
34
  if (record = SolidErrors::Error.find_by(fingerprint: fingerprint))
35
+ opens_error = :reopened if record.resolved?
35
36
  record.update!(resolved_at: nil, updated_at: Time.now)
36
37
  else
38
+ opens_error = :created
37
39
  record = SolidErrors::Error.create!(error_attributes.merge(fingerprint: fingerprint))
38
40
  end
39
41
 
40
42
  SolidErrors::Occurrence.create(
41
43
  error_id: record.id,
42
44
  backtrace: error.backtrace&.join("\n"),
43
- context: s(context)
45
+ context: s(context),
46
+ opens_error: opens_error
44
47
  )
45
48
  end
46
49
 
@@ -49,7 +52,7 @@ module SolidErrors
49
52
  end
50
53
 
51
54
  def ignore_by_class?(error_class_name)
52
- IGNORED_ERRORS.any? do |ignored_class|
55
+ [*IGNORED_ERRORS, *SolidErrors.ignored_errors].any? do |ignored_class|
53
56
  ignored_class_name = ignored_class.respond_to?(:name) ? ignored_class.name : ignored_class
54
57
 
55
58
  ignored_class_name == error_class_name
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidErrors
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/solid_errors.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "solid_errors/subscriber"
6
6
  require_relative "solid_errors/engine"
7
7
 
8
8
  module SolidErrors
9
+ mattr_accessor :enabled, default: true
9
10
  mattr_accessor :connects_to
10
11
  mattr_accessor :base_controller_class, default: "::ActionController::Base"
11
12
  mattr_writer :username
@@ -14,7 +15,10 @@ module SolidErrors
14
15
  mattr_accessor :email_from, default: "solid_errors@noreply.com"
15
16
  mattr_accessor :email_to
16
17
  mattr_accessor :email_subject_prefix
18
+ mattr_accessor :email_every_occurrence, default: true
17
19
  mattr_accessor :destroy_after
20
+ mattr_accessor :ignored_errors, default: []
21
+ mattr_writer :url_helper_name, :url_options
18
22
 
19
23
  class << self
20
24
  # use method instead of attr_accessor to ensure
@@ -30,7 +34,41 @@ module SolidErrors
30
34
  end
31
35
 
32
36
  def send_emails?
33
- send_emails && email_to.present?
37
+ send_emails && email_to.present? && !ActiveSupport::IsolatedExecutionState[:solid_errors_without_emails]
38
+ end
39
+
40
+ # Errors reported inside the block are recorded but never emailed
41
+ def without_emails
42
+ previous = ActiveSupport::IsolatedExecutionState[:solid_errors_without_emails]
43
+ ActiveSupport::IsolatedExecutionState[:solid_errors_without_emails] = true
44
+ yield
45
+ ensure
46
+ ActiveSupport::IsolatedExecutionState[:solid_errors_without_emails] = previous
47
+ end
48
+
49
+ def url_helper_name
50
+ @url_helper_name ||= detect_url_helper_name
51
+ end
52
+
53
+ def url_options
54
+ @url_options ||= detect_url_options
55
+ end
56
+
57
+ private
58
+
59
+ def find_solid_errors_route
60
+ @solid_errors_route ||= Rails.application.routes.routes.find do |r|
61
+ r.name&.to_s&.end_with?("solid_errors")
62
+ end
63
+ end
64
+
65
+ def detect_url_helper_name
66
+ find_solid_errors_route&.name&.to_sym || :solid_errors
67
+ end
68
+
69
+ def detect_url_options
70
+ subdomain = find_solid_errors_route&.constraints&.dig(:subdomain)
71
+ subdomain.is_a?(String) ? {subdomain: subdomain} : {}
34
72
  end
35
73
  end
36
74
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_errors
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Margheim
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-06-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -15,70 +15,70 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.0'
18
+ version: '7.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.0'
25
+ version: '7.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: actionview
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '7.0'
32
+ version: '7.1'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '7.0'
39
+ version: '7.1'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: activerecord
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '7.0'
46
+ version: '7.1'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '7.0'
53
+ version: '7.1'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: activesupport
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '7.0'
60
+ version: '7.1'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '7.0'
67
+ version: '7.1'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: railties
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: '7.0'
74
+ version: '7.1'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
- version: '7.0'
81
+ version: '7.1'
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: sqlite3
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -109,7 +109,7 @@ files:
109
109
  - app/models/solid_errors/error.rb
110
110
  - app/models/solid_errors/occurrence.rb
111
111
  - app/models/solid_errors/record.rb
112
- - app/views/layouts/solid_errors/_style.html
112
+ - app/views/layouts/solid_errors/_style.html.erb
113
113
  - app/views/layouts/solid_errors/application.html.erb
114
114
  - app/views/solid_errors/error_mailer/error_occurred.html.erb
115
115
  - app/views/solid_errors/error_mailer/error_occurred.text.erb
@@ -120,6 +120,7 @@ files:
120
120
  - app/views/solid_errors/errors/_row.html.erb
121
121
  - app/views/solid_errors/errors/index.html.erb
122
122
  - app/views/solid_errors/errors/show.html.erb
123
+ - app/views/solid_errors/occurrences/_backtrace_line.html.erb
123
124
  - app/views/solid_errors/occurrences/_collection.html.erb
124
125
  - app/views/solid_errors/occurrences/_occurrence.html.erb
125
126
  - config/locales/en.yml
@@ -145,14 +146,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
145
146
  requirements:
146
147
  - - ">="
147
148
  - !ruby/object:Gem::Version
148
- version: 2.6.0
149
+ version: 3.2.0
149
150
  required_rubygems_version: !ruby/object:Gem::Requirement
150
151
  requirements:
151
152
  - - ">="
152
153
  - !ruby/object:Gem::Version
153
154
  version: '0'
154
155
  requirements: []
155
- rubygems_version: 3.6.2
156
+ rubygems_version: 4.0.16
156
157
  specification_version: 4
157
158
  summary: Database-backed Rails error subscriber
158
159
  test_files: []