exception_hunter 0.4.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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