diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fd4bfd8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, they will be +# requested for review when someone opens a pull request. +* @brunvez @t-romani @martinjaimem diff --git a/.reek.yml b/.reek.yml index 92dbd90..ad817d4 100644 --- a/.reek.yml +++ b/.reek.yml @@ -32,6 +32,7 @@ detectors: exclude: - ExceptionHunter::Middleware::RequestHunter#catch_prey - ExceptionHunter::Middleware::SidekiqHunter#track_exception + - ExceptionHunter::DashboardPresenter#calculate_tabs_counts InstanceVariableAssumption: enabled: false @@ -84,6 +85,9 @@ detectors: - initialize max_statements: 12 + TooManyConstants: + enabled: false + UncommunicativeMethodName: enabled: true exclude: [] diff --git a/Gemfile.lock b/Gemfile.lock index f843e90..06d2458 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,13 @@ GIT remote: https://github.com/rspec/rspec-core - revision: 464b8488e80a90c5c1a76b476c1b814f79fc8efb + revision: 9f55fafe62ea367a4801ebed2ca19cf6dfdceb39 specs: rspec-core (3.10.0.pre) rspec-support (= 3.10.0.pre) GIT remote: https://github.com/rspec/rspec-expectations - revision: 7b7fecf35e3a93d74893020f55faa62901fdf6d0 + revision: a7205975dcda3f53466ba5952f7dec8ac452e18c specs: rspec-expectations (3.10.0.pre) diff-lcs (>= 1.2.0, < 2.0) @@ -15,7 +15,7 @@ GIT GIT remote: https://github.com/rspec/rspec-mocks - revision: a0005a15cc092c7cde75a8429f2da6704235488e + revision: 1728885f65b25a9676c5ce54133ef8a1283e2e0d specs: rspec-mocks (3.10.0.pre) diff-lcs (>= 1.2.0, < 2.0) @@ -23,7 +23,7 @@ GIT GIT remote: https://github.com/rspec/rspec-support - revision: 0e322b49c412947c306d394fdaa506d7d830f1cd + revision: 6553911974ee93855008f8ff41c26cea6652d74d specs: rspec-support (3.10.0.pre) @@ -36,70 +36,70 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (6.0.2.2) - actionpack (= 6.0.2.2) + actioncable (6.0.3.1) + actionpack (= 6.0.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.2.2) - actionpack (= 6.0.2.2) - activejob (= 6.0.2.2) - activerecord (= 6.0.2.2) - activestorage (= 6.0.2.2) - activesupport (= 6.0.2.2) + actionmailbox (6.0.3.1) + actionpack (= 6.0.3.1) + activejob (= 6.0.3.1) + activerecord (= 6.0.3.1) + activestorage (= 6.0.3.1) + activesupport (= 6.0.3.1) mail (>= 2.7.1) - actionmailer (6.0.2.2) - actionpack (= 6.0.2.2) - actionview (= 6.0.2.2) - activejob (= 6.0.2.2) + actionmailer (6.0.3.1) + actionpack (= 6.0.3.1) + actionview (= 6.0.3.1) + activejob (= 6.0.3.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.2.2) - actionview (= 6.0.2.2) - activesupport (= 6.0.2.2) + actionpack (6.0.3.1) + actionview (= 6.0.3.1) + activesupport (= 6.0.3.1) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.2.2) - actionpack (= 6.0.2.2) - activerecord (= 6.0.2.2) - activestorage (= 6.0.2.2) - activesupport (= 6.0.2.2) + actiontext (6.0.3.1) + actionpack (= 6.0.3.1) + activerecord (= 6.0.3.1) + activestorage (= 6.0.3.1) + activesupport (= 6.0.3.1) nokogiri (>= 1.8.5) - actionview (6.0.2.2) - activesupport (= 6.0.2.2) + actionview (6.0.3.1) + activesupport (= 6.0.3.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.2.2) - activesupport (= 6.0.2.2) + activejob (6.0.3.1) + activesupport (= 6.0.3.1) globalid (>= 0.3.6) - activemodel (6.0.2.2) - activesupport (= 6.0.2.2) - activerecord (6.0.2.2) - activemodel (= 6.0.2.2) - activesupport (= 6.0.2.2) - activestorage (6.0.2.2) - actionpack (= 6.0.2.2) - activejob (= 6.0.2.2) - activerecord (= 6.0.2.2) + activemodel (6.0.3.1) + activesupport (= 6.0.3.1) + activerecord (6.0.3.1) + activemodel (= 6.0.3.1) + activesupport (= 6.0.3.1) + activestorage (6.0.3.1) + actionpack (= 6.0.3.1) + activejob (= 6.0.3.1) + activerecord (= 6.0.3.1) marcel (~> 0.3.1) - activesupport (6.0.2.2) + activesupport (6.0.3.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - zeitwerk (~> 2.2) + zeitwerk (~> 2.2, >= 2.2.2) ast (2.4.0) axiom-types (0.1.1) descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) bcrypt (3.1.13) - brakeman (4.8.0) + brakeman (4.8.2) builder (3.2.4) - byebug (11.1.1) + byebug (11.1.3) code_analyzer (0.5.1) sexp_processor codeclimate-engine-rb (0.4.1) @@ -122,10 +122,10 @@ GEM equalizer (0.0.11) erubi (1.9.0) erubis (2.7.0) - factory_bot (5.1.2) + factory_bot (5.2.0) activesupport (>= 4.2.0) - factory_bot_rails (5.1.1) - factory_bot (~> 5.1.0) + factory_bot_rails (5.2.0) + factory_bot (~> 5.2.0) railties (>= 4.2.0) globalid (0.4.2) activesupport (>= 4.2.0) @@ -135,7 +135,7 @@ GEM jaro_winkler (1.5.4) json (2.3.0) kwalify (0.7.2) - loofah (2.4.0) + loofah (2.5.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -143,17 +143,17 @@ GEM marcel (0.3.3) mimemagic (~> 0.3.2) method_source (1.0.0) - mimemagic (0.3.4) + mimemagic (0.3.5) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.14.0) + minitest (5.14.1) nio4r (2.5.2) nokogiri (1.10.9) mini_portile2 (~> 2.4.0) orm_adapter (0.5.0) pagy (3.8.1) parallel (1.19.1) - parser (2.7.0.5) + parser (2.7.1.3) ast (~> 2.4.0) pg (1.2.3) psych (3.1.0) @@ -162,20 +162,20 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.2.2) - actioncable (= 6.0.2.2) - actionmailbox (= 6.0.2.2) - actionmailer (= 6.0.2.2) - actionpack (= 6.0.2.2) - actiontext (= 6.0.2.2) - actionview (= 6.0.2.2) - activejob (= 6.0.2.2) - activemodel (= 6.0.2.2) - activerecord (= 6.0.2.2) - activestorage (= 6.0.2.2) - activesupport (= 6.0.2.2) + rails (6.0.3.1) + actioncable (= 6.0.3.1) + actionmailbox (= 6.0.3.1) + actionmailer (= 6.0.3.1) + actionpack (= 6.0.3.1) + actiontext (= 6.0.3.1) + actionview (= 6.0.3.1) + activejob (= 6.0.3.1) + activemodel (= 6.0.3.1) + activerecord (= 6.0.3.1) + activestorage (= 6.0.3.1) + activesupport (= 6.0.3.1) bundler (>= 1.3.0) - railties (= 6.0.2.2) + railties (= 6.0.3.1) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.4) actionpack (>= 5.0.1.x) @@ -194,9 +194,9 @@ GEM json require_all (~> 3.0) ruby-progressbar - railties (6.0.2.2) - actionpack (= 6.0.2.2) - activesupport (= 6.0.2.2) + railties (6.0.3.1) + actionpack (= 6.0.3.1) + activesupport (= 6.0.3.1) method_source rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) @@ -214,7 +214,7 @@ GEM actionpack (>= 5.0) railties (>= 5.0) rexml (3.2.4) - rspec-rails (4.0.0) + rspec-rails (4.0.1) actionpack (>= 4.2) activesupport (>= 4.2) railties (>= 4.2) @@ -252,7 +252,7 @@ GEM sprockets (>= 3.0.0) thor (1.0.1) thread_safe (0.3.6) - tzinfo (1.2.6) + tzinfo (1.2.7) thread_safe (~> 0.1) unicode-display_width (1.6.1) virtus (1.0.5) @@ -262,9 +262,9 @@ GEM equalizer (~> 0.0, >= 0.0.9) warden (1.2.8) rack (>= 2.0.6) - websocket-driver (0.7.1) + websocket-driver (0.7.2) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.4) + websocket-extensions (0.1.5) zeitwerk (2.3.0) PLATFORMS diff --git a/README.md b/README.md index 022ec0b..4ec6e0f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,27 @@ # ExceptionHunter -Short description and motivation. -## Usage -How to use my plugin. +![Index screenshot](doc/screenshot.png) + +Exception Hunter is a Rails engine meant to track errors in your Rails project. It works +by using your Postgres database to save errors with their corresponding metadata (like backtrace +or environment data at the time of failure). + +To do so we hook to various points of your application where we can rescue from errors, track and +then re-raise those errors so they are handled normally. As such, the gem does not conflict with any +other service so you can have your favorite error tracking service running in parallel with Exception Hunter +while you decide which you like best. + +## Motivation + +Error tracking is one of the most important tools a developer can have in their toolset. As such +we think it'd be nice to provide a way for everyone to have it in their project, be it a personal +project, and MVP or something else. ## Installation Add Exception Hunter to your application's Gemfile: ```ruby -gem 'exception_hunter', '~> 0.2.0' +gem 'exception_hunter', '~> 0.3.0' ``` You may also need to add [Devise](https://github.com/heartcombo/devise) to your Gemfile @@ -32,8 +45,18 @@ you can run the command with the `--skip-users` flag. Additionally it should add the 'ExceptionHunter.routes(self)' line to your routes, which means you can go to `/exception_hunter/errors` in your browser and start enjoying some good old fashioned exception tracking! -## Contributing -Contribution directions go here. +## Stale data + +You can get rid of stale errors by running the rake task to purge them: + +```bash +$ rake exception_hunter:purge_errors +``` + +We recommend you run this task once in a while to de-clutter your DB, using a recurring tasks once +a week would be ideal. You can also purge errors by running `ExceptionHunter::ErrorReaper.purge`. + +The time it takes for an error to go stale defaults to 45 days but it's configurable via the initializer. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/app/assets/stylesheets/exception_hunter/base.css b/app/assets/stylesheets/exception_hunter/base.css index 5cf2429..3d33657 100644 --- a/app/assets/stylesheets/exception_hunter/base.css +++ b/app/assets/stylesheets/exception_hunter/base.css @@ -1,19 +1,73 @@ :root { - --main-color: #C8193C; - --secondary-color: #F4F5F6; - --link-color: #158FEF; - --border-color: #D1D1D1; - --file-name-color: #16AF90 + --background-grey: #E5E5E5; + --highlight-color: #CF4031; + --highlight-good-color: #2CBB85; + --highlight-inactive-color: #808183; + --inactive-grey: #D1D1D3; + --focused-grey: #808183; + --highlighted-link-blue: #0036F7; + --header-grey: #F8F8F8; + --border-grey: #F1F2F5; + --file-name-color: #2CBB85; + --tag-color: #EAE639; +} + +body { + font-family: 'Inter', sans-serif; + background-color: var(--background-grey); +} + +.container { + padding: 0; +} + +.row { + margin: 0; + width: 100%; +} + +.row .column { + padding: 0; + margin-bottom: 0; } .wrapper { - margin: 5.5rem auto auto; + margin: 6.5rem auto auto; +} + +.text--underline { + text-decoration: underline; } a { - color: var(--link-color); + color: inherit; } a:hover, a:focus, a:active { - color: var(--main-color); + color: inherit; +} + +form { + margin-bottom: 0; +} + +.flash.flash--notice { + background-color: #FFF; + margin-bottom: 2rem; + padding: 0.7rem 2rem; + border-radius: 5px; + line-height: 3.8rem; + font-weight: 400; +} + +.button.button-dismiss { + margin-bottom: 0; + padding: 0; + color: var(--focused-grey); + width: 100%; + text-align: right; +} + +.button.button-dismiss:hover, .button.button-dismiss:focus, .button.button-dismiss:active { + color: var(--inactive-grey); } diff --git a/app/assets/stylesheets/exception_hunter/errors.css b/app/assets/stylesheets/exception_hunter/errors.css index 96f06ed..0d66de6 100644 --- a/app/assets/stylesheets/exception_hunter/errors.css +++ b/app/assets/stylesheets/exception_hunter/errors.css @@ -2,65 +2,203 @@ Place all the styles related to the matching controller here. They will automatically be included in application.css. */ +.errors__container { + background-color: #FFF; + padding: 32px; + border-radius: 0 5px 5px 5px; +} + +.errors-tabs { + display: flex; +} + +ul.errors-tabs { + list-style-type: none; + margin-bottom: 0; +} + +.errors-tab { + border-radius: 5px 5px 0 0; + margin-right: 1rem; + background-color: var(--inactive-grey); +} + +li.errors-tab { + margin-bottom: 0; +} -.row.statistics-row { - padding-top: 1rem; +.errors-tab--active, li.errors-tab a[aria-selected="true"] .errors-tab__content { + background-color: #FFF; + color: var(--highlight-color); } -.statistics__cell { - color: var(--main-color); - background-color: var(--secondary-color); - border: 1px solid var(--border-color); - height: 5rem; - border-radius: 10px; +.errors-tab__resolved.errors-tab--active { + color: var(--highlight-good-color); +} + +.errors-tab__content { + padding: 1rem; + display: flex; +} + +.errors-tab__badge { + background: var(--highlight-inactive-color); + border-radius: 5px; + color: #FFF; + padding: 4px 8px; +} + +.errors-tab--active .errors-tab__badge { + background-color: var(--highlight-color); +} + +.errors-tab__resolved.errors-tab--active .errors-tab__badge { + background-color: var(--highlight-good-color); +} + +.errors-tab__description { + margin-left: 1.3rem; display: flex; - align-items: center; justify-content: center; - font-size: 2.2rem; + align-items: center; +} + +.button.purge-button { + background-color: var(--highlight-color); + border: none; +} + +.button.purge-button:hover, .button.purge-button:focus { + background: var(--focused-grey); +} + +.errors-date-group { + background-color: var(--header-grey); + padding: 0.5rem 1rem; +} + +.errors-date-group ~ .errors-date-group { + border-bottom: 1px solid var(--border-grey); } .row.error-row { - padding-top: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid; + padding: 1rem; } -.row.error-row-no-border { - padding-top: 1rem; - padding-bottom: 1rem; +.error-row:not(.error-row--header) { + border: 1px solid var(--border-grey); +} + +.error-message, .error-message:hover, .error-message:focus, .error-message:active { + color: var(--highlighted-link-blue); } .error-row.error-row--header { - font-weight: bold; + background-color: var(--header-grey); + font-weight: 500; + color: #000; +} + +.error-cell__message { + overflow-x: hidden; +} + +.error-cell__tags { + font-weight: 500; + font-size: 12px; + color: #000; + padding-bottom: 3px; } -.error-cell.error-cell--highlight { - color: var(--main-color); +.error-cell__tags .error-tag { + background-color: var(--tag-color); + border-radius: 5px; + padding: 1px 5px 1px 5px; } .error-title { - font-size: 20px; - border-bottom: 1px solid var(--border-color); + color: var(--highlighted-link-blue); + padding-left: 2rem; +} + +.error-occurrence__header { + background: #FFF; + padding: 2rem 1rem; + margin-bottom: 2rem; + border-radius: 5px; +} + +.button_to .button.button-outlined.resolve-button { + color: var(--highlight-good-color); + border-color: var(--highlight-good-color); + font-size: 10px; + padding: 0 1rem; + height: 3rem; + line-height: 3rem; +} + +.button_to .button.resolve-button { + color: #FFF; + background-color: var(--highlight-good-color); + border-color: var(--highlight-good-color); + font-size: 10px; + padding: 0 1rem; + height: 3rem; + line-height: 3rem; + margin-bottom: 0; +} + +.button.resolve-button:hover, .button.resolve-button:focus { + color: var(--focused-grey); + border-color: var(--focused-grey); } .error-occurred_at { - color: var(--main-color); + color: var(--highlight-color); font-size: 14px; margin-top: 0.5em; margin-bottom: 2em; } +.error-occurrences__nav { + display: flex; + justify-content: flex-end; + height: 100%; + align-items: center; +} + +.button.button-outline.error-occurrences__nav-link { + margin-bottom: 0; + padding: 0 1rem; + height: 2.5rem; + line-height: 2.5rem; + margin-right: 0.5rem; + border-color: #000; + color: #000; +} + +.button.button-outline.error-occurrences__nav-link[disabled="disabled"], +.button.button-outline.error-occurrences__nav-link[disabled="disabled"]:focus, +.button.button-outline.error-occurrences__nav-link[disabled="disabled"]:hover { + border-color: var(--highlight-inactive-color); + color: var(--highlight-inactive-color); +} + +.error-occurrences__nav-current { + margin: 0 2rem; +} + .tab-content { padding: 1em 0.5em; } .data-title { font-weight: bold; - color: var(--main-color); + color: var(--highlight-color); } .tracked-data { - border-left-color: var(--main-color); + border-left-color: var(--highlight-color); } .backtrace { @@ -80,7 +218,7 @@ .backtrace-line__line-number { margin-right: 5px; - color: var(--main-color); + color: var(--highlight-color); } .backtrace-line__file-name { diff --git a/app/assets/stylesheets/exception_hunter/navigation.css b/app/assets/stylesheets/exception_hunter/navigation.css index 92f9acf..fdb8834 100644 --- a/app/assets/stylesheets/exception_hunter/navigation.css +++ b/app/assets/stylesheets/exception_hunter/navigation.css @@ -1,8 +1,7 @@ .nav { - background: var(--secondary-color); - border-bottom: .1rem solid var(--border-color); + background: #000; display: block; - height: 5.2rem; + height: 4.2rem; left: 0; max-width: 100%; position: fixed; @@ -16,6 +15,22 @@ height: 100%; } -.nav__logo img { - height: 100%; +.nav__title { + line-height: 4.2rem; + font-style: normal; + font-weight: 600; + font-size: 16px; + color: #FFF; +} + +.footer { + text-align: center; + padding-top: 4rem; + padding-bottom: 1rem; +} + +.logout { + line-height: 4.2rem; + color: #FFF; + text-align: right; } diff --git a/app/assets/stylesheets/exception_hunter/sessions.css b/app/assets/stylesheets/exception_hunter/sessions.css new file mode 100644 index 0000000..71055a0 --- /dev/null +++ b/app/assets/stylesheets/exception_hunter/sessions.css @@ -0,0 +1,71 @@ +.login_form_container { + margin: 0rem auto auto; + display: flex; + max-width: 75%; + margin-top: 20rem; + width: 910px; + height: 356px; + font-family: 'Inter', sans-serif; + background-color: white; +} + +.login_left_container { + width: 455px; + text-align: center; + width: 38rem; + background-color: black; + +} + +.left_column { + max-width: 50%; + padding-top: 15%; + padding-left: 10%; + line-height: 130%; + color: white; + text-align: left; + font-size: 30px; + font-weight: 600; +} + +.login_right_container { + width: 350px; + margin: 3rem 3.5rem 1rem; + font-size: 24px; + font-weight: 400; + text-align: left; + line-height: 29px; + line-height: 100%; + color: black; +} + +.login_row { + margin: 2rem 0rem 0rem; + width: 100%; + align-items: center; +} + +.login_button{ + margin: 3rem 3rem 0rem; + align-items: right; +} + +.button-log-in { + background-color: #2CBB85!important; + border-color: #2CBB85!important; +} + +.field{ + border: 0.1rem solid black; + border-radius: .4rem; +} + +input[type='password'], +input[type='email'], +textarea:focus, +select:focus { + border-color: black!important; + outline: 0; + font-size: 1.2rem!important; + color: black!important; +} diff --git a/app/controllers/concerns/exception_hunter/authorization.rb b/app/controllers/concerns/exception_hunter/authorization.rb new file mode 100644 index 0000000..7cded05 --- /dev/null +++ b/app/controllers/concerns/exception_hunter/authorization.rb @@ -0,0 +1,23 @@ +module ExceptionHunter + module Authorization + extend ActiveSupport::Concern + + included do + before_action :authenticate_admin_user_class + end + + def authenticate_admin_user_class + return unless ExceptionHunter::Config.auth_enabled? && !send("current_#{underscored_admin_user_class}") + + redirect_to '/exception_hunter/login' + end + + def redirect_to_login + render 'exception_hunter/devise/sessions/new' + end + + def underscored_admin_user_class + ExceptionHunter::Config.admin_user_class.underscore + end + end +end diff --git a/app/controllers/exception_hunter/application_controller.rb b/app/controllers/exception_hunter/application_controller.rb index cd8fd59..5899aed 100644 --- a/app/controllers/exception_hunter/application_controller.rb +++ b/app/controllers/exception_hunter/application_controller.rb @@ -1,5 +1,7 @@ module ExceptionHunter class ApplicationController < ActionController::Base + include ExceptionHunter::Authorization + protect_from_forgery with: :exception end end diff --git a/app/controllers/exception_hunter/errors_controller.rb b/app/controllers/exception_hunter/errors_controller.rb index 9641a48..036f3a3 100644 --- a/app/controllers/exception_hunter/errors_controller.rb +++ b/app/controllers/exception_hunter/errors_controller.rb @@ -5,14 +5,20 @@ class ErrorsController < ApplicationController include Pagy::Backend def index - @errors = ErrorGroup.all.order(created_at: :desc) - @errors_count = Error.count - @month_errors = Error.in_current_month.count + @dashboard = DashboardPresenter.new(current_tab) + shown_errors = errors_for_tab(@dashboard).order(updated_at: :desc).distinct + @errors = ErrorGroupPresenter.wrap_collection(shown_errors) end def show @pagy, errors = pagy(most_recent_errors, items: 1) - @error = ErrorPresenter.new(errors.first) + @error = ErrorPresenter.new(errors.first!) + end + + def destroy + ErrorReaper.purge + + redirect_back fallback_location: errors_path, notice: 'Errors purged successfully' end private @@ -20,5 +26,22 @@ def show def most_recent_errors Error.most_recent(params[:id]) end + + def current_tab + params[:tab] + end + + def errors_for_tab(dashboard) + case dashboard.current_tab + when DashboardPresenter::LAST_7_DAYS_TAB + ErrorGroup.with_errors_in_last_7_days.active + when DashboardPresenter::CURRENT_MONTH_TAB + ErrorGroup.with_errors_in_current_month.active + when DashboardPresenter::TOTAL_ERRORS_TAB + ErrorGroup.active + when DashboardPresenter::RESOLVED_ERRORS_TAB + ErrorGroup.resolved + end + end end end diff --git a/app/controllers/exception_hunter/resolved_errors_controller.rb b/app/controllers/exception_hunter/resolved_errors_controller.rb new file mode 100644 index 0000000..fbe9661 --- /dev/null +++ b/app/controllers/exception_hunter/resolved_errors_controller.rb @@ -0,0 +1,11 @@ +require_dependency 'exception_hunter/application_controller' + +module ExceptionHunter + class ResolvedErrorsController < ApplicationController + def create + ErrorGroup.find(params[:error_group][:id]).resolved! + + redirect_to errors_path, notice: 'Error resolved successfully' + end + end +end diff --git a/app/helpers/exception_hunter/sessions_helper.rb b/app/helpers/exception_hunter/sessions_helper.rb new file mode 100644 index 0000000..f82e72c --- /dev/null +++ b/app/helpers/exception_hunter/sessions_helper.rb @@ -0,0 +1,16 @@ +module ExceptionHunter + module SessionsHelper + def current_admin_user? + underscored_admin_user_class && + current_admin_class_name(underscored_admin_user_class) + end + + def underscored_admin_user_class + ExceptionHunter::Config.admin_user_class.try(:underscore) + end + + def current_admin_class_name(class_name) + send("current_#{class_name.underscore}") + end + end +end diff --git a/app/models/exception_hunter/application_record.rb b/app/models/exception_hunter/application_record.rb index b95c9c2..62ec2a2 100644 --- a/app/models/exception_hunter/application_record.rb +++ b/app/models/exception_hunter/application_record.rb @@ -1,5 +1,13 @@ module ExceptionHunter class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + class << self + delegate :[], to: :arel_table + + def sql_similarity(attr, value) + Arel::Nodes::NamedFunction.new('similarity', [attr, Arel::Nodes.build_quoted(value)]) + end + end end end diff --git a/app/models/exception_hunter/error.rb b/app/models/exception_hunter/error.rb index cd05402..369b5df 100644 --- a/app/models/exception_hunter/error.rb +++ b/app/models/exception_hunter/error.rb @@ -3,24 +3,37 @@ class Error < ApplicationRecord validates :class_name, presence: true validates :occurred_at, presence: true - belongs_to :error_group + belongs_to :error_group, touch: true before_validation :set_occurred_at, on: :create + after_create :unresolve_error_group, unless: -> { error_group.active? } scope :most_recent, lambda { |error_group_id| where(error_group_id: error_group_id).order(occurred_at: :desc) } - - def self.in_current_month - current_month = Date.today.beginning_of_month..Date.today.end_of_month - - where(occurred_at: current_month) - end + scope :with_occurrences_before, lambda { |max_occurrence_date| + where(Error[:occurred_at].lteq(max_occurrence_date)) + } + scope :in_period, ->(period) { where(occurred_at: period) } + scope :in_last_7_days, -> { in_period(7.days.ago.beginning_of_day..Time.now) } + scope :in_current_month, lambda { + in_period(Date.current.beginning_of_month.beginning_of_day..Date.current.end_of_month.end_of_day) + } + scope :from_active_error_groups, lambda { + joins(:error_group).where(error_group: ErrorGroup.active) + } + scope :from_resolved_error_groups, lambda { + joins(:error_group).where(error_group: ErrorGroup.resolved) + } private def set_occurred_at self.occurred_at ||= Time.now end + + def unresolve_error_group + error_group.active! + end end end diff --git a/app/models/exception_hunter/error_group.rb b/app/models/exception_hunter/error_group.rb index 9d501d3..447ff94 100644 --- a/app/models/exception_hunter/error_group.rb +++ b/app/models/exception_hunter/error_group.rb @@ -4,12 +4,27 @@ class ErrorGroup < ApplicationRecord validates :error_class_name, presence: true - has_many :grouped_errors, class_name: 'ExceptionHunter::Error' + has_many :grouped_errors, class_name: 'ExceptionHunter::Error', dependent: :destroy + + enum status: { active: 0, resolved: 1 } scope :most_similar, lambda { |message| - quoted_message = ActiveRecord::Base.connection.quote_string(message) - where("similarity(exception_hunter_error_groups.message, :message) >= #{SIMILARITY_THRESHOLD}", message: message) - .order(Arel.sql("similarity(exception_hunter_error_groups.message, '#{quoted_message}') DESC")) + message_similarity = sql_similarity(ErrorGroup[:message], message) + where(message_similarity.gteq(SIMILARITY_THRESHOLD)) + .order(message_similarity.desc) + } + + scope :without_errors, lambda { + is_associated_error = Error[:error_group_id].eq(ErrorGroup[:id]) + where.not(Error.where(is_associated_error).arel.exists) + } + scope :with_errors_in_last_7_days, lambda { + joins(:grouped_errors) + .where(Error.in_last_7_days.where(Error[:error_group_id].eq(ErrorGroup[:id])).arel.exists) + } + scope :with_errors_in_current_month, lambda { + joins(:grouped_errors) + .where(Error.in_current_month.where(Error[:error_group_id].eq(ErrorGroup[:id])).arel.exists) } def self.find_matching_group(error) @@ -18,6 +33,10 @@ def self.find_matching_group(error) .first end + def first_occurrence + @first_occurrence ||= grouped_errors.minimum(:occurred_at) + end + def last_occurrence @last_occurrence ||= grouped_errors.maximum(:occurred_at) end diff --git a/app/presenters/exception_hunter/dashboard_presenter.rb b/app/presenters/exception_hunter/dashboard_presenter.rb new file mode 100644 index 0000000..099fbd9 --- /dev/null +++ b/app/presenters/exception_hunter/dashboard_presenter.rb @@ -0,0 +1,54 @@ +module ExceptionHunter + class DashboardPresenter + LAST_7_DAYS_TAB = 'last_7_days'.freeze + CURRENT_MONTH_TAB = 'current_month'.freeze + TOTAL_ERRORS_TAB = 'total_errors'.freeze + RESOLVED_ERRORS_TAB = 'resolved'.freeze + TABS = [LAST_7_DAYS_TAB, CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB].freeze + DEFAULT_TAB = LAST_7_DAYS_TAB + + attr_reader :current_tab + + def initialize(current_tab) + assign_tab(current_tab) + calculate_tabs_counts + end + + def tab_active?(tab) + tab == current_tab + end + + def partial_for_tab + case current_tab + when LAST_7_DAYS_TAB + 'exception_hunter/errors/last_7_days_errors_table' + when CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB + 'exception_hunter/errors/errors_table' + end + end + + def errors_count(tab) + @tabs_counts[tab] + end + + private + + def assign_tab(tab) + @current_tab = if TABS.include?(tab) + tab + else + DEFAULT_TAB + end + end + + def calculate_tabs_counts + active_errors = Error.from_active_error_groups + @tabs_counts = { + LAST_7_DAYS_TAB => active_errors.in_last_7_days.count, + CURRENT_MONTH_TAB => active_errors.in_current_month.count, + TOTAL_ERRORS_TAB => active_errors.count, + RESOLVED_ERRORS_TAB => Error.from_resolved_error_groups.count + } + end + end +end diff --git a/app/presenters/exception_hunter/error_group_presenter.rb b/app/presenters/exception_hunter/error_group_presenter.rb new file mode 100644 index 0000000..dfe5a31 --- /dev/null +++ b/app/presenters/exception_hunter/error_group_presenter.rb @@ -0,0 +1,25 @@ +module ExceptionHunter + class ErrorGroupPresenter + delegate_missing_to :error_group + + def initialize(error_group) + @error_group = error_group + end + + def self.wrap_collection(collection) + collection.map { |error_group| new(error_group) } + end + + def self.format_occurrence_day(day) + day.to_date.strftime('%A, %B %d') + end + + def show_for_day?(day) + last_occurrence.in_time_zone.to_date == day.to_date + end + + private + + attr_reader :error_group + end +end diff --git a/app/presenters/exception_hunter/error_presenter.rb b/app/presenters/exception_hunter/error_presenter.rb index a642329..0f90d91 100644 --- a/app/presenters/exception_hunter/error_presenter.rb +++ b/app/presenters/exception_hunter/error_presenter.rb @@ -1,6 +1,7 @@ module ExceptionHunter class ErrorPresenter delegate_missing_to :error + delegate :tags, to: :error_group BacktraceLine = Struct.new(:path, :file_name, :line_number, :method_call) diff --git a/app/views/exception_hunter/devise/sessions/new.html.erb b/app/views/exception_hunter/devise/sessions/new.html.erb new file mode 100644 index 0000000..fe4dbfa --- /dev/null +++ b/app/views/exception_hunter/devise/sessions/new.html.erb @@ -0,0 +1,24 @@ +
+
+
Exception Hunter
+
+
+ <%= form_for(resource, as: resource_name, url: exception_hunter_create_session_path) do |f| %> +
+ Sign into your account +
+ + + + + <% end %> +
+
diff --git a/app/views/exception_hunter/errors/_error_row.erb b/app/views/exception_hunter/errors/_error_row.erb new file mode 100644 index 0000000..e7f25bf --- /dev/null +++ b/app/views/exception_hunter/errors/_error_row.erb @@ -0,0 +1,44 @@ +
+
+ <% error.tags.each do |tag| %> +
+ <%= tag %> +
+ <% end %> +
+
+ <%= link_to error.message, error_path(error.id), class: %w[error-message] %> +
+ +
+ <% if error.first_occurrence.present? %> + <%= time_ago_in_words(error.first_occurrence) %> ago + <% else %> + Never + <% end %> +
+ +
+ <% if error.last_occurrence.present? %> + <%= time_ago_in_words(error.last_occurrence) %> ago + <% else %> + Never + <% end %> +
+ +
+ <%= error.total_occurrences %> +
+ +
+ <% if error.active? %> +
+ <%= button_to('Resolve', resolved_errors_path(error_group: { id: error.id }), + method: :post, + class: %w[button button-outline resolve-button], + data: { confirm: 'Are you sure you want to resolve this error?' }) %> +
+ <% end %> +
+
+ diff --git a/app/views/exception_hunter/errors/_error_summary.erb b/app/views/exception_hunter/errors/_error_summary.erb index 2caa95f..ffc9ffc 100644 --- a/app/views/exception_hunter/errors/_error_summary.erb +++ b/app/views/exception_hunter/errors/_error_summary.erb @@ -1,9 +1,9 @@ <% if error.environment_data.empty? %> -
+
Unfortunately, no environment information has been registered for this error.
<% else %> -
+
Environment Data
@@ -13,7 +13,7 @@
 <% end %>
 
 <% unless error.tracked_params.nil? %>
-  
+
Tracked Params
@@ -23,11 +23,11 @@
 <% end %>
 
 <% if error.custom_data.nil? %>
-  
+
No custom data included.
<% else %> -
+
Custom Data
diff --git a/app/views/exception_hunter/errors/_errors_table.erb b/app/views/exception_hunter/errors/_errors_table.erb
new file mode 100644
index 0000000..2bfe4c6
--- /dev/null
+++ b/app/views/exception_hunter/errors/_errors_table.erb
@@ -0,0 +1 @@
+<%= render partial: 'exception_hunter/errors/error_row', collection: errors, as: :error %>
diff --git a/app/views/exception_hunter/errors/_last_7_days_errors_table.erb b/app/views/exception_hunter/errors/_last_7_days_errors_table.erb
new file mode 100644
index 0000000..222e3f2
--- /dev/null
+++ b/app/views/exception_hunter/errors/_last_7_days_errors_table.erb
@@ -0,0 +1,12 @@
+<% today_errors = errors.select { |error| error.show_for_day?(Date.current) } %>
+<%= render partial: 'exception_hunter/errors/error_row', collection: today_errors, as: :error %>
+
+<% yesterday_errors = errors.select { |error| error.show_for_day?(Date.yesterday) } %>
+
Yesterday
+<%= render partial: 'exception_hunter/errors/error_row', collection: yesterday_errors, as: :error %> + +<% (2..6).each do |i| %> + <% errors_on_day = errors.select { |error| error.show_for_day?(i.days.ago) } %> +
<%= ExceptionHunter::ErrorGroupPresenter.format_occurrence_day(i.days.ago) %>
+ <%= render partial: 'exception_hunter/errors/error_row', collection: errors_on_day, as: :error %> +<% end %> diff --git a/app/views/exception_hunter/errors/index.html.erb b/app/views/exception_hunter/errors/index.html.erb index abab2ab..569b081 100644 --- a/app/views/exception_hunter/errors/index.html.erb +++ b/app/views/exception_hunter/errors/index.html.erb @@ -1,36 +1,77 @@ -
-
-
- <%= number_with_delimiter(@errors_count) %> Total errors -
-
-
-
- <%= number_with_delimiter(@month_errors) %> Errors this month +
+
+
+
+ <%= link_to errors_path(tab: @dashboard.class::LAST_7_DAYS_TAB) do %> +
+
+ <%= @dashboard.errors_count(@dashboard.class::LAST_7_DAYS_TAB) %> +
+
+ Errors in the last 7 days +
+
+ <% end %> +
+ +
+ <%= link_to errors_path(tab: @dashboard.class::CURRENT_MONTH_TAB) do %> +
+
+ <%= @dashboard.errors_count(@dashboard.class::CURRENT_MONTH_TAB) %> +
+
+ Errors this month +
+
+ <% end %> +
+ +
+ <%= link_to errors_path(tab: @dashboard.class::TOTAL_ERRORS_TAB) do %> +
+
+ <%= @dashboard.errors_count(@dashboard.class::TOTAL_ERRORS_TAB) %> +
+
+ Total errors +
+
+ <% end %> +
+ +
+ <%= link_to errors_path(tab: @dashboard.class::RESOLVED_ERRORS_TAB) do %> +
+
+ <%= @dashboard.errors_count(@dashboard.class::RESOLVED_ERRORS_TAB) || '-' %> +
+
+ Resolved +
+
+ <% end %> +
-
-
-
Message
-
Last Occurrence
-
Occurrences
+
+ <%= button_to 'Purge', purge_errors_path, + class: %w[button purge-button], + method: :delete, + data: { confirm: 'This will delete all stale errors, do you want to continue?' } %> +
-<% @errors.each do |error| %> -
-
- <%= link_to error.message, error_path(error.id) %> -
-
- <% if error.last_occurrence.present? %> - <%= time_ago_in_words(error.last_occurrence) %> ago - <% else %> - Never - <% end %> -
-
- <%= error.total_occurrences %> -
+
+
+
Tags
+
Message
+
First Occurrence
+
Last Occurrence
+
Total
+
-<% end %> + + <%= render partial: @dashboard.partial_for_tab, locals: { errors: @errors } %> +
diff --git a/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb b/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb index 3010909..081d036 100644 --- a/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb +++ b/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb @@ -1,17 +1,17 @@ -<% link = pagy_link_proc(pagy) %> - +
+ <%= occurred_at %> (<%= pagy.page %>/<%= pagy.last %>) +
+ <%= link_to pagy_url_for(pagy.next, pagy) do %> + <%= button_tag '>', class: %w[button button-outline error-occurrences__nav-link], disabled: pagy.next.nil? %> + <% end %> + <%= link_to pagy_url_for(pagy.last, pagy) do %> + <%= button_tag 'Last', class: %w[button button-outline error-occurrences__nav-link], disabled: pagy.next.nil? %> + <% end %> +
diff --git a/app/views/exception_hunter/errors/show.html.erb b/app/views/exception_hunter/errors/show.html.erb index 5f75b12..bb54ef9 100644 --- a/app/views/exception_hunter/errors/show.html.erb +++ b/app/views/exception_hunter/errors/show.html.erb @@ -1,33 +1,69 @@ -
-
- <%= render partial: 'exception_hunter/errors/pagy/pagy_nav', locals: { pagy: @pagy } %> +
+
+ <% @error.tags.each do|tag| %> +
+ <%= tag %> +
+ <% end %> +
+
+
+ <%= @error.class_name %>: <%= @error.message %> +
+
+
+ <%= button_to('Resolve', resolved_errors_path(error_group: { id: @error.error_group_id }), + method: :post, + class: %w[button resolve-button], + data: { confirm: 'Are you sure you want to resolve this error?' }) %>
-
- <%= @error.class_name %>: <%= @error.message %> -
-
- <%= @error.occurred_at %> +
+ +
+ <%= render partial: 'exception_hunter/errors/pagy/pagy_nav', locals: { pagy: @pagy, occurred_at: @error.occurred_at } %> +
- -
-
- <%= render partial: 'exception_hunter/errors/error_summary', locals: { error: @error } %> -
+
+
+
+ <%= render partial: 'exception_hunter/errors/error_summary', locals: { error: @error } %> +
-
- <%= render partial: 'exception_hunter/errors/error_backtrace', locals: { error: @error } %> -
+
+ <%= render partial: 'exception_hunter/errors/error_backtrace', locals: { error: @error } %> +
-
- <%= render partial: 'exception_hunter/errors/error_user_data', locals: { error: @error } %> +
+ <%= render partial: 'exception_hunter/errors/error_user_data', locals: { error: @error } %> +
diff --git a/app/views/layouts/exception_hunter/application.html.erb b/app/views/layouts/exception_hunter/application.html.erb index a346f0e..b98e86c 100644 --- a/app/views/layouts/exception_hunter/application.html.erb +++ b/app/views/layouts/exception_hunter/application.html.erb @@ -7,12 +7,11 @@ <%= favicon_link_tag 'exception_hunter/logo.png' %> - + - <%= stylesheet_link_tag "exception_hunter/application", media: "all" %> @@ -22,18 +21,78 @@
+ <% if flash[:notice] %> +
+
+ <%= flash[:notice] %> +
+
+
+ <%= ['Cool!', 'Nice!', 'Ok', 'Dismiss', 'Fine. Whatever.', 'I know that'].sample %> +
+
+
+ <% end %> + <%= yield %>
+ +
+ + diff --git a/app/views/layouts/exception_hunter/exception_hunter_logged_out.html.erb b/app/views/layouts/exception_hunter/exception_hunter_logged_out.html.erb new file mode 100644 index 0000000..42fa244 --- /dev/null +++ b/app/views/layouts/exception_hunter/exception_hunter_logged_out.html.erb @@ -0,0 +1,24 @@ + + + + Exception Hunter + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + + + + + + + + + <%= stylesheet_link_tag "exception_hunter/application", media: "all" %> + + +
+ <%= yield %> +
+ + diff --git a/config/initializers/exception_hunter.rb b/config/initializers/exception_hunter.rb deleted file mode 100644 index da45661..0000000 --- a/config/initializers/exception_hunter.rb +++ /dev/null @@ -1,16 +0,0 @@ -ExceptionHunter.setup do |config| - # == Current User - # - # Exception Hunter will include the user as part of the environment - # data, if it was to be available. The default configuration uses devise - # :current_user method. You can change it in case - # - config.current_user_method = :current_user - - # == Current User Attributes - # - # Exception Hunter will try to include the attributes defined here - # as part of the user information that is kept from the request. - # - config.user_attributes = [:id, :email] -end diff --git a/config/rails_best_practices.yml b/config/rails_best_practices.yml index 8a23776..9b63e68 100644 --- a/config/rails_best_practices.yml +++ b/config/rails_best_practices.yml @@ -16,7 +16,7 @@ MoveFinderToNamedScopeCheck: { } MoveModelLogicIntoModelCheck: { use_count: 4 } NeedlessDeepNestingCheck: { nested_count: 2 } NotUseDefaultRouteCheck: { } -NotUseTimeAgoInWordsCheck: { ignored_files: ['index.html.erb'] } +#NotUseTimeAgoInWordsCheck: { ignored_files: ['index.html.erb'] } OveruseRouteCustomizationsCheck: { customize_count: 3 } ProtectMassAssignmentCheck: { } RemoveEmptyHelpersCheck: { } @@ -29,7 +29,7 @@ ReplaceComplexCreationWithFactoryMethodCheck: { attribute_assignment_count: 2 } ReplaceInstanceVariableWithLocalVariableCheck: { } RestrictAutoGeneratedRoutesCheck: { } SimplifyRenderInControllersCheck: { } -SimplifyRenderInViewsCheck: { } +#SimplifyRenderInViewsCheck: { } #UseBeforeFilterCheck: { customize_count: 2 } UseModelAssociationCheck: { } UseMultipartAlternativeAsContentTypeOfEmailCheck: { } diff --git a/config/routes.rb b/config/routes.rb index 38bab3e..c59e363 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,21 @@ ExceptionHunter::Engine.routes.draw do - resources :errors, only: %i[index show] + resources :errors, only: %i[index show] do + delete 'purge', on: :collection, to: 'errors#destroy', as: :purge + end + + resources :resolved_errors, only: %i[create] + + get '/', to: redirect('/exception_hunter/errors') + + if ExceptionHunter::Config.auth_enabled? + admin_user_class = ExceptionHunter::Config.admin_user_class.underscore.to_sym + + devise_scope admin_user_class do + get '/login', to: 'devise/sessions#new', as: :exception_hunter_login + post '/login', to: 'devise/sessions#create', as: :exception_hunter_create_session + get '/logout', to: 'devise/sessions#destroy', as: :exception_hunter_logout + end + + devise_for admin_user_class, only: [] + end end diff --git a/doc/screenshot.png b/doc/screenshot.png new file mode 100644 index 0000000..dc5fd10 Binary files /dev/null and b/doc/screenshot.png differ diff --git a/lib/exception_hunter.rb b/lib/exception_hunter.rb index 38f7ae2..6972a1a 100644 --- a/lib/exception_hunter.rb +++ b/lib/exception_hunter.rb @@ -1,16 +1,26 @@ +require 'pagy' + require 'exception_hunter/engine' require 'exception_hunter/middleware/request_hunter' require 'exception_hunter/middleware/sidekiq_hunter' if defined?(Sidekiq) require 'exception_hunter/config' +require 'exception_hunter/error_creator' +require 'exception_hunter/error_reaper' +require 'exception_hunter/tracking' require 'exception_hunter/user_attributes_collector' -require 'pagy' module ExceptionHunter + autoload :Devise, 'exception_hunter/devise' + + extend ::ExceptionHunter::Tracking + def self.setup(&block) block.call(Config) end def self.routes(router) + return unless Config.enabled + router.mount(ExceptionHunter::Engine, at: 'exception_hunter') end end diff --git a/lib/exception_hunter/config.rb b/lib/exception_hunter/config.rb index f1be243..5b104a4 100644 --- a/lib/exception_hunter/config.rb +++ b/lib/exception_hunter/config.rb @@ -1,5 +1,12 @@ module ExceptionHunter class Config - cattr_accessor :current_user_method, :user_attributes + cattr_accessor :admin_user_class, + :current_user_method, :user_attributes + cattr_accessor :enabled, default: true + cattr_accessor :errors_stale_time, default: 45.days + + def self.auth_enabled? + admin_user_class.present? && admin_user_class.try(:underscore) + end end end diff --git a/lib/exception_hunter/devise.rb b/lib/exception_hunter/devise.rb new file mode 100644 index 0000000..d53bfb7 --- /dev/null +++ b/lib/exception_hunter/devise.rb @@ -0,0 +1,17 @@ +module ExceptionHunter + module Devise + class SessionsController < ::Devise::SessionsController + skip_before_action :verify_authenticity_token + + layout 'exception_hunter/exception_hunter_logged_out' + + def after_sign_out_path_for(*) + '/exception_hunter/login' + end + + def after_sign_in_path_for(*) + '/exception_hunter' + end + end + end +end diff --git a/app/services/exception_hunter/error_creator.rb b/lib/exception_hunter/error_creator.rb similarity index 68% rename from app/services/exception_hunter/error_creator.rb rename to lib/exception_hunter/error_creator.rb index 5252375..2cbe86f 100644 --- a/app/services/exception_hunter/error_creator.rb +++ b/lib/exception_hunter/error_creator.rb @@ -1,12 +1,18 @@ module ExceptionHunter class ErrorCreator + HTTP_TAG = 'HTTP'.freeze + WORKER_TAG = 'Worker'.freeze + MANUAL_TAG = 'Manual'.freeze + class << self - def call(**error_attrs) + def call(tag: nil, **error_attrs) + return unless should_create? + ActiveRecord::Base.transaction do error_attrs = extract_user_data(error_attrs) error = Error.new(error_attrs) error_group = ErrorGroup.find_matching_group(error) || ErrorGroup.new - update_error_group(error_group, error) + update_error_group(error_group, error, tag) error.error_group = error_group error.save! error @@ -17,9 +23,15 @@ def call(**error_attrs) private - def update_error_group(error_group, error) + def should_create? + Config.enabled + end + + def update_error_group(error_group, error, tag) error_group.error_class_name = error.class_name error_group.message = error.message + error_group.tags << tag unless tag.nil? + error_group.tags.uniq! error_group.save! end diff --git a/lib/exception_hunter/error_reaper.rb b/lib/exception_hunter/error_reaper.rb new file mode 100644 index 0000000..f58be97 --- /dev/null +++ b/lib/exception_hunter/error_reaper.rb @@ -0,0 +1,12 @@ +module ExceptionHunter + class ErrorReaper + class << self + def purge(stale_time: Config.errors_stale_time) + ActiveRecord::Base.transaction do + Error.with_occurrences_before(Date.today - stale_time).destroy_all + ErrorGroup.without_errors.destroy_all + end + end + end + end +end diff --git a/lib/exception_hunter/middleware/request_hunter.rb b/lib/exception_hunter/middleware/request_hunter.rb index 7cdb269..3008f7e 100644 --- a/lib/exception_hunter/middleware/request_hunter.rb +++ b/lib/exception_hunter/middleware/request_hunter.rb @@ -30,6 +30,7 @@ def call(env) def catch_prey(env, exception) user = user_from_env(env) ErrorCreator.call( + tag: ErrorCreator::HTTP_TAG, class_name: exception.class.to_s, message: exception.message, environment_data: environment_data(env), diff --git a/lib/exception_hunter/middleware/sidekiq_hunter.rb b/lib/exception_hunter/middleware/sidekiq_hunter.rb index 6cd2deb..c304a28 100644 --- a/lib/exception_hunter/middleware/sidekiq_hunter.rb +++ b/lib/exception_hunter/middleware/sidekiq_hunter.rb @@ -29,6 +29,7 @@ def track_exception(exception, context) return unless should_track?(context) ErrorCreator.call( + tag: ErrorCreator::WORKER_TAG, class_name: exception.class.to_s, message: exception.message, environment_data: environment_data(context), diff --git a/lib/exception_hunter/tracking.rb b/lib/exception_hunter/tracking.rb new file mode 100644 index 0000000..dc3e4aa --- /dev/null +++ b/lib/exception_hunter/tracking.rb @@ -0,0 +1,16 @@ +module ExceptionHunter + module Tracking + def track(exception, custom_data: {}, user: nil) + ErrorCreator.call( + tag: ErrorCreator::MANUAL_TAG, + class_name: exception.class.to_s, + message: exception.message, + backtrace: exception.backtrace, + custom_data: custom_data, + user: user + ) + + nil + end + end +end diff --git a/lib/exception_hunter/user_attributes_collector.rb b/lib/exception_hunter/user_attributes_collector.rb index 66f0a2c..ade5bfe 100644 --- a/lib/exception_hunter/user_attributes_collector.rb +++ b/lib/exception_hunter/user_attributes_collector.rb @@ -3,11 +3,15 @@ module UserAttributesCollector extend self def collect_attributes(user) + return unless user + attributes.reduce({}) do |data, attribute| data.merge(attribute => user.try(attribute)) end end + private + def attributes Config.user_attributes end diff --git a/lib/exception_hunter/version.rb b/lib/exception_hunter/version.rb index 7f2833b..5e77990 100644 --- a/lib/exception_hunter/version.rb +++ b/lib/exception_hunter/version.rb @@ -1,3 +1,3 @@ module ExceptionHunter - VERSION = '0.2.0'.freeze + VERSION = '0.3.0'.freeze end diff --git a/lib/generators/exception_hunter/create_users/create_users_generator.rb b/lib/generators/exception_hunter/create_users/create_users_generator.rb index 0e76467..df2dd1b 100644 --- a/lib/generators/exception_hunter/create_users/create_users_generator.rb +++ b/lib/generators/exception_hunter/create_users/create_users_generator.rb @@ -22,7 +22,14 @@ def install_devise end def create_admin_user - invoke 'devise', [name] + invoke 'devise', [name], routes: false + end + + def remove_registerable_from_model + return if options[:registerable] + + model_file = File.join(destination_root, 'app', 'models', "#{file_path}.rb") + gsub_file model_file, /\:registerable([.]*,)?/, '' end end end diff --git a/lib/generators/exception_hunter/install/install_generator.rb b/lib/generators/exception_hunter/install/install_generator.rb index a269810..6a4a584 100644 --- a/lib/generators/exception_hunter/install/install_generator.rb +++ b/lib/generators/exception_hunter/install/install_generator.rb @@ -15,7 +15,9 @@ def copy_initializer def setup_routes if options[:users] - inject_into_file 'config/routes.rb', "\n ExceptionHunter.routes(self)", after: /devise_for .*/ + gsub_file 'config/routes.rb', + "\n devise_for :#{plural_table_name}, skip: :all", + "\n ExceptionHunter.routes(self)" else route 'ExceptionHunter.routes(self)' end diff --git a/lib/generators/exception_hunter/install/templates/create_exception_hunter_error_groups.rb.erb b/lib/generators/exception_hunter/install/templates/create_exception_hunter_error_groups.rb.erb index 8474590..978bc7c 100644 --- a/lib/generators/exception_hunter/install/templates/create_exception_hunter_error_groups.rb.erb +++ b/lib/generators/exception_hunter/install/templates/create_exception_hunter_error_groups.rb.erb @@ -5,10 +5,13 @@ class CreateExceptionHunterErrorGroups < ActiveRecord::Migration[<%= ActiveRecor create_table :exception_hunter_error_groups do |t| t.string :error_class_name, null: false t.string :message + t.integer :status, default: 0 + t.text :tags, array: true, default: [] t.timestamps t.index :message, opclass: :gin_trgm_ops, using: :gin + t.index :status end end end diff --git a/lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb b/lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb index aaaf584..ecb59d5 100644 --- a/lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb +++ b/lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb @@ -1,4 +1,19 @@ ExceptionHunter.setup do |config| + # == Enabling + # + # This flag allows disabling error tracking, it's set to track in + # any environment but development or test by default + # + config.enabled = !(Rails.env.development? || Rails.env.test?) + + # == Dashboard User + # Exception Hunter allows you to restrict users who can see the dashboard + # to the ones included in the database. You can change the table name in + # case you are not satisfied with the default one. You can also remove the + # configuration if you wish to have no access restrictions for the dashboard. + # + <%= @use_authentication_method ? "config.admin_user_class = '#{name}'" : "# config.admin_user_class = '#{name}'" %> + # == Current User # # Exception Hunter will include the user as part of the environment @@ -15,4 +30,12 @@ ExceptionHunter.setup do |config| # as part of the user information that is kept from the request. # config.user_attributes = [:id, :email] + + # == Stale errors + # + # You can configure how long it takes for errors to go stale. This is + # taken into account when purging old error messages but nothing will + # happen automatically. + # + # config.errors_stale_time = 45.days end diff --git a/lib/tasks/exception_hunter_tasks.rake b/lib/tasks/exception_hunter_tasks.rake index 4a931c5..4402b4f 100644 --- a/lib/tasks/exception_hunter_tasks.rake +++ b/lib/tasks/exception_hunter_tasks.rake @@ -1,4 +1,6 @@ -# desc "Explaining what the task does" -# task :exception_hunter do -# # Task goes here -# end +namespace :exception_hunter do + desc 'Purges old errors' + task purge_errors: [:environment] do + ::ExceptionHunter::ErrorReaper.call + end +end diff --git a/spec/dummy/app/models/admin_user.rb b/spec/dummy/app/models/admin_user.rb new file mode 100644 index 0000000..caafb2f --- /dev/null +++ b/spec/dummy/app/models/admin_user.rb @@ -0,0 +1,6 @@ +class AdminUser < ApplicationRecord + # Include default devise modules. Others available are: + # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable + devise :database_authenticatable, + :recoverable, :rememberable, :validatable +end diff --git a/spec/dummy/config/initializers/exception_hunter.rb b/spec/dummy/config/initializers/exception_hunter.rb index aaaf584..c6cb5d9 100644 --- a/spec/dummy/config/initializers/exception_hunter.rb +++ b/spec/dummy/config/initializers/exception_hunter.rb @@ -1,4 +1,19 @@ ExceptionHunter.setup do |config| + # == Enabling + # + # This flag allows disabling error tracking, it's set to track in + # any environment but development or test by default + # + # config.enabled = !(Rails.env.development? || Rails.env.test?) + + # == Dashboard User + # Exception Hunter allows you to restrict users who can see the dashboard + # to the ones included in the database. You can change the table name in + # case you are not satisfied with the default one. You can also remove the + # configuration if you wish to have no access restrictions for the dashboard. + # + config.admin_user_class = 'AdminUser' + # == Current User # # Exception Hunter will include the user as part of the environment @@ -15,4 +30,12 @@ # as part of the user information that is kept from the request. # config.user_attributes = [:id, :email] + + # == Stale errors + # + # You can configure how long it takes for errors to go stale. This is + # taken into account when purging old error messages but nothing will + # happen automatically. + # + # config.errors_stale_time = 45.days end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 3543d81..a25d4e1 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -1,8 +1,8 @@ require 'sidekiq/web' Rails.application.routes.draw do - devise_for :users ExceptionHunter.routes(self) + devise_for :users mount Sidekiq::Web => '/sidekiq' get :raising_endpoint, to: 'exception#raising_endpoint' diff --git a/spec/dummy/db/migrate/20200601134028_devise_create_admin_users.rb b/spec/dummy/db/migrate/20200601134028_devise_create_admin_users.rb new file mode 100644 index 0000000..3790c84 --- /dev/null +++ b/spec/dummy/db/migrate/20200601134028_devise_create_admin_users.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class DeviseCreateAdminUsers < ActiveRecord::Migration[6.0] + def change + create_table :admin_users do |t| + ## Database authenticatable + t.string :email, null: false, default: "" + t.string :encrypted_password, null: false, default: "" + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + + ## Rememberable + t.datetime :remember_created_at + + ## Trackable + # t.integer :sign_in_count, default: 0, null: false + # t.datetime :current_sign_in_at + # t.datetime :last_sign_in_at + # t.inet :current_sign_in_ip + # t.inet :last_sign_in_ip + + ## Confirmable + # t.string :confirmation_token + # t.datetime :confirmed_at + # t.datetime :confirmation_sent_at + # t.string :unconfirmed_email # Only if using reconfirmable + + ## Lockable + # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts + # t.string :unlock_token # Only if unlock strategy is :email or :both + # t.datetime :locked_at + + + t.timestamps null: false + end + + add_index :admin_users, :email, unique: true + add_index :admin_users, :reset_password_token, unique: true + # add_index :admin_users, :confirmation_token, unique: true + # add_index :admin_users, :unlock_token, unique: true + end +end diff --git a/spec/dummy/db/migrate/20200428212928_create_exception_hunter_error_groups.rb b/spec/dummy/db/migrate/20200608130254_create_exception_hunter_error_groups.rb similarity index 76% rename from spec/dummy/db/migrate/20200428212928_create_exception_hunter_error_groups.rb rename to spec/dummy/db/migrate/20200608130254_create_exception_hunter_error_groups.rb index 18f12a5..3b25df3 100644 --- a/spec/dummy/db/migrate/20200428212928_create_exception_hunter_error_groups.rb +++ b/spec/dummy/db/migrate/20200608130254_create_exception_hunter_error_groups.rb @@ -5,10 +5,13 @@ def change create_table :exception_hunter_error_groups do |t| t.string :error_class_name, null: false t.string :message + t.integer :status, default: 0 + t.text :tags, array: true, default: [] t.timestamps t.index :message, opclass: :gin_trgm_ops, using: :gin + t.index :status end end end diff --git a/spec/dummy/db/migrate/20200428212929_create_exception_hunter_errors.rb b/spec/dummy/db/migrate/20200608130255_create_exception_hunter_errors.rb similarity index 100% rename from spec/dummy/db/migrate/20200428212929_create_exception_hunter_errors.rb rename to spec/dummy/db/migrate/20200608130255_create_exception_hunter_errors.rb diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 46bcaaf..be19be7 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,18 +10,33 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_04_28_212929) do +ActiveRecord::Schema.define(version: 2020_06_08_130255) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" enable_extension "plpgsql" + create_table "admin_users", force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["email"], name: "index_admin_users_on_email", unique: true + t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true + end + create_table "exception_hunter_error_groups", force: :cascade do |t| t.string "error_class_name", null: false t.string "message" + t.integer "status", default: 0 + t.text "tags", default: [], array: true t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.index ["message"], name: "index_exception_hunter_error_groups_on_message", opclass: :gin_trgm_ops, using: :gin + t.index ["status"], name: "index_exception_hunter_error_groups_on_status" end create_table "exception_hunter_errors", force: :cascade do |t| diff --git a/spec/services/exception_hunter/error_creator_spec.rb b/spec/error_creator_spec.rb similarity index 70% rename from spec/services/exception_hunter/error_creator_spec.rb rename to spec/error_creator_spec.rb index fa74f3b..f41777b 100644 --- a/spec/services/exception_hunter/error_creator_spec.rb +++ b/spec/error_creator_spec.rb @@ -31,6 +31,21 @@ module ExceptionHunter it 'updates the error message' do expect { subject }.to change { error_group.reload.message }.to('Something went very wrong 123') end + + context 'with repeating tag' do + before do + error_attributes[:tag] = ErrorCreator::HTTP_TAG + described_class.call(error_attributes) + end + + it 'does not repeat tags' do + expect(error_group.reload.tags).to eq(['HTTP']) + + subject + + expect(error_group.reload.tags).to eq(['HTTP']) + end + end end context 'without a matching error group' do @@ -66,6 +81,24 @@ module ExceptionHunter expect { subject }.not_to change(ErrorGroup, :count) end end + + context 'when error tracking is disabled' do + let(:error_attributes) do + { class_name: 'SomeError', message: 'Something went very wrong 123' } + end + + before do + Config.enabled = false + end + + after do + Config.enabled = true + end + + it 'does not track errors' do + expect { subject }.not_to change(Error, :count) + end + end end end end diff --git a/spec/error_reaper_spec.rb b/spec/error_reaper_spec.rb new file mode 100644 index 0000000..fb71591 --- /dev/null +++ b/spec/error_reaper_spec.rb @@ -0,0 +1,49 @@ +module ExceptionHunter + describe ErrorReaper do + describe '.purge' do + subject { described_class.purge(stale_time: stale_time) } + let(:stale_time) { 1.month } + + let(:error_group) { create(:error_group) } + let!(:old_errors) do + (1..3).map { |i| create(:error, occurred_at: i.months.ago - 1.week, error_group: error_group) } + end + let!(:new_errors) do + (1..2).map { |i| create(:error, occurred_at: i.weeks.ago, error_group: error_group) } + end + + let!(:empty_error_group) { create(:error_group) } + + let(:only_old_errors_error_group) { create(:error_group) } + let!(:old_error) { create(:error, occurred_at: 5.weeks.ago, error_group: only_old_errors_error_group) } + + it 'deletes errors with old occurrences' do + expect { subject }.to change(Error, :count).by(-4) + expect(Error.where(id: [old_error.id].concat(old_errors.map(&:id)))).not_to exist + end + + it 'does not delete errors with new occurrences' do + subject + + expect(Error.where(id: new_errors.map(&:id))).to exist + end + + it 'deletes error groups with no associated errors' do + expect { subject }.to change(ErrorGroup, :count).by(-2) + expect(ErrorGroup.where(id: empty_error_group.id)).not_to exist + end + + it 'does not delete error groups with associated errors' do + subject + + expect(ErrorGroup.where(id: error_group.id)).to exist + end + + it 'deletes error groups which have all associated errors with old occurrences' do + subject + + expect(ErrorGroup.where(id: only_old_errors_error_group.id)).not_to exist + end + end + end +end diff --git a/spec/exception_hunter_spec.rb b/spec/exception_hunter_spec.rb new file mode 100644 index 0000000..e317b66 --- /dev/null +++ b/spec/exception_hunter_spec.rb @@ -0,0 +1,75 @@ +module ExceptionHunter + describe ::ExceptionHunter do + describe '.track' do + subject { ::ExceptionHunter.track(exception, custom_data: custom_data, user: user) } + + let(:exception) do + RuntimeError.new('Some error').tap { |exception| exception.set_backtrace(caller) } + end + let(:custom_data) do + { + some_id: 12, + some_other_data: { + name: 'John' + } + } + end + let(:user) { OpenStruct.new(id: 3, email: 'example@example.com', name: 'John') } + + context 'when tracking is enabled' do + let(:error) { Error.last } + + it 'creates a new error' do + expect { subject }.to change(Error, :count).by(1) + end + + it 'tracks the exception data' do + subject + + expect(error.class_name).to eq(exception.class.name) + expect(error.backtrace).to eq(exception.backtrace) + end + + it 'tracks the custom data' do + subject + + expect(error.custom_data).to eq({ + 'some_id' => 12, + 'some_other_data' => { + 'name' => 'John' + } + }) + end + + it 'tracks the user attributes' do + subject + + expect(error.user_data).to eq({ + 'id' => 3, + 'email' => 'example@example.com' + }) + end + + it 'adds the tag Manual to the error group' do + subject + + expect(error.error_group.tags).to eq(['Manual']) + end + end + + context 'when tracking is disabled' do + before do + Config.enabled = false + end + + after do + Config.enabled = true + end + + it 'does not create a new error' do + expect { subject }.not_to change(Error, :count) + end + end + end + end +end diff --git a/spec/factories/exception_hunter/admin_user.rb b/spec/factories/exception_hunter/admin_user.rb new file mode 100644 index 0000000..5f83788 --- /dev/null +++ b/spec/factories/exception_hunter/admin_user.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :admin_user do + email { 'admin@example.com' } + password { 'password' } + end +end diff --git a/spec/factories/exception_hunter/error_groups.rb b/spec/factories/exception_hunter/error_groups.rb index cf6cf7a..a7461d3 100644 --- a/spec/factories/exception_hunter/error_groups.rb +++ b/spec/factories/exception_hunter/error_groups.rb @@ -2,5 +2,6 @@ factory :error_group, class: 'ExceptionHunter::ErrorGroup' do error_class_name { 'ExceptionName' } message { 'Exception message' } + status { :active } end end diff --git a/spec/middleware/exception_hunter/request_hunter_spec.rb b/spec/middleware/exception_hunter/request_hunter_spec.rb index 7e54462..f069c9e 100644 --- a/spec/middleware/exception_hunter/request_hunter_spec.rb +++ b/spec/middleware/exception_hunter/request_hunter_spec.rb @@ -79,6 +79,13 @@ module Middleware expect(error.environment_data).not_to include(not_tracked_env) end + it 'adds the tag HTTP to the error groups' do + subject rescue nil + + error = Error.last + expect(error.error_group.tags).to eq(['HTTP']) + end + describe 'user data' do context 'when the exception was raised by a logged user' do before do diff --git a/spec/middleware/exception_hunter/sidekiq_hunter_spec.rb b/spec/middleware/exception_hunter/sidekiq_hunter_spec.rb index 5ef9f2a..f0443a5 100644 --- a/spec/middleware/exception_hunter/sidekiq_hunter_spec.rb +++ b/spec/middleware/exception_hunter/sidekiq_hunter_spec.rb @@ -48,6 +48,13 @@ module Middleware 'enqueued_at' => '2020-05-18T02:23:30Z' }) end + + it 'adds the tag Worker to the error group' do + subject rescue nil + + error = Error.last + expect(error.error_group.tags).to eq(['Worker']) + end end context 'when the worker is retrying' do diff --git a/spec/models/exception_hunter/error_group_spec.rb b/spec/models/exception_hunter/error_group_spec.rb index 0a01cdc..c1617f5 100644 --- a/spec/models/exception_hunter/error_group_spec.rb +++ b/spec/models/exception_hunter/error_group_spec.rb @@ -23,6 +23,74 @@ module ExceptionHunter end end + describe 'without_errors' do + subject { ErrorGroup.without_errors } + + let!(:groups_without_errors) { create_list(:error_group, 3) } + let!(:groups_with_errors) { create_list(:error_group, 3) } + + before do + groups_with_errors.each_with_index do |group, index| + create_list(:error, index + 1, error_group: group) + end + end + + it 'returns error groups without associated errors' do + expect(subject.to_a).to match_array(groups_without_errors) + end + + it 'does not return error groups with associated errors' do + expect(subject.to_a).not_to include(*groups_with_errors) + end + end + + describe 'with_errors_in_last_7_days' do + subject { ErrorGroup.with_errors_in_last_7_days } + let(:groups_with_valid_errors) { create_list(:error_group, 3) } + let(:groups_without_valid_errors) { create_list(:error_group, 2) } + + before do + create(:error, error_group: groups_with_valid_errors.first, occurred_at: Date.current) + create(:error, error_group: groups_with_valid_errors.second, occurred_at: 3.days.ago) + create(:error, error_group: groups_with_valid_errors.third, occurred_at: 6.days.ago) + + create(:error, error_group: groups_without_valid_errors.first, occurred_at: 8.days.ago) + create(:error, error_group: groups_without_valid_errors.second, occurred_at: 1.month.ago) + end + + it 'returns error groups with errors in the last 7 days' do + expect(subject).to include(*groups_with_valid_errors) + end + + it 'does not return error groups without errors in the last 7 days' do + expect(subject).not_to include(*groups_without_valid_errors) + end + end + + describe 'with_errors_in_current_month' do + subject { ErrorGroup.with_errors_in_current_month } + let(:groups_with_valid_errors) { create_list(:error_group, 3) } + let(:groups_without_valid_errors) { create_list(:error_group, 2) } + + before do + create(:error, error_group: groups_with_valid_errors.first, occurred_at: Date.current) + create(:error, error_group: groups_with_valid_errors.second, + occurred_at: Date.current.beginning_of_month + 15.days) + create(:error, error_group: groups_with_valid_errors.third, occurred_at: Date.current.beginning_of_month) + + create(:error, error_group: groups_without_valid_errors.first, occurred_at: 32.days.ago) + create(:error, error_group: groups_without_valid_errors.second, occurred_at: 2.months.ago) + end + + it 'returns error groups with errors in the last 7 days' do + expect(subject).to include(*groups_with_valid_errors) + end + + it 'does not return error groups without errors in the last 7 days' do + expect(subject).not_to include(*groups_without_valid_errors) + end + end + describe '#last_occurence' do subject { error_group.last_occurrence } diff --git a/spec/models/exception_hunter/error_spec.rb b/spec/models/exception_hunter/error_spec.rb index 7db77a7..a40c4d4 100644 --- a/spec/models/exception_hunter/error_spec.rb +++ b/spec/models/exception_hunter/error_spec.rb @@ -12,6 +12,21 @@ module ExceptionHunter it { is_expected.to belong_to(:error_group) } end + describe 'on creation' do + let(:error_group) { create(:error_group) } + let(:error) { build(:error, error_group: error_group) } + + before { error_group.resolved! } + + it 'touches error group' do + expect { error.save }.to change { error_group.reload.updated_at } + end + + it 'unresolves error group' do + expect { error.save }.to change { error_group.reload.status } + end + end + describe 'occurred at' do let!(:error) { create(:error, occurred_at: nil) } @@ -49,7 +64,56 @@ module ExceptionHunter end end - describe '.in_current_month' do + describe 'with_occurrences_before' do + subject { Error.with_occurrences_before(deadline) } + let(:deadline) { 1.month.ago } + + let!(:old_errors) do + (1..3).map do |i| + create(:error, occurred_at: deadline - i.days) + end + end + let!(:new_errors) do + (1..3).map do |i| + create(:error, occurred_at: deadline + i.days) + end + end + + it 'returns errors with occurrences before the given date' do + expect(subject.to_a).to match_array(old_errors) + end + + it 'does not return errors with occurrences after the given date' do + expect(subject.to_a).not_to include(*new_errors) + end + end + + describe 'in_last_7_days' do + subject { Error.in_last_7_days } + let!(:valid_errors) do + [ + create(:error, occurred_at: Date.current), + create(:error, occurred_at: 3.days.ago), + create(:error, occurred_at: 6.days.ago) + ] + end + let!(:invalid_errors) do + [ + create(:error, occurred_at: 8.days.ago), + create(:error, occurred_at: 1.month.ago) + ] + end + + it 'returns error groups with errors in the last 7 days' do + expect(subject).to include(*valid_errors) + end + + it 'does not return error groups without errors in the last 7 days' do + expect(subject).not_to include(*invalid_errors) + end + end + + describe 'in_current_month' do subject { Error.in_current_month } context 'when there are errors in the current month' do @@ -75,5 +139,65 @@ module ExceptionHunter it { is_expected.to be_empty } end end + + describe 'from_active_error_groups' do + subject { Error.from_active_error_groups } + + let(:resolved_error_group) { create(:error_group, status: :resolved) } + let(:active_error_group) { create(:error_group, status: :active) } + + let(:resolved_errors) do + [ + create(:error, error_group: resolved_error_group), + create(:error, error_group: resolved_error_group), + create(:error, error_group: resolved_error_group) + ] + end + + let(:active_errors) do + [ + create(:error, error_group: active_error_group), + create(:error, error_group: active_error_group) + ] + end + + it 'returns active errors' do + expect(subject).to match_array(active_errors) + end + + it 'does not return resolved errors' do + expect(subject).not_to include(resolved_errors) + end + end + + describe 'from_resolved_error_groups' do + subject { Error.from_active_error_groups } + + let(:resolved_error_group) { create(:error_group, status: :resolved) } + let(:active_error_group) { create(:error_group, status: :active) } + + let(:resolved_errors) do + [ + create(:error, error_group: resolved_error_group), + create(:error, error_group: resolved_error_group), + create(:error, error_group: resolved_error_group) + ] + end + + let(:active_errors) do + [ + create(:error, error_group: active_error_group), + create(:error, error_group: active_error_group) + ] + end + + it 'returns resolved errors' do + expect(subject).to match_array(resolved_errors) + end + + it 'does not return active errors' do + expect(subject).not_to include(active_errors) + end + end end end diff --git a/spec/presenters/exception_hunter/dashboard_presenter_spec.rb b/spec/presenters/exception_hunter/dashboard_presenter_spec.rb new file mode 100644 index 0000000..5218bc4 --- /dev/null +++ b/spec/presenters/exception_hunter/dashboard_presenter_spec.rb @@ -0,0 +1,45 @@ +module ExceptionHunter + describe DashboardPresenter do + describe '#current_tab' do + it 'sets a default value when the tab is not valid' do + presenter = DashboardPresenter.new('some_random_tab') + expect(presenter.current_tab).to eq(DashboardPresenter::DEFAULT_TAB) + end + + it 'sets a default value when the tab is nil' do + presenter = DashboardPresenter.new(nil) + expect(presenter.current_tab).to eq(DashboardPresenter::DEFAULT_TAB) + end + end + + describe '#partial_for_tab' do + it 'returns a value for each valid tab' do + DashboardPresenter::TABS.each do |tab| + presenter = DashboardPresenter.new(tab) + expect(presenter.partial_for_tab).not_to be_nil + end + end + end + + describe '#tab_active?' do + it 'returns true when the tab is active' do + presenter = DashboardPresenter.new(DashboardPresenter::TOTAL_ERRORS_TAB) + expect(presenter.tab_active?(DashboardPresenter::TOTAL_ERRORS_TAB)).to be true + end + + it 'returns false when the tab is not active' do + presenter = DashboardPresenter.new(DashboardPresenter::TOTAL_ERRORS_TAB) + expect(presenter.tab_active?(DashboardPresenter::CURRENT_MONTH_TAB)).to be false + end + end + + describe '#errors_count' do + it 'returns a value for each valid tab' do + presenter = DashboardPresenter.new(DashboardPresenter::CURRENT_MONTH_TAB) + DashboardPresenter::TABS.each do |tab| + expect(presenter.errors_count(tab)).not_to be_nil + end + end + end + end +end diff --git a/spec/presenters/exception_hunter/error_group_presenter_spec.rb b/spec/presenters/exception_hunter/error_group_presenter_spec.rb new file mode 100644 index 0000000..4d2178b --- /dev/null +++ b/spec/presenters/exception_hunter/error_group_presenter_spec.rb @@ -0,0 +1,46 @@ +module ExceptionHunter + describe ErrorGroupPresenter do + describe '.wrap_collection' do + let(:error_groups) { create_list(:error_group, 3) } + + it 'returns an array of ErrorGroupPresenters' do + expect(ErrorGroupPresenter.wrap_collection(error_groups)).to all(be_an ErrorGroupPresenter) + end + end + + describe '.format_occurrence_day' do + let(:day) { Date.new(2020, 5, 30) } + + it 'returns the last occurrence date formatted as a string' do + expect(ErrorGroupPresenter.format_occurrence_day(day)).to eq('Saturday, May 30') + end + end + + describe '#show_for_day?' do + subject { presenter.show_for_day?(day) } + let(:presenter) { ErrorGroupPresenter.new(error_group) } + let(:error_group) { create(:error_group) } + let(:day) { Date.yesterday } + + context 'when the last occurrence is on the day' do + before do + create(:error, occurred_at: day, error_group: error_group) + end + + it 'returns true' do + expect(subject).to be true + end + end + + context 'when the last occurrence is not on the day' do + before do + create(:error, occurred_at: day - 1.day, error_group: error_group) + end + + it 'returns false' do + expect(subject).to be false + end + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index e6fed7b..394f7cb 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -12,6 +12,7 @@ require 'factory_bot_rails' require 'support/controller_routes' +require 'support/devise_request_spec_helpers' # Add additional requires below this line. Rails is not loaded until this point! # Requires supporting ruby files with custom matchers and macros, etc, in @@ -41,6 +42,9 @@ config.include FactoryBot::Syntax::Methods config.include ControllerRoutes, type: :controller config.include ControllerRoutes, type: :routing + config.include DeviseRequestSpecHelpers, type: :request + config.include ExceptionHunter::Engine.routes.url_helpers + # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false # instead of true. diff --git a/spec/requests/exception_hunter/errors_request_spec.rb b/spec/requests/exception_hunter/errors_request_spec.rb index c617bba..6307b51 100644 --- a/spec/requests/exception_hunter/errors_request_spec.rb +++ b/spec/requests/exception_hunter/errors_request_spec.rb @@ -1,37 +1,206 @@ require 'rails_helper' -describe 'Errors', type: :request do - describe 'index' do - subject { get '/exception_hunter/errors' } +module ExceptionHunter + describe 'Errors', type: :request do + let(:admin) { create(:admin_user) } + before { sign_in(admin) } - before do - 3.times do |i| - create(:error_group).tap do |error_group| - create_list(:error, i + 1, error_group: error_group) + describe 'GET /exception_hunter/errors' do + subject { get "/exception_hunter/errors?tab=#{tab}" } + + context 'in the last 7 days tab' do + let(:tab) { DashboardPresenter::LAST_7_DAYS_TAB } + let(:shown_errors) do + [ + create(:error_group, message: 'Group 1'), + create(:error_group, message: 'Group 2'), + create(:error_group, message: 'Group 3') + ] + end + let(:hidden_errors) do + [ + create(:error_group, message: 'Group 4'), + create(:error_group, message: 'Group 5') + ] + end + + before do + create(:error, error_group: shown_errors.first, occurred_at: Date.current) + create(:error, error_group: shown_errors.second, occurred_at: 3.days.ago) + create(:error, error_group: shown_errors.third, occurred_at: 6.days.ago) + + create(:error, error_group: hidden_errors.first, occurred_at: 8.days.ago) + create(:error, error_group: hidden_errors.second, occurred_at: 1.month.ago) + end + + it 'renders the index template' do + subject + + expect(response).to render_template(:index) + end + + it 'shows date groups' do + subject + + expect(response.body).to include('Yesterday') + expect(response.body).to include(ErrorGroupPresenter.format_occurrence_day(2.days.ago)) + end + + it 'shows the valid groups' do + subject + + shown_errors.each do |group| + expect(response.body).to include(group.message) + end + end + + it 'does not show invalid groups' do + subject + + hidden_errors.each do |group| + expect(response.body).not_to include(group.message) + end + end + end + + context 'in the current month tab' do + let(:tab) { DashboardPresenter::CURRENT_MONTH_TAB } + let(:shown_errors) do + [ + create(:error_group, message: 'Group 1'), + create(:error_group, message: 'Group 2'), + create(:error_group, message: 'Group 3') + ] + end + let(:hidden_errors) do + [ + create(:error_group, message: 'Group 4'), + create(:error_group, message: 'Group 5') + ] + end + + before do + create(:error, error_group: shown_errors.first, occurred_at: Date.current) + create(:error, error_group: shown_errors.second, occurred_at: Date.current.beginning_of_month + 15.days) + create(:error, error_group: shown_errors.third, occurred_at: Date.current.beginning_of_month) + + create(:error, error_group: hidden_errors.first, occurred_at: 32.days.ago) + create(:error, error_group: hidden_errors.second, occurred_at: 2.months.ago) + end + + it 'renders the index template' do + subject + expect(response).to render_template(:index) + end + + it 'shows the valid groups' do + subject + + shown_errors.each do |group| + expect(response.body).to include(group.message) + end + end + + it 'does not show invalid groups' do + subject + + hidden_errors.each do |group| + expect(response.body).not_to include(group.message) + end + end + end + + context 'in the total errors tab' do + let(:tab) { DashboardPresenter::TOTAL_ERRORS_TAB } + let(:shown_errors) do + [ + create(:error_group, message: 'Group 1'), + create(:error_group, message: 'Group 2'), + create(:error_group, message: 'Group 3'), + create(:error_group, message: 'Group 4'), + create(:error_group, message: 'Group 5') + ] + end + + before do + create(:error, error_group: shown_errors.first, occurred_at: Date.current) + create(:error, error_group: shown_errors.second, occurred_at: 15.days.ago) + create(:error, error_group: shown_errors.third, occurred_at: Date.current.beginning_of_month) + create(:error, error_group: shown_errors.first, occurred_at: 32.days.ago) + create(:error, error_group: shown_errors.second, occurred_at: 2.months.ago) + end + + it 'renders the index template' do + subject + expect(response).to render_template(:index) + end + + it 'shows the valid groups' do + subject + + shown_errors.each do |group| + expect(response.body).to include(group.message) + end + end + end + + context 'in the resolved errors tab' do + let(:tab) { DashboardPresenter::RESOLVED_ERRORS_TAB } + + it 'renders the index template' do + subject + expect(response).to render_template(:index) end end end - it 'renders the index template' do - subject + describe 'GET /exception_hunter/errors/:id' do + let(:error_group) { create(:error_group) } - expect(response).to render_template(:index) + subject { get "/exception_hunter/errors/#{error_group.id}" } + + before do + create_list(:error, 2, error_group: error_group) + end + + it 'renders the show template' do + subject + + expect(response).to render_template(:show) + end end - end - describe 'show' do - let(:error_group) { create(:error_group) } + describe 'DELETE /exception_hunter/errors/purge' do + subject { delete '/exception_hunter/errors/purge' } - subject { get "/exception_hunter/errors/#{error_group.id}" } + it 'calls the ErrorReaper' do + expect(ErrorReaper).to receive(:purge) + + subject + end - before do - create_list(:error, 2, error_group: error_group) + it 'redirects back' do + subject + + expect(response).to have_http_status(302) + end end - it 'renders the show template' do - subject + describe 'POST /exception_hunter/resolved_errors' do + let(:error_group) { create(:error_group) } + let(:params) { { error_group: { id: error_group.id } } } - expect(response).to render_template(:show) + subject { post '/exception_hunter/resolved_errors', params: params } + + it 'resolves the error group' do + expect { subject }.to change { error_group.reload.status }.from('active').to('resolved') + end + + it 'redirects back' do + subject + + expect(response).to have_http_status(302) + end end end end diff --git a/spec/support/devise_request_spec_helpers.rb b/spec/support/devise_request_spec_helpers.rb new file mode 100644 index 0000000..ca3a7e9 --- /dev/null +++ b/spec/support/devise_request_spec_helpers.rb @@ -0,0 +1,14 @@ +module DeviseRequestSpecHelpers + include Warden::Test::Helpers + + def sign_in(resource_or_scope, resource = nil) + resource ||= resource_or_scope + scope = Devise::Mapping.find_scope!(resource_or_scope) + login_as(resource, scope: scope) + end + + def sign_out(resource_or_scope) + scope = Devise::Mapping.find_scope!(resource_or_scope) + logout(scope) + end +end