hawk-ruby 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 +10 -0
- data/LICENSE +21 -0
- data/README.md +104 -0
- data/docs/configuration.md +160 -0
- data/docs/integrations.md +149 -0
- data/docs/releasing.md +23 -0
- data/docs/verification.md +85 -0
- data/lib/hawk/boundary.rb +26 -0
- data/lib/hawk/breadcrumbs/logger.rb +64 -0
- data/lib/hawk/breadcrumbs/net_http.rb +63 -0
- data/lib/hawk/breadcrumbs/rails.rb +24 -0
- data/lib/hawk/breadcrumbs.rb +92 -0
- data/lib/hawk/client.rb +175 -0
- data/lib/hawk/configuration.rb +138 -0
- data/lib/hawk/delayed_job.rb +61 -0
- data/lib/hawk/delivery.rb +113 -0
- data/lib/hawk/diagnostics.rb +53 -0
- data/lib/hawk/event.rb +136 -0
- data/lib/hawk/filter.rb +36 -0
- data/lib/hawk/filtered_fields.rb +56 -0
- data/lib/hawk/framework_filter.rb +16 -0
- data/lib/hawk/hook.rb +12 -0
- data/lib/hawk/job_context.rb +89 -0
- data/lib/hawk/json_value.rb +77 -0
- data/lib/hawk/process_hooks.rb +24 -0
- data/lib/hawk/rack/body.rb +76 -0
- data/lib/hawk/rack.rb +71 -0
- data/lib/hawk/rails/action_cable.rb +79 -0
- data/lib/hawk/rails/active_job.rb +74 -0
- data/lib/hawk/rails/error_subscriber.rb +38 -0
- data/lib/hawk/rails/integration.rb +51 -0
- data/lib/hawk/rails/payload_filter.rb +34 -0
- data/lib/hawk/rails.rb +5 -0
- data/lib/hawk/rake.rb +29 -0
- data/lib/hawk/release.rb +31 -0
- data/lib/hawk/resque.rb +77 -0
- data/lib/hawk/scope.rb +108 -0
- data/lib/hawk/scope_store.rb +55 -0
- data/lib/hawk/sidekiq.rb +80 -0
- data/lib/hawk/source.rb +83 -0
- data/lib/hawk/token.rb +53 -0
- data/lib/hawk/transport.rb +111 -0
- data/lib/hawk/version.rb +5 -0
- data/lib/hawk.rb +85 -0
- data/sig/hawk/boundary.rbs +7 -0
- data/sig/hawk/breadcrumbs/logger.rbs +17 -0
- data/sig/hawk/breadcrumbs/net_http.rbs +16 -0
- data/sig/hawk/breadcrumbs/rails.rbs +7 -0
- data/sig/hawk/breadcrumbs.rbs +13 -0
- data/sig/hawk/client.rbs +41 -0
- data/sig/hawk/configuration.rbs +61 -0
- data/sig/hawk/delayed_job.rbs +24 -0
- data/sig/hawk/delivery.rbs +27 -0
- data/sig/hawk/diagnostics.rbs +24 -0
- data/sig/hawk/event.rbs +24 -0
- data/sig/hawk/filter.rbs +13 -0
- data/sig/hawk/filtered_fields.rbs +11 -0
- data/sig/hawk/framework_filter.rbs +5 -0
- data/sig/hawk/hook.rbs +5 -0
- data/sig/hawk/job_context.rbs +15 -0
- data/sig/hawk/json_value.rbs +17 -0
- data/sig/hawk/process_hooks.rbs +7 -0
- data/sig/hawk/rack.rbs +74 -0
- data/sig/hawk/rails/action_cable.rbs +40 -0
- data/sig/hawk/rails/active_job.rbs +34 -0
- data/sig/hawk/rails/error_subscriber.rbs +9 -0
- data/sig/hawk/rails/payload_filter.rbs +16 -0
- data/sig/hawk/rails.rbs +26 -0
- data/sig/hawk/rake.rbs +13 -0
- data/sig/hawk/release.rbs +6 -0
- data/sig/hawk/resque.rbs +25 -0
- data/sig/hawk/scope.rbs +31 -0
- data/sig/hawk/scope_store.rbs +15 -0
- data/sig/hawk/sidekiq.rbs +29 -0
- data/sig/hawk/source.rbs +18 -0
- data/sig/hawk/token.rbs +14 -0
- data/sig/hawk/transport.rbs +32 -0
- data/sig/hawk.rbs +16 -0
- metadata +223 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
module Rails
|
|
5
|
+
# These wrappers match Rails 7.2, 8.0, and 8.1 lifecycle methods.
|
|
6
|
+
module ActionCable
|
|
7
|
+
def self.install
|
|
8
|
+
return if @installed || !::Rails.version.match?(/\A(?:7\.2|8\.[01])\./)
|
|
9
|
+
|
|
10
|
+
ActiveSupport.on_load(:action_cable_channel) do |base|
|
|
11
|
+
base.prepend(ChannelMethods)
|
|
12
|
+
base.prepend(RescueMethods)
|
|
13
|
+
end
|
|
14
|
+
ActiveSupport.on_load(:action_cable_connection) do |base|
|
|
15
|
+
base.prepend(ConnectionMethods)
|
|
16
|
+
base.prepend(RescueMethods)
|
|
17
|
+
end
|
|
18
|
+
@installed = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.client
|
|
22
|
+
Rails.integration_client(:action_cable)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.run(owner, operation, &)
|
|
26
|
+
client = self.client
|
|
27
|
+
return yield unless client
|
|
28
|
+
|
|
29
|
+
metadata = { "cable" => { "class" => owner.class.name, "operation" => operation } }
|
|
30
|
+
Boundary.run(client, kind: :cable, metadata: metadata, &)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.report(error, handled:)
|
|
34
|
+
client = self.client
|
|
35
|
+
return unless client
|
|
36
|
+
return if client.consume_escape(error)
|
|
37
|
+
|
|
38
|
+
client.report(error, mechanism: "action_cable", handled: handled)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Channel entry points establish per-operation scopes.
|
|
42
|
+
module ChannelMethods
|
|
43
|
+
def perform_action(data)
|
|
44
|
+
ActionCable.run(self, "perform_action") { super }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def subscribe_to_channel
|
|
48
|
+
ActionCable.run(self, "subscribe_to_channel") { super }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unsubscribe_from_channel
|
|
52
|
+
ActionCable.run(self, "unsubscribe_from_channel") { super }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Connection lifecycle callbacks run on reusable Cable workers.
|
|
57
|
+
module ConnectionMethods
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def handle_open
|
|
61
|
+
ActionCable.run(self, "handle_open") { super }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def handle_close
|
|
65
|
+
ActionCable.run(self, "handle_close") { super }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Observe consumed errors without replacing application rescue handlers.
|
|
70
|
+
module RescueMethods
|
|
71
|
+
def rescue_with_handler(exception)
|
|
72
|
+
result = super
|
|
73
|
+
ActionCable.report(exception, handled: !result.nil?)
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
module Rails
|
|
5
|
+
# Active Job owns retry decisions; Hawk observes escaping and terminal errors.
|
|
6
|
+
module ActiveJob
|
|
7
|
+
def self.install
|
|
8
|
+
return if @installed || !::Rails.version.match?(/\A(?:7\.2|8\.[01])\./)
|
|
9
|
+
|
|
10
|
+
ActiveSupport.on_load(:active_job) do
|
|
11
|
+
::ActiveJob::Base.singleton_class.prepend(ClassMethods)
|
|
12
|
+
::ActiveJob::Base.prepend(InstanceMethods)
|
|
13
|
+
::ActiveJob::Base.after_discard do |_job, error|
|
|
14
|
+
Hawk::Rails::ActiveJob.client&.report(error, mechanism: "active_job.discard", handled: true)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
@installed = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.client
|
|
21
|
+
Rails.integration_client(:active_job)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.run(data, context, &block)
|
|
25
|
+
client = self.client
|
|
26
|
+
return yield unless client
|
|
27
|
+
|
|
28
|
+
Boundary.run(client, kind: :active_job, metadata: { "job" => metadata(client, data) }) do
|
|
29
|
+
JobContext.apply(client, context)
|
|
30
|
+
block.call
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.metadata(client, data)
|
|
35
|
+
result = JSONValue.object(data.slice("job_class", "job_id", "queue_name", "executions"))
|
|
36
|
+
result["arguments"] = JSONValue.normalize(data["arguments"]) if client.configuration.include_job_arguments
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Wrap deserialization failures as well as normal job execution.
|
|
41
|
+
module ClassMethods
|
|
42
|
+
def execute(job_data)
|
|
43
|
+
ActiveJob.run(job_data, job_data["hawk"]) { super }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Serialization keeps application arguments and backend retry data intact.
|
|
48
|
+
module InstanceMethods
|
|
49
|
+
def serialize
|
|
50
|
+
data = super
|
|
51
|
+
client = ActiveJob.client
|
|
52
|
+
context = JobContext.dump(client) if client
|
|
53
|
+
data["hawk"] = context if context
|
|
54
|
+
data
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def deserialize(job_data)
|
|
58
|
+
@hawk_context = JobContext.load(job_data["hawk"])
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def perform_now
|
|
63
|
+
client = ActiveJob.client
|
|
64
|
+
return super unless client
|
|
65
|
+
|
|
66
|
+
data = { "job_class" => self.class.name, "job_id" => job_id, "queue_name" => queue_name,
|
|
67
|
+
"executions" => executions, "arguments" => arguments }
|
|
68
|
+
context = @hawk_context || JobContext.dump(client)
|
|
69
|
+
ActiveJob.run(data, context) { super }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
module Rails
|
|
5
|
+
# Rails owns subscriber lifetime; each report resolves the current client.
|
|
6
|
+
class ErrorSubscriber
|
|
7
|
+
def self.install
|
|
8
|
+
return if @installed
|
|
9
|
+
|
|
10
|
+
::Rails.error.subscribe(new)
|
|
11
|
+
@installed = true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report(error, handled:, severity:, context:, source: nil)
|
|
15
|
+
client = Rails.integration_client(:rails_error)
|
|
16
|
+
return unless client
|
|
17
|
+
return if request_owned?(client, error, source)
|
|
18
|
+
|
|
19
|
+
client.with_scope do |scope|
|
|
20
|
+
scope.addons["rails_source"] = source
|
|
21
|
+
client.report(error, mechanism: "rails.error", context: JSONValue.object(context),
|
|
22
|
+
level: severity, handled: handled)
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def request_owned?(client, error, source)
|
|
32
|
+
return false unless source == "application.action_dispatch"
|
|
33
|
+
|
|
34
|
+
client.current_scope.addons["boundary"] == "request" || client.consume_escape(error)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rack"
|
|
4
|
+
require "active_support/parameter_filter"
|
|
5
|
+
require_relative "payload_filter"
|
|
6
|
+
require_relative "error_subscriber"
|
|
7
|
+
require_relative "action_cable"
|
|
8
|
+
require_relative "active_job"
|
|
9
|
+
require_relative "../breadcrumbs/rails"
|
|
10
|
+
|
|
11
|
+
module Hawk
|
|
12
|
+
# Framework filtering is injected without changing the user's before_send hook.
|
|
13
|
+
module Rails
|
|
14
|
+
def self.configure(configuration)
|
|
15
|
+
return unless configuration.integration_enabled?(:rails)
|
|
16
|
+
|
|
17
|
+
application = ::Rails.application
|
|
18
|
+
return unless application
|
|
19
|
+
|
|
20
|
+
filter = PayloadFilter.new(ActiveSupport::ParameterFilter.new(application.config.filter_parameters))
|
|
21
|
+
configuration.framework_filter = ->(payload) { filter.call(payload) }
|
|
22
|
+
activate_integrations(configuration)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.integration_client(integration)
|
|
27
|
+
client = Hawk.client
|
|
28
|
+
return unless client&.enabled? && client.configuration.integration_enabled?(:rails)
|
|
29
|
+
|
|
30
|
+
client if client.configuration.integration_enabled?(integration)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.activate_integrations(configuration)
|
|
34
|
+
ErrorSubscriber.install
|
|
35
|
+
ActionCable.install
|
|
36
|
+
ActiveJob.install
|
|
37
|
+
Breadcrumbs::Rails.install if configuration.breadcrumbs_rails
|
|
38
|
+
require_relative "../sidekiq" if defined?(::Sidekiq)
|
|
39
|
+
require_relative "../resque" if defined?(::Resque)
|
|
40
|
+
require_relative "../delayed_job" if defined?(::Delayed::Worker)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Rails constructs this middleware before its stack is frozen.
|
|
46
|
+
class Railtie < ::Rails::Railtie
|
|
47
|
+
initializer "hawk.middleware", after: :load_config_initializers do |application|
|
|
48
|
+
application.middleware.insert_after(ActionDispatch::DebugExceptions, Hawk::Rack, integration: :rails)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
module Rails
|
|
5
|
+
# Rails deep filters and original-params blocks need each logical tree root.
|
|
6
|
+
class PayloadFilter
|
|
7
|
+
PATHS = [%w[context], %w[user], %w[data], %w[addons request params],
|
|
8
|
+
%w[addons request body], %w[addons request headers], %w[addons job arguments]].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(filter)
|
|
11
|
+
@filter = filter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(payload)
|
|
15
|
+
fields = FilteredFields.new(payload, PATHS, containers_only: true)
|
|
16
|
+
fields.restore(filter(payload)) { |key, value| filter_tree(key, value) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def filter(payload)
|
|
22
|
+
JSONValue.object(@filter.filter(payload))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def filter_tree(key, value)
|
|
26
|
+
case value
|
|
27
|
+
when Hash then filter(value)
|
|
28
|
+
when Array then value.map { |item| filter_tree(key, item) }
|
|
29
|
+
else filter({ key => value })[key]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/hawk/rails.rb
ADDED
data/lib/hawk/rake.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hawk"
|
|
4
|
+
require "rake"
|
|
5
|
+
|
|
6
|
+
module Hawk
|
|
7
|
+
# Optional task capture delegates execution and preserves application errors.
|
|
8
|
+
module Rake
|
|
9
|
+
# Prepended to tasks without replacing their original execute implementation.
|
|
10
|
+
module TaskMethods
|
|
11
|
+
def execute(args = nil)
|
|
12
|
+
client = Hawk.client
|
|
13
|
+
return super unless client
|
|
14
|
+
return super unless client.configuration.integration_enabled?(:rake)
|
|
15
|
+
|
|
16
|
+
Boundary.run(client, kind: :rake, metadata: { "rake" => { "task" => name } }) { super }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.install
|
|
21
|
+
return unless ::Rake::VERSION.start_with?("13.")
|
|
22
|
+
|
|
23
|
+
::Rake::Task.prepend(TaskMethods) unless ::Rake::Task.ancestors.include?(TaskMethods)
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Hawk::Rake.install
|
data/lib/hawk/release.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Hawk
|
|
6
|
+
# Git is consulted only during explicit initialization-time detection.
|
|
7
|
+
module Release
|
|
8
|
+
def self.detect(configuration)
|
|
9
|
+
return configuration.release if configuration.release
|
|
10
|
+
return unless configuration.detect_release
|
|
11
|
+
|
|
12
|
+
git_revision
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.git_revision
|
|
16
|
+
Open3.popen3("git", "rev-parse", "HEAD") do |input, output, _error, waiter|
|
|
17
|
+
input.close
|
|
18
|
+
unless waiter.join(1.0)
|
|
19
|
+
Process.kill("KILL", waiter.pid)
|
|
20
|
+
waiter.join
|
|
21
|
+
return nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
revision = output.read(128)
|
|
25
|
+
revision&.strip if waiter.value.success?
|
|
26
|
+
end
|
|
27
|
+
rescue SystemCallError, IOError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/hawk/resque.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hawk"
|
|
4
|
+
require "resque"
|
|
5
|
+
require "resque/failure"
|
|
6
|
+
|
|
7
|
+
module Hawk
|
|
8
|
+
# Resque 2.7 delegates failures to its existing backend without payload changes.
|
|
9
|
+
module Resque
|
|
10
|
+
def self.install
|
|
11
|
+
return unless ::Resque::VERSION == "2.7.0"
|
|
12
|
+
|
|
13
|
+
::Resque::Job.prepend(JobMethods) unless ::Resque::Job.ancestors.include?(JobMethods)
|
|
14
|
+
singleton = ::Resque::Failure.singleton_class
|
|
15
|
+
singleton.prepend(FailureMethods) unless singleton.ancestors.include?(FailureMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.client
|
|
19
|
+
client = Hawk.client
|
|
20
|
+
client if client&.enabled? && client.configuration.integration_enabled?(:resque)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.metadata(client, payload, queue)
|
|
24
|
+
data = JSONValue.object(payload.slice("class"))
|
|
25
|
+
data["queue"] = JSONValue.normalize(queue)
|
|
26
|
+
data["arguments"] = JSONValue.normalize(payload["args"]) if client.configuration.include_job_arguments
|
|
27
|
+
{ "job" => data }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.run(payload, queue, &)
|
|
31
|
+
# @type var client: Client?
|
|
32
|
+
client = self.client
|
|
33
|
+
return yield unless client
|
|
34
|
+
|
|
35
|
+
Boundary.run(client, kind: :resque, metadata: metadata(client, payload, queue), &)
|
|
36
|
+
ensure
|
|
37
|
+
client&.flush
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.report_failure(options)
|
|
41
|
+
client = self.client
|
|
42
|
+
return unless client
|
|
43
|
+
|
|
44
|
+
case error = options[:exception]
|
|
45
|
+
when Exception
|
|
46
|
+
return if client.consume_escape(error)
|
|
47
|
+
|
|
48
|
+
client.with_boundary(kind: :resque,
|
|
49
|
+
metadata: metadata(client, JSONValue.object(options[:payload]), options[:queue])) do
|
|
50
|
+
client.report(error, mechanism: "resque.backend")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Flush every completed attempt because Resque may exit children with exit!.
|
|
59
|
+
module JobMethods
|
|
60
|
+
def perform
|
|
61
|
+
Resque.run(payload, queue) { super }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Failures before perform still reach the configured Resque failure backend.
|
|
66
|
+
module FailureMethods
|
|
67
|
+
def create(options = {})
|
|
68
|
+
Resque.report_failure(options)
|
|
69
|
+
super
|
|
70
|
+
ensure
|
|
71
|
+
Resque.client&.flush
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Hawk::Resque.install
|
data/lib/hawk/scope.rb
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Hawk
|
|
6
|
+
# Execution data is copied at scope boundaries and before event delivery.
|
|
7
|
+
class Scope
|
|
8
|
+
# Identity comparison avoids invoking application exception equality methods.
|
|
9
|
+
class Ledger
|
|
10
|
+
def initialize
|
|
11
|
+
@errors = {}
|
|
12
|
+
@errors.compare_by_identity
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def accept(exception, evict: false)
|
|
16
|
+
return :duplicate if @errors[exception]
|
|
17
|
+
|
|
18
|
+
if @errors.size >= 256
|
|
19
|
+
return :overflow unless evict
|
|
20
|
+
|
|
21
|
+
@errors.shift
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@errors[exception] = true
|
|
25
|
+
:accepted
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :context, :user, :anonymous_id
|
|
30
|
+
attr_accessor :addons, :breadcrumbs, :ledger, :boundary, :request_metadata
|
|
31
|
+
|
|
32
|
+
def initialize(context: {}, user: nil, anonymous_id: SecureRandom.uuid)
|
|
33
|
+
@context = JSONValue.object(context)
|
|
34
|
+
@user = user ? JSONValue.object(user) : nil
|
|
35
|
+
@anonymous_id = anonymous_id
|
|
36
|
+
@addons = {}
|
|
37
|
+
@breadcrumbs = []
|
|
38
|
+
@ledger = Ledger.new
|
|
39
|
+
@boundary = false
|
|
40
|
+
@request_metadata = nil
|
|
41
|
+
@filtered_context_keys = {}
|
|
42
|
+
@filtered_user = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_context(context)
|
|
46
|
+
update_context(context, false)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def set_user(user)
|
|
50
|
+
update_user(user, false)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def restore_filtered(context, user)
|
|
54
|
+
update_context(context, true)
|
|
55
|
+
update_user(user, true) if user
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def filtered_paths(except_context: [], user_override: false)
|
|
60
|
+
paths = (@filtered_context_keys.keys - except_context).map { |key| ["context", key] }
|
|
61
|
+
paths << ["user"] if @filtered_user && !user_override
|
|
62
|
+
paths << ["breadcrumbs"]
|
|
63
|
+
paths
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def event_addons
|
|
67
|
+
reader = @request_metadata
|
|
68
|
+
reader ? @addons.merge("request" => reader.call) : @addons
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def event_data(context, user, default_user, addons)
|
|
72
|
+
context = JSONValue.object(context)
|
|
73
|
+
value = JSONValue.object(user || @user || default_user || {})
|
|
74
|
+
value["id"] ||= @anonymous_id
|
|
75
|
+
{ context: @context.merge(context), user: value, breadcrumbs: @breadcrumbs, addons: event_addons.merge(addons),
|
|
76
|
+
filtered_paths: filtered_paths(except_context: context.keys, user_override: !user.nil?) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def copy
|
|
80
|
+
duplicate = Scope.new(context: @context, user: @user, anonymous_id: @anonymous_id)
|
|
81
|
+
duplicate.addons = JSONValue.object(@addons)
|
|
82
|
+
duplicate.breadcrumbs = @breadcrumbs.map { |item| JSONValue.object(item) }
|
|
83
|
+
duplicate.ledger = @ledger
|
|
84
|
+
duplicate.boundary = @boundary
|
|
85
|
+
duplicate.request_metadata = @request_metadata
|
|
86
|
+
duplicate.restore_filtered(@context.slice(*@filtered_context_keys.keys), @filtered_user ? @user : nil)
|
|
87
|
+
duplicate
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def update_context(context, filtered)
|
|
93
|
+
values = JSONValue.object(context)
|
|
94
|
+
values.each_key do |key|
|
|
95
|
+
@filtered_context_keys.delete(key)
|
|
96
|
+
@filtered_context_keys[key] = true if filtered
|
|
97
|
+
end
|
|
98
|
+
@context.merge!(values)
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def update_user(user, filtered)
|
|
103
|
+
@user = user ? JSONValue.object(user) : nil
|
|
104
|
+
@filtered_user = filtered
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Thread#[] stores values per fiber, including fibers sharing one thread.
|
|
5
|
+
class ScopeStore
|
|
6
|
+
def initialize(configuration, anonymous_id)
|
|
7
|
+
@configuration = configuration
|
|
8
|
+
@anonymous_id = anonymous_id
|
|
9
|
+
@scope_key = "hawk.scope.#{anonymous_id}"
|
|
10
|
+
@escape_key = "hawk.escape.#{anonymous_id}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def current
|
|
14
|
+
# @type var value: top
|
|
15
|
+
value = Thread.current[@scope_key]
|
|
16
|
+
case value
|
|
17
|
+
when Scope then value
|
|
18
|
+
else
|
|
19
|
+
scope = Scope.new(context: @configuration.context, user: @configuration.user, anonymous_id: @anonymous_id)
|
|
20
|
+
replace(scope)
|
|
21
|
+
scope
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def replace(scope)
|
|
26
|
+
Thread.current[@scope_key] = scope
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def remember_escape(exception)
|
|
31
|
+
Thread.current[@escape_key] = exception
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def consume_escape(exception)
|
|
36
|
+
# @type var value: top
|
|
37
|
+
value = Thread.current[@escape_key]
|
|
38
|
+
clear_escape
|
|
39
|
+
case value
|
|
40
|
+
when Exception then value.equal?(exception)
|
|
41
|
+
else false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear_escape
|
|
46
|
+
Thread.current[@escape_key] = nil
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear
|
|
51
|
+
Thread.current[@scope_key] = nil
|
|
52
|
+
clear_escape
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/hawk/sidekiq.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hawk"
|
|
4
|
+
require "sidekiq"
|
|
5
|
+
|
|
6
|
+
module Hawk
|
|
7
|
+
# Native middleware covers each backend attempt, including pre-execution errors.
|
|
8
|
+
module Sidekiq
|
|
9
|
+
def self.install
|
|
10
|
+
return unless ::Sidekiq::VERSION.match?(/\A[78]\./)
|
|
11
|
+
|
|
12
|
+
::Sidekiq.configure_client { |config| register_client(config) }
|
|
13
|
+
::Sidekiq.configure_server { |config| register(config) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.register_client(config)
|
|
17
|
+
config.client_middleware { |chain| chain.add(ClientMiddleware) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.register(config)
|
|
21
|
+
register_client(config)
|
|
22
|
+
config.server_middleware { |chain| chain.add(ServerMiddleware) }
|
|
23
|
+
config.error_handlers << ERROR_HANDLER unless config.error_handlers.include?(ERROR_HANDLER)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.client
|
|
27
|
+
client = Hawk.client
|
|
28
|
+
client if client&.enabled? && client.configuration.integration_enabled?(:sidekiq)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.metadata(client, job)
|
|
32
|
+
result = JSONValue.object(job.slice("class", "jid", "queue", "retry_count"))
|
|
33
|
+
result["arguments"] = JSONValue.normalize(job["args"]) if client.configuration.include_job_arguments
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Enqueue context uses Sidekiq's supported top-level metadata channel.
|
|
38
|
+
class ClientMiddleware
|
|
39
|
+
def call(_worker, job, _queue, _redis_pool = nil)
|
|
40
|
+
client = Sidekiq.client
|
|
41
|
+
context = JobContext.dump(client) if client
|
|
42
|
+
job["hawk"] = context if context
|
|
43
|
+
yield
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Middleware delegates retries and acknowledgement to Sidekiq.
|
|
48
|
+
class ServerMiddleware
|
|
49
|
+
def call(_worker, job, _queue)
|
|
50
|
+
client = Sidekiq.client
|
|
51
|
+
return yield unless client
|
|
52
|
+
|
|
53
|
+
Boundary.run(client, kind: :sidekiq, metadata: { "job" => Sidekiq.metadata(client, job) }) do
|
|
54
|
+
JobContext.apply(client, job["hawk"])
|
|
55
|
+
yield
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The execution marker is consumed only by this client's outer error handler.
|
|
61
|
+
class ErrorHandler
|
|
62
|
+
def call(error, context, _config = nil)
|
|
63
|
+
client = Sidekiq.client
|
|
64
|
+
return unless client
|
|
65
|
+
return if client.consume_escape(error)
|
|
66
|
+
|
|
67
|
+
job = JSONValue.object(context[:job])
|
|
68
|
+
client.with_boundary(kind: :sidekiq, metadata: { "job" => Sidekiq.metadata(client, job) }) do
|
|
69
|
+
JobContext.apply(client, job["hawk"])
|
|
70
|
+
client.report(error, mechanism: "sidekiq.backend")
|
|
71
|
+
end
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
ERROR_HANDLER = ErrorHandler.new
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Hawk::Sidekiq.install
|