diff --git a/.gitignore b/.gitignore index 927a7dc..46510a7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /tmp/ .DS_Store test/dummy/log/*.log +test/dummy/db/*.sqlite3 diff --git a/README.md b/README.md index b235e3c..8276909 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ You can configure Solid Errors via the Rails configuration object, under the `so * `email_to` - The email address(es) to send a notification to. See [Email notifications](#email-notifications) for more information. * `email_subject_prefix` - Prefix added to the subject line for email notifications. See [Email notifications](#email-notifications) for more information. * `base_controller_class` - Specify a different controller as the base class for the Solid Errors controller. See [Authentication](#authentication) for more information. +* `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. ### Database Configuration @@ -256,6 +257,20 @@ config.solid_errors.email_subject_prefix = "[#{Rails.application.name}][#{Rails. 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. +#### Automatically destroying old records + +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`. + +```ruby +# Automatically destroy records older than 30 days +config.solid_errors.destroy_after = 30.days +``` + +```ruby +# Automatically destroy records older than 6 months +config.solid_errors.destroy_after = 6.months +``` + ### Examples There are only two screens in the dashboard. @@ -351,7 +366,7 @@ You can always take control of the views by creating your own views and/or parti ## Development -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. If you want to set up a local development database, run `rake db:migrate`. To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). diff --git a/Rakefile b/Rakefile index 5bb6087..66a8ce6 100644 --- a/Rakefile +++ b/Rakefile @@ -1,14 +1,18 @@ # frozen_string_literal: true +require "bundler/setup" + +APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) +load "rails/tasks/engine.rake" + require "bundler/gem_tasks" require "rake/testtask" +require "standard/rake" + +task default: %i[test standard] Rake::TestTask.new(:test) do |t| t.libs << "test" t.libs << "lib" - t.test_files = FileList["test/**/test_*.rb"] + t.test_files = FileList["test/**/*_test.rb"] end - -require "standard/rake" - -task default: %i[test standard] diff --git a/app/models/solid_errors/occurrence.rb b/app/models/solid_errors/occurrence.rb index 2c815b5..4f034e0 100644 --- a/app/models/solid_errors/occurrence.rb +++ b/app/models/solid_errors/occurrence.rb @@ -3,6 +3,7 @@ class Occurrence < Record belongs_to :error, class_name: "SolidErrors::Error" after_create_commit :send_email, if: -> { SolidErrors.send_emails? && SolidErrors.email_to.present? } + after_create_commit :clear_resolved_errors, if: :should_clear_resolved_errors? # The parsed exception backtrace. Lines in this backtrace that are from installed gems # have the base path for gem installs replaced by "[GEM_ROOT]", while those in the project @@ -23,5 +24,26 @@ def parse_backtrace(backtrace) def send_email ErrorMailer.error_occurred(self).deliver_later end + + def clear_resolved_errors + transaction do + SolidErrors::Occurrence + .where(error: SolidErrors::Error.resolved) + .where(created_at: ...SolidErrors.destroy_after.ago) + .delete_all + SolidErrors::Error.resolved + .where + .missing(:occurrences) + .delete_all + end + end + + def should_clear_resolved_errors? + return false unless SolidErrors.destroy_after + return false unless SolidErrors.destroy_after.respond_to?(:ago) + return false unless (id % 100).zero? + + true + end end end diff --git a/lib/solid_errors.rb b/lib/solid_errors.rb index 6fff7a2..c2a3788 100644 --- a/lib/solid_errors.rb +++ b/lib/solid_errors.rb @@ -10,36 +10,27 @@ module SolidErrors mattr_accessor :base_controller_class, default: "::ActionController::Base" mattr_writer :username mattr_writer :password - mattr_writer :send_emails - mattr_writer :email_from - mattr_writer :email_to - mattr_writer :email_subject_prefix + mattr_accessor :send_emails, default: false + mattr_accessor :email_from, default: "solid_errors@noreply.com" + mattr_accessor :email_to + mattr_accessor :email_subject_prefix + mattr_accessor :destroy_after class << self # use method instead of attr_accessor to ensure - # this works if variable set after SolidErrors is loaded + # this works if ENV variable set after SolidErrors is loaded def username @username ||= ENV["SOLIDERRORS_USERNAME"] || @@username end + # use method instead of attr_accessor to ensure + # this works if ENV variable set after SolidErrors is loaded def password @password ||= ENV["SOLIDERRORS_PASSWORD"] || @@password end def send_emails? - @send_emails ||= ENV["SOLIDERRORS_SEND_EMAILS"] || @@send_emails || false - end - - def email_from - @email_from ||= ENV["SOLIDERRORS_EMAIL_FROM"] || @@email_from || "solid_errors@noreply.com" - end - - def email_to - @email_to ||= ENV["SOLIDERRORS_EMAIL_TO"] || @@email_to - end - - def email_subject_prefix - @email_subject_prefix ||= ENV["SOLIDERRORS_EMAIL_SUBJECT_PREFIX"] || @@email_subject_prefix + send_emails && email_to.present? end end end diff --git a/lib/solid_errors/subscriber.rb b/lib/solid_errors/subscriber.rb index 05f6e46..2063250 100644 --- a/lib/solid_errors/subscriber.rb +++ b/lib/solid_errors/subscriber.rb @@ -39,7 +39,7 @@ def report(error, handled:, severity:, context:, source: nil) SolidErrors::Occurrence.create( error_id: record.id, - backtrace: error.backtrace.join("\n"), + backtrace: error.backtrace&.join("\n"), context: s(context) ) end diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/test/dummy/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml index fbf0653..cfa50d8 100644 --- a/test/dummy/config/database.yml +++ b/test/dummy/config/database.yml @@ -9,42 +9,15 @@ default: &default pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 -primary: &primary - <<: *default - database: storage/<%= ENV.fetch("RAILS_ENV", "development") %>.sqlite3 - -queue: &queue - <<: *default - migrations_paths: db/queue_migrate - database: storage/queue.sqlite3 - -errors: &errors - <<: *default - migrations_paths: db/errors_migrate - database: storage/errors.sqlite3 - development: primary: - <<: *primary - database: storage/<%= `git branch --show-current`.chomp || 'development' %>.sqlite3 - queue: *queue - errors: *errors + <<: *default + database: db/development.sqlite3 # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". # Do not set this db to the same as development or production. test: primary: - <<: *primary + <<: *default database: db/test.sqlite3 - queue: - <<: *queue - database: db/queue.sqlite3 - errors: - <<: *errors - database: db/errors.sqlite3 - -production: - primary: *primary - queue: *queue - errors: *errors diff --git a/test/dummy/db/errors.sqlite3 b/test/dummy/db/errors.sqlite3 deleted file mode 100644 index 3166839..0000000 Binary files a/test/dummy/db/errors.sqlite3 and /dev/null differ diff --git a/test/dummy/db/migrate/20241115125349_add_solid_errors_tables.rb b/test/dummy/db/migrate/20241115125349_add_solid_errors_tables.rb new file mode 100644 index 0000000..cded9a2 --- /dev/null +++ b/test/dummy/db/migrate/20241115125349_add_solid_errors_tables.rb @@ -0,0 +1,27 @@ +class AddSolidErrorsTables < ActiveRecord::Migration[7.1] + def change + create_table "solid_errors", force: :cascade do |t| + t.text "exception_class", null: false + t.text "message", null: false + t.text "severity", null: false + t.text "source" + t.datetime "resolved_at" + t.string "fingerprint", limit: 64, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["fingerprint"], name: "index_solid_errors_on_fingerprint", unique: true + t.index ["resolved_at"], name: "index_solid_errors_on_resolved_at" + end + + create_table "solid_errors_occurrences", force: :cascade do |t| + t.bigint "error_id", null: false + t.text "backtrace" + t.json "context" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["error_id"], name: "index_solid_errors_occurrences_on_error_id" + end + + add_foreign_key "solid_errors_occurrences", "solid_errors", column: "error_id" + end +end diff --git a/test/dummy/db/queue.sqlite3 b/test/dummy/db/queue.sqlite3 deleted file mode 100644 index 3166839..0000000 Binary files a/test/dummy/db/queue.sqlite3 and /dev/null differ diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb new file mode 100644 index 0000000..f694bd1 --- /dev/null +++ b/test/dummy/db/schema.rb @@ -0,0 +1,37 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 2024_11_15_125349) do + create_table "solid_errors", force: :cascade do |t| + t.text "exception_class", null: false + t.text "message", null: false + t.text "severity", null: false + t.text "source" + t.datetime "resolved_at" + t.string "fingerprint", limit: 64, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["fingerprint"], name: "index_solid_errors_on_fingerprint", unique: true + t.index ["resolved_at"], name: "index_solid_errors_on_resolved_at" + end + + create_table "solid_errors_occurrences", force: :cascade do |t| + t.bigint "error_id", null: false + t.text "backtrace" + t.json "context" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["error_id"], name: "index_solid_errors_occurrences_on_error_id" + end + + add_foreign_key "solid_errors_occurrences", "solid_errors", column: "error_id" +end diff --git a/test/dummy/db/test.sqlite3 b/test/dummy/db/test.sqlite3 deleted file mode 100644 index 3166839..0000000 Binary files a/test/dummy/db/test.sqlite3 and /dev/null differ diff --git a/test/models/solid_errors/occurrence_test.rb b/test/models/solid_errors/occurrence_test.rb new file mode 100644 index 0000000..c994e7d --- /dev/null +++ b/test/models/solid_errors/occurrence_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class SolidErrors::OccurrenceTest < ActiveSupport::TestCase + def teardown + SolidErrors.destroy_after = nil + end + + test "do not destroy if destroy_after is not set" do + SolidErrors.destroy_after = nil + simulate_99_old_exceptions(:resolved) + + assert_difference -> { SolidErrors::Error.count }, +1 do + assert_difference -> { SolidErrors::Occurrence.count }, +1 do + Rails.error.report(StandardError.new("oof")) + end + end + end + + test "destroy old occurrences every 100 insertions if destroy_after is set" do + SolidErrors.destroy_after = 1.day + simulate_99_old_exceptions(:resolved) + + assert_difference -> { SolidErrors::Error.count }, 0 do + assert_difference -> { SolidErrors::Occurrence.count }, 0 do + Rails.error.report(StandardError.new("oof")) + end + end + end + + test "not destroy if errors are unresolved" do + SolidErrors.destroy_after = 1.day + simulate_99_old_exceptions(:unresolved) + + assert_difference -> { SolidErrors::Error.count }, +1 do + assert_difference -> { SolidErrors::Occurrence.count }, +1 do + assert_empty SolidErrors::Error.resolved + Rails.error.report(StandardError.new("oof")) + end + end + end + + private + + def simulate_99_old_exceptions(status) + Rails.error.report(StandardError.new("argh")) + SolidErrors::Error.update_all(resolved_at: Time.current) if status == :resolved + SolidErrors::Occurrence.last.update!(id: 99, created_at: 1.day.ago) + end +end diff --git a/test/test_solid_errors.rb b/test/solid_errors_test.rb similarity index 100% rename from test/test_solid_errors.rb rename to test/solid_errors_test.rb diff --git a/test/test_helper.rb b/test/test_helper.rb index e83f265..9fcf2e2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,5 +8,3 @@ ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] require "rails/test_help" require "solid_errors" - -require "minitest/autorun"