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,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
|
data/lib/hawk/client.rb
ADDED
|
@@ -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
|