goodsorcery 0.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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE +21 -0
  4. data/README.md +299 -0
  5. data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
  6. data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
  7. data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
  8. data/lib/goodsorcery/backtrace/code_context.rb +27 -0
  9. data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
  10. data/lib/goodsorcery/backtrace/parser.rb +38 -0
  11. data/lib/goodsorcery/backtrace/paths.rb +42 -0
  12. data/lib/goodsorcery/bundler_plugin.rb +117 -0
  13. data/lib/goodsorcery/client.rb +180 -0
  14. data/lib/goodsorcery/configuration/validation.rb +59 -0
  15. data/lib/goodsorcery/configuration.rb +204 -0
  16. data/lib/goodsorcery/defaults.rb +40 -0
  17. data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
  18. data/lib/goodsorcery/delivery/async.rb +122 -0
  19. data/lib/goodsorcery/delivery/backoff.rb +20 -0
  20. data/lib/goodsorcery/delivery/custom.rb +27 -0
  21. data/lib/goodsorcery/delivery/event_queue.rb +124 -0
  22. data/lib/goodsorcery/delivery/sync.rb +27 -0
  23. data/lib/goodsorcery/delivery/test.rb +30 -0
  24. data/lib/goodsorcery/delivery/worker.rb +71 -0
  25. data/lib/goodsorcery/delivery.rb +39 -0
  26. data/lib/goodsorcery/delivery_job.rb +26 -0
  27. data/lib/goodsorcery/env_flag.rb +24 -0
  28. data/lib/goodsorcery/environment_gate.rb +61 -0
  29. data/lib/goodsorcery/errors.rb +14 -0
  30. data/lib/goodsorcery/event/breadcrumb.rb +32 -0
  31. data/lib/goodsorcery/event/builder.rb +77 -0
  32. data/lib/goodsorcery/event/cause_chain.rb +39 -0
  33. data/lib/goodsorcery/event/exception_section.rb +68 -0
  34. data/lib/goodsorcery/event/fields.rb +61 -0
  35. data/lib/goodsorcery/event/job_section.rb +34 -0
  36. data/lib/goodsorcery/event/request_section.rb +41 -0
  37. data/lib/goodsorcery/event/runtime_context.rb +52 -0
  38. data/lib/goodsorcery/event/tag_set.rb +41 -0
  39. data/lib/goodsorcery/event/user_section.rb +36 -0
  40. data/lib/goodsorcery/event_filter.rb +54 -0
  41. data/lib/goodsorcery/event_pipeline.rb +35 -0
  42. data/lib/goodsorcery/event_serializer.rb +25 -0
  43. data/lib/goodsorcery/exception_coercion.rb +18 -0
  44. data/lib/goodsorcery/extra_filter_probe.rb +32 -0
  45. data/lib/goodsorcery/ignore_list.rb +15 -0
  46. data/lib/goodsorcery/install_check.rb +58 -0
  47. data/lib/goodsorcery/integrations/action_cable.rb +75 -0
  48. data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
  49. data/lib/goodsorcery/integrations/active_job.rb +39 -0
  50. data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
  51. data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
  52. data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
  53. data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
  54. data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
  55. data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
  56. data/lib/goodsorcery/integrations/rails.rb +56 -0
  57. data/lib/goodsorcery/integrations/rake.rb +68 -0
  58. data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
  59. data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
  60. data/lib/goodsorcery/integrations.rb +27 -0
  61. data/lib/goodsorcery/job_arguments.rb +77 -0
  62. data/lib/goodsorcery/key_filter.rb +99 -0
  63. data/lib/goodsorcery/log.rb +55 -0
  64. data/lib/goodsorcery/masked_key.rb +29 -0
  65. data/lib/goodsorcery/rack/middleware.rb +57 -0
  66. data/lib/goodsorcery/rack/request_details.rb +120 -0
  67. data/lib/goodsorcery/rack/request_enricher.rb +20 -0
  68. data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
  69. data/lib/goodsorcery/railtie.rb +29 -0
  70. data/lib/goodsorcery/release_detector.rb +42 -0
  71. data/lib/goodsorcery/reported_mark.rb +19 -0
  72. data/lib/goodsorcery/sanitizer.rb +71 -0
  73. data/lib/goodsorcery/scope.rb +106 -0
  74. data/lib/goodsorcery/status_report.rb +50 -0
  75. data/lib/goodsorcery/tasks.rb +46 -0
  76. data/lib/goodsorcery/test_event.rb +46 -0
  77. data/lib/goodsorcery/text.rb +35 -0
  78. data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
  79. data/lib/goodsorcery/transport/connection.rb +87 -0
  80. data/lib/goodsorcery/transport/result.rb +103 -0
  81. data/lib/goodsorcery/transport.rb +172 -0
  82. data/lib/goodsorcery/verification.rb +47 -0
  83. data/lib/goodsorcery/version.rb +6 -0
  84. data/lib/goodsorcery.rb +176 -0
  85. data/plugins.rb +6 -0
  86. metadata +130 -0
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../goodsorcery"
4
+ require_relative "status_report"
5
+
6
+ module GoodSorcery
7
+ # Runs after `bundle install` (via the Bundler plugin) in a subprocess of
8
+ # the app root. Boots the app when config/environment.rb exists, so the
9
+ # initializer's settings apply, then reports per config.install_check:
10
+ # :verify (default), :test_event or false. Prints one "[goodsorcery]" line
11
+ # per finding and never raises.
12
+ class InstallCheck
13
+ def initialize(root: Dir.pwd, out: $stdout, env: ENV)
14
+ @root = root
15
+ @out = out
16
+ @env = env
17
+ end
18
+
19
+ def run
20
+ boot_app
21
+ check
22
+ rescue StandardError, ScriptError => e
23
+ @out.puts("#{StatusReport::PREFIX} install check failed: #{e.class}: #{one_line(e.message)}")
24
+ false
25
+ end
26
+
27
+ private
28
+
29
+ def boot_app
30
+ environment_file = File.join(@root, "config", "environment.rb")
31
+ return unless File.file?(environment_file)
32
+
33
+ @env["RAILS_ENV"] ||= "development"
34
+ require environment_file
35
+ end
36
+
37
+ def check
38
+ report = StatusReport.new(GoodSorcery.client, out: @out)
39
+ mode = GoodSorcery.configuration.install_check
40
+ return skipped(report) unless mode
41
+
42
+ report.print_settings
43
+ verified = report.print_verification
44
+ return verified unless mode == :test_event
45
+
46
+ report.print_test_event && verified
47
+ end
48
+
49
+ def skipped(report)
50
+ report.say("install check skipped (config.install_check = false)")
51
+ true
52
+ end
53
+
54
+ def one_line(message)
55
+ Text.limit(message.to_s.lines.first.to_s.strip, 200)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ # The Action Cable layer (DESIGN.md 5.1), prepended into
6
+ # ActionCable::Channel::Base. Each subscribe, unsubscribe and action runs
7
+ # in a fresh scope named "ChatChannel#speak"; exceptions that escape the
8
+ # channel's own rescue_from handlers are reported, then re-raised.
9
+ module ActionCable
10
+ # Prepended into ActionCable::Channel::Base.
11
+ module Extension
12
+ def subscribe_to_channel
13
+ Integrations.run_unit_of_work(ChannelAction.new(self, "subscribed")) { super }
14
+ end
15
+
16
+ def unsubscribe_from_channel
17
+ Integrations.run_unit_of_work(ChannelAction.new(self, "unsubscribed")) { super }
18
+ end
19
+
20
+ def perform_action(data)
21
+ Integrations.run_unit_of_work(ChannelAction.new(self, ChannelAction.action_name(data), data)) { super }
22
+ end
23
+ end
24
+
25
+ def self.install(base = ::ActionCable::Channel::Base)
26
+ base.prepend(Extension) unless base <= Extension
27
+ end
28
+
29
+ # One channel action. The request section (the connection's request,
30
+ # with the subscription params and action data as params) is read only
31
+ # when an error is reported.
32
+ class ChannelAction
33
+ def self.action_name(data)
34
+ name = data.is_a?(Hash) ? data["action"].to_s : ""
35
+ name.empty? ? "receive" : name
36
+ end
37
+
38
+ def initialize(channel, action, data = nil)
39
+ @channel = channel
40
+ @action = action
41
+ @data = data
42
+ end
43
+
44
+ def open(scope)
45
+ scope.transaction = "#{@channel.class.name}##{@action}"
46
+ scope.enricher = self
47
+ end
48
+
49
+ def enrich(scope)
50
+ scope.request ||= request_section
51
+ end
52
+
53
+ private
54
+
55
+ def request_section
56
+ env = @channel.connection.respond_to?(:env) ? @channel.connection.env : nil
57
+ section = env ? connection_request(env) : {}
58
+ section["params"] = params if GoodSorcery.configuration.send_request_params
59
+ section.compact
60
+ end
61
+
62
+ def connection_request(env)
63
+ details = GoodSorcery::Rack::RequestDetails.new(env, GoodSorcery.configuration)
64
+ { "url" => details.url, "request_id" => details.request_id, "remote_ip" => details.remote_ip,
65
+ "headers" => details.headers }
66
+ end
67
+
68
+ def params
69
+ channel_params = @channel.params
70
+ { "channel" => channel_params.is_a?(Hash) ? channel_params.to_h : nil, "data" => @data }.compact
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ module ActiveJob
6
+ # The "job" section, transaction and queue tag for one Active Job
7
+ # execution, read when an error is reported (arguments are deserialized
8
+ # by then). Action Mailer deliveries (deliver_later) are named
9
+ # "UserMailer#welcome" and carry the mailer method's args and params.
10
+ class JobDetails
11
+ def initialize(job)
12
+ @job = job
13
+ end
14
+
15
+ def open(scope)
16
+ scope.merge_tags("queue" => @job.queue_name)
17
+ scope.enricher = self
18
+ end
19
+
20
+ def enrich(scope)
21
+ mail = MailDelivery.for(@job)
22
+ scope.transaction ||= mail ? mail.transaction : @job.class.name
23
+ scope.job = to_h(mail)
24
+ end
25
+
26
+ def to_h(mail = MailDelivery.for(@job))
27
+ section = execution_fields.merge(timestamps)
28
+ section.merge!(mail.to_h) if mail
29
+ section["arguments"] = arguments(mail) if send_arguments?
30
+ section.compact
31
+ end
32
+
33
+ private
34
+
35
+ def execution_fields
36
+ {
37
+ "framework" => "active_job", "class" => @job.class.name, "id" => @job.job_id,
38
+ "provider_job_id" => @job.provider_job_id&.to_s, "queue" => @job.queue_name,
39
+ "priority" => @job.priority, "executions" => @job.executions
40
+ }
41
+ end
42
+
43
+ def timestamps
44
+ scheduled = @job.respond_to?(:scheduled_at) ? @job.scheduled_at : nil
45
+ {
46
+ "enqueued_at" => Event::Fields.iso_time(@job.enqueued_at),
47
+ "scheduled_at" => Event::Fields.iso_time(scheduled)
48
+ }
49
+ end
50
+
51
+ def send_arguments?
52
+ GoodSorcery.configuration.send_job_arguments && @job.class.goodsorcery_send_arguments
53
+ end
54
+
55
+ def arguments(mail)
56
+ JobArguments.new.serialize(mail ? mail.arguments : @job.arguments)
57
+ end
58
+ end
59
+
60
+ # ActionMailer::MailDeliveryJob's arguments:
61
+ # [mailer, method, delivery_method, { args:, kwargs:, params: }].
62
+ class MailDelivery
63
+ def self.for(job)
64
+ return nil unless defined?(::ActionMailer::MailDeliveryJob) && job.is_a?(::ActionMailer::MailDeliveryJob)
65
+
66
+ new(job.arguments)
67
+ end
68
+
69
+ def initialize(arguments)
70
+ mailer, action, _delivery_method, options = arguments
71
+ @mailer = mailer.to_s
72
+ @action = action.to_s
73
+ @options = options.is_a?(Hash) ? options : {}
74
+ end
75
+
76
+ def transaction
77
+ "#{@mailer}##{@action}"
78
+ end
79
+
80
+ def to_h
81
+ { "mailer" => @mailer, "action" => @action }
82
+ end
83
+
84
+ def arguments
85
+ { "args" => @options[:args], "kwargs" => @options[:kwargs], "params" => @options[:params] }.compact
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "active_job/job_details"
4
+
5
+ module GoodSorcery
6
+ module Integrations
7
+ # The Active Job layer (DESIGN.md 5.1), prepended into ActiveJob::Base.
8
+ # It wraps perform_now, outside the job's own rescue_from/retry_on
9
+ # handling, so an error is reported once, when it escapes the job (after
10
+ # the last retry), not on every attempt the job handles itself.
11
+ module ActiveJob
12
+ # Prepended into ActiveJob::Base.
13
+ module Extension
14
+ def self.prepended(base)
15
+ base.class_attribute :goodsorcery_send_arguments, instance_accessor: false, default: true
16
+ base.extend(ClassMethods)
17
+ end
18
+
19
+ # Class macro: `goodsorcery_arguments false` never sends this job's
20
+ # arguments (inherited by subclasses).
21
+ module ClassMethods
22
+ def goodsorcery_arguments(enabled)
23
+ self.goodsorcery_send_arguments = enabled ? true : false
24
+ end
25
+ end
26
+
27
+ def perform_now
28
+ return super if Integrations.delivery_job?(self)
29
+
30
+ Integrations.run_unit_of_work(JobDetails.new(self)) { super }
31
+ end
32
+ end
33
+
34
+ def self.install(base = ::ActiveJob::Base)
35
+ base.prepend(Extension) unless base <= Extension
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ module Rails
6
+ # The controller and action handling a request, from the controller
7
+ # instance when Rails dispatched to one, else from the router's path
8
+ # parameters.
9
+ class ControllerAction
10
+ attr_reader :controller, :action
11
+
12
+ def self.from_env(env)
13
+ instance = env["action_controller.instance"]
14
+ return new(instance.class.name, instance.action_name) if instance.respond_to?(:action_name)
15
+
16
+ from_path_parameters(env["action_dispatch.request.path_parameters"])
17
+ end
18
+
19
+ def self.from_path_parameters(params)
20
+ return nil unless params.is_a?(Hash) && params[:controller] && params[:action]
21
+
22
+ new("#{::ActiveSupport::Inflector.camelize(params[:controller].to_s)}Controller", params[:action].to_s)
23
+ end
24
+
25
+ def initialize(controller, action)
26
+ @controller = controller.to_s
27
+ @action = action.to_s
28
+ end
29
+
30
+ def transaction
31
+ "#{controller}##{action}"
32
+ end
33
+
34
+ def to_h
35
+ { "controller" => controller, "action" => action }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ module Rails
6
+ # The Action Controller layer (DESIGN.md 5.1): the Rack request plus the
7
+ # transaction ("UsersController#update"), context.controller and the
8
+ # signed-in user. Runs only when an error is reported.
9
+ class ControllerEnricher < GoodSorcery::Rack::RequestEnricher
10
+ def enrich(scope)
11
+ super
12
+ add_controller(scope, ControllerAction.from_env(@env))
13
+ scope.user ||= UserLookup.new(GoodSorcery.configuration.user_method).find(@env["action_controller.instance"])
14
+ end
15
+
16
+ private
17
+
18
+ def add_controller(scope, action)
19
+ return unless action
20
+
21
+ scope.transaction ||= action.transaction
22
+ scope.merge_context("controller" => action.to_h)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ module Rails
6
+ # Subscribed to Rails.error. Unhandled errors are reported at level
7
+ # "error"; handled ones (Rails.error.handle/report) at their severity,
8
+ # unless report_handled_errors is false. The reporter's context goes to
9
+ # context.rails_error (filtered like any context), with the source.
10
+ class ErrorSubscriber
11
+ LEVELS = { error: "error", warning: "warning", info: "info" }.freeze
12
+
13
+ def report(error, handled:, severity: nil, context: {}, source: nil)
14
+ return if handled && !GoodSorcery.configuration.report_handled_errors
15
+ return if ReportedMark.reported?(error) || from_delivery_job?(context)
16
+
17
+ GoodSorcery.notify(error, level: level(handled, severity),
18
+ context: { "rails_error" => rails_error_context(context, source) })
19
+ end
20
+
21
+ private
22
+
23
+ def level(handled, severity)
24
+ handled ? LEVELS.fetch(severity.to_s.to_sym, "warning") : "error"
25
+ end
26
+
27
+ def from_delivery_job?(context)
28
+ context.is_a?(Hash) && Integrations.delivery_job?(context[:job])
29
+ end
30
+
31
+ # Controllers and jobs from Rails' execution context become
32
+ # "#<ClassName>"; records become GlobalID strings.
33
+ def rails_error_context(context, source)
34
+ details = context.is_a?(Hash) ? JobArguments.new.serialize(context) : {}
35
+ details.merge("source" => source&.to_s).compact
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ module Rails
6
+ # Turns ActiveSupport::Notifications events into breadcrumbs, recorded
7
+ # only inside a scope an integration opened (a request, job or channel
8
+ # action). Never raises into the instrumented code.
9
+ class NotificationBreadcrumbs
10
+ EVENTS = %w[
11
+ start_processing.action_controller sql.active_record perform.active_job deliver.action_mailer
12
+ ].freeze
13
+ IGNORED_SQL_NAMES = %w[SCHEMA CACHE TRANSACTION].freeze
14
+
15
+ def self.subscribe
16
+ return if @subscribed
17
+
18
+ @subscribed = true
19
+ recorder = new
20
+ EVENTS.each do |event_name|
21
+ ::ActiveSupport::Notifications.subscribe(event_name) do |name, started, finished, _id, payload|
22
+ recorder.record_notification(name, started, finished, payload)
23
+ end
24
+ end
25
+ end
26
+
27
+ def record_notification(name, started, finished, payload)
28
+ return unless Scope.unit_of_work && GoodSorcery.client.collecting_breadcrumbs?
29
+
30
+ record(name, payload, duration_ms(started, finished))
31
+ rescue StandardError
32
+ nil
33
+ end
34
+
35
+ private
36
+
37
+ def record(name, payload, duration)
38
+ case name
39
+ when "start_processing.action_controller" then controller(payload)
40
+ when "sql.active_record" then sql(payload, duration)
41
+ when "perform.active_job" then job(payload)
42
+ when "deliver.action_mailer" then mail(payload)
43
+ end
44
+ end
45
+
46
+ def controller(payload)
47
+ format = payload[:format].to_s.upcase
48
+ message = "#{payload[:controller]}##{payload[:action]}"
49
+ GoodSorcery.add_breadcrumb(format.empty? ? message : "#{message} as #{format}", category: "controller")
50
+ end
51
+
52
+ def sql(payload, duration)
53
+ return if payload[:cached] || IGNORED_SQL_NAMES.include?(payload[:name])
54
+
55
+ statement = SqlStatement.new(payload[:sql]).obfuscated
56
+ GoodSorcery.add_breadcrumb("#{payload[:name] || "SQL"} (#{duration}ms)", category: "sql",
57
+ data: { "sql" => statement })
58
+ end
59
+
60
+ def job(payload)
61
+ job = payload[:job]
62
+ return unless job
63
+
64
+ failure = payload[:exception_object]
65
+ message = failure ? "#{job.class.name} failed (#{failure.class})" : "Performed #{job.class.name}"
66
+ GoodSorcery.add_breadcrumb(message, category: "job", level: failure ? "error" : "info",
67
+ data: { "queue" => job.queue_name })
68
+ end
69
+
70
+ def mail(payload)
71
+ GoodSorcery.add_breadcrumb("Delivered mail from #{payload[:mailer]}", category: "custom")
72
+ end
73
+
74
+ def duration_ms(started, finished)
75
+ ((finished - started) * 1000).round(1)
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ module Rails
6
+ # The SQL text recorded in a breadcrumb, with literal values replaced by
7
+ # "?": quoted strings (also dollar-quoted, and one cut off by the length
8
+ # cap) and numbers. Adapters that inline values instead of using bind
9
+ # parameters (mysql2, prepared_statements: false, sanitize_sql) would
10
+ # otherwise send emails, tokens or password digests unfiltered.
11
+ # Identifiers, keywords and placeholders such as $1 stay readable.
12
+ class SqlStatement
13
+ MAX_LENGTH = 1_000
14
+ # A dollar-quoted string ($$…$$, $tag$…$tag$) or a quoted one; the
15
+ # leftmost wins, so "$" inside a quoted string is not a dollar quote.
16
+ LITERAL = /\$((?:[a-z_]\w*)?)\$.*?(?:\$\1\$|\z)|'(?:[^'\\]|\\.|'')*(?:'|\z)/im
17
+ NUMBER = /(?<![\w$])\d+(?:\.\d+)?(?:e[+-]?\d+)?/i
18
+ PLACEHOLDER = "?"
19
+
20
+ def initialize(sql)
21
+ @sql = sql.to_s
22
+ end
23
+
24
+ # The first MAX_LENGTH characters, without literal values.
25
+ def obfuscated
26
+ Text.limit(@sql, MAX_LENGTH).gsub(LITERAL, PLACEHOLDER).gsub(NUMBER, PLACEHOLDER)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodSorcery
4
+ module Integrations
5
+ module Rails
6
+ # Finds the signed-in user for a report: the controller's user_method
7
+ # (default current_user), else Current.user. Keeps only id, email,
8
+ # username and name. Anything the app raises while looking is ignored.
9
+ class UserLookup
10
+ def initialize(user_method)
11
+ @user_method = user_method
12
+ end
13
+
14
+ def find(controller)
15
+ user = from_controller(controller) || from_current_attributes
16
+ user && Event::UserSection.build(user)
17
+ end
18
+
19
+ private
20
+
21
+ def from_controller(controller)
22
+ return nil unless controller && @user_method && controller.respond_to?(@user_method, true)
23
+
24
+ controller.send(@user_method)
25
+ rescue StandardError
26
+ nil
27
+ end
28
+
29
+ def from_current_attributes
30
+ return nil unless defined?(::Current) && ::Current.respond_to?(:user)
31
+
32
+ ::Current.user
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integrations"
4
+ require_relative "rails/controller_action"
5
+ require_relative "rails/user_lookup"
6
+ require_relative "rails/controller_enricher"
7
+ require_relative "rails/error_subscriber"
8
+ require_relative "rails/sql_statement"
9
+ require_relative "rails/notification_breadcrumbs"
10
+
11
+ module GoodSorcery
12
+ module Integrations
13
+ # Wiring for the Rails layers; GoodSorcery::Railtie calls these.
14
+ module Rails
15
+ module_function
16
+
17
+ def install_middleware(middleware)
18
+ middleware.insert_after(::ActionDispatch::ShowExceptions, GoodSorcery::Rack::Middleware,
19
+ enricher: ControllerEnricher)
20
+ end
21
+
22
+ def install_framework_hooks
23
+ ::ActiveSupport.on_load(:active_job) do
24
+ require_relative "active_job"
25
+ GoodSorcery::Integrations::ActiveJob.install(self)
26
+ end
27
+ ::ActiveSupport.on_load(:action_cable_channel) do
28
+ require_relative "action_cable"
29
+ GoodSorcery::Integrations::ActionCable.install(self)
30
+ end
31
+ end
32
+
33
+ # After the app's initializers ran: subscribe to Rails.error and to
34
+ # notifications, hook Sidekiq, and recompile the filters so the final
35
+ # filter_parameters apply.
36
+ def after_initialize
37
+ subscribe_to_error_reporter
38
+ NotificationBreadcrumbs.subscribe if GoodSorcery.configuration.breadcrumbs
39
+ install_sidekiq if defined?(::Sidekiq)
40
+ GoodSorcery.client.reconfigure(rebuild_delivery: false)
41
+ end
42
+
43
+ def subscribe_to_error_reporter
44
+ return if @error_subscriber || !::Rails.respond_to?(:error)
45
+
46
+ @error_subscriber = ErrorSubscriber.new
47
+ ::Rails.error.subscribe(@error_subscriber)
48
+ end
49
+
50
+ def install_sidekiq
51
+ require_relative "sidekiq"
52
+ Integrations::Sidekiq.install
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integrations"
4
+
5
+ module GoodSorcery
6
+ module Integrations
7
+ # Rake: an exception that ends the rake process is reported with
8
+ # transaction "rake <top-level tasks>" and context.rake (the failing task
9
+ # and its arguments), then the queue is flushed (up to shutdown_timeout)
10
+ # before Rake prints the error and exits.
11
+ module Rake
12
+ IVAR = :@__goodsorcery_rake_task
13
+
14
+ def self.install
15
+ ::Rake::Task.prepend(TaskExtension) unless ::Rake::Task <= TaskExtension
16
+ ::Rake::Application.prepend(ApplicationExtension) unless ::Rake::Application <= ApplicationExtension
17
+ end
18
+
19
+ # Remembers the innermost task an exception escaped from.
20
+ module TaskExtension
21
+ def execute(args = nil)
22
+ super
23
+ rescue Exception => e # rubocop:disable Lint/RescueException
24
+ FailedTask.remember(e, self, args)
25
+ raise
26
+ end
27
+ end
28
+
29
+ # Reports the exception before Rake displays it and exits.
30
+ module ApplicationExtension
31
+ def display_error_message(exception)
32
+ FailedTask.report(exception, top_level_tasks)
33
+ super
34
+ end
35
+ end
36
+
37
+ # The task (name and arguments) an exception escaped from.
38
+ module FailedTask
39
+ module_function
40
+
41
+ def remember(exception, task, args)
42
+ return if exception.frozen? || exception.instance_variable_defined?(IVAR)
43
+
44
+ arguments = args.respond_to?(:to_h) ? args.to_h : {}
45
+ exception.instance_variable_set(IVAR, { "task" => task.name, "arguments" => arguments })
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ def report(exception, top_level_tasks)
51
+ rake = exception.instance_variable_defined?(IVAR) ? exception.instance_variable_get(IVAR) : nil
52
+ GoodSorcery.notify(exception, transaction: transaction(top_level_tasks),
53
+ context: rake ? { "rake" => rake } : nil)
54
+ GoodSorcery.flush(GoodSorcery.configuration.shutdown_timeout)
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ # Task arguments ("db:seed[a,b]") stay out of the transaction name;
60
+ # they are sent, filtered, in context.rake.
61
+ def transaction(top_level_tasks)
62
+ names = Array(top_level_tasks).map { |name| name.to_s.sub(/\[.*\z/m, "") }
63
+ "rake #{names.join(" ")}".strip
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end