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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Breadcrumbs
5
+ # Guard Net::HTTP's internal recursion while retaining the response block API.
6
+ module NetHTTP
7
+ def self.install
8
+ ::Net::HTTP.prepend(Methods) unless ::Net::HTTP.ancestors.include?(Methods)
9
+ end
10
+
11
+ def self.record(client, request, host, status, started)
12
+ path = request.path.split("?", 2).first.to_s
13
+ path = URI.parse(path).path if path.start_with?("http://", "https://")
14
+ data = { "method" => request.method, "host" => host, "path" => path, "status" => status,
15
+ "duration_ms" => (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000 }
16
+ client.add_breadcrumb(category: "http", message: "HTTP request", data: data)
17
+ rescue StandardError
18
+ nil
19
+ end
20
+
21
+ def self.connection_failed(client, host, started)
22
+ data = { "method" => "CONNECT", "host" => host,
23
+ "duration_ms" => (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000 }
24
+ client.add_breadcrumb(category: "http", message: "HTTP connection failed", data: data)
25
+ rescue StandardError
26
+ nil
27
+ end
28
+
29
+ # The ensure path records failed operations without replacing their errors.
30
+ module Methods
31
+ def start(&block)
32
+ # @type var client: Hawk::Client?
33
+ client = Hawk::Breadcrumbs.client(:http)
34
+ # @type var started: Float
35
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ yielded = false
37
+ return super unless block && client
38
+
39
+ super do |http|
40
+ yielded = true
41
+ block.call(http)
42
+ end
43
+ rescue StandardError
44
+ NetHTTP.connection_failed(client, address, started) if client && !yielded
45
+ raise
46
+ end
47
+
48
+ def request(request, body = nil, &)
49
+ # @type var client: Hawk::Client?
50
+ client = Hawk::Breadcrumbs.client(:http)
51
+ return super unless client
52
+
53
+ # @type var started: Float?
54
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ # @type var response: ::Net::HTTPResponse?
56
+ response = Hawk::Breadcrumbs.guard(:http) { super }
57
+ ensure
58
+ NetHTTP.record(client, request, address, response&.code, started) if client && started
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ module Breadcrumbs
5
+ # Notification payloads are intentionally excluded from automatic breadcrumbs.
6
+ module Rails
7
+ EVENTS = /\A(?:
8
+ sql\.active_record|process_action\.action_controller|deliver\.action_mailer|
9
+ (?:enqueue|enqueue_at|enqueue_retry|perform|perform_start|retry_stopped|discard)\.active_job
10
+ )\z/x
11
+
12
+ def self.install
13
+ return if @installed
14
+
15
+ ActiveSupport::Notifications.monotonic_subscribe(EVENTS) do |name, started, finished, _id, _payload|
16
+ client = Hawk::Breadcrumbs.client(:rails)
17
+ client&.add_breadcrumb(category: "rails", message: name,
18
+ data: { "duration_ms" => (finished - started) * 1000 })
19
+ end
20
+ @installed = true
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ # Breadcrumb buffers belong to scopes; instrumentation guards belong to fibers.
5
+ module Breadcrumbs
6
+ def self.install(configuration)
7
+ if configuration.breadcrumbs_logger || configuration.logger_error_level
8
+ require_relative "breadcrumbs/logger"
9
+ Logger.install
10
+ end
11
+ return unless configuration.breadcrumbs_http
12
+
13
+ require_relative "breadcrumbs/net_http"
14
+ NetHTTP.install
15
+ end
16
+
17
+ def self.guard(category)
18
+ key = "hawk.instrumentation.#{category}"
19
+ # @type var previous: top
20
+ previous = Thread.current[key]
21
+ Thread.current[key] = true
22
+ yield
23
+ ensure
24
+ Thread.current[key] = previous
25
+ end
26
+
27
+ def self.suppressed?(category)
28
+ # @type var value: top
29
+ value = Thread.current["hawk.instrumentation.#{category}"]
30
+ case value
31
+ when true then true
32
+ else false
33
+ end
34
+ end
35
+
36
+ def self.client(category)
37
+ client = Hawk.client
38
+ return unless client&.enabled?
39
+ return if suppressed?(:sdk) || suppressed?(category)
40
+
41
+ client if enabled?(client.configuration, category)
42
+ end
43
+
44
+ def self.enabled?(config, category)
45
+ case category
46
+ when :logger then config.breadcrumbs_logger || !config.logger_error_level.nil?
47
+ when :http then config.breadcrumbs_http
48
+ when :rails then config.breadcrumbs_rails && config.integration_enabled?(:rails)
49
+ else false
50
+ end
51
+ end
52
+
53
+ def self.add(client, raw)
54
+ return unless client.enabled?
55
+
56
+ entry = filtered(client.configuration, raw)
57
+ return unless entry
58
+
59
+ buffer = client.current_scope.breadcrumbs
60
+ buffer << entry
61
+ buffer.shift while buffer.size > 50
62
+ nil
63
+ rescue StandardError
64
+ client.diagnostics.increment(:filtered)
65
+ nil
66
+ end
67
+
68
+ def self.filtered(configuration, raw)
69
+ entry = JSONValue.object(raw).merge("timestamp" => (Time.now.to_f * 1000).to_i)
70
+ entry = JSONValue.object(Filter.new(configuration).call(entry))
71
+ entry = framework_filtered(configuration, entry)
72
+ return unless entry
73
+
74
+ hook = configuration.before_breadcrumb
75
+ return entry unless hook
76
+
77
+ case result = Hook.call { hook.call(entry) }
78
+ when Hash then JSONValue.object(result)
79
+ end
80
+ end
81
+
82
+ def self.framework_filtered(configuration, entry)
83
+ filter = configuration.framework_filter
84
+ return entry unless filter
85
+
86
+ case result = Hook.call { filter.call(entry) }
87
+ when Hash then JSONValue.object(result)
88
+ end
89
+ end
90
+ private_class_method :filtered, :enabled?, :framework_filtered
91
+ end
92
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ # Owns policy and scope state; delivery receives detached JSON strings.
5
+ class Client
6
+ attr_reader :configuration, :diagnostics
7
+
8
+ def self.build(configuration)
9
+ diagnostics = Diagnostics.new(configuration.diagnostic_callback, logger: configuration.diagnostic_logger)
10
+ delivery = Delivery.new(configuration, transport: Transport.new(configuration), diagnostics: diagnostics)
11
+ new(configuration, delivery: delivery, diagnostics: diagnostics)
12
+ end
13
+
14
+ def initialize(configuration, delivery:, diagnostics: nil)
15
+ configuration.validate!
16
+ configuration.release = Release.detect(configuration)
17
+ @configuration = configuration
18
+ @delivery = delivery
19
+ @diagnostics = diagnostics || Diagnostics.new(configuration.diagnostic_callback,
20
+ logger: configuration.diagnostic_logger)
21
+ @store = ScopeStore.new(configuration, SecureRandom.uuid)
22
+ @closed = false
23
+ end
24
+
25
+ def current_scope
26
+ @store.current
27
+ end
28
+
29
+ def enabled?
30
+ !@closed && @configuration.enabled?
31
+ end
32
+
33
+ def set_context(context)
34
+ current_scope.set_context(context)
35
+ end
36
+
37
+ def set_user(user)
38
+ current_scope.set_user(user)
39
+ end
40
+
41
+ def with_scope
42
+ previous = current_scope
43
+ @store.replace(previous.copy)
44
+ begin
45
+ yield current_scope
46
+ ensure
47
+ @store.replace(previous)
48
+ end
49
+ end
50
+
51
+ def using_scope(scope)
52
+ previous = current_scope
53
+ @store.replace(scope.copy)
54
+ begin
55
+ yield
56
+ ensure
57
+ @store.replace(previous)
58
+ end
59
+ end
60
+
61
+ def with_boundary(kind:, metadata:, inherit: false)
62
+ previous = current_scope
63
+ @store.clear_escape unless previous.boundary
64
+ scope = boundary_scope(previous, kind, metadata, inherit)
65
+ @store.replace(scope)
66
+ begin
67
+ yield
68
+ ensure
69
+ @store.replace(previous)
70
+ end
71
+ end
72
+
73
+ def capture_exception(exception, context: {}, user: nil, level: :error)
74
+ identifier = dispatch(exception: exception, context: context, user: user, level: level)
75
+ current_scope.ledger.accept(exception, evict: !current_scope.boundary) if identifier
76
+ identifier
77
+ end
78
+
79
+ def capture_message(message, context: {}, user: nil, level: :info)
80
+ dispatch(message: message, context: context, user: user, level: level)
81
+ end
82
+
83
+ def add_breadcrumb(entry)
84
+ Breadcrumbs.add(self, entry)
85
+ end
86
+
87
+ def capture(&)
88
+ Boundary.run(self, kind: :capture, metadata: {}, inherit: true, &)
89
+ end
90
+
91
+ def report(exception, mechanism:, context: {}, level: :error, handled: false)
92
+ return if normal_termination?(exception)
93
+ return if mechanism == "request" && expected_request_error?(exception)
94
+
95
+ case current_scope.ledger.accept(exception, evict: !current_scope.boundary)
96
+ when :duplicate then return nil
97
+ when :overflow
98
+ @diagnostics.increment(:duplicate_overflow)
99
+ return nil
100
+ end
101
+ dispatch(exception: exception, context: context, level: level,
102
+ addons: { "mechanism" => mechanism, "handled" => handled })
103
+ end
104
+
105
+ def remember_escape(exception)
106
+ @store.remember_escape(exception)
107
+ end
108
+
109
+ def consume_escape(exception)
110
+ @store.consume_escape(exception)
111
+ end
112
+
113
+ def flush(timeout: @configuration.flush_timeout)
114
+ @delivery.flush(timeout: timeout)
115
+ rescue StandardError
116
+ false
117
+ end
118
+
119
+ def close(timeout: @configuration.flush_timeout)
120
+ @closed = true
121
+ @store.clear
122
+ @delivery.close(timeout: timeout)
123
+ rescue StandardError
124
+ false
125
+ end
126
+
127
+ private
128
+
129
+ def boundary_scope(previous, kind, metadata, inherit)
130
+ scope = inherit ? previous.copy : Scope.new(context: @configuration.context, user: @configuration.user)
131
+ scope.ledger = previous.boundary ? previous.ledger : Scope::Ledger.new
132
+ scope.boundary = true
133
+ scope.addons = (inherit ? previous.addons : scope.addons).merge(metadata).merge("boundary" => kind.to_s)
134
+ scope
135
+ end
136
+
137
+ def dispatch(exception: nil, message: nil, context: {}, user: nil, level: :error, addons: {})
138
+ return unless eligible?(exception)
139
+
140
+ identifier = SecureRandom.uuid
141
+ encoded = Event.new(configuration: @configuration, id: identifier, exception: exception, message: message,
142
+ level: level, **current_scope.event_data(context, user, @configuration.user, addons)).encode
143
+ unless encoded
144
+ @diagnostics.increment(:filtered)
145
+ return nil
146
+ end
147
+ identifier if @delivery.enqueue(identifier, encoded.freeze)
148
+ rescue StandardError
149
+ @diagnostics.increment(:failed)
150
+ nil
151
+ end
152
+
153
+ def eligible?(exception)
154
+ return false unless enabled?
155
+ return false unless rand < @configuration.sample_rate
156
+ return true unless exception
157
+
158
+ names = exception.class.ancestors.filter_map(&:name)
159
+ @configuration.ignored_exceptions.none? { |name| names.include?(name) }
160
+ end
161
+
162
+ def normal_termination?(exception)
163
+ case exception
164
+ when SystemExit, SignalException then true
165
+ else false
166
+ end
167
+ end
168
+
169
+ def expected_request_error?(exception)
170
+ expected = %w[ActionController::RoutingError AbstractController::ActionNotFound
171
+ ActionController::UnknownAction ActiveRecord::RecordNotFound]
172
+ exception.class.ancestors.any? { |ancestor| expected.include?(ancestor.name) }
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ # Settings are validated before clients install handlers or start delivery.
5
+ class Configuration
6
+ attr_accessor :token, :endpoint, :environment, :enabled_environments, :release, :detect_release,
7
+ :sample_rate, :delivery_mode, :queue_capacity, :source_roots, :send_source,
8
+ :connect_timeout, :read_timeout, :write_timeout, :flush_timeout, :limit_cooldown,
9
+ :context, :user, :ignored_exceptions, :filter_parameters, :integrations, :global_errors,
10
+ :before_send, :before_breadcrumb, :framework_filter, :diagnostic_callback, :diagnostic_logger,
11
+ :propagate_job_context,
12
+ :include_request_body, :include_job_arguments, :include_headers, :include_cookies,
13
+ :breadcrumbs_logger, :breadcrumbs_http, :breadcrumbs_rails, :logger_error_level
14
+
15
+ def initialize
16
+ initialize_identity
17
+ initialize_delivery
18
+ initialize_privacy
19
+ initialize_hooks
20
+ end
21
+
22
+ def validate!
23
+ value = @token
24
+ Token.decode(value) if value && !value.empty?
25
+ collector_uri if enabled?
26
+ validate_delivery
27
+ validate_callbacks
28
+ unless valid_number?(@sample_rate) && @sample_rate <= 1
29
+ raise ConfigurationError, "sample_rate must be finite and between 0 and 1"
30
+ end
31
+
32
+ nil
33
+ end
34
+
35
+ def enabled?
36
+ value = @token
37
+ environments = @enabled_environments
38
+ !!(value && !value.empty? && (environments.nil? || environments.include?(@environment)))
39
+ end
40
+
41
+ def collector_uri
42
+ override = @endpoint
43
+ return Token.decode(@token || "").endpoint unless override
44
+
45
+ uri = URI.parse(override)
46
+ raise ConfigurationError, "Collector endpoint must use HTTPS or loopback HTTP" unless allowed_uri?(uri)
47
+
48
+ uri
49
+ rescue URI::InvalidURIError
50
+ raise ConfigurationError, "Invalid collector endpoint"
51
+ end
52
+
53
+ def integration_enabled?(name)
54
+ @integrations.fetch(name, true)
55
+ end
56
+
57
+ private
58
+
59
+ def allowed_uri?(uri)
60
+ return false unless uri.host && uri.userinfo.nil? && uri.fragment.nil?
61
+ return true if uri.is_a?(URI::HTTPS)
62
+
63
+ uri.is_a?(URI::HTTP) && ["127.0.0.1", "::1"].include?(uri.hostname)
64
+ end
65
+
66
+ def initialize_identity
67
+ @token = ENV.fetch("HAWK_TOKEN", nil)
68
+ @endpoint = nil
69
+ @environment = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
70
+ @enabled_environments = nil
71
+ @release = ENV["HAWK_RELEASE"] || ENV.fetch("REVISION", nil)
72
+ @detect_release = false
73
+ @source_roots = [Dir.pwd]
74
+ @send_source = true
75
+ end
76
+
77
+ def initialize_delivery
78
+ @sample_rate = 1.0
79
+ @delivery_mode = :async
80
+ @queue_capacity = 100
81
+ @connect_timeout = 1.0
82
+ @read_timeout = 2.0
83
+ @write_timeout = 2.0
84
+ @flush_timeout = 5.0
85
+ @limit_cooldown = 60.0
86
+ end
87
+
88
+ def initialize_privacy
89
+ @context = {}
90
+ @user = nil
91
+ @ignored_exceptions = []
92
+ @filter_parameters = []
93
+ @include_request_body = false
94
+ @include_job_arguments = false
95
+ @include_headers = false
96
+ @include_cookies = false
97
+ end
98
+
99
+ def initialize_hooks
100
+ @before_send = nil
101
+ @before_breadcrumb = nil
102
+ @framework_filter = nil
103
+ @diagnostic_callback = nil
104
+ @diagnostic_logger = nil
105
+ @propagate_job_context = true
106
+ @integrations = {}
107
+ @global_errors = true
108
+ @breadcrumbs_logger = false
109
+ @breadcrumbs_http = false
110
+ @breadcrumbs_rails = true
111
+ @logger_error_level = nil
112
+ end
113
+
114
+ def validate_delivery
115
+ unless @queue_capacity.is_a?(Integer) && @queue_capacity.positive?
116
+ raise ConfigurationError, "queue_capacity must be a positive integer"
117
+ end
118
+ raise ConfigurationError, "delivery_mode must be async or sync" unless %i[async sync].include?(@delivery_mode)
119
+
120
+ [@connect_timeout, @read_timeout, @write_timeout, @flush_timeout, @limit_cooldown].each do |value|
121
+ raise ConfigurationError, "Timeouts must be finite and nonnegative" unless valid_number?(value)
122
+ end
123
+ end
124
+
125
+ def valid_number?(value)
126
+ case value
127
+ when Float, Integer then value.finite? && value >= 0
128
+ else false
129
+ end
130
+ end
131
+
132
+ def validate_callbacks
133
+ [@before_send, @before_breadcrumb, @framework_filter, @diagnostic_callback].each do |callback|
134
+ raise ConfigurationError, "Hooks must be callable" unless callback.nil? || callback.respond_to?(:call)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hawk"
4
+ require "delayed_job"
5
+ require "delayed/plugin"
6
+
7
+ module Hawk
8
+ # Native lifecycle callbacks retain retries, payloads, and failure storage.
9
+ module DelayedJob
10
+ def self.install
11
+ return unless supported_version?
12
+ return if ::Delayed::Worker.plugins.include?(Plugin)
13
+
14
+ # Initialize existing callbacks before attaching the new plugin once.
15
+ ::Delayed::Worker.lifecycle
16
+ ::Delayed::Worker.plugins << Plugin
17
+ Plugin.new
18
+ end
19
+
20
+ def self.supported_version?
21
+ specification = Gem.loaded_specs.fetch("delayed_job")
22
+ specification.version.to_s.start_with?("4.")
23
+ end
24
+
25
+ def self.client
26
+ client = Hawk.client
27
+ client if client&.enabled? && client.configuration.integration_enabled?(:delayed_job)
28
+ end
29
+
30
+ def self.run(job, &)
31
+ client = self.client
32
+ return yield unless client
33
+
34
+ metadata = { "job" => { "backend" => "delayed_job", "id" => job.id,
35
+ "queue" => job.queue, "attempts" => job.attempts } }
36
+ Boundary.run(client, kind: :delayed_job, metadata: metadata, &)
37
+ end
38
+
39
+ def self.report(job)
40
+ client = self.client
41
+ error = job.error
42
+ client.report(error, mechanism: "delayed_job.backend") if client && error
43
+ nil
44
+ end
45
+
46
+ AROUND = lambda do |_worker, job, &block|
47
+ DelayedJob.run(job, &block) if block
48
+ end
49
+
50
+ # Worker rebuilds this callback set when a new worker is constructed.
51
+ class Plugin < ::Delayed::Plugin
52
+ callbacks do |lifecycle|
53
+ lifecycle.around(:perform, &AROUND)
54
+ lifecycle.after(:error) { |_worker, job| DelayedJob.report(job) }
55
+ lifecycle.after(:failure) { |_worker, job| DelayedJob.report(job) }
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ Hawk::DelayedJob.install
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hawk
4
+ # Queue capacity never blocks capture; flush and close have explicit deadlines.
5
+ class Delivery
6
+ def initialize(configuration, transport:, diagnostics:)
7
+ @configuration = configuration
8
+ @transport = transport
9
+ @diagnostics = diagnostics
10
+ @closed = false
11
+ reset
12
+ end
13
+
14
+ def enqueue(_identifier, encoded)
15
+ reset if @pid != Process.pid
16
+ return false unless @transport.available?
17
+
18
+ snapshot = encoded.dup.freeze
19
+ accepted = @mutex.synchronize { accept(snapshot) }
20
+ unless accepted
21
+ @diagnostics.increment(:overflow) unless @closed
22
+ return false
23
+ end
24
+ deliver(snapshot) if @configuration.delivery_mode == :sync
25
+ true
26
+ end
27
+
28
+ def flush(timeout:)
29
+ reset if @pid != Process.pid
30
+ return false unless timeout.finite? && timeout >= 0
31
+ return false if Thread.current.equal?(@worker)
32
+
33
+ deadline = monotonic + timeout
34
+ @mutex.synchronize do
35
+ while @pending.positive?
36
+ remaining = deadline - monotonic
37
+ return false unless remaining.positive?
38
+
39
+ @condition.wait(@mutex, remaining)
40
+ end
41
+ end
42
+ true
43
+ end
44
+
45
+ def close(timeout:)
46
+ reset if @pid != Process.pid
47
+ deadline = monotonic + timeout
48
+ @mutex.synchronize do
49
+ @closed = true
50
+ @condition.broadcast
51
+ end
52
+ return false unless flush(timeout: timeout)
53
+
54
+ worker = @worker
55
+ worker.nil? || !worker.join([deadline - monotonic, 0.0].max).nil?
56
+ end
57
+
58
+ private
59
+
60
+ def reset
61
+ @mutex = Mutex.new
62
+ @condition = ConditionVariable.new
63
+ @queue = []
64
+ @pending = 0
65
+ @worker = nil
66
+ @pid = Process.pid
67
+ end
68
+
69
+ def monotonic
70
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
+ end
72
+
73
+ def accept(encoded)
74
+ return false if @closed
75
+ return false if @queue.size >= @configuration.queue_capacity
76
+
77
+ @pending += 1
78
+ if @configuration.delivery_mode == :async
79
+ @queue << encoded
80
+ worker = @worker || Thread.new { work }
81
+ worker.report_on_exception = false
82
+ @worker = worker
83
+ @condition.signal
84
+ end
85
+ true
86
+ end
87
+
88
+ def next_event
89
+ @mutex.synchronize do
90
+ @condition.wait(@mutex) while @queue.empty? && !@closed
91
+ @queue.shift
92
+ end
93
+ end
94
+
95
+ def work
96
+ while (encoded = next_event)
97
+ deliver(encoded)
98
+ end
99
+ end
100
+
101
+ def deliver(encoded)
102
+ result = @transport.send(encoded)
103
+ @diagnostics.increment(result == :accepted ? :sent : :failed)
104
+ rescue StandardError
105
+ @diagnostics.increment(:failed)
106
+ ensure
107
+ @mutex.synchronize do
108
+ @pending -= 1
109
+ @condition.broadcast
110
+ end
111
+ end
112
+ end
113
+ end