solid_errors 0.7.1 → 0.9.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: f3f112f7c3ae7a71fa1f7d92be55cfa8e6fff8263fdff9a0c406c1db46c37126
4
- data.tar.gz: fccfcd6e806e1f3fd17db5c89cc30f2c35302e6dc64b0a386f417bf1d28f8106
3
+ metadata.gz: 389a4c6c9c7910c471dfc93caf7003790e1afa6c0a6781f010d6453b99731ec2
4
+ data.tar.gz: 0ad0508538e6ff2f523a865e4230ff371400a56b8e897b6078c47570dd71eccc
5
5
  SHA512:
6
- metadata.gz: f9415aa986a5ba6ff7491527f4cb053415df72fe73b585bd877f47e8e5ca869ba866210d9e32aac80373c43578b7efa59545dac9f82f808576f8ef80d60582b5
7
- data.tar.gz: 1d0dd34c0920999496cd57e5729f3f18da0ca8c729b557d182cb9a47cfa3e8c038f1756a8d75fe5f08424e49d86db8050f85d2783e59751e7e778cc54d0282df
6
+ metadata.gz: c91d2c051098cd1f3530592f76f85b9dffe8aa264f693950673f1ac8110a09f1c0b6afa86ec7eb3b071d52825899cf71cf17e75ad01f394639446df4fbf21fbd
7
+ data.tar.gz: 7947a0212c04437d205c822da7a0a96fccba64907d5ad3a062afe6919934767d4d533da0f977e6d48218b7c7f6bd9d992a10cf5d157c2a661154edb0921488bc
data/README.md CHANGED
@@ -79,6 +79,9 @@ production:
79
79
 
80
80
  Then run `db:prepare` in production to ensure the database is created and the schema is loaded.
81
81
 
82
+ > [!WARNING]
83
+ > Use `db:prepare`, not `db:create` followed by `db:migrate`. Solid Errors ships a schema rather than migrations, so `db:migrate` leaves the errors database empty and then dumps that empty schema over `db/errors_schema.rb`. If that has happened, copy the schema back from [the install template](lib/generators/solid_errors/install/templates/db/errors_schema.rb) and run `db:prepare` again.
84
+
82
85
  Then mount the engine in your `config/routes.rb` file:
83
86
  ```ruby
84
87
  authenticate :user, -> (user) { user.admin? } do
@@ -151,19 +154,34 @@ Rails.error.handle(context: { user_id: user.id }, severity: :info) do
151
154
  end
152
155
  ```
153
156
 
157
+
158
+ ### What each occurrence records
159
+
160
+ 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:
161
+
162
+ * **Requests:** the controller action (`UsersController#show`) and its params, with your `config.filter_parameters` applied.
163
+ * **Background jobs:** the job class, its id and queue, and its arguments, also filtered.
164
+
165
+ 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.
166
+
167
+ 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.
154
168
  ### Configuration
155
169
 
156
- You can configure Solid Errors via the Rails configuration object, under the `solid_errors` key. Currently, 6 configuration options are available:
170
+ You can configure Solid Errors via the Rails configuration object, under the `solid_errors` key. Currently, the following configuration options are available:
157
171
 
172
+ * `enabled` - Enable or disable error tracking. Defaults to `true`. See [Disabling error tracking](#disabling-error-tracking) for more information.
158
173
  * `connects_to` - The database configuration to use for the Solid Errors database. See [Database Configuration](#database-configuration) for more information.
159
174
  * `username` - The username to use for HTTP authentication. See [Authentication](#authentication) for more information.
160
175
  * `password` - The password to use for HTTP authentication. See [Authentication](#authentication) for more information.
161
176
  * `sends_email` - Whether or not to send emails when an error occurs. See [Email notifications](#email-notifications) for more information.
162
177
  * `email_from` - The email address to send a notification from. See [Email notifications](#email-notifications) for more information.
163
178
  * `email_to` - The email address(es) to send a notification to. See [Email notifications](#email-notifications) for more information.
179
+ * `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.
164
180
  * `email_subject_prefix` - Prefix added to the subject line for email notifications. See [Email notifications](#email-notifications) for more information.
181
+ * `email_subject` - A proc that builds the subject line for email notifications. See [Email notifications](#email-notifications) for more information.
165
182
  * `base_controller_class` - Specify a different controller as the base class for the Solid Errors controller. See [Authentication](#authentication) for more information.
166
183
  * `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.
184
+ * `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"]`.
167
185
 
168
186
  ### Database Configuration
169
187
 
@@ -191,6 +209,46 @@ Running Solid Errors in a separate database is recommended, but it's also possib
191
209
 
192
210
  You won't have multiple databases, so `database.yml` doesn't need to have the errors database configuration.
193
211
 
212
+ #### Apps that use `schema_format = :sql`
213
+
214
+ 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.
215
+
216
+ 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`:
217
+
218
+ ```yaml
219
+ production:
220
+ errors:
221
+ <<: *default
222
+ database: storage/production_errors.sqlite3
223
+ migrations_paths: db/errors_migrate
224
+ schema_format: ruby
225
+ ```
226
+
227
+ On earlier Rails versions, create the errors database, load the Ruby schema into it, and dump it as SQL for your database adapter:
228
+
229
+ ```sh
230
+ bin/rails db:create:errors
231
+ bin/rails runner 'ActiveRecord::Tasks::DatabaseTasks.load_schema(ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: "errors"), :ruby, "db/errors_schema.rb")'
232
+ bin/rails db:schema:dump:errors
233
+ ```
234
+
235
+ This writes `db/errors_structure.sql`, which you commit in place of `db/errors_schema.rb`.
236
+
237
+ #### Disabling error tracking
238
+
239
+ Solid Errors is enabled by default. If you want to disable error tracking in a specific environment, you can set `enabled` to `false`:
240
+
241
+ ```ruby
242
+ # config/environments/development.rb
243
+ config.solid_errors.enabled = false
244
+ ```
245
+
246
+ When disabled, Solid Errors will not subscribe to the Rails error reporter and no errors will be recorded.
247
+
248
+ Disable it in every environment that loads the gem but has no errors database, such as development or test. Otherwise Solid Errors records errors to the primary database, fails with `Could not find table 'solid_errors'`, and in development and test that failure can hide the error you were looking for.
249
+
250
+ Configuration belongs in `config/environments/<environment>.rb`, not in `config/<environment>.rb`, which Rails does not load.
251
+
194
252
  #### Authentication
195
253
 
196
254
  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.
@@ -233,14 +291,14 @@ Solid Errors _can_ send email notifications whenever an error occurs, if your ap
233
291
 
234
292
  There are two ways to configure email notifications. First, you can use environment variables:
235
293
 
236
- ```ruby
237
- ENV["SOLIDERRORS_SEND_EMAILS"] = true # defaults to false
238
- ENV["SOLIDERRORS_EMAIL_FROM"] = "errors@myapp.com" # defaults to "solid_errors@noreply.com"
239
- ENV["SOLIDERRORS_EMAIL_TO"] = "devs@myapp.com" # no default, must be set
240
- ENV["SOLIDERRORS_EMAIL_SUBJECT_PREFIX"] = "[Application name][Environment]" # no default, optional
294
+ ```sh
295
+ SOLIDERRORS_SEND_EMAILS=true # defaults to false
296
+ SOLIDERRORS_EMAIL_FROM=errors@myapp.com # defaults to solid_errors@noreply.com
297
+ SOLIDERRORS_EMAIL_TO=devs@myapp.com # no default, must be set
298
+ SOLIDERRORS_EMAIL_SUBJECT_PREFIX="[Application name][Environment]" # no default, optional
241
299
  ```
242
300
 
243
- Second, you can set the values via the configuration object:
301
+ Second, you can set the values via the configuration object, which takes precedence over the environment variables:
244
302
 
245
303
  ```ruby
246
304
  # Set authentication credentials and optional subject prefix for Solid Errors
@@ -252,6 +310,57 @@ config.solid_errors.email_subject_prefix = "[#{Rails.application.name}][#{Rails.
252
310
 
253
311
  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.
254
312
 
313
+ To be emailed only when an error is new, or when a resolved error occurs again, rather than for every occurrence:
314
+
315
+ ```ruby
316
+ config.solid_errors.email_every_occurrence = false # defaults to true
317
+ ```
318
+
319
+ The subject names the error, and says when a resolved error has occurred again (`🔥 TimeoutError (reopened)`). To build it yourself, set `email_subject` to a proc. It is called with the error, the occurrence, and `reason`: `:created` or `:reopened` when the occurrence opened its error, `nil` for any other. `email_subject_prefix` is still added in front of what it returns.
320
+
321
+ ```ruby
322
+ config.solid_errors.email_subject = ->(error:, occurrence:, reason:) {
323
+ # Counting up to this occurrence, rather than all of them, gives the same answer however late the email is sent
324
+ count = error.occurrences.where(id: ..occurrence.id).count
325
+ subject = "#{error.severity_emoji} #{error.exception_class}"
326
+
327
+ case reason
328
+ when :reopened then "🔄 REOPENED: #{subject}"
329
+ when :resolved then "✅ RESOLVED: #{error.exception_class}"
330
+ else count.in?([10, 100, 1_000]) ? "#{subject} (#{count.ordinalize} occurrence)" : subject
331
+ end
332
+ }
333
+ ```
334
+
335
+ The error email can also be sent for events of your own, such as an error being resolved or occurring unusually often. Whatever reason you pass reaches `email_subject`, as `:resolved` does in the example above:
336
+
337
+ ```ruby
338
+ # config/initializers/solid_errors.rb
339
+ ActiveSupport::Notifications.subscribe("error_resolved.solid_errors") do |event|
340
+ occurrence = event.payload[:error].occurrences.last
341
+ SolidErrors::ErrorMailer.error_occurred(occurrence, :resolved).deliver_later if occurrence && SolidErrors.send_emails?
342
+ end
343
+ ```
344
+
345
+ #### Lifecycle events
346
+
347
+ 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:
348
+
349
+ | Event | When | Payload |
350
+ | --- | --- | --- |
351
+ | `error_created.solid_errors` | An error is recorded for the first time | `:error`, `:occurrence` |
352
+ | `error_reopened.solid_errors` | A resolved error occurs again | `:error`, `:occurrence` |
353
+ | `error_resolved.solid_errors` | An error is marked as resolved | `:error` |
354
+ | `occurrence_created.solid_errors` | Any occurrence is recorded, including those above | `:error`, `:occurrence` |
355
+
356
+ ```ruby
357
+ # config/initializers/solid_errors.rb
358
+ ActiveSupport::Notifications.subscribe(/\Aerror_(created|reopened)\.solid_errors\z/) do |event|
359
+ error = event.payload[:error]
360
+ SlackNotifier.post("#{error.exception_class}: #{error.message}")
361
+ end
362
+ ```
363
+
255
364
  #### Automatically destroying old records
256
365
 
257
366
  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`.
@@ -266,6 +375,21 @@ config.solid_errors.destroy_after = 30.days
266
375
  config.solid_errors.destroy_after = 6.months
267
376
  ```
268
377
 
378
+ #### Checking the schema after an upgrade
379
+
380
+ When a release changes the schema, [UPGRADE.md](UPGRADE.md) describes the migration. To have your test suite fail until you've applied it, assert that the errors database has everything the installed version uses:
381
+
382
+ ```ruby
383
+ # test/solid_errors_test.rb
384
+ require "test_helper"
385
+
386
+ class SolidErrorsTest < ActiveSupport::TestCase
387
+ test "the errors database is up to date" do
388
+ assert SolidErrors.migrated?
389
+ end
390
+ end
391
+ ```
392
+
269
393
  ### Examples
270
394
 
271
395
  There are only two screens in the dashboard.
@@ -348,6 +472,11 @@ You can always take control of the views by creating your own views and/or parti
348
472
  </html>
349
473
  ```
350
474
 
475
+
476
+ ### Locales
477
+
478
+ The dashboard is written in English and renders in English whenever your app allows the `:en` locale. If `config.i18n.available_locales` leaves out `:en`, the dashboard renders in your app's locale instead, and you will need to provide the `datetime.distance_in_words.short` translations from [`config/locales/en.yml`](config/locales/en.yml) in that locale.
479
+
351
480
  ## Sponsors
352
481
 
353
482
  <p align="center">
@@ -376,4 +505,3 @@ The gem is available as open source under the terms of the [MIT License](https:/
376
505
  ## Code of Conduct
377
506
 
378
507
  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).
379
-
@@ -1,6 +1,6 @@
1
1
  module SolidErrors
2
2
  class ErrorsController < ApplicationController
3
- around_action :force_english_locale!
3
+ around_action :use_english_when_available
4
4
 
5
5
  before_action :set_error, only: %i[show update destroy]
6
6
 
@@ -53,7 +53,10 @@ module SolidErrors
53
53
  @error = Error.find(params[:id])
54
54
  end
55
55
 
56
- def force_english_locale!(&action)
56
+ # The dashboard's strings ship in English only; apps that don't allow :en keep their own locale
57
+ def use_english_when_available(&action)
58
+ return action.call unless I18n.locale_available?(:en)
59
+
57
60
  I18n.with_locale(:en, &action)
58
61
  end
59
62
 
@@ -8,15 +8,19 @@ module SolidErrors
8
8
  Rails.error.report(exception, handled: true, source: "solid_errors.error_mailer")
9
9
  end
10
10
  end
11
+
12
+ helper_method :occurrence_url
11
13
  end
12
14
 
13
- def error_occurred(occurrence)
15
+ # reason is passed on to SolidErrors.email_subject: :created or :reopened when the occurrence
16
+ # opened its error, or whatever an app sending this email for its own events passes
17
+ def error_occurred(occurrence, reason = nil)
14
18
  unless defined?(ActionMailer::Base)
15
19
  raise "ActionMailer is not available. Make sure that you require \"action_mailer/railtie\" in application.rb"
16
20
  end
17
21
  @occurrence = occurrence
18
22
  @error = occurrence.error
19
- subject = "#{@error.severity_emoji} #{@error.exception_class}"
23
+ subject = SolidErrors.email_subject.call(error: @error, occurrence: @occurrence, reason: reason&.to_sym)
20
24
  if SolidErrors.email_subject_prefix.present?
21
25
  subject = [SolidErrors.email_subject_prefix, subject].join(" ").squish!
22
26
  end
@@ -26,5 +30,14 @@ module SolidErrors
26
30
  to: SolidErrors.email_to
27
31
  )
28
32
  end
33
+
34
+ private
35
+
36
+ # Optional: an app without default_url_options[:host] still gets the email, just unlinked
37
+ def occurrence_url
38
+ public_send(SolidErrors.url_helper_name).error_url(@error, **SolidErrors.url_options, anchor: "occurrence_#{@occurrence.id}")
39
+ rescue ArgumentError, NoMethodError, ActionController::UrlGenerationError
40
+ nil
41
+ end
29
42
  end
30
43
  end
@@ -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
@@ -5,7 +5,11 @@ module SolidErrors
5
5
  # Adapters that report the json column as text (MariaDB) would otherwise store Hash#inspect
6
6
  attribute :context, :json
7
7
 
8
- after_create_commit :send_email, if: -> { SolidErrors.send_emails? && SolidErrors.email_to.present? }
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
9
13
  after_create_commit :clear_resolved_errors, if: :should_clear_resolved_errors?
10
14
 
11
15
  # The parsed exception backtrace. Lines in this backtrace that are from installed gems
@@ -24,8 +28,15 @@ module SolidErrors
24
28
  Backtrace.parse(backtrace)
25
29
  end
26
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
+
27
37
  def send_email
28
- ErrorMailer.error_occurred(self).deliver_later
38
+ # Passed along rather than read off the occurrence, which the mailer job loads afresh without it
39
+ ErrorMailer.error_occurred(self, opens_error).deliver_later
29
40
  end
30
41
 
31
42
  def clear_resolved_errors
@@ -9,6 +9,9 @@
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>
@@ -1,17 +1,169 @@
1
1
  <html>
2
2
  <head>
3
3
  <title>Solid Errors | <%= @error.severity_emoji %> <%= @error.exception_class %></title>
4
- <%= render "layouts/solid_errors/style" %>
4
+ <style>
5
+ body {
6
+ padding: 20px;
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
8
+ }
9
+ .email-header {
10
+ margin-bottom: 16px;
11
+ }
12
+ .email-header h1 {
13
+ font-size: 20px;
14
+ font-weight: 700;
15
+ margin: 0 0 8px 0;
16
+ }
17
+ .email-header h1 a {
18
+ color: #1f2937;
19
+ text-decoration: none;
20
+ }
21
+ .email-header h1 a:hover {
22
+ text-decoration: underline;
23
+ }
24
+ .email-link {
25
+ font-size: 14px;
26
+ color: #2563eb;
27
+ text-decoration: none;
28
+ margin-bottom: 12px;
29
+ display: inline-block;
30
+ }
31
+ .error-message {
32
+ background: #f3f4f6;
33
+ padding: 12px;
34
+ border-radius: 4px;
35
+ margin: 12px 0;
36
+ font-family: monospace;
37
+ font-size: 13px;
38
+ white-space: pre-wrap;
39
+ }
40
+ .info-table {
41
+ width: auto;
42
+ border-collapse: collapse;
43
+ margin: 12px 0;
44
+ font-size: 13px;
45
+ }
46
+ .info-table td {
47
+ padding: 4px 8px;
48
+ border-bottom: 1px solid #e5e7eb;
49
+ }
50
+ .info-table td:first-child {
51
+ font-weight: 600;
52
+ white-space: nowrap;
53
+ vertical-align: top;
54
+ }
55
+ .backtrace {
56
+ background: #1e293b;
57
+ color: #fff;
58
+ padding: 12px;
59
+ border-radius: 4px;
60
+ margin: 12px 0;
61
+ font-family: 'Monaco', 'Courier New', monospace;
62
+ font-size: 12px;
63
+ line-height: 1.4;
64
+ overflow-x: auto;
65
+ }
66
+ .backtrace-line {
67
+ margin: 2px 0;
68
+ }
69
+ .backtrace-highlight {
70
+ background: #374151;
71
+ margin-left: -12px;
72
+ margin-right: -12px;
73
+ padding-left: 12px;
74
+ padding-right: 12px;
75
+ }
76
+ .context-table {
77
+ width: auto;
78
+ border-collapse: collapse;
79
+ margin: 12px 0;
80
+ font-size: 13px;
81
+ }
82
+ .context-table th {
83
+ background: #f3f4f6;
84
+ padding: 6px 8px;
85
+ text-align: left;
86
+ font-weight: 600;
87
+ border: 1px solid #e5e7eb;
88
+ white-space: nowrap;
89
+ }
90
+ .context-table td {
91
+ padding: 6px 8px;
92
+ border: 1px solid #e5e7eb;
93
+ font-family: monospace;
94
+ font-size: 12px;
95
+ }
96
+ .context-table td:first-child {
97
+ white-space: nowrap;
98
+ }
99
+ .section-title {
100
+ font-weight: 700;
101
+ font-size: 14px;
102
+ margin: 16px 0 8px 0;
103
+ }
104
+ </style>
5
105
  </head>
6
106
  <body>
7
- <%= render "solid_errors/errors/error",
8
- error: @error,
9
- show_actions: false %>
10
107
 
11
- <br>
12
- <br>
108
+ <div class="email-header">
109
+ <h1>
110
+ <%= @error.severity_emoji %>
111
+ <%= @error.exception_class %>
112
+ from <em><%= @error.source %></em>
113
+ </h1>
114
+ <% if (url = occurrence_url) %>
115
+ <a href="<%= url %>" class="email-link">View in Solid Errors &rarr;</a>
116
+ <% end %>
117
+ </div>
13
118
 
14
- <%= render "solid_errors/occurrences/occurrence",
15
- occurrence: @occurrence %>
119
+ <div class="error-message"><%= @error.message %></div>
120
+
121
+ <table class="info-table">
122
+ <tr>
123
+ <td>Severity</td>
124
+ <td><%= @error.severity_emoji %> <%= @error.severity %></td>
125
+ </tr>
126
+ <tr>
127
+ <td>Status</td>
128
+ <td><%= @error.status_emoji %> <%= @error.status %></td>
129
+ </tr>
130
+ <tr>
131
+ <td>Occurred at</td>
132
+ <td><%= @occurrence.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></td>
133
+ </tr>
134
+ </table>
135
+
136
+ <% if @occurrence.context&.any? %>
137
+ <div class="section-title">Context</div>
138
+ <table class="context-table">
139
+ <thead>
140
+ <tr>
141
+ <th>Key</th>
142
+ <th>Value</th>
143
+ </tr>
144
+ </thead>
145
+ <tbody>
146
+ <% @occurrence.context.each do |key, value| %>
147
+ <tr>
148
+ <td><%= key %></td>
149
+ <td><%= value %></td>
150
+ </tr>
151
+ <% end %>
152
+ </tbody>
153
+ </table>
154
+ <% end %>
155
+
156
+ <div class="section-title">Backtrace</div>
157
+ <div class="backtrace">
158
+ <% @occurrence.parsed_backtrace.lines.each_with_index do |line, i| %>
159
+ <div class="backtrace-line <%= i.zero? ? 'backtrace-highlight' : '' %>">
160
+ <% if line.filtered_file %>
161
+ <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>
162
+ <% else %>
163
+ <span style="color: #9ca3af;"><%= line.unparsed_line %></span>
164
+ <% end %>
165
+ </div>
166
+ <% end %>
167
+ </div>
16
168
  </body>
17
169
  </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 %>
@@ -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
@@ -49,6 +49,10 @@ module SolidErrors
49
49
  when String
50
50
  sanitize_string(data)
51
51
  else # all other objects
52
+ if (global_id = global_id(data))
53
+ return global_id
54
+ end
55
+
52
56
  klass = data.class
53
57
 
54
58
  begin
@@ -67,6 +71,15 @@ module SolidErrors
67
71
 
68
72
  attr_reader :max_depth
69
73
 
74
+ # A saved record is identified by its GlobalID (gid://app/User/1) instead of just its class
75
+ def global_id(object)
76
+ return unless object.respond_to?(:to_global_id)
77
+
78
+ object.to_global_id.to_s
79
+ rescue
80
+ nil
81
+ end
82
+
70
83
  def basic_object?(object)
71
84
  object.respond_to?(:to_s)
72
85
  false
@@ -32,24 +32,47 @@ 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(readable_context(context)),
46
+ opens_error: opens_error
44
47
  )
45
48
  end
46
49
 
50
+ # Rails puts the current controller or job in the context; keep what identifies the work, not the object
51
+ def readable_context(context)
52
+ context = context.dup
53
+ if defined?(ActionController::Metal) && (controller = context[:controller]).is_a?(ActionController::Metal)
54
+ context[:controller] = "#{controller.class.name}##{controller.action_name}"
55
+ context[:params] ||= controller.request.filtered_parameters.except("controller", "action")
56
+ end
57
+ if defined?(ActiveJob::Base) && (job = context[:job]).is_a?(ActiveJob::Base)
58
+ context[:job] = job.class.name
59
+ context[:job_id] ||= job.job_id
60
+ context[:queue] ||= job.queue_name
61
+ context[:arguments] ||= parameter_filter.filter(arguments: s(job.arguments))[:arguments]
62
+ end
63
+ context
64
+ end
65
+
66
+ def parameter_filter
67
+ ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
68
+ end
69
+
47
70
  def s(data)
48
71
  Sanitizer.sanitize(data)
49
72
  end
50
73
 
51
74
  def ignore_by_class?(error_class_name)
52
- IGNORED_ERRORS.any? do |ignored_class|
75
+ [*IGNORED_ERRORS, *SolidErrors.ignored_errors].any? do |ignored_class|
53
76
  ignored_class_name = ignored_class.respond_to?(:name) ? ignored_class.name : ignored_class
54
77
 
55
78
  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.1"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/solid_errors.rb CHANGED
@@ -6,15 +6,22 @@ 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
12
13
  mattr_writer :password
13
- mattr_accessor :send_emails, default: false
14
- mattr_accessor :email_from, default: "solid_errors@noreply.com"
15
- mattr_accessor :email_to
16
- mattr_accessor :email_subject_prefix
14
+ mattr_writer :send_emails, :email_from, :email_to, :email_subject_prefix, default: nil
15
+ mattr_accessor :email_every_occurrence, default: true
16
+ # Builds the error email's subject, before email_subject_prefix. reason is :created or :reopened when
17
+ # the occurrence opened its error, nil for any other occurrence, or the app's own reason when it sends the email itself
18
+ mattr_accessor :email_subject, default: ->(error:, occurrence:, reason:) {
19
+ subject = "#{error.severity_emoji} #{error.exception_class}"
20
+ (reason == :reopened) ? "#{subject} (reopened)" : subject
21
+ }
17
22
  mattr_accessor :destroy_after
23
+ mattr_accessor :ignored_errors, default: []
24
+ mattr_writer :url_helper_name, :url_options
18
25
 
19
26
  class << self
20
27
  # use method instead of attr_accessor to ensure
@@ -29,6 +36,34 @@ module SolidErrors
29
36
  @password ||= ENV["SOLIDERRORS_PASSWORD"] || @@password
30
37
  end
31
38
 
39
+ # Each email setting falls back to its SOLIDERRORS_* environment variable when not configured
40
+ def send_emails
41
+ @@send_emails.nil? ? ActiveModel::Type::Boolean.new.cast(ENV["SOLIDERRORS_SEND_EMAILS"]) || false : @@send_emails
42
+ end
43
+
44
+ def email_from
45
+ @@email_from || ENV["SOLIDERRORS_EMAIL_FROM"] || "solid_errors@noreply.com"
46
+ end
47
+
48
+ def email_to
49
+ @@email_to || ENV["SOLIDERRORS_EMAIL_TO"]
50
+ end
51
+
52
+ def email_subject_prefix
53
+ @@email_subject_prefix || ENV["SOLIDERRORS_EMAIL_SUBJECT_PREFIX"]
54
+ end
55
+
56
+ # Whether the errors database has every table and column this version reads and writes.
57
+ # Assert it in your test suite to catch a schema change you haven't applied yet.
58
+ def migrated?
59
+ {
60
+ SolidErrors::Error => %w[exception_class message severity source resolved_at fingerprint created_at updated_at],
61
+ SolidErrors::Occurrence => %w[error_id backtrace context created_at updated_at]
62
+ }.all? do |model, columns|
63
+ model.table_exists? && (columns - model.column_names).empty?
64
+ end
65
+ end
66
+
32
67
  def send_emails?
33
68
  send_emails && email_to.present? && !ActiveSupport::IsolatedExecutionState[:solid_errors_without_emails]
34
69
  end
@@ -41,5 +76,30 @@ module SolidErrors
41
76
  ensure
42
77
  ActiveSupport::IsolatedExecutionState[:solid_errors_without_emails] = previous
43
78
  end
79
+
80
+ def url_helper_name
81
+ @@url_helper_name ||= detect_url_helper_name
82
+ end
83
+
84
+ def url_options
85
+ @@url_options ||= detect_url_options
86
+ end
87
+
88
+ private
89
+
90
+ def find_solid_errors_route
91
+ @solid_errors_route ||= Rails.application.routes.routes.find do |r|
92
+ r.name&.to_s&.end_with?("solid_errors")
93
+ end
94
+ end
95
+
96
+ def detect_url_helper_name
97
+ find_solid_errors_route&.name&.to_sym || :solid_errors
98
+ end
99
+
100
+ def detect_url_options
101
+ subdomain = find_solid_errors_route&.constraints&.dig(:subdomain)
102
+ subdomain.is_a?(String) ? {subdomain: subdomain} : {}
103
+ end
44
104
  end
45
105
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_errors
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Margheim
@@ -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