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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +299 -0
- data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
- data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
- data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
- data/lib/goodsorcery/backtrace/code_context.rb +27 -0
- data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
- data/lib/goodsorcery/backtrace/parser.rb +38 -0
- data/lib/goodsorcery/backtrace/paths.rb +42 -0
- data/lib/goodsorcery/bundler_plugin.rb +117 -0
- data/lib/goodsorcery/client.rb +180 -0
- data/lib/goodsorcery/configuration/validation.rb +59 -0
- data/lib/goodsorcery/configuration.rb +204 -0
- data/lib/goodsorcery/defaults.rb +40 -0
- data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
- data/lib/goodsorcery/delivery/async.rb +122 -0
- data/lib/goodsorcery/delivery/backoff.rb +20 -0
- data/lib/goodsorcery/delivery/custom.rb +27 -0
- data/lib/goodsorcery/delivery/event_queue.rb +124 -0
- data/lib/goodsorcery/delivery/sync.rb +27 -0
- data/lib/goodsorcery/delivery/test.rb +30 -0
- data/lib/goodsorcery/delivery/worker.rb +71 -0
- data/lib/goodsorcery/delivery.rb +39 -0
- data/lib/goodsorcery/delivery_job.rb +26 -0
- data/lib/goodsorcery/env_flag.rb +24 -0
- data/lib/goodsorcery/environment_gate.rb +61 -0
- data/lib/goodsorcery/errors.rb +14 -0
- data/lib/goodsorcery/event/breadcrumb.rb +32 -0
- data/lib/goodsorcery/event/builder.rb +77 -0
- data/lib/goodsorcery/event/cause_chain.rb +39 -0
- data/lib/goodsorcery/event/exception_section.rb +68 -0
- data/lib/goodsorcery/event/fields.rb +61 -0
- data/lib/goodsorcery/event/job_section.rb +34 -0
- data/lib/goodsorcery/event/request_section.rb +41 -0
- data/lib/goodsorcery/event/runtime_context.rb +52 -0
- data/lib/goodsorcery/event/tag_set.rb +41 -0
- data/lib/goodsorcery/event/user_section.rb +36 -0
- data/lib/goodsorcery/event_filter.rb +54 -0
- data/lib/goodsorcery/event_pipeline.rb +35 -0
- data/lib/goodsorcery/event_serializer.rb +25 -0
- data/lib/goodsorcery/exception_coercion.rb +18 -0
- data/lib/goodsorcery/extra_filter_probe.rb +32 -0
- data/lib/goodsorcery/ignore_list.rb +15 -0
- data/lib/goodsorcery/install_check.rb +58 -0
- data/lib/goodsorcery/integrations/action_cable.rb +75 -0
- data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
- data/lib/goodsorcery/integrations/active_job.rb +39 -0
- data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
- data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
- data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
- data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
- data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
- data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
- data/lib/goodsorcery/integrations/rails.rb +56 -0
- data/lib/goodsorcery/integrations/rake.rb +68 -0
- data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
- data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
- data/lib/goodsorcery/integrations.rb +27 -0
- data/lib/goodsorcery/job_arguments.rb +77 -0
- data/lib/goodsorcery/key_filter.rb +99 -0
- data/lib/goodsorcery/log.rb +55 -0
- data/lib/goodsorcery/masked_key.rb +29 -0
- data/lib/goodsorcery/rack/middleware.rb +57 -0
- data/lib/goodsorcery/rack/request_details.rb +120 -0
- data/lib/goodsorcery/rack/request_enricher.rb +20 -0
- data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
- data/lib/goodsorcery/railtie.rb +29 -0
- data/lib/goodsorcery/release_detector.rb +42 -0
- data/lib/goodsorcery/reported_mark.rb +19 -0
- data/lib/goodsorcery/sanitizer.rb +71 -0
- data/lib/goodsorcery/scope.rb +106 -0
- data/lib/goodsorcery/status_report.rb +50 -0
- data/lib/goodsorcery/tasks.rb +46 -0
- data/lib/goodsorcery/test_event.rb +46 -0
- data/lib/goodsorcery/text.rb +35 -0
- data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
- data/lib/goodsorcery/transport/connection.rb +87 -0
- data/lib/goodsorcery/transport/result.rb +103 -0
- data/lib/goodsorcery/transport.rb +172 -0
- data/lib/goodsorcery/verification.rb +47 -0
- data/lib/goodsorcery/version.rb +6 -0
- data/lib/goodsorcery.rb +176 -0
- data/plugins.rb +6 -0
- 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
|