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,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
@@ -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"