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.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +104 -0
  5. data/docs/configuration.md +160 -0
  6. data/docs/integrations.md +149 -0
  7. data/docs/releasing.md +23 -0
  8. data/docs/verification.md +85 -0
  9. data/lib/hawk/boundary.rb +26 -0
  10. data/lib/hawk/breadcrumbs/logger.rb +64 -0
  11. data/lib/hawk/breadcrumbs/net_http.rb +63 -0
  12. data/lib/hawk/breadcrumbs/rails.rb +24 -0
  13. data/lib/hawk/breadcrumbs.rb +92 -0
  14. data/lib/hawk/client.rb +175 -0
  15. data/lib/hawk/configuration.rb +138 -0
  16. data/lib/hawk/delayed_job.rb +61 -0
  17. data/lib/hawk/delivery.rb +113 -0
  18. data/lib/hawk/diagnostics.rb +53 -0
  19. data/lib/hawk/event.rb +136 -0
  20. data/lib/hawk/filter.rb +36 -0
  21. data/lib/hawk/filtered_fields.rb +56 -0
  22. data/lib/hawk/framework_filter.rb +16 -0
  23. data/lib/hawk/hook.rb +12 -0
  24. data/lib/hawk/job_context.rb +89 -0
  25. data/lib/hawk/json_value.rb +77 -0
  26. data/lib/hawk/process_hooks.rb +24 -0
  27. data/lib/hawk/rack/body.rb +76 -0
  28. data/lib/hawk/rack.rb +71 -0
  29. data/lib/hawk/rails/action_cable.rb +79 -0
  30. data/lib/hawk/rails/active_job.rb +74 -0
  31. data/lib/hawk/rails/error_subscriber.rb +38 -0
  32. data/lib/hawk/rails/integration.rb +51 -0
  33. data/lib/hawk/rails/payload_filter.rb +34 -0
  34. data/lib/hawk/rails.rb +5 -0
  35. data/lib/hawk/rake.rb +29 -0
  36. data/lib/hawk/release.rb +31 -0
  37. data/lib/hawk/resque.rb +77 -0
  38. data/lib/hawk/scope.rb +108 -0
  39. data/lib/hawk/scope_store.rb +55 -0
  40. data/lib/hawk/sidekiq.rb +80 -0
  41. data/lib/hawk/source.rb +83 -0
  42. data/lib/hawk/token.rb +53 -0
  43. data/lib/hawk/transport.rb +111 -0
  44. data/lib/hawk/version.rb +5 -0
  45. data/lib/hawk.rb +85 -0
  46. data/sig/hawk/boundary.rbs +7 -0
  47. data/sig/hawk/breadcrumbs/logger.rbs +17 -0
  48. data/sig/hawk/breadcrumbs/net_http.rbs +16 -0
  49. data/sig/hawk/breadcrumbs/rails.rbs +7 -0
  50. data/sig/hawk/breadcrumbs.rbs +13 -0
  51. data/sig/hawk/client.rbs +41 -0
  52. data/sig/hawk/configuration.rbs +61 -0
  53. data/sig/hawk/delayed_job.rbs +24 -0
  54. data/sig/hawk/delivery.rbs +27 -0
  55. data/sig/hawk/diagnostics.rbs +24 -0
  56. data/sig/hawk/event.rbs +24 -0
  57. data/sig/hawk/filter.rbs +13 -0
  58. data/sig/hawk/filtered_fields.rbs +11 -0
  59. data/sig/hawk/framework_filter.rbs +5 -0
  60. data/sig/hawk/hook.rbs +5 -0
  61. data/sig/hawk/job_context.rbs +15 -0
  62. data/sig/hawk/json_value.rbs +17 -0
  63. data/sig/hawk/process_hooks.rbs +7 -0
  64. data/sig/hawk/rack.rbs +74 -0
  65. data/sig/hawk/rails/action_cable.rbs +40 -0
  66. data/sig/hawk/rails/active_job.rbs +34 -0
  67. data/sig/hawk/rails/error_subscriber.rbs +9 -0
  68. data/sig/hawk/rails/payload_filter.rbs +16 -0
  69. data/sig/hawk/rails.rbs +26 -0
  70. data/sig/hawk/rake.rbs +13 -0
  71. data/sig/hawk/release.rbs +6 -0
  72. data/sig/hawk/resque.rbs +25 -0
  73. data/sig/hawk/scope.rbs +31 -0
  74. data/sig/hawk/scope_store.rbs +15 -0
  75. data/sig/hawk/sidekiq.rbs +29 -0
  76. data/sig/hawk/source.rbs +18 -0
  77. data/sig/hawk/token.rbs +14 -0
  78. data/sig/hawk/transport.rbs +32 -0
  79. data/sig/hawk.rbs +16 -0
  80. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hawk"
4
+ require "rails/railtie"
5
+ require_relative "rails/integration"
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
@@ -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
@@ -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
@@ -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