exception_hunter 0.3.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/app/assets/stylesheets/exception_hunter/base.css +4 -0
  4. data/app/assets/stylesheets/exception_hunter/errors.css +25 -0
  5. data/app/controllers/exception_hunter/errors_controller.rb +2 -0
  6. data/app/controllers/exception_hunter/ignored_errors_controller.rb +25 -0
  7. data/app/helpers/exception_hunter/application_helper.rb +22 -0
  8. data/app/jobs/exception_hunter/send_notification_job.rb +15 -0
  9. data/app/models/exception_hunter/error.rb +6 -2
  10. data/app/models/exception_hunter/error_group.rb +2 -2
  11. data/app/presenters/exception_hunter/dashboard_presenter.rb +5 -3
  12. data/app/presenters/exception_hunter/error_presenter.rb +2 -2
  13. data/app/views/exception_hunter/errors/_error_row.erb +19 -11
  14. data/app/views/exception_hunter/errors/index.html.erb +17 -3
  15. data/app/views/layouts/exception_hunter/application.html.erb +3 -1
  16. data/config/rails_best_practices.yml +1 -1
  17. data/config/routes.rb +2 -0
  18. data/lib/exception_hunter.rb +33 -1
  19. data/lib/exception_hunter/config.rb +19 -2
  20. data/lib/exception_hunter/data_redacter.rb +27 -0
  21. data/lib/exception_hunter/devise.rb +2 -0
  22. data/lib/exception_hunter/engine.rb +6 -0
  23. data/lib/exception_hunter/error_creator.rb +27 -8
  24. data/lib/exception_hunter/error_reaper.rb +8 -0
  25. data/lib/exception_hunter/middleware/delayed_job_hunter.rb +70 -0
  26. data/lib/exception_hunter/middleware/request_hunter.rb +4 -2
  27. data/lib/exception_hunter/middleware/sidekiq_hunter.rb +0 -1
  28. data/lib/exception_hunter/notifiers/misconfigured_notifiers.rb +10 -0
  29. data/lib/exception_hunter/notifiers/slack_notifier.rb +42 -0
  30. data/lib/exception_hunter/notifiers/slack_notifier_serializer.rb +20 -0
  31. data/lib/exception_hunter/tracking.rb +20 -1
  32. data/lib/exception_hunter/user_attributes_collector.rb +18 -1
  33. data/lib/exception_hunter/version.rb +1 -1
  34. data/lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb +29 -0
  35. metadata +32 -12
  36. data/lib/tasks/code_analysis.rake +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ba4ba0c2e1205126ac00e8d0c27b9b98cc3b932dff902e6d7552b7bb640e681
4
- data.tar.gz: 49b05057f76bb754bec371065918aeb0cb087208794326c78b837fb894af2fad
3
+ metadata.gz: 5657d47c0ace57c4aac4aa9ec45374739ff2a282271d9d3f7ec6c3e21cffd509
4
+ data.tar.gz: 999dccabdad8f6b8e78a6db43d8d8cb5ff9454fcd7bb0df7e93ede4bb8a04aea
5
5
  SHA512:
6
- metadata.gz: dfd7faf102765439718b72f1c643593d48c6f1613db7ea573a4cb0ea66206819d6fcf5f17eb9f229beb31efa20f76901e995539703ed266073986cdae5795712
7
- data.tar.gz: b9711187a6887b4542973bc5f1c687a9e6034dc5b136cc6755fec9126d4ccce4311bbad8eafdfd49c53be7e437c63f69f505b4868f46da3abf387e3e6bc02ab1
6
+ metadata.gz: f01e7a68936214ac9c2673ad44d2603731d517bfdfa7a22f4f88669629328d26a8b2161233c844c87accd6b455831ebb8162ddf2dd4d9a80fd95a5c132571551
7
+ data.tar.gz: 7d84f1b86524e91f9d2b73c6f42fd4d75d34e3b946d5948a30513ef5fb1ee026dcfc58e09438bc27d881fdb4d63483442ad90c2bf8ca852acd33fabcdc3c8aeb
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # ExceptionHunter
2
2
 
3
- ![Index screenshot](doc/screenshot.png)
3
+ ![CI](https://github.com/rootstrap/exception_hunter/workflows/Rails%20tests/badge.svg)
4
+ [![Maintainability](https://api.codeclimate.com/v1/badges/86f6aaa2377c894f8ee4/maintainability)](https://codeclimate.com/github/rootstrap/exception_hunter/maintainability)
5
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/86f6aaa2377c894f8ee4/test_coverage)](https://codeclimate.com/github/rootstrap/exception_hunter/test_coverage)
6
+
7
+ ![Index screenshot](docs/index-screenshot.png)
4
8
 
5
9
  Exception Hunter is a Rails engine meant to track errors in your Rails project. It works
6
10
  by using your Postgres database to save errors with their corresponding metadata (like backtrace
@@ -17,11 +21,15 @@ Error tracking is one of the most important tools a developer can have in their
17
21
  we think it'd be nice to provide a way for everyone to have it in their project, be it a personal
18
22
  project, and MVP or something else.
19
23
 
24
+ ## Docs
25
+
26
+ You can check the full documentation at [https://rootstrap.github.io/exception_hunter]().
27
+
20
28
  ## Installation
21
29
  Add Exception Hunter to your application's Gemfile:
22
30
 
23
31
  ```ruby
24
- gem 'exception_hunter', '~> 0.3.0'
32
+ gem 'exception_hunter', '~> 1.0'
25
33
  ```
26
34
 
27
35
  You may also need to add [Devise](https://github.com/heartcombo/devise) to your Gemfile
@@ -45,6 +53,19 @@ you can run the command with the `--skip-users` flag.
45
53
  Additionally it should add the 'ExceptionHunter.routes(self)' line to your routes, which means you can go to
46
54
  `/exception_hunter/errors` in your browser and start enjoying some good old fashioned exception tracking!
47
55
 
56
+ #### Testing it on dev:
57
+
58
+ ExceptionHunter is disabled on dev by default so if you want to test it before shipping it to another
59
+ environment, which we highly recommend, you should enable it by going to the initializer and changing the
60
+ line that says `config.enabled = !(Rails.env.development? || Rails.env.test?)` with something like
61
+ `config.enabled = !(Rails.env.test?)` while you test. Don't forget to change it back if you don't
62
+ want a bunch of errors in your local DB!
63
+
64
+ You can then open a `rails console` and manually track an exception to check that it
65
+ works `ExceptionHunter.track(StandardError.new("It works!"))`. You should now see the exception
66
+ on [http://localhost:3000/exception_hunter]().
67
+
68
+
48
69
  ## Stale data
49
70
 
50
71
  You can get rid of stale errors by running the rake task to purge them:
@@ -58,6 +79,59 @@ a week would be ideal. You can also purge errors by running `ExceptionHunter::Er
58
79
 
59
80
  The time it takes for an error to go stale defaults to 45 days but it's configurable via the initializer.
60
81
 
82
+ ## Manual tracking
83
+
84
+ ExceptionHunter also includes a facility to manually log from anywhere in the code. Imagine the following case:
85
+
86
+ ```ruby
87
+ case current_user.status
88
+ when :inactive then do_something
89
+ when :active then do_something_else
90
+ when :banned then do_something_else_else
91
+ else
92
+ ExceptionHunter.track(ArgumentError.new('This should never happen'), custom_data: { status: current_user.status }, current_user: user)
93
+ end
94
+ ```
95
+
96
+ In this scenario we don't really want to raise an exception but we might want to be alerted if by any chance a user
97
+ has an invalid status.
98
+
99
+ ## Slack notifications
100
+
101
+ You can configure ExceptionHunter to send a message to slack every time an error occurs.
102
+ You have to do the following:
103
+
104
+ 1. Create a Slack app.
105
+ 2. Add it to your workspace.
106
+ 3. Add one or more webhooks linked to the channels you want to receive the notifications.
107
+ 4. Set the webhook urls in the `exception_hunter` initializer.
108
+
109
+ ```ruby
110
+ config.notifiers << {
111
+ name: :slack,
112
+ options: {
113
+ webhook: 'SLACK_WEBHOOK_URL_1'
114
+ }
115
+ }
116
+
117
+ config.notifiers << {
118
+ name: :slack,
119
+ options: {
120
+ webhook: 'SLACK_WEBHOOK_URL_2'
121
+ }
122
+ }
123
+ ```
124
+
125
+ 6. Add the code below to the environment config file where you are using ExceptionHunter with the correct server url.
126
+
127
+ ```ruby
128
+ ExceptionHunter::Engine.configure do |config|
129
+ config.routes.default_url_options = { host: "your_server_url" }
130
+ end
131
+ ```
132
+
133
+ This uses ActiveJob to send notification in the background, so [make sure you configure](https://guides.rubyonrails.org/active_job_basics.html#setting-the-backend) it with the adapter you are using, if not notifications will be sent synchronously.
134
+
61
135
  ## License
62
136
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
63
137
 
@@ -71,3 +71,7 @@ form {
71
71
  .button.button-dismiss:hover, .button.button-dismiss:focus, .button.button-dismiss:active {
72
72
  color: var(--inactive-grey);
73
73
  }
74
+
75
+ .mr-5{
76
+ margin-right: 5px;
77
+ }
@@ -153,6 +153,31 @@ li.errors-tab {
153
153
  border-color: var(--focused-grey);
154
154
  }
155
155
 
156
+ .button_to .button.button-outlined.ignore-button {
157
+ color: var(--highlight-color);
158
+ border-color: var(--highlight-color);
159
+ font-size: 10px;
160
+ padding: 0 1rem;
161
+ height: 3rem;
162
+ line-height: 3rem;
163
+ }
164
+
165
+ .button_to .button.ignore-button {
166
+ color: #FFF;
167
+ background-color: var(--highlight-color);
168
+ border-color: var(--highlight-color);
169
+ font-size: 10px;
170
+ padding: 0 1rem;
171
+ height: 3rem;
172
+ line-height: 3rem;
173
+ margin-bottom: 0;
174
+ }
175
+
176
+ .button.ignore-button:hover, .button.ignore-button:focus {
177
+ color: var(--focused-grey);
178
+ border-color: var(--focused-grey);
179
+ }
180
+
156
181
  .error-occurred_at {
157
182
  color: var(--highlight-color);
158
183
  font-size: 14px;
@@ -41,6 +41,8 @@ module ExceptionHunter
41
41
  ErrorGroup.active
42
42
  when DashboardPresenter::RESOLVED_ERRORS_TAB
43
43
  ErrorGroup.resolved
44
+ when DashboardPresenter::IGNORED_ERRORS_TAB
45
+ ErrorGroup.ignored
44
46
  end
45
47
  end
46
48
  end
@@ -0,0 +1,25 @@
1
+ require_dependency 'exception_hunter/application_controller'
2
+
3
+ module ExceptionHunter
4
+ class IgnoredErrorsController < ApplicationController
5
+ def create
6
+ error_group.ignored!
7
+ redirect_to errors_path, notice: 'Error ignored successfully'
8
+ end
9
+
10
+ def reopen
11
+ error_group.active!
12
+ redirect_to errors_path, notice: 'Error re-opened successfully'
13
+ end
14
+
15
+ private
16
+
17
+ def error_group
18
+ @error_group ||= ErrorGroup.find(error_group_params[:id])
19
+ end
20
+
21
+ def error_group_params
22
+ params.require(:error_group).permit(:id)
23
+ end
24
+ end
25
+ end
@@ -1,5 +1,27 @@
1
1
  module ExceptionHunter
2
2
  module ApplicationHelper
3
3
  include Pagy::Frontend
4
+
5
+ def application_name
6
+ if defined? Rails.application.class.module_parent_name
7
+ Rails.application.class.module_parent_name
8
+ else
9
+ Rails.application.class.parent_name
10
+ end
11
+ end
12
+
13
+ def display_action_button(title, error)
14
+ button_to(title.to_s, route_for_button(title, error),
15
+ class: "button button-outline #{title}-button",
16
+ data: { confirm: "Are you sure you want to #{title} this error?" }).to_s
17
+ end
18
+
19
+ def route_for_button(title, error)
20
+ if title.eql?('ignore')
21
+ ignored_errors_path(error_group: { id: error.id })
22
+ else
23
+ resolved_errors_path(error_group: { id: error.id })
24
+ end
25
+ end
4
26
  end
5
27
  end
@@ -0,0 +1,15 @@
1
+ module ExceptionHunter
2
+ class SendNotificationJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(serialized_notifier)
6
+ # Use SlackNotifierSerializer as it's the only one for now.
7
+ serializer = ExceptionHunter::Notifiers::SlackNotifierSerializer
8
+ deserialized_notifier = serializer.deserialize(serialized_notifier)
9
+ deserialized_notifier.notify
10
+ rescue Exception # rubocop:disable Lint/RescueException
11
+ # Suppress all exceptions to avoid loop as this would create a new error in EH.
12
+ false
13
+ end
14
+ end
15
+ end
@@ -1,12 +1,12 @@
1
1
  module ExceptionHunter
2
- class Error < ApplicationRecord
2
+ class Error < ::ExceptionHunter::ApplicationRecord
3
3
  validates :class_name, presence: true
4
4
  validates :occurred_at, presence: true
5
5
 
6
6
  belongs_to :error_group, touch: true
7
7
 
8
8
  before_validation :set_occurred_at, on: :create
9
- after_create :unresolve_error_group, unless: -> { error_group.active? }
9
+ after_create :unresolve_error_group, if: -> { error_group.resolved? }
10
10
 
11
11
  scope :most_recent, lambda { |error_group_id|
12
12
  where(error_group_id: error_group_id).order(occurred_at: :desc)
@@ -26,6 +26,10 @@ module ExceptionHunter
26
26
  joins(:error_group).where(error_group: ErrorGroup.resolved)
27
27
  }
28
28
 
29
+ scope :from_ignored_error_groups, lambda {
30
+ joins(:error_group).where(error_group: ErrorGroup.ignored)
31
+ }
32
+
29
33
  private
30
34
 
31
35
  def set_occurred_at
@@ -1,12 +1,12 @@
1
1
  module ExceptionHunter
2
- class ErrorGroup < ApplicationRecord
2
+ class ErrorGroup < ::ExceptionHunter::ApplicationRecord
3
3
  SIMILARITY_THRESHOLD = 0.75
4
4
 
5
5
  validates :error_class_name, presence: true
6
6
 
7
7
  has_many :grouped_errors, class_name: 'ExceptionHunter::Error', dependent: :destroy
8
8
 
9
- enum status: { active: 0, resolved: 1 }
9
+ enum status: { active: 0, resolved: 1, ignored: 2 }
10
10
 
11
11
  scope :most_similar, lambda { |message|
12
12
  message_similarity = sql_similarity(ErrorGroup[:message], message)
@@ -4,7 +4,8 @@ module ExceptionHunter
4
4
  CURRENT_MONTH_TAB = 'current_month'.freeze
5
5
  TOTAL_ERRORS_TAB = 'total_errors'.freeze
6
6
  RESOLVED_ERRORS_TAB = 'resolved'.freeze
7
- TABS = [LAST_7_DAYS_TAB, CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB].freeze
7
+ IGNORED_ERRORS_TAB = 'ignored'.freeze
8
+ TABS = [LAST_7_DAYS_TAB, CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB, IGNORED_ERRORS_TAB].freeze
8
9
  DEFAULT_TAB = LAST_7_DAYS_TAB
9
10
 
10
11
  attr_reader :current_tab
@@ -22,7 +23,7 @@ module ExceptionHunter
22
23
  case current_tab
23
24
  when LAST_7_DAYS_TAB
24
25
  'exception_hunter/errors/last_7_days_errors_table'
25
- when CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB
26
+ when CURRENT_MONTH_TAB, TOTAL_ERRORS_TAB, RESOLVED_ERRORS_TAB, IGNORED_ERRORS_TAB
26
27
  'exception_hunter/errors/errors_table'
27
28
  end
28
29
  end
@@ -47,7 +48,8 @@ module ExceptionHunter
47
48
  LAST_7_DAYS_TAB => active_errors.in_last_7_days.count,
48
49
  CURRENT_MONTH_TAB => active_errors.in_current_month.count,
49
50
  TOTAL_ERRORS_TAB => active_errors.count,
50
- RESOLVED_ERRORS_TAB => Error.from_resolved_error_groups.count
51
+ RESOLVED_ERRORS_TAB => Error.from_resolved_error_groups.count,
52
+ IGNORED_ERRORS_TAB => Error.from_ignored_error_groups.count
51
53
  }
52
54
  end
53
55
  end
@@ -10,13 +10,13 @@ module ExceptionHunter
10
10
  end
11
11
 
12
12
  def backtrace
13
- error.backtrace.map do |line|
13
+ (error.backtrace || []).map do |line|
14
14
  format_backtrace_line(line)
15
15
  end
16
16
  end
17
17
 
18
18
  def environment_data
19
- error.environment_data.except('params')
19
+ error.environment_data&.except('params') || {}
20
20
  end
21
21
 
22
22
  def tracked_params
@@ -6,7 +6,7 @@
6
6
  </div>
7
7
  <% end %>
8
8
  </div>
9
- <div class="column column-40 error-cell error-cell__message">
9
+ <div class="column column-33 error-cell error-cell__message">
10
10
  <%= link_to error.message, error_path(error.id), class: %w[error-message] %>
11
11
  </div>
12
12
 
@@ -26,19 +26,27 @@
26
26
  <% end %>
27
27
  </div>
28
28
 
29
- <div class="column column-10 error-cell">
29
+ <div class="column column-8 error-cell">
30
30
  <%= error.total_occurrences %>
31
31
  </div>
32
32
 
33
- <div class="column column-10 error-cell">
34
- <% if error.active? %>
35
- <div class="color-green">
36
- <%= button_to('Resolve', resolved_errors_path(error_group: { id: error.id }),
37
- method: :post,
33
+ <div class="column column-18 error-cell">
34
+ <div class="row">
35
+ <% if error.active? %>
36
+ <div class="column mr-5">
37
+ <%= display_action_button('resolve', error) %>
38
+ </div>
39
+ <div class="column">
40
+ <%= display_action_button('ignore', error) %>
41
+ </div>
42
+ <% elsif error.ignored? %>
43
+ <div class="column">
44
+ <%= button_to('Reopen', reopen_path(error_group: { id: error.id }),
38
45
  class: %w[button button-outline resolve-button],
39
- data: { confirm: 'Are you sure you want to resolve this error?' }) %>
40
- </div>
41
- <% end %>
42
- </div>
46
+ data: { confirm: 'Are you sure you want to reopen this error?' }) %>
47
+ </div>
48
+ <% end %>
49
+ </div>
50
+ </div>
43
51
  </div>
44
52
 
@@ -52,6 +52,19 @@
52
52
  </div>
53
53
  <% end %>
54
54
  </div>
55
+
56
+ <div class="errors-tab errors-tab__ignored <%= 'errors-tab--active' if @dashboard.tab_active?(@dashboard.class::IGNORED_ERRORS_TAB) %>">
57
+ <%= link_to errors_path(tab: @dashboard.class::IGNORED_ERRORS_TAB) do %>
58
+ <div class="errors-tab__content">
59
+ <div class="errors-tab__badge">
60
+ <%= @dashboard.errors_count(@dashboard.class::IGNORED_ERRORS_TAB) || '-' %>
61
+ </div>
62
+ <div class="errors-tab__description">
63
+ Ignored
64
+ </div>
65
+ </div>
66
+ <% end %>
67
+ </div>
55
68
  </div>
56
69
  </div>
57
70
 
@@ -66,11 +79,12 @@
66
79
  <div class="errors__container">
67
80
  <div class="row error-row error-row--header">
68
81
  <div class="column column-10">Tags</div>
69
- <div class="column column-40">Message</div>
82
+ <div class="column column-33">Message</div>
70
83
  <div class="column column-15">First Occurrence</div>
71
84
  <div class="column column-15">Last Occurrence</div>
72
- <div class="column column-10">Total</div>
73
- <div class="column column-10"></div>
85
+ <div class="column column-8">Total</div>
86
+ <div class="column column-18"></div>
87
+
74
88
  </div>
75
89
 
76
90
  <%= render partial: @dashboard.partial_for_tab, locals: { errors: @errors } %>
@@ -24,7 +24,9 @@
24
24
  <div class="row">
25
25
  <div class="column column-40">
26
26
  <%= link_to errors_path do %>
27
- <div class="nav__title">Exception Hunter</div>
27
+ <div class="nav__title">
28
+ Exception Hunter / <%= application_name %>
29
+ </div>
28
30
  <% end %>
29
31
  </div>
30
32
  <div class="column column-10 column-offset-50">
@@ -13,7 +13,7 @@ MoveCodeIntoControllerCheck: { }
13
13
  MoveCodeIntoHelperCheck: { array_count: 3 }
14
14
  MoveCodeIntoModelCheck: { use_count: 2 }
15
15
  MoveFinderToNamedScopeCheck: { }
16
- MoveModelLogicIntoModelCheck: { use_count: 4 }
16
+ # MoveModelLogicIntoModelCheck: { use_count: 4 }
17
17
  NeedlessDeepNestingCheck: { nested_count: 2 }
18
18
  NotUseDefaultRouteCheck: { }
19
19
  #NotUseTimeAgoInWordsCheck: { ignored_files: ['index.html.erb'] }
@@ -4,6 +4,8 @@ ExceptionHunter::Engine.routes.draw do
4
4
  end
5
5
 
6
6
  resources :resolved_errors, only: %i[create]
7
+ resources :ignored_errors, only: %i[create]
8
+ post :reopen, to: 'ignored_errors#reopen'
7
9
 
8
10
  get '/', to: redirect('/exception_hunter/errors')
9
11
 
@@ -2,25 +2,57 @@ require 'pagy'
2
2
 
3
3
  require 'exception_hunter/engine'
4
4
  require 'exception_hunter/middleware/request_hunter'
5
- require 'exception_hunter/middleware/sidekiq_hunter' if defined?(Sidekiq)
6
5
  require 'exception_hunter/config'
7
6
  require 'exception_hunter/error_creator'
8
7
  require 'exception_hunter/error_reaper'
9
8
  require 'exception_hunter/tracking'
10
9
  require 'exception_hunter/user_attributes_collector'
10
+ require 'exception_hunter/notifiers/slack_notifier'
11
+ require 'exception_hunter/notifiers/slack_notifier_serializer'
12
+ require 'exception_hunter/notifiers/misconfigured_notifiers'
13
+ require 'exception_hunter/data_redacter'
11
14
 
15
+ # @api public
12
16
  module ExceptionHunter
13
17
  autoload :Devise, 'exception_hunter/devise'
14
18
 
15
19
  extend ::ExceptionHunter::Tracking
16
20
 
21
+ # Used to setup ExceptionHunter's configuration
22
+ # it receives a block with the {ExceptionHunter::Config} singleton
23
+ # class.
24
+ #
25
+ # @return [void]
17
26
  def self.setup(&block)
18
27
  block.call(Config)
28
+ validate_config!
19
29
  end
20
30
 
31
+ # Mounts the ExceptionHunter dashboard at /exception_hunter
32
+ # if it's enabled on the current environment.
33
+ #
34
+ # @example
35
+ # Rails.application.routes.draw do
36
+ # ExceptionHunter.routes(self)
37
+ # end
38
+ #
39
+ # @param [ActionDispatch::Routing::Mapper] router to mount to
40
+ # @return [void]
21
41
  def self.routes(router)
22
42
  return unless Config.enabled
23
43
 
24
44
  router.mount(ExceptionHunter::Engine, at: 'exception_hunter')
25
45
  end
46
+
47
+ # @private
48
+ def self.validate_config!
49
+ notifiers = Config.notifiers
50
+ return if notifiers.blank?
51
+
52
+ notifiers.each do |notifier|
53
+ next if notifier[:name] == :slack && notifier.dig(:options, :webhook).present?
54
+
55
+ raise ExceptionHunter::Notifiers::MisconfiguredNotifiers, notifier
56
+ end
57
+ end
26
58
  end
@@ -1,10 +1,27 @@
1
1
  module ExceptionHunter
2
+ # Config singleton class used to customize ExceptionHunter
2
3
  class Config
3
- cattr_accessor :admin_user_class,
4
- :current_user_method, :user_attributes
4
+ # @!attribute
5
+ # @return [Boolean] whether ExceptionHunter is active or not
5
6
  cattr_accessor :enabled, default: true
7
+ # @!attribute
8
+ # @return [String] the name of the admin class (generally AdminUser)
9
+ cattr_accessor :admin_user_class
10
+ # @!attribute
11
+ # @return [Symbol] the name of the current user method provided by Devise
12
+ cattr_accessor :current_user_method
13
+ # @return [Array<Symbol>] attributes to whitelist on the user (see {ExceptionHunter::UserAttributesCollector})
14
+ cattr_accessor :user_attributes
15
+ # @return [Numeric] number of days until an error is considered stale
6
16
  cattr_accessor :errors_stale_time, default: 45.days
17
+ # @return [Array<Hash>] configured notifiers for the application (see {ExceptionHunter::Notifiers})
18
+ cattr_accessor :notifiers, default: []
19
+ cattr_accessor :sensitive_fields, default: []
7
20
 
21
+ # Returns true if there's an admin user class configured to
22
+ # authenticate against.
23
+ #
24
+ # @return Boolean
8
25
  def self.auth_enabled?
9
26
  admin_user_class.present? && admin_user_class.try(:underscore)
10
27
  end
@@ -0,0 +1,27 @@
1
+ module ExceptionHunter
2
+ class DataRedacter
3
+ attr_reader :params, :params_to_filter
4
+
5
+ def initialize(params, params_to_filter)
6
+ @params = params
7
+ @params_to_filter = params_to_filter
8
+ end
9
+
10
+ def redact
11
+ return params if params.blank?
12
+
13
+ parameter_filter = params_filter.new(params_to_filter)
14
+ parameter_filter.filter(params)
15
+ end
16
+
17
+ private
18
+
19
+ def params_filter
20
+ if defined?(::ActiveSupport::ParameterFilter)
21
+ ::ActiveSupport::ParameterFilter
22
+ else
23
+ ::ActionDispatch::Http::ParameterFilter
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,7 @@
1
1
  module ExceptionHunter
2
2
  module Devise
3
+ # Used so we can integrate with {https://github.com/heartcombo/devise Devise} and
4
+ # provide a custom login on the dashboard.
3
5
  class SessionsController < ::Devise::SessionsController
4
6
  skip_before_action :verify_authenticity_token
5
7
 
@@ -1,4 +1,5 @@
1
1
  module ExceptionHunter
2
+ # @private
2
3
  class Engine < ::Rails::Engine
3
4
  isolate_namespace ExceptionHunter
4
5
 
@@ -12,5 +13,10 @@ module ExceptionHunter
12
13
  app.config.assets.precompile << 'exception_hunter/application.css'
13
14
  app.config.assets.precompile << 'exception_hunter/logo.png'
14
15
  end
16
+
17
+ initializer 'exception_hunter.load_middleware', group: :all do
18
+ require 'exception_hunter/middleware/sidekiq_hunter' if defined?(Sidekiq)
19
+ require 'exception_hunter/middleware/delayed_job_hunter' if defined?(Delayed)
20
+ end
15
21
  end
16
22
  end
@@ -1,20 +1,30 @@
1
1
  module ExceptionHunter
2
+ # Core class in charge of the actual persistence of errors and notifications.
2
3
  class ErrorCreator
3
4
  HTTP_TAG = 'HTTP'.freeze
4
5
  WORKER_TAG = 'Worker'.freeze
5
6
  MANUAL_TAG = 'Manual'.freeze
6
7
 
7
8
  class << self
9
+ # Creates an error with the given attributes and persists it to
10
+ # the database.
11
+ #
12
+ # @param [HTTP_TAG, WORKER_TAG, MANUAL_TAG] tag to append to the error if any
13
+ # @return [ExceptionHunter::Error, false] the error or false if it was not possible to create it
8
14
  def call(tag: nil, **error_attrs)
9
15
  return unless should_create?
10
16
 
11
17
  ActiveRecord::Base.transaction do
12
18
  error_attrs = extract_user_data(error_attrs)
13
- error = Error.new(error_attrs)
14
- error_group = ErrorGroup.find_matching_group(error) || ErrorGroup.new
19
+ error_attrs = hide_sensitive_values(error_attrs)
20
+ error = ::ExceptionHunter::Error.new(error_attrs)
21
+ error_group = ::ExceptionHunter::ErrorGroup.find_matching_group(error) || ::ExceptionHunter::ErrorGroup.new
15
22
  update_error_group(error_group, error, tag)
16
23
  error.error_group = error_group
17
24
  error.save!
25
+ return if error_group.ignored?
26
+
27
+ notify(error)
18
28
  error
19
29
  end
20
30
  rescue ActiveRecord::RecordInvalid
@@ -38,16 +48,25 @@ module ExceptionHunter
38
48
 
39
49
  def extract_user_data(**error_attrs)
40
50
  user = error_attrs[:user]
41
- error_attrs[:user_data] =
42
- if user.nil?
43
- {}
44
- else
45
- UserAttributesCollector.collect_attributes(user)
46
- end
51
+ error_attrs[:user_data] = UserAttributesCollector.collect_attributes(user)
47
52
 
48
53
  error_attrs.delete(:user)
49
54
  error_attrs
50
55
  end
56
+
57
+ def notify(error)
58
+ ExceptionHunter::Config.notifiers.each do |notifier|
59
+ slack_notifier = ExceptionHunter::Notifiers::SlackNotifier.new(error, notifier)
60
+ serializer = ExceptionHunter::Notifiers::SlackNotifierSerializer
61
+ serialized_slack_notifier = serializer.serialize(slack_notifier)
62
+ ExceptionHunter::SendNotificationJob.perform_later(serialized_slack_notifier)
63
+ end
64
+ end
65
+
66
+ def hide_sensitive_values(error_attrs)
67
+ sensitive_fields = ExceptionHunter::Config.sensitive_fields
68
+ ExceptionHunter::DataRedacter.new(error_attrs, sensitive_fields).redact
69
+ end
51
70
  end
52
71
  end
53
72
  end
@@ -1,6 +1,14 @@
1
1
  module ExceptionHunter
2
+ # Class in charge of disposing of stale errors as specified in the {ExceptionHunter::Config}.
2
3
  class ErrorReaper
3
4
  class << self
5
+ # Destroys all stale errors.
6
+ #
7
+ # @example
8
+ # ErrorReaper.purge(stale_time: 30.days)
9
+ #
10
+ # @param [Numeric] stale_time considered when destroying errors
11
+ # @return [void]
4
12
  def purge(stale_time: Config.errors_stale_time)
5
13
  ActiveRecord::Base.transaction do
6
14
  Error.with_occurrences_before(Date.today - stale_time).destroy_all
@@ -0,0 +1,70 @@
1
+ require 'delayed_job'
2
+
3
+ module ExceptionHunter
4
+ module Middleware
5
+ # DelayedJob plugin to track exceptions on apps using DelayedJob.
6
+ class DelayedJobHunter < ::Delayed::Plugin
7
+ TRACK_AT_RETRY = [0, 3, 6, 10].freeze
8
+ JOB_TRACKED_DATA = %w[
9
+ attempts
10
+ ].freeze
11
+ ARGS_TRACKED_DATA = %w[
12
+ queue_name
13
+ job_class
14
+ job_id
15
+ arguments
16
+ enqueued_at
17
+ ].freeze
18
+
19
+ callbacks do |lifecycle|
20
+ lifecycle.around(:invoke_job) do |job, *args, &block|
21
+ block.call(job, *args)
22
+
23
+ rescue Exception => exception # rubocop:disable Lint/RescueException
24
+ track_exception(exception, job)
25
+
26
+ raise exception
27
+ end
28
+ end
29
+
30
+ def self.track_exception(exception, job)
31
+ return unless should_track?(job.attempts)
32
+
33
+ ErrorCreator.call(
34
+ tag: ErrorCreator::WORKER_TAG,
35
+ class_name: exception.class.to_s,
36
+ message: exception.message,
37
+ environment_data: environment_data(job),
38
+ backtrace: exception.backtrace
39
+ )
40
+ end
41
+
42
+ def self.environment_data(job)
43
+ job_data =
44
+ JOB_TRACKED_DATA.reduce({}) do |dict, data_param|
45
+ dict.merge(data_param => job.try(data_param))
46
+ end
47
+
48
+ job_class = if job.payload_object.class.name == 'ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper'
49
+ # support for Rails 4.2 ActiveJob
50
+ job.payload_object.job_data['job_class']
51
+ elsif job.payload_object.object.is_a?(Class)
52
+ job.payload_object.object.name
53
+ else
54
+ job.payload_object.object.class.name
55
+ end
56
+ args_data = (job.payload_object.try(:job_data) || {}).select { |key, _value| ARGS_TRACKED_DATA.include?(key) }
57
+
58
+ args_data['job_class'] = job_class || job.payload_object.class.name if args_data['job_class'].nil?
59
+
60
+ job_data.merge(args_data)
61
+ end
62
+
63
+ def self.should_track?(attempts)
64
+ TRACK_AT_RETRY.include?(attempts)
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ Delayed::Worker.plugins << ExceptionHunter::Middleware::DelayedJobHunter
@@ -1,5 +1,7 @@
1
1
  module ExceptionHunter
2
2
  module Middleware
3
+ # {https://www.rubyguides.com/2018/09/rack-middleware Rack Middleware} used to
4
+ # rescue from exceptions track them and then re-raise them.
3
5
  class RequestHunter
4
6
  ENVIRONMENT_KEYS =
5
7
  %w[PATH_INFO
@@ -53,14 +55,14 @@ module ExceptionHunter
53
55
 
54
56
  def filtered_sensitive_params(env)
55
57
  params = env['action_dispatch.request.parameters']
56
- parameter_filter = ::ActiveSupport::ParameterFilter.new(FILTERED_PARAMS)
57
- parameter_filter.filter(params || {})
58
+ ExceptionHunter::DataRedacter.new(params, FILTERED_PARAMS).redact
58
59
  end
59
60
  end
60
61
  end
61
62
  end
62
63
 
63
64
  module ExceptionHunter
65
+ # @private
64
66
  class Railtie < Rails::Railtie
65
67
  initializer 'exception_hunter.add_middleware', after: :load_config_initializers do |app|
66
68
  app.config.middleware.insert_after(
@@ -2,7 +2,6 @@ module ExceptionHunter
2
2
  module Middleware
3
3
  # Middleware to report errors
4
4
  # when a Sidekiq worker fails
5
- #
6
5
  class SidekiqHunter
7
6
  TRACK_AT_RETRY = [0, 3, 6, 10].freeze
8
7
  JOB_TRACKED_DATA = %w[
@@ -0,0 +1,10 @@
1
+ module ExceptionHunter
2
+ module Notifiers
3
+ # Error raised when there's a malformed notifier.
4
+ class MisconfiguredNotifiers < StandardError
5
+ def initialize(notifier)
6
+ super("Notifier has incorrect configuration: #{notifier.inspect}")
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,42 @@
1
+ require 'slack-notifier'
2
+
3
+ module ExceptionHunter
4
+ module Notifiers
5
+ # Notifier that sends a message to a Slack channel every time an
6
+ # exception is tracked.
7
+ class SlackNotifier
8
+ attr_reader :error, :notifier
9
+
10
+ def initialize(error, notifier)
11
+ @error = error
12
+ @notifier = notifier
13
+ end
14
+
15
+ def notify
16
+ slack_notifier = Slack::Notifier.new(notifier[:options][:webhook])
17
+ slack_notifier.ping(slack_notification_message)
18
+ end
19
+
20
+ private
21
+
22
+ def slack_notification_message
23
+ {
24
+ blocks: [
25
+ {
26
+ type: 'section',
27
+ text: {
28
+ type: 'mrkdwn',
29
+ text: error_message
30
+ }
31
+ }
32
+ ]
33
+ }
34
+ end
35
+
36
+ def error_message
37
+ "*#{error.class_name}*: #{error.message}. \n" \
38
+ "<#{ExceptionHunter::Engine.routes.url_helpers.error_url(error.error_group)}|Click to see the error>"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ module ExceptionHunter
2
+ module Notifiers
3
+ # @private
4
+ class SlackNotifierSerializer
5
+ def self.serialize(slack_notifier)
6
+ {
7
+ error: slack_notifier.error,
8
+ notifier: slack_notifier.notifier.as_json
9
+ }
10
+ end
11
+
12
+ def self.deserialize(hash)
13
+ ExceptionHunter::Notifiers::SlackNotifier.new(
14
+ hash[:error],
15
+ hash[:notifier].deep_symbolize_keys
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,5 +1,23 @@
1
1
  module ExceptionHunter
2
+ # Mixin used to track manual exceptions.
2
3
  module Tracking
4
+ # Used to manually track errors in cases where raising might
5
+ # not be adequate and but some insight is desired.
6
+ #
7
+ # @example Track the else clause on a case
8
+ # case user.status
9
+ # when :active then do_something()
10
+ # when :inactive then do_something_else()
11
+ # else
12
+ # ExceptionHunter.track(StandardError.new("User with unknown status"),
13
+ # custom_data: { status: user.status },
14
+ # user: user)
15
+ # end
16
+ #
17
+ # @param [Exception] exception to track.
18
+ # @param [Hash] custom_data to include and help debug the error. (optional)
19
+ # @param [User] user in the current session. (optional)
20
+ # @return [void]
3
21
  def track(exception, custom_data: {}, user: nil)
4
22
  ErrorCreator.call(
5
23
  tag: ErrorCreator::MANUAL_TAG,
@@ -7,7 +25,8 @@ module ExceptionHunter
7
25
  message: exception.message,
8
26
  backtrace: exception.backtrace,
9
27
  custom_data: custom_data,
10
- user: user
28
+ user: user,
29
+ environment_data: {}
11
30
  )
12
31
 
13
32
  nil
@@ -1,9 +1,26 @@
1
1
  module ExceptionHunter
2
+ # Utility module used to whitelist the user's attributes.
3
+ # Can be configured in {ExceptionHunter.setup ExceptionHunter.setup} to extract
4
+ # custom attributes.
5
+ #
6
+ # @example
7
+ # ExceptionHunter.setup do |config|
8
+ # config.user_attributes = [:id, :email, :role, :active?]
9
+ # end
10
+ #
2
11
  module UserAttributesCollector
3
12
  extend self
4
13
 
14
+ # Gets the attributes configured for the user.
15
+ #
16
+ # @example
17
+ # UserAttributesCollector.collect_attributes(current_user)
18
+ # # => { id: 42, email: "example@user.com" }
19
+ #
20
+ # @param user instance in your application
21
+ # @return [Hash] the whitelisted attributes from the user
5
22
  def collect_attributes(user)
6
- return unless user
23
+ return {} unless user
7
24
 
8
25
  attributes.reduce({}) do |data, attribute|
9
26
  data.merge(attribute => user.try(attribute))
@@ -1,3 +1,3 @@
1
1
  module ExceptionHunter
2
- VERSION = '0.3.0'.freeze
2
+ VERSION = '1.0.1'.freeze
3
3
  end
@@ -38,4 +38,33 @@ ExceptionHunter.setup do |config|
38
38
  # happen automatically.
39
39
  #
40
40
  # config.errors_stale_time = 45.days
41
+
42
+ # == Slack notifications
43
+ #
44
+ # You can configure if you want to send notifications to slack for each error occurrence.
45
+ # You can enter multiple webhook urls.
46
+ # Default: []
47
+ #
48
+ # config.notifiers << {
49
+ # name: :slack,
50
+ # options: {
51
+ # webhook: 'SLACK_WEBHOOK_URL_1'
52
+ # }
53
+ # }
54
+ #
55
+ # config.notifiers << {
56
+ # name: :slack,
57
+ # options: {
58
+ # webhook: SLACK_WEBHOOK_URL_2'
59
+ # }
60
+ # }
61
+
62
+ # == Filter sensitive parameters
63
+ #
64
+ # You can configure if you want to filter some fields on the error's data for security or privacy issues.
65
+ # We use ActiveSupport::ParameterFilter for this, any accepted pattern will work.
66
+ # https://api.rubyonrails.org/classes/ActiveSupport/ParameterFilter.html
67
+ # Default: []
68
+ #
69
+ # config.sensitive_parameters = [:id, :name]
41
70
  end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exception_hunter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Vezoli
8
8
  - Tiziana Romani
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2020-06-10 00:00:00.000000000 Z
12
+ date: 2020-12-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: pagy
@@ -17,14 +17,28 @@ dependencies:
17
17
  requirements:
18
18
  - - "~>"
19
19
  - !ruby/object:Gem::Version
20
- version: '3.8'
20
+ version: '3'
21
21
  type: :runtime
22
22
  prerelease: false
23
23
  version_requirements: !ruby/object:Gem::Requirement
24
24
  requirements:
25
25
  - - "~>"
26
26
  - !ruby/object:Gem::Version
27
- version: '3.8'
27
+ version: '3'
28
+ - !ruby/object:Gem::Dependency
29
+ name: slack-notifier
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '2.3'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.3'
28
42
  - !ruby/object:Gem::Dependency
29
43
  name: brakeman
30
44
  requirement: !ruby/object:Gem::Requirement
@@ -115,15 +129,15 @@ dependencies:
115
129
  requirements:
116
130
  - - "~>"
117
131
  - !ruby/object:Gem::Version
118
- version: 0.18.5
132
+ version: 0.17.1
119
133
  type: :development
120
134
  prerelease: false
121
135
  version_requirements: !ruby/object:Gem::Requirement
122
136
  requirements:
123
137
  - - "~>"
124
138
  - !ruby/object:Gem::Version
125
- version: 0.18.5
126
- description:
139
+ version: 0.17.1
140
+ description:
127
141
  email:
128
142
  - bruno.vezoli@rootstrap.com
129
143
  executables: []
@@ -143,11 +157,13 @@ files:
143
157
  - app/controllers/concerns/exception_hunter/authorization.rb
144
158
  - app/controllers/exception_hunter/application_controller.rb
145
159
  - app/controllers/exception_hunter/errors_controller.rb
160
+ - app/controllers/exception_hunter/ignored_errors_controller.rb
146
161
  - app/controllers/exception_hunter/resolved_errors_controller.rb
147
162
  - app/helpers/exception_hunter/application_helper.rb
148
163
  - app/helpers/exception_hunter/errors_helper.rb
149
164
  - app/helpers/exception_hunter/sessions_helper.rb
150
165
  - app/jobs/exception_hunter/application_job.rb
166
+ - app/jobs/exception_hunter/send_notification_job.rb
151
167
  - app/mailers/exception_hunter/application_mailer.rb
152
168
  - app/models/exception_hunter/application_record.rb
153
169
  - app/models/exception_hunter/error.rb
@@ -171,12 +187,17 @@ files:
171
187
  - config/routes.rb
172
188
  - lib/exception_hunter.rb
173
189
  - lib/exception_hunter/config.rb
190
+ - lib/exception_hunter/data_redacter.rb
174
191
  - lib/exception_hunter/devise.rb
175
192
  - lib/exception_hunter/engine.rb
176
193
  - lib/exception_hunter/error_creator.rb
177
194
  - lib/exception_hunter/error_reaper.rb
195
+ - lib/exception_hunter/middleware/delayed_job_hunter.rb
178
196
  - lib/exception_hunter/middleware/request_hunter.rb
179
197
  - lib/exception_hunter/middleware/sidekiq_hunter.rb
198
+ - lib/exception_hunter/notifiers/misconfigured_notifiers.rb
199
+ - lib/exception_hunter/notifiers/slack_notifier.rb
200
+ - lib/exception_hunter/notifiers/slack_notifier_serializer.rb
180
201
  - lib/exception_hunter/tracking.rb
181
202
  - lib/exception_hunter/user_attributes_collector.rb
182
203
  - lib/exception_hunter/version.rb
@@ -186,13 +207,12 @@ files:
186
207
  - lib/generators/exception_hunter/install/templates/create_exception_hunter_error_groups.rb.erb
187
208
  - lib/generators/exception_hunter/install/templates/create_exception_hunter_errors.rb.erb
188
209
  - lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb
189
- - lib/tasks/code_analysis.rake
190
210
  - lib/tasks/exception_hunter_tasks.rake
191
211
  homepage: https://github.com/rootstrap/exception_hunter
192
212
  licenses:
193
213
  - MIT
194
214
  metadata: {}
195
- post_install_message:
215
+ post_install_message:
196
216
  rdoc_options: []
197
217
  require_paths:
198
218
  - lib
@@ -207,8 +227,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
207
227
  - !ruby/object:Gem::Version
208
228
  version: '0'
209
229
  requirements: []
210
- rubygems_version: 3.1.2
211
- signing_key:
230
+ rubygems_version: 3.0.8
231
+ signing_key:
212
232
  specification_version: 4
213
233
  summary: Exception tracking engine
214
234
  test_files: []
@@ -1,7 +0,0 @@
1
- desc 'code analysis'
2
- task :code_analysis do
3
- sh 'bundle exec brakeman . -z -q'
4
- sh 'bundle exec rubocop app config lib spec'
5
- sh 'bundle exec reek app config lib'
6
- sh 'bundle exec rails_best_practices .'
7
- end