exception_hunter 0.2.0 → 1.0.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +103 -6
  3. data/app/assets/stylesheets/exception_hunter/base.css +66 -8
  4. data/app/assets/stylesheets/exception_hunter/errors.css +188 -25
  5. data/app/assets/stylesheets/exception_hunter/navigation.css +20 -5
  6. data/app/assets/stylesheets/exception_hunter/sessions.css +71 -0
  7. data/app/controllers/concerns/exception_hunter/authorization.rb +23 -0
  8. data/app/controllers/exception_hunter/application_controller.rb +2 -0
  9. data/app/controllers/exception_hunter/errors_controller.rb +29 -4
  10. data/app/controllers/exception_hunter/ignored_errors_controller.rb +25 -0
  11. data/app/controllers/exception_hunter/resolved_errors_controller.rb +11 -0
  12. data/app/helpers/exception_hunter/application_helper.rb +22 -0
  13. data/app/helpers/exception_hunter/sessions_helper.rb +16 -0
  14. data/app/jobs/exception_hunter/send_notification_job.rb +15 -0
  15. data/app/models/exception_hunter/application_record.rb +8 -0
  16. data/app/models/exception_hunter/error.rb +24 -7
  17. data/app/models/exception_hunter/error_group.rb +24 -5
  18. data/app/presenters/exception_hunter/dashboard_presenter.rb +56 -0
  19. data/app/presenters/exception_hunter/error_group_presenter.rb +25 -0
  20. data/app/presenters/exception_hunter/error_presenter.rb +3 -2
  21. data/app/views/exception_hunter/devise/sessions/new.html.erb +24 -0
  22. data/app/views/exception_hunter/errors/_error_row.erb +52 -0
  23. data/app/views/exception_hunter/errors/_error_summary.erb +5 -5
  24. data/app/views/exception_hunter/errors/_errors_table.erb +1 -0
  25. data/app/views/exception_hunter/errors/_last_7_days_errors_table.erb +12 -0
  26. data/app/views/exception_hunter/errors/index.html.erb +84 -29
  27. data/app/views/exception_hunter/errors/pagy/_pagy_nav.html.erb +15 -15
  28. data/app/views/exception_hunter/errors/show.html.erb +58 -22
  29. data/app/views/layouts/exception_hunter/application.html.erb +67 -6
  30. data/app/views/layouts/exception_hunter/exception_hunter_logged_out.html.erb +24 -0
  31. data/config/rails_best_practices.yml +3 -3
  32. data/config/routes.rb +21 -1
  33. data/lib/exception_hunter.rb +44 -2
  34. data/lib/exception_hunter/config.rb +25 -1
  35. data/lib/exception_hunter/data_redacter.rb +27 -0
  36. data/lib/exception_hunter/devise.rb +19 -0
  37. data/lib/exception_hunter/engine.rb +6 -0
  38. data/lib/exception_hunter/error_creator.rb +72 -0
  39. data/lib/exception_hunter/error_reaper.rb +20 -0
  40. data/lib/exception_hunter/middleware/delayed_job_hunter.rb +70 -0
  41. data/lib/exception_hunter/middleware/request_hunter.rb +5 -2
  42. data/lib/exception_hunter/middleware/sidekiq_hunter.rb +1 -1
  43. data/lib/exception_hunter/notifiers/misconfigured_notifiers.rb +10 -0
  44. data/lib/exception_hunter/notifiers/slack_notifier.rb +42 -0
  45. data/lib/exception_hunter/notifiers/slack_notifier_serializer.rb +20 -0
  46. data/lib/exception_hunter/tracking.rb +35 -0
  47. data/lib/exception_hunter/user_attributes_collector.rb +21 -0
  48. data/lib/exception_hunter/version.rb +1 -1
  49. data/lib/generators/exception_hunter/create_users/create_users_generator.rb +8 -1
  50. data/lib/generators/exception_hunter/install/install_generator.rb +3 -1
  51. data/lib/generators/exception_hunter/install/templates/create_exception_hunter_error_groups.rb.erb +3 -0
  52. data/lib/generators/exception_hunter/install/templates/exception_hunter.rb.erb +52 -0
  53. data/lib/tasks/exception_hunter_tasks.rake +6 -4
  54. metadata +46 -12
  55. data/app/services/exception_hunter/error_creator.rb +0 -41
  56. data/config/initializers/exception_hunter.rb +0 -16
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Exception Hunter</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
9
+ <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css">
10
+ <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css">
11
+ <link rel="stylesheet" href="https://rsms.me/inter/inter.css">
12
+
13
+ <!-- Get patch fixes within a minor version -->
14
+ <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/cferdinandi/tabby@12.0/dist/css/tabby-ui.min.css">
15
+ <script src="https://cdn.jsdelivr.net/gh/cferdinandi/tabby@12.0/dist/js/tabby.polyfills.min.js"></script>
16
+
17
+ <%= stylesheet_link_tag "exception_hunter/application", media: "all" %>
18
+ </head>
19
+ <body style="background-color: #E5E5E5;">
20
+ <div class="container">
21
+ <%= yield %>
22
+ </div>
23
+ </body>
24
+ </html>
@@ -13,10 +13,10 @@ 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
- NotUseTimeAgoInWordsCheck: { ignored_files: ['index.html.erb'] }
19
+ #NotUseTimeAgoInWordsCheck: { ignored_files: ['index.html.erb'] }
20
20
  OveruseRouteCustomizationsCheck: { customize_count: 3 }
21
21
  ProtectMassAssignmentCheck: { }
22
22
  RemoveEmptyHelpersCheck: { }
@@ -29,7 +29,7 @@ ReplaceComplexCreationWithFactoryMethodCheck: { attribute_assignment_count: 2 }
29
29
  ReplaceInstanceVariableWithLocalVariableCheck: { }
30
30
  RestrictAutoGeneratedRoutesCheck: { }
31
31
  SimplifyRenderInControllersCheck: { }
32
- SimplifyRenderInViewsCheck: { }
32
+ #SimplifyRenderInViewsCheck: { }
33
33
  #UseBeforeFilterCheck: { customize_count: 2 }
34
34
  UseModelAssociationCheck: { }
35
35
  UseMultipartAlternativeAsContentTypeOfEmailCheck: { }
@@ -1,3 +1,23 @@
1
1
  ExceptionHunter::Engine.routes.draw do
2
- resources :errors, only: %i[index show]
2
+ resources :errors, only: %i[index show] do
3
+ delete 'purge', on: :collection, to: 'errors#destroy', as: :purge
4
+ end
5
+
6
+ resources :resolved_errors, only: %i[create]
7
+ resources :ignored_errors, only: %i[create]
8
+ post :reopen, to: 'ignored_errors#reopen'
9
+
10
+ get '/', to: redirect('/exception_hunter/errors')
11
+
12
+ if ExceptionHunter::Config.auth_enabled?
13
+ admin_user_class = ExceptionHunter::Config.admin_user_class.underscore.to_sym
14
+
15
+ devise_scope admin_user_class do
16
+ get '/login', to: 'devise/sessions#new', as: :exception_hunter_login
17
+ post '/login', to: 'devise/sessions#create', as: :exception_hunter_create_session
18
+ get '/logout', to: 'devise/sessions#destroy', as: :exception_hunter_logout
19
+ end
20
+
21
+ devise_for admin_user_class, only: []
22
+ end
3
23
  end
@@ -1,16 +1,58 @@
1
+ require 'pagy'
2
+
1
3
  require 'exception_hunter/engine'
2
4
  require 'exception_hunter/middleware/request_hunter'
3
- require 'exception_hunter/middleware/sidekiq_hunter' if defined?(Sidekiq)
4
5
  require 'exception_hunter/config'
6
+ require 'exception_hunter/error_creator'
7
+ require 'exception_hunter/error_reaper'
8
+ require 'exception_hunter/tracking'
5
9
  require 'exception_hunter/user_attributes_collector'
6
- require 'pagy'
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'
7
14
 
15
+ # @api public
8
16
  module ExceptionHunter
17
+ autoload :Devise, 'exception_hunter/devise'
18
+
19
+ extend ::ExceptionHunter::Tracking
20
+
21
+ # Used to setup ExceptionHunter's configuration
22
+ # it receives a block with the {ExceptionHunter::Config} singleton
23
+ # class.
24
+ #
25
+ # @return [void]
9
26
  def self.setup(&block)
10
27
  block.call(Config)
28
+ validate_config!
11
29
  end
12
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]
13
41
  def self.routes(router)
42
+ return unless Config.enabled
43
+
14
44
  router.mount(ExceptionHunter::Engine, at: 'exception_hunter')
15
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
16
58
  end
@@ -1,5 +1,29 @@
1
1
  module ExceptionHunter
2
+ # Config singleton class used to customize ExceptionHunter
2
3
  class Config
3
- cattr_accessor :current_user_method, :user_attributes
4
+ # @!attribute
5
+ # @return [Boolean] whether ExceptionHunter is active or not
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
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
+
21
+ # Returns true if there's an admin user class configured to
22
+ # authenticate against.
23
+ #
24
+ # @return Boolean
25
+ def self.auth_enabled?
26
+ admin_user_class.present? && admin_user_class.try(:underscore)
27
+ end
4
28
  end
5
29
  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
@@ -0,0 +1,19 @@
1
+ module ExceptionHunter
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.
5
+ class SessionsController < ::Devise::SessionsController
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ layout 'exception_hunter/exception_hunter_logged_out'
9
+
10
+ def after_sign_out_path_for(*)
11
+ '/exception_hunter/login'
12
+ end
13
+
14
+ def after_sign_in_path_for(*)
15
+ '/exception_hunter'
16
+ end
17
+ end
18
+ end
19
+ end
@@ -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
@@ -0,0 +1,72 @@
1
+ module ExceptionHunter
2
+ # Core class in charge of the actual persistence of errors and notifications.
3
+ class ErrorCreator
4
+ HTTP_TAG = 'HTTP'.freeze
5
+ WORKER_TAG = 'Worker'.freeze
6
+ MANUAL_TAG = 'Manual'.freeze
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
14
+ def call(tag: nil, **error_attrs)
15
+ return unless should_create?
16
+
17
+ ActiveRecord::Base.transaction do
18
+ error_attrs = extract_user_data(error_attrs)
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
22
+ update_error_group(error_group, error, tag)
23
+ error.error_group = error_group
24
+ error.save!
25
+ return if error_group.ignored?
26
+
27
+ notify(error)
28
+ error
29
+ end
30
+ rescue ActiveRecord::RecordInvalid
31
+ false
32
+ end
33
+
34
+ private
35
+
36
+ def should_create?
37
+ Config.enabled
38
+ end
39
+
40
+ def update_error_group(error_group, error, tag)
41
+ error_group.error_class_name = error.class_name
42
+ error_group.message = error.message
43
+ error_group.tags << tag unless tag.nil?
44
+ error_group.tags.uniq!
45
+
46
+ error_group.save!
47
+ end
48
+
49
+ def extract_user_data(**error_attrs)
50
+ user = error_attrs[:user]
51
+ error_attrs[:user_data] = UserAttributesCollector.collect_attributes(user)
52
+
53
+ error_attrs.delete(:user)
54
+ error_attrs
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
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,20 @@
1
+ module ExceptionHunter
2
+ # Class in charge of disposing of stale errors as specified in the {ExceptionHunter::Config}.
3
+ class ErrorReaper
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]
12
+ def purge(stale_time: Config.errors_stale_time)
13
+ ActiveRecord::Base.transaction do
14
+ Error.with_occurrences_before(Date.today - stale_time).destroy_all
15
+ ErrorGroup.without_errors.destroy_all
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -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
@@ -30,6 +32,7 @@ module ExceptionHunter
30
32
  def catch_prey(env, exception)
31
33
  user = user_from_env(env)
32
34
  ErrorCreator.call(
35
+ tag: ErrorCreator::HTTP_TAG,
33
36
  class_name: exception.class.to_s,
34
37
  message: exception.message,
35
38
  environment_data: environment_data(env),
@@ -52,14 +55,14 @@ module ExceptionHunter
52
55
 
53
56
  def filtered_sensitive_params(env)
54
57
  params = env['action_dispatch.request.parameters']
55
- parameter_filter = ::ActiveSupport::ParameterFilter.new(FILTERED_PARAMS)
56
- parameter_filter.filter(params || {})
58
+ ExceptionHunter::DataRedacter.new(params, FILTERED_PARAMS).redact
57
59
  end
58
60
  end
59
61
  end
60
62
  end
61
63
 
62
64
  module ExceptionHunter
65
+ # @private
63
66
  class Railtie < Rails::Railtie
64
67
  initializer 'exception_hunter.add_middleware', after: :load_config_initializers do |app|
65
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
+ tag: ErrorCreator::WORKER_TAG,
32
32
  class_name: exception.class.to_s,
33
33
  message: exception.message,
34
34
  environment_data: environment_data(context),