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/Gemfile.lock b/Gemfile.lock index 9a997c5..83d777c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,8 @@ GEM net-smtp mini_mime (1.1.5) minitest (5.25.1) + mocha (2.4.5) + ruby2_keywords (>= 0.0.5) net-imap (0.4.14) date net-protocol @@ -157,6 +159,7 @@ GEM rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) securerandom (0.3.1) sqlite3 (2.0.4-arm64-darwin) sqlite3 (2.0.4-x86_64-linux-gnu) @@ -189,6 +192,7 @@ PLATFORMS DEPENDENCIES minitest (~> 5.0) + mocha rake (~> 13.0) solid_errors! sqlite3 diff --git a/README.md b/README.md index 8332ad4..eb16a99 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ You can configure Solid Errors via the Rails configuration object, under the `so * `email_from` - The email address to send a notification from. See [Email notifications](#email-notifications) for more information. * `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. +* `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 @@ -242,6 +243,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. @@ -337,7 +352,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/cleaner.rb b/app/models/solid_errors/cleaner.rb new file mode 100644 index 0000000..83abd9d --- /dev/null +++ b/app/models/solid_errors/cleaner.rb @@ -0,0 +1,36 @@ +module SolidErrors + class Cleaner + class << self + def go! + destroy_records if destroy_after_last_create? + end + + private + + def destroy_after_last_create? + SolidErrors.destroy_after.respond_to?(:ago) && (SolidErrors::Occurrence.last.id % 100).zero? + end + + def destroy_records + ActiveRecord::Base.transaction do + destroy_occurrences + destroy_errors + end + end + + def destroy_occurrences + SolidErrors::Occurrence.joins(:error) + .merge(SolidErrors::Error.resolved) + .where(created_at: ...SolidErrors.destroy_after.ago) + .delete_all + end + + def destroy_errors + SolidErrors::Error.resolved + .where + .missing(:occurrences) + .delete_all + end + end + end +end diff --git a/app/models/solid_errors/occurrence.rb b/app/models/solid_errors/occurrence.rb index 2c815b5..7046550 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 -> { SolidErrors::Cleaner.go! } # 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 diff --git a/lib/solid_errors.rb b/lib/solid_errors.rb index e0716a8..b417358 100644 --- a/lib/solid_errors.rb +++ b/lib/solid_errors.rb @@ -13,6 +13,7 @@ module SolidErrors mattr_writer :email_from mattr_writer :email_to mattr_writer :email_subject_prefix + mattr_writer :destroy_after class << self # use method instead of attr_accessor to ensure @@ -42,5 +43,9 @@ def email_to def email_subject_prefix @email_subject_prefix ||= ENV["SOLIDERRORS_EMAIL_SUBJECT_PREFIX"] || @@email_subject_prefix end + + def destroy_after + @destroy_after ||= ENV["SOLIDERRORS_DESTROY_AFTER"] || @@destroy_after + end end end diff --git a/solid_errors.gemspec b/solid_errors.gemspec index 67e2228..842d9e6 100644 --- a/solid_errors.gemspec +++ b/solid_errors.gemspec @@ -30,4 +30,5 @@ Gem::Specification.new do |spec| end spec.add_development_dependency "sqlite3" + spec.add_development_dependency "mocha" 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/cleaner_test.rb b/test/models/solid_errors/cleaner_test.rb new file mode 100644 index 0000000..57bc9d0 --- /dev/null +++ b/test/models/solid_errors/cleaner_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +class SolidErrors::CleanerTest < ActiveSupport::TestCase + setup do + assert_nil SolidErrors.destroy_after + end + + test "not destroy if destroy_after is not set" do + simulate_99_old_exceptions(:resolved) + + assert_difference -> { SolidErrors::Error.count }, +1 do + assert_difference -> { SolidErrors::Occurrence.count }, +1 do + Rails.error.report(dummy_exception) + end + end + end + + test "destroy old occurrences every 100 insertions if destroy_after is set" do + set_destroy_after + simulate_99_old_exceptions(:resolved) + Rails.error.report(dummy_exception) + + assert_equal 1, SolidErrors::Error.count + assert_equal 1, SolidErrors::Occurrence.count + end + + test "not destroy if errors are unresolved" do + set_destroy_after + 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(dummy_exception) + end + end + end + + private + + def simulate_99_old_exceptions(status) + Rails.error.report(dummy_exception("argh")) + SolidErrors::Error.update_all(resolved_at: Time.current) if status == :resolved + SolidErrors::Occurrence.last.update!(id: 99, created_at: 1.day.ago) + end + + def set_destroy_after + SolidErrors.stubs(destroy_after: 1.day) + end + + def dummy_exception(message = "oof") + exception = StandardError.new(message) + exception.set_backtrace(caller) + exception + 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..86dd1e7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,4 +9,4 @@ require "rails/test_help" require "solid_errors" -require "minitest/autorun" +require "mocha/minitest"