exception_hunter 0.2.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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),