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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Counters and callbacks contain no event data or collector credentials.
|
|
5
|
+
class Diagnostics
|
|
6
|
+
def initialize(callback = nil, logger: nil)
|
|
7
|
+
@callback = callback
|
|
8
|
+
@logger = logger
|
|
9
|
+
reset
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def increment(name)
|
|
13
|
+
reset if @pid != Process.pid
|
|
14
|
+
@mutex.synchronize { @counts[name] = @counts.fetch(name, 0) + 1 }
|
|
15
|
+
notify
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def snapshot
|
|
20
|
+
reset if @pid != Process.pid
|
|
21
|
+
@mutex.synchronize { @counts.dup }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def notify
|
|
27
|
+
callback = @callback
|
|
28
|
+
logger = @logger
|
|
29
|
+
return unless callback || logger
|
|
30
|
+
|
|
31
|
+
acquired = @mutex.synchronize { !@notifying && (@notifying = true) }
|
|
32
|
+
return unless acquired
|
|
33
|
+
|
|
34
|
+
emit
|
|
35
|
+
ensure
|
|
36
|
+
@mutex.synchronize { @notifying = false } if acquired
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def emit
|
|
40
|
+
Hook.call do
|
|
41
|
+
@callback&.call(snapshot)
|
|
42
|
+
@logger&.warn("Hawk delivery counters: #{JSON.generate(snapshot)}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reset
|
|
47
|
+
@pid = Process.pid
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
@counts = {}
|
|
50
|
+
@notifying = false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/hawk/event.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Builds the native protocol payload; hooks never receive integration tokens.
|
|
5
|
+
class Event
|
|
6
|
+
def initialize(configuration:, id:, exception: nil, message: nil, context: {}, user: nil,
|
|
7
|
+
breadcrumbs: [], addons: {}, level: :error, filtered_paths: [])
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@filtered_paths = filtered_paths
|
|
10
|
+
@addons = metadata(id, level).merge(JSONValue.object(addons))
|
|
11
|
+
@payload = {
|
|
12
|
+
"title" => exception ? title(exception) : (message || "Message"),
|
|
13
|
+
"type" => exception ? exception.class.name.to_s : "Message",
|
|
14
|
+
"catcherVersion" => VERSION,
|
|
15
|
+
"context" => JSONValue.normalize(context),
|
|
16
|
+
"breadcrumbs" => JSONValue.normalize(breadcrumbs),
|
|
17
|
+
"user" => JSONValue.normalize(user),
|
|
18
|
+
"release" => configuration.release,
|
|
19
|
+
"addons" => @addons
|
|
20
|
+
}.compact
|
|
21
|
+
attach_exception(exception) if exception
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def encode
|
|
25
|
+
payload = filtered_payload
|
|
26
|
+
return unless payload
|
|
27
|
+
|
|
28
|
+
hook = @configuration.before_send
|
|
29
|
+
if hook
|
|
30
|
+
case result = Hook.call { hook.call(payload) }
|
|
31
|
+
when Hash then payload = JSONValue.object(result)
|
|
32
|
+
else return nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
return unless valid_payload?(payload)
|
|
36
|
+
|
|
37
|
+
bounded_envelope(payload)
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def filtered_payload
|
|
45
|
+
payload = JSONValue.object(Filter.new(@configuration).call(@payload))
|
|
46
|
+
FrameworkFilter.call(@configuration, payload, @filtered_paths)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def title(exception)
|
|
50
|
+
JSONValue.normalize(exception.message).to_s
|
|
51
|
+
rescue StandardError
|
|
52
|
+
exception.class.name.to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def metadata(id, level)
|
|
56
|
+
{ "event_id" => id, "level" => level.to_s, "environment" => @configuration.environment,
|
|
57
|
+
"ruby" => { "version" => RUBY_VERSION, "platform" => RUBY_PLATFORM, "engine" => RUBY_ENGINE } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def attach_exception(exception)
|
|
61
|
+
@payload["backtrace"] = Source.new(@configuration).frames(exception)
|
|
62
|
+
chain = causes(exception)
|
|
63
|
+
@addons["causes"] = chain unless chain.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def causes(exception)
|
|
67
|
+
# @type var result: Array[payload]
|
|
68
|
+
result = []
|
|
69
|
+
# @type var seen: Hash[Exception, bool]
|
|
70
|
+
seen = {}
|
|
71
|
+
seen.compare_by_identity
|
|
72
|
+
current = exception.cause
|
|
73
|
+
while current && result.size < 10 && !seen[current]
|
|
74
|
+
seen[current] = true
|
|
75
|
+
result << { "type" => current.class.name.to_s, "title" => title(current),
|
|
76
|
+
"backtrace" => Source.new(@configuration).frames(current) }
|
|
77
|
+
current = current.cause
|
|
78
|
+
end
|
|
79
|
+
result
|
|
80
|
+
rescue StandardError
|
|
81
|
+
result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def valid_payload?(payload)
|
|
85
|
+
case value = payload["title"]
|
|
86
|
+
when String then !value.empty?
|
|
87
|
+
else false
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def envelope(payload)
|
|
92
|
+
JSON.generate("token" => @configuration.token, "catcherType" => "errors/ruby", "payload" => payload)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def bounded_envelope(payload)
|
|
96
|
+
%i[sources breadcrumbs causes context addons frames].each do |phase|
|
|
97
|
+
encoded = envelope(payload)
|
|
98
|
+
return encoded if encoded.bytesize <= 262_144
|
|
99
|
+
|
|
100
|
+
trim(payload, phase)
|
|
101
|
+
end
|
|
102
|
+
encoded = envelope(payload)
|
|
103
|
+
encoded if encoded.bytesize <= 262_144
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def trim(payload, phase)
|
|
107
|
+
case phase
|
|
108
|
+
when :sources then trim_sources(payload)
|
|
109
|
+
when :breadcrumbs then keep_first(payload, "breadcrumbs", newest: true)
|
|
110
|
+
when :causes
|
|
111
|
+
case addons = payload["addons"]
|
|
112
|
+
when Hash then keep_first(addons, "causes")
|
|
113
|
+
end
|
|
114
|
+
when :context, :addons then payload.delete(phase.to_s)
|
|
115
|
+
else keep_first(payload, "backtrace")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def trim_sources(payload)
|
|
120
|
+
case frames = payload["backtrace"]
|
|
121
|
+
when Array
|
|
122
|
+
frames.each do |frame|
|
|
123
|
+
case frame
|
|
124
|
+
when Hash then frame.delete("sourceCode")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def keep_first(payload, key, newest: false)
|
|
131
|
+
case items = payload[key]
|
|
132
|
+
when Array then payload[key] = newest ? items.last(1) : items.take(1)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/hawk/filter.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Filters detached JSON data before hooks or transmission can observe it.
|
|
5
|
+
class Filter
|
|
6
|
+
SENSITIVE = /password|secret|passwd|api_?key|access_token|auth|credentials|mysql_pwd|stripetoken|card/i
|
|
7
|
+
|
|
8
|
+
def initialize(configuration)
|
|
9
|
+
@patterns = configuration.filter_parameters.map do |pattern|
|
|
10
|
+
case pattern
|
|
11
|
+
when String then Regexp.new(Regexp.escape(pattern), Regexp::IGNORECASE)
|
|
12
|
+
else pattern
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(value)
|
|
18
|
+
filter(JSONValue.normalize(value))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def filter(value)
|
|
24
|
+
case value
|
|
25
|
+
when Hash
|
|
26
|
+
value.to_h { |key, item| [key, sensitive?(key) ? "[FILTERED]" : filter(item)] }
|
|
27
|
+
when Array then value.map { |item| filter(item) }
|
|
28
|
+
else value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sensitive?(key)
|
|
33
|
+
SENSITIVE.match?(key) || @patterns.any? { |pattern| pattern.match?(key) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Empty placeholders retain parent/key masking while excluding saved contents.
|
|
5
|
+
class FilteredFields
|
|
6
|
+
def initialize(payload, paths, containers_only: false)
|
|
7
|
+
@entries = []
|
|
8
|
+
paths.each { |path| extract(payload, path, containers_only) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def restore(payload)
|
|
12
|
+
@entries.each do |path, key, value|
|
|
13
|
+
parent = parent_at(payload, path)
|
|
14
|
+
next unless parent
|
|
15
|
+
|
|
16
|
+
case placeholder = parent[key]
|
|
17
|
+
when Hash then parent[key] = yield(key, value) if placeholder.empty?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
payload
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def extract(payload, path, containers_only)
|
|
26
|
+
key = path.fetch(-1)
|
|
27
|
+
parent_path = path.take(path.size - 1)
|
|
28
|
+
parent = parent_at(payload, parent_path)
|
|
29
|
+
return unless parent&.key?(key)
|
|
30
|
+
|
|
31
|
+
value = parent[key]
|
|
32
|
+
return if containers_only && !container?(value)
|
|
33
|
+
|
|
34
|
+
@entries << [parent_path, key, value]
|
|
35
|
+
parent[key] = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def container?(value)
|
|
39
|
+
case value
|
|
40
|
+
when Hash, Array then true
|
|
41
|
+
else false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parent_at(payload, path)
|
|
46
|
+
current = payload
|
|
47
|
+
path.each do |key|
|
|
48
|
+
case child = current[key]
|
|
49
|
+
when Hash then current = child
|
|
50
|
+
else return nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
current
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Previously filtered fields cross later boundaries without another transform.
|
|
5
|
+
module FrameworkFilter
|
|
6
|
+
def self.call(configuration, payload, filtered_paths = [])
|
|
7
|
+
filter = configuration.framework_filter
|
|
8
|
+
return payload unless filter
|
|
9
|
+
|
|
10
|
+
saved = FilteredFields.new(payload, filtered_paths)
|
|
11
|
+
case result = Hook.call { filter.call(payload) }
|
|
12
|
+
when Hash then saved.restore(JSONValue.object(result)) { |_key, value| value }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/hawk/hook.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# User callbacks must not change application control flow or stop delivery.
|
|
5
|
+
module Hook
|
|
6
|
+
def self.call(&)
|
|
7
|
+
Breadcrumbs.guard(:sdk, &)
|
|
8
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Isolate every exception raised by a user hook.
|
|
9
|
+
nil
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Only explicit, filtered JSON context crosses a queue boundary.
|
|
5
|
+
module JobContext
|
|
6
|
+
def self.dump(client)
|
|
7
|
+
return unless client.configuration.propagate_job_context
|
|
8
|
+
|
|
9
|
+
scope = client.current_scope
|
|
10
|
+
data = { "version" => 1, "context" => scope.context, "user" => scope.user }
|
|
11
|
+
data = Filter.new(client.configuration).call(data)
|
|
12
|
+
load(FrameworkFilter.call(client.configuration, JSONValue.object(data), scope.filtered_paths))
|
|
13
|
+
rescue StandardError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.load(raw)
|
|
18
|
+
return unless valid?(raw, 0, { remaining: 1024 })
|
|
19
|
+
|
|
20
|
+
data = JSONValue.object(raw)
|
|
21
|
+
return unless valid_header?(data) && JSON.generate(data).bytesize <= 8192
|
|
22
|
+
|
|
23
|
+
data.slice("version", "context", "user")
|
|
24
|
+
rescue StandardError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.apply(client, raw)
|
|
29
|
+
return unless client.configuration.propagate_job_context
|
|
30
|
+
|
|
31
|
+
data = load(raw)
|
|
32
|
+
return unless data
|
|
33
|
+
|
|
34
|
+
user = JSONValue.object(data["user"]) if data["user"]
|
|
35
|
+
client.current_scope.restore_filtered(JSONValue.object(data["context"]), user)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.valid_header?(data)
|
|
39
|
+
case version = data["version"]
|
|
40
|
+
when Integer then version == 1 && object?(data["context"]) && object?(data["user"], optional: true)
|
|
41
|
+
else false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.object?(value, optional: false)
|
|
46
|
+
case value
|
|
47
|
+
when Hash then true
|
|
48
|
+
when nil then optional
|
|
49
|
+
else false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.valid?(value, depth, budget)
|
|
54
|
+
budget[:remaining] = budget.fetch(:remaining) - 1
|
|
55
|
+
return false if depth > 6 || budget.fetch(:remaining).negative?
|
|
56
|
+
|
|
57
|
+
case value
|
|
58
|
+
when Hash then valid_object?(value, depth, budget)
|
|
59
|
+
when Array then valid_array?(value, depth, budget)
|
|
60
|
+
when String then valid_key?(value)
|
|
61
|
+
else valid_scalar?(value)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.valid_scalar?(value)
|
|
66
|
+
case value
|
|
67
|
+
when Float then value.finite?
|
|
68
|
+
when Integer, true, false, nil then true
|
|
69
|
+
else false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.valid_key?(value)
|
|
74
|
+
case value
|
|
75
|
+
when String then value.valid_encoding? && value.encoding.ascii_compatible? && value.bytesize <= 8192
|
|
76
|
+
else false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.valid_object?(value, depth, budget)
|
|
81
|
+
value.size <= 100 && value.all? { |key, item| valid_key?(key) && valid?(item, depth + 1, budget) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.valid_array?(value, depth, budget)
|
|
85
|
+
value.size <= 100 && value.all? { |item| valid?(item, depth + 1, budget) }
|
|
86
|
+
end
|
|
87
|
+
private_class_method :valid?, :valid_header?, :object?, :valid_scalar?, :valid_key?, :valid_object?, :valid_array?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Copies boundary data into a bounded JSON tree without custom serializers.
|
|
5
|
+
class JSONValue
|
|
6
|
+
def self.normalize(value)
|
|
7
|
+
new.visit(value, 0)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.object(value)
|
|
11
|
+
case result = normalize(value)
|
|
12
|
+
when Hash then result
|
|
13
|
+
else {}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@seen = {}
|
|
19
|
+
@seen.compare_by_identity
|
|
20
|
+
@remaining = 5000
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit(value, depth)
|
|
24
|
+
@remaining -= 1
|
|
25
|
+
return "[Truncated]" if depth > 10 || @remaining.negative?
|
|
26
|
+
|
|
27
|
+
case value
|
|
28
|
+
when String then text(value)
|
|
29
|
+
when Array, Hash then container(value, depth)
|
|
30
|
+
else scalar(value)
|
|
31
|
+
end
|
|
32
|
+
rescue StandardError
|
|
33
|
+
"[Unavailable]"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def scalar(value)
|
|
39
|
+
case value
|
|
40
|
+
when NilClass, TrueClass, FalseClass, Integer then value
|
|
41
|
+
when Float then value.finite? ? value : nil
|
|
42
|
+
when Object then "[#{value.class.name || 'Object'}]"
|
|
43
|
+
else "[Unsupported]"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text(value)
|
|
48
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).byteslice(0, 8192).to_s.scrub("")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def container(value, depth)
|
|
52
|
+
entered = false
|
|
53
|
+
return "[Circular]" if @seen[value]
|
|
54
|
+
|
|
55
|
+
@seen[value] = true
|
|
56
|
+
entered = true
|
|
57
|
+
case value
|
|
58
|
+
when Array then value.take(100).map { |item| visit(item, depth + 1) }
|
|
59
|
+
else mapping(value, depth)
|
|
60
|
+
end
|
|
61
|
+
ensure
|
|
62
|
+
@seen.delete(value) if entered
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mapping(value, depth)
|
|
66
|
+
# @type var result: payload
|
|
67
|
+
result = {}
|
|
68
|
+
value.first(100).each do |key, item|
|
|
69
|
+
case key
|
|
70
|
+
when String, Symbol
|
|
71
|
+
result[text(key.to_s)] = visit(item, depth + 1)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
# Resolving the client at shutdown keeps repeated initialization idempotent.
|
|
5
|
+
module ProcessHooks
|
|
6
|
+
def self.install
|
|
7
|
+
return if @installed
|
|
8
|
+
|
|
9
|
+
@installed = true
|
|
10
|
+
at_exit { report_exit($!) }
|
|
11
|
+
nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.report_exit(exception)
|
|
15
|
+
client = Hawk.client
|
|
16
|
+
return unless client
|
|
17
|
+
return unless client.configuration.global_errors
|
|
18
|
+
|
|
19
|
+
client.report(exception, mechanism: "at_exit", level: :fatal) if exception && !client.consume_escape(exception)
|
|
20
|
+
client.close
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hawk
|
|
4
|
+
class Rack
|
|
5
|
+
# Guards establish each foreign protocol before its type is narrowed.
|
|
6
|
+
class Body
|
|
7
|
+
def self.wrap(body, client:, scope:)
|
|
8
|
+
if body.respond_to?(:each)
|
|
9
|
+
if body.respond_to?(:to_path)
|
|
10
|
+
file_body = body #: _RackFileBody
|
|
11
|
+
return FileBody.new(file_body, client: client, scope: scope)
|
|
12
|
+
end
|
|
13
|
+
enumerable = body #: _RackEnumerableBody
|
|
14
|
+
return new(enumerable, client: client, scope: scope)
|
|
15
|
+
end
|
|
16
|
+
if body.respond_to?(:call)
|
|
17
|
+
callable = body #: _RackCallableBody
|
|
18
|
+
return CallableBody.new(callable, client: client, scope: scope)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
body
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(body, client:, scope:)
|
|
25
|
+
@body = body
|
|
26
|
+
@client = client
|
|
27
|
+
@scope = scope
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each(&block)
|
|
31
|
+
return enum_for(:each) unless block_given?
|
|
32
|
+
|
|
33
|
+
Boundary.resume(@client, @scope) { @body.each(&block) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def close
|
|
37
|
+
return unless @body.respond_to?(:close)
|
|
38
|
+
|
|
39
|
+
body = @body #: _RackClosableBody
|
|
40
|
+
Boundary.resume(@client, @scope) { body.close }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# File responses retain the path API used by Rack sendfile middleware.
|
|
45
|
+
class FileBody < Body
|
|
46
|
+
def initialize(body, client:, scope:)
|
|
47
|
+
super
|
|
48
|
+
@file_body = body
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_path
|
|
52
|
+
Boundary.resume(@client, @scope) { @file_body.to_path }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Rack 3 can stream by calling a body with the server's stream object.
|
|
57
|
+
class CallableBody
|
|
58
|
+
def initialize(body, client:, scope:)
|
|
59
|
+
@body = body
|
|
60
|
+
@client = client
|
|
61
|
+
@scope = scope
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(stream)
|
|
65
|
+
Boundary.resume(@client, @scope) { @body.call(stream) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def close
|
|
69
|
+
return unless @body.respond_to?(:close)
|
|
70
|
+
|
|
71
|
+
body = @body #: _RackClosableBody
|
|
72
|
+
Boundary.resume(@client, @scope) { body.close }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/hawk/rack.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hawk"
|
|
4
|
+
|
|
5
|
+
module Hawk
|
|
6
|
+
# Request scope covers application calls and the deferred response body.
|
|
7
|
+
class Rack
|
|
8
|
+
def initialize(app, client: nil, integration: :rack)
|
|
9
|
+
@app = app
|
|
10
|
+
@client = client
|
|
11
|
+
@integration = integration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
client = active_client
|
|
16
|
+
return @app.call(env) unless client
|
|
17
|
+
|
|
18
|
+
Boundary.run(client, kind: :request, metadata: {}) do
|
|
19
|
+
status, headers, body = invoke(client, env)
|
|
20
|
+
[status, headers, Body.wrap(body, client: client, scope: client.current_scope.copy)]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def active_client
|
|
27
|
+
client = @client || Hawk.client
|
|
28
|
+
client if client&.enabled? && client.configuration.integration_enabled?(@integration)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def invoke(client, env)
|
|
32
|
+
scope = client.current_scope
|
|
33
|
+
scope.request_metadata = -> { metadata(env) }
|
|
34
|
+
begin
|
|
35
|
+
@app.call(env)
|
|
36
|
+
ensure
|
|
37
|
+
scope.request_metadata = nil
|
|
38
|
+
scope.addons["request"] = metadata(env)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def metadata(env)
|
|
43
|
+
data = JSONValue.object(env.slice("REQUEST_METHOD", "PATH_INFO", "action_dispatch.request_id"))
|
|
44
|
+
result = { "method" => data["REQUEST_METHOD"], "path" => data["PATH_INFO"],
|
|
45
|
+
"request_id" => data["action_dispatch.request_id"] }
|
|
46
|
+
params = JSONValue.object(env["action_dispatch.request.parameters"] || env["rack.request.query_hash"])
|
|
47
|
+
path = JSONValue.object(env["action_dispatch.request.path_parameters"])
|
|
48
|
+
result.merge!("params" => params, "controller" => path["controller"], "action" => path["action"])
|
|
49
|
+
optional_metadata(result, env)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
{}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def optional_metadata(result, env)
|
|
55
|
+
client = active_client
|
|
56
|
+
return result unless client
|
|
57
|
+
|
|
58
|
+
config = client.configuration
|
|
59
|
+
result["headers"] = headers(env) if config.include_headers
|
|
60
|
+
result["cookies"] = JSONValue.normalize(env["HTTP_COOKIE"]) if config.include_cookies
|
|
61
|
+
result["body"] = JSONValue.normalize(env["RAW_POST_DATA"]) if config.include_request_body
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def headers(env)
|
|
66
|
+
JSONValue.object(env.select { |key, _value| key.start_with?("HTTP_") && key != "HTTP_COOKIE" })
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
require_relative "rack/body"
|