exception_hunter 0.4.2 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -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/async_logging_job.rb +13 -0
  9. data/app/jobs/exception_hunter/send_notification_job.rb +15 -0
  10. data/app/models/exception_hunter/error.rb +5 -1
  11. data/app/models/exception_hunter/error_group.rb +1 -1
  12. data/app/presenters/exception_hunter/dashboard_presenter.rb +5 -3
  13. data/app/presenters/exception_hunter/error_group_presenter.rb +2 -1
  14. data/app/views/exception_hunter/errors/_error_row.erb +19 -11
  15. data/app/views/exception_hunter/errors/_last_7_days_errors_table.erb +1 -5
  16. data/app/views/exception_hunter/errors/index.html.erb +17 -3
  17. data/app/views/layouts/exception_hunter/application.html.erb +3 -1
  18. data/config/rails_best_practices.yml +1 -1
  19. data/config/routes.rb +2 -0
  20. data/lib/exception_hunter.rb +33 -0
  21. data/lib/exception_hunter/config.rb +22 -2
  22. data/lib/exception_hunter/data_redacter.rb +27 -0
  23. data/lib/exception_hunter/devise.rb +2 -0
  24. data/lib/exception_hunter/engine.rb +1 -0
  25. data/lib/exception_hunter/error_creator.rb +42 -11
  26. data/lib/exception_hunter/error_reaper.rb +8 -0
  27. data/lib/exception_hunter/middleware/delayed_job_hunter.rb +3 -1
  28. data/lib/exception_hunter/middleware/request_hunter.rb +4 -2
  29. data/lib/exception_hunter/middleware/sidekiq_hunter.rb +1 -1
  30. data/lib/exception_hunter/notifiers/misconfigured_notifiers.rb +10 -0
  31. data/lib/exception_hunter/notifiers/slack_notifier.rb +42 -0
  32. data/lib/exception_hunter/notifiers/slack_notifier_serializer.rb +20 -0
  33. data/lib/exception_hunter/tracking.rb +41 -1
  34. data/lib/exception_hunter/user_attributes_collector.rb +18 -1
  35. data/lib/exception_hunter/version.rb +1 -1
  36. data/lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb +29 -0
  37. data/lib/tasks/exception_hunter_tasks.rake +1 -1
  38. metadata +27 -7
  39. data/lib/tasks/code_analysis.rake +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cdc954e1b44cc37921b8ba178e55fb404bc6e49e67ae5a946e52e196ca3730e
4
- data.tar.gz: d852ae798bc6a70543a3bf7a4541d26cdb5c1628e26a3f85decb536093275a1d
3
+ metadata.gz: 2fb501bfc67399400c0d73e8d6df68fb8a20c25f6e12ef2dbc0507afda20aa93
4
+ data.tar.gz: 7f9af95d0edc69281d63c6b9cffb3ae259dec67bda2ce7e7901d6cb5bda371b8
5
5
  SHA512:
6
- metadata.gz: 2d7e25d78109f69be6c37275e69b8300792f645a0a745e679d14c5ac8d2bb682ef60033646d28e6c6e85e937055e45a3e9d6c1fd04d2205092405eb55f9ab9e9
7
- data.tar.gz: 8d3945a9c830adc7dbe195bb755990948746b46a3d2785b6993f6a6c7777d76d77d01074f134fa38eb2790300c68378dd29bfbf5166ba0a9420d4dc6d290b6ef
6
+ metadata.gz: 6170a00e2225b75eb4c7b9e8ee4278cf520df79728fe717ba5bd785136195f31fe72c417d8d794e38d63b64b528dcbc4a449fee09f2976b286542e1c69ff2dcd
7
+ data.tar.gz: b349b87bf65da803cf32044b69cfb53a81329d6a02b50dc535c6a257aaad16dd32789547eb3858d2714015322f67008efdf217e4d3f5474418ed008f19abb2d5
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Maintainability](https://api.codeclimate.com/v1/badges/86f6aaa2377c894f8ee4/maintainability)](https://codeclimate.com/github/rootstrap/exception_hunter/maintainability)
5
5
  [![Test Coverage](https://api.codeclimate.com/v1/badges/86f6aaa2377c894f8ee4/test_coverage)](https://codeclimate.com/github/rootstrap/exception_hunter/test_coverage)
6
6
 
7
- ![Index screenshot](doc/screenshot.png)
7
+ ![Index screenshot](docs/index-screenshot.png)
8
8
 
9
9
  Exception Hunter is a Rails engine meant to track errors in your Rails project. It works
10
10
  by using your Postgres database to save errors with their corresponding metadata (like backtrace
@@ -21,11 +21,16 @@ Error tracking is one of the most important tools a developer can have in their
21
21
  we think it'd be nice to provide a way for everyone to have it in their project, be it a personal
22
22
  project, and MVP or something else.
23
23
 
24
+ ## Docs
25
+
26
+ You can check the full documentation at [https://rootstrap.github.io/exception_hunter](https://rootstrap.github.io/exception_hunter).
27
+
24
28
  ## Installation
29
+
25
30
  Add Exception Hunter to your application's Gemfile:
26
31
 
27
32
  ```ruby
28
- gem 'exception_hunter', '~> 0.4.2'
33
+ gem 'exception_hunter', '~> 1.0'
29
34
  ```
30
35
 
31
36
  You may also need to add [Devise](https://github.com/heartcombo/devise) to your Gemfile
@@ -49,6 +54,18 @@ you can run the command with the `--skip-users` flag.
49
54
  Additionally it should add the 'ExceptionHunter.routes(self)' line to your routes, which means you can go to
50
55
  `/exception_hunter/errors` in your browser and start enjoying some good old fashioned exception tracking!
51
56
 
57
+ #### Testing it on dev:
58
+
59
+ ExceptionHunter is disabled on dev by default so if you want to test it before shipping it to another
60
+ environment, which we highly recommend, you should enable it by going to the initializer and changing the
61
+ line that says `config.enabled = !(Rails.env.development? || Rails.env.test?)` with something like
62
+ `config.enabled = !(Rails.env.test?)` while you test. Don't forget to change it back if you don't
63
+ want a bunch of errors in your local DB!
64
+
65
+ You can then open a `rails console` and manually track an exception to check that it
66
+ works `ExceptionHunter.track(StandardError.new("It works!"))`. You should now see the exception
67
+ on [http://localhost:3000/exception_hunter]().
68
+
52
69
  ## Stale data
53
70
 
54
71
  You can get rid of stale errors by running the rake task to purge them:
@@ -62,7 +79,74 @@ a week would be ideal. You can also purge errors by running `ExceptionHunter::Er
62
79
 
63
80
  The time it takes for an error to go stale defaults to 45 days but it's configurable via the initializer.
64
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 }, user: current_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
+ 1. Add it to your workspace.
106
+ 1. Add one or more webhooks linked to the channels you want to receive the notifications.
107
+ 1. 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
+ 1. 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
+
135
+ ## Async Logging
136
+
137
+ You can configure ExceptionHunter to log async when an error occurs.
138
+ You have to do the following:
139
+
140
+ ```ruby
141
+ config.async_logging = true;
142
+ ```
143
+
144
+ This uses ActiveJob to log the error 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 the error will be logged synchronously.
145
+
146
+ Note: Errors from jobs will still be logged synchronously to not queue a job from a job (which sound like a bad idea)
147
+
65
148
  ## License
149
+
66
150
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
67
151
 
68
152
  ## Credits
@@ -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,13 @@
1
+ module ExceptionHunter
2
+ class AsyncLoggingJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(tag, error_attrs)
6
+ error_attrs = error_attrs.merge(occurred_at: Time.at(error_attrs[:occurred_at])) if error_attrs[:occurred_at]
7
+ ErrorCreator.call(async_logging: false, tag: tag, **error_attrs)
8
+ rescue Exception
9
+ # Suppress all exceptions to avoid loop as this would create a new error in EH.
10
+ false
11
+ end
12
+ end
13
+ 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
@@ -6,7 +6,7 @@ module ExceptionHunter
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
@@ -6,7 +6,7 @@ module ExceptionHunter
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
@@ -11,7 +11,8 @@ module ExceptionHunter
11
11
  end
12
12
 
13
13
  def self.format_occurrence_day(day)
14
- day.to_date.strftime('%A, %B %d')
14
+ date = day.to_date
15
+ date == Date.yesterday ? 'Yesterday' : date.strftime('%A, %B %d')
15
16
  end
16
17
 
17
18
  def show_for_day?(day)
@@ -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
 
@@ -1,11 +1,7 @@
1
1
  <% today_errors = errors.select { |error| error.show_for_day?(Date.current) } %>
2
2
  <%= render partial: 'exception_hunter/errors/error_row', collection: today_errors, as: :error %>
3
3
 
4
- <% yesterday_errors = errors.select { |error| error.show_for_day?(Date.yesterday) } %>
5
- <div class="errors-date-group">Yesterday</div>
6
- <%= render partial: 'exception_hunter/errors/error_row', collection: yesterday_errors, as: :error %>
7
-
8
- <% (2..6).each do |i| %>
4
+ <% (1..6).each do |i| %>
9
5
  <% errors_on_day = errors.select { |error| error.show_for_day?(i.days.ago) } %>
10
6
  <div class="errors-date-group"><%= ExceptionHunter::ErrorGroupPresenter.format_occurrence_day(i.days.ago) %></div>
11
7
  <%= render partial: 'exception_hunter/errors/error_row', collection: errors_on_day, as: :error %>
@@ -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'] }
data/config/routes.rb CHANGED
@@ -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
 
@@ -7,19 +7,52 @@ require 'exception_hunter/error_creator'
7
7
  require 'exception_hunter/error_reaper'
8
8
  require 'exception_hunter/tracking'
9
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'
10
14
 
15
+ # @api public
11
16
  module ExceptionHunter
12
17
  autoload :Devise, 'exception_hunter/devise'
13
18
 
14
19
  extend ::ExceptionHunter::Tracking
15
20
 
21
+ # Used to setup ExceptionHunter's configuration
22
+ # it receives a block with the {ExceptionHunter::Config} singleton
23
+ # class.
24
+ #
25
+ # @return [void]
16
26
  def self.setup(&block)
17
27
  block.call(Config)
28
+ validate_config!
18
29
  end
19
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]
20
41
  def self.routes(router)
21
42
  return unless Config.enabled
22
43
 
23
44
  router.mount(ExceptionHunter::Engine, at: 'exception_hunter')
24
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
25
58
  end
@@ -1,10 +1,30 @@
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: []
20
+ # @!attribute
21
+ # @return [Boolean] whether ExceptionHunter should log async or not
22
+ cattr_accessor :async_logging, default: false
7
23
 
24
+ # Returns true if there's an admin user class configured to
25
+ # authenticate against.
26
+ #
27
+ # @return Boolean
8
28
  def self.auth_enabled?
9
29
  admin_user_class.present? && admin_user_class.try(:underscore)
10
30
  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
 
@@ -1,28 +1,48 @@
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
7
+ NOTIFICATION_DELAY = 1.minute
6
8
 
7
9
  class << self
8
- def call(tag: nil, **error_attrs)
10
+ # Creates an error with the given attributes and persists it to
11
+ # the database.
12
+ #
13
+ # @param [HTTP_TAG, WORKER_TAG, MANUAL_TAG] tag to append to the error if any
14
+ # @return [ExceptionHunter::Error, false] the error or false if it was not possible to create it
15
+ def call(async_logging: Config.async_logging, tag: nil, **error_attrs)
9
16
  return unless should_create?
10
17
 
18
+ if async_logging
19
+ # Time is sent in unix format and then converted to Time to avoid ActiveJob::SerializationError
20
+ ::ExceptionHunter::AsyncLoggingJob.perform_later(tag, error_attrs.merge(occurred_at: Time.now.to_i))
21
+ else
22
+ create_error(tag, error_attrs)
23
+ end
24
+ rescue ActiveRecord::RecordInvalid
25
+ false
26
+ end
27
+
28
+ private
29
+
30
+ def create_error(tag, error_attrs)
11
31
  ActiveRecord::Base.transaction do
12
32
  error_attrs = extract_user_data(error_attrs)
33
+ error_attrs = hide_sensitive_values(error_attrs)
13
34
  error = ::ExceptionHunter::Error.new(error_attrs)
14
35
  error_group = ::ExceptionHunter::ErrorGroup.find_matching_group(error) || ::ExceptionHunter::ErrorGroup.new
15
36
  update_error_group(error_group, error, tag)
16
37
  error.error_group = error_group
17
38
  error.save!
39
+ return if error_group.ignored?
40
+
41
+ notify(error)
18
42
  error
19
43
  end
20
- rescue ActiveRecord::RecordInvalid
21
- false
22
44
  end
23
45
 
24
- private
25
-
26
46
  def should_create?
27
47
  Config.enabled
28
48
  end
@@ -38,16 +58,27 @@ module ExceptionHunter
38
58
 
39
59
  def extract_user_data(**error_attrs)
40
60
  user = error_attrs[:user]
41
- error_attrs[:user_data] =
42
- if user.nil?
43
- {}
44
- else
45
- UserAttributesCollector.collect_attributes(user)
46
- end
61
+ error_attrs[:user_data] = UserAttributesCollector.collect_attributes(user)
47
62
 
48
63
  error_attrs.delete(:user)
49
64
  error_attrs
50
65
  end
66
+
67
+ def notify(error)
68
+ ExceptionHunter::Config.notifiers.each do |notifier|
69
+ slack_notifier = ExceptionHunter::Notifiers::SlackNotifier.new(error, notifier)
70
+ serializer = ExceptionHunter::Notifiers::SlackNotifierSerializer
71
+ serialized_slack_notifier = serializer.serialize(slack_notifier)
72
+ ExceptionHunter::SendNotificationJob.set(
73
+ wait: NOTIFICATION_DELAY
74
+ ).perform_later(serialized_slack_notifier)
75
+ end
76
+ end
77
+
78
+ def hide_sensitive_values(error_attrs)
79
+ sensitive_fields = ExceptionHunter::Config.sensitive_fields
80
+ ExceptionHunter::DataRedacter.new(error_attrs, sensitive_fields).redact
81
+ end
51
82
  end
52
83
  end
53
84
  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
@@ -2,6 +2,7 @@ require 'delayed_job'
2
2
 
3
3
  module ExceptionHunter
4
4
  module Middleware
5
+ # DelayedJob plugin to track exceptions on apps using DelayedJob.
5
6
  class DelayedJobHunter < ::Delayed::Plugin
6
7
  TRACK_AT_RETRY = [0, 3, 6, 10].freeze
7
8
  JOB_TRACKED_DATA = %w[
@@ -30,6 +31,7 @@ module ExceptionHunter
30
31
  return unless should_track?(job.attempts)
31
32
 
32
33
  ErrorCreator.call(
34
+ async_logging: false,
33
35
  tag: ErrorCreator::WORKER_TAG,
34
36
  class_name: exception.class.to_s,
35
37
  message: exception.message,
@@ -40,7 +42,7 @@ module ExceptionHunter
40
42
 
41
43
  def self.environment_data(job)
42
44
  job_data =
43
- JOB_TRACKED_DATA.each_with_object({}) do |data_param, dict|
45
+ JOB_TRACKED_DATA.reduce({}) do |dict, data_param|
44
46
  dict.merge(data_param => job.try(data_param))
45
47
  end
46
48
 
@@ -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[
@@ -29,6 +28,7 @@ module ExceptionHunter
29
28
  return unless should_track?(context)
30
29
 
31
30
  ErrorCreator.call(
31
+ async_logging: false,
32
32
  tag: ErrorCreator::WORKER_TAG,
33
33
  class_name: exception.class.to_s,
34
34
  message: exception.message,
@@ -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,6 +1,44 @@
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)
22
+ if open_transactions?
23
+ create_error_within_new_thread(exception, custom_data, user)
24
+ else
25
+ create_error(exception, custom_data, user)
26
+ end
27
+
28
+ nil
29
+ end
30
+
31
+ private
32
+
33
+ def create_error_within_new_thread(exception, custom_data, user)
34
+ Thread.new {
35
+ ActiveRecord::Base.connection_pool.with_connection do
36
+ create_error(exception, custom_data, user)
37
+ end
38
+ }.join
39
+ end
40
+
41
+ def create_error(exception, custom_data, user)
4
42
  ErrorCreator.call(
5
43
  tag: ErrorCreator::MANUAL_TAG,
6
44
  class_name: exception.class.to_s,
@@ -10,8 +48,10 @@ module ExceptionHunter
10
48
  user: user,
11
49
  environment_data: {}
12
50
  )
51
+ end
13
52
 
14
- nil
53
+ def open_transactions?
54
+ ActiveRecord::Base.connection.open_transactions.positive?
15
55
  end
16
56
  end
17
57
  end
@@ -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.4.2'.freeze
2
+ VERSION = '1.1.0'.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
@@ -1,6 +1,6 @@
1
1
  namespace :exception_hunter do
2
2
  desc 'Purges old errors'
3
3
  task purge_errors: [:environment] do
4
- ::ExceptionHunter::ErrorReaper.call
4
+ ::ExceptionHunter::ErrorReaper.purge
5
5
  end
6
6
  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.4.2
4
+ version: 1.1.0
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-09-17 00:00:00.000000000 Z
12
+ date: 2021-07-30 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: pagy
@@ -25,6 +25,20 @@ dependencies:
25
25
  - - "~>"
26
26
  - !ruby/object:Gem::Version
27
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
@@ -123,7 +137,7 @@ dependencies:
123
137
  - - "~>"
124
138
  - !ruby/object:Gem::Version
125
139
  version: 0.17.1
126
- description:
140
+ description:
127
141
  email:
128
142
  - bruno.vezoli@rootstrap.com
129
143
  executables: []
@@ -143,11 +157,14 @@ 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/async_logging_job.rb
167
+ - app/jobs/exception_hunter/send_notification_job.rb
151
168
  - app/mailers/exception_hunter/application_mailer.rb
152
169
  - app/models/exception_hunter/application_record.rb
153
170
  - app/models/exception_hunter/error.rb
@@ -171,6 +188,7 @@ files:
171
188
  - config/routes.rb
172
189
  - lib/exception_hunter.rb
173
190
  - lib/exception_hunter/config.rb
191
+ - lib/exception_hunter/data_redacter.rb
174
192
  - lib/exception_hunter/devise.rb
175
193
  - lib/exception_hunter/engine.rb
176
194
  - lib/exception_hunter/error_creator.rb
@@ -178,6 +196,9 @@ files:
178
196
  - lib/exception_hunter/middleware/delayed_job_hunter.rb
179
197
  - lib/exception_hunter/middleware/request_hunter.rb
180
198
  - lib/exception_hunter/middleware/sidekiq_hunter.rb
199
+ - lib/exception_hunter/notifiers/misconfigured_notifiers.rb
200
+ - lib/exception_hunter/notifiers/slack_notifier.rb
201
+ - lib/exception_hunter/notifiers/slack_notifier_serializer.rb
181
202
  - lib/exception_hunter/tracking.rb
182
203
  - lib/exception_hunter/user_attributes_collector.rb
183
204
  - lib/exception_hunter/version.rb
@@ -187,13 +208,12 @@ files:
187
208
  - lib/generators/exception_hunter/install/templates/create_exception_hunter_error_groups.rb.erb
188
209
  - lib/generators/exception_hunter/install/templates/create_exception_hunter_errors.rb.erb
189
210
  - lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb
190
- - lib/tasks/code_analysis.rake
191
211
  - lib/tasks/exception_hunter_tasks.rake
192
212
  homepage: https://github.com/rootstrap/exception_hunter
193
213
  licenses:
194
214
  - MIT
195
215
  metadata: {}
196
- post_install_message:
216
+ post_install_message:
197
217
  rdoc_options: []
198
218
  require_paths:
199
219
  - lib
@@ -209,7 +229,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
209
229
  version: '0'
210
230
  requirements: []
211
231
  rubygems_version: 3.0.8
212
- signing_key:
232
+ signing_key:
213
233
  specification_version: 4
214
234
  summary: Exception tracking engine
215
235
  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