restless-sdk 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/CONFORMANCE.md +112 -0
- data/README.md +148 -0
- data/exe/restless-conformance +108 -0
- data/install.md +299 -0
- data/lib/restless/caches.rb +147 -0
- data/lib/restless/capture.rb +289 -0
- data/lib/restless/client.rb +111 -0
- data/lib/restless/conformance.rb +164 -0
- data/lib/restless/env.rb +82 -0
- data/lib/restless/fingerprint.rb +287 -0
- data/lib/restless/har.rb +113 -0
- data/lib/restless/injection.rb +99 -0
- data/lib/restless/mask.rb +57 -0
- data/lib/restless/rack.rb +374 -0
- data/lib/restless/redact.rb +342 -0
- data/lib/restless/request_id.rb +63 -0
- data/lib/restless/settings.rb +101 -0
- data/lib/restless/stack_frames.rb +146 -0
- data/lib/restless/text.rb +171 -0
- data/lib/restless/uploader.rb +319 -0
- data/lib/restless/version.rb +25 -0
- data/lib/restless.rb +57 -0
- data/spec/VECTORS_VERSION +1 -0
- data/spec/vectors/fingerprint.json +1119 -0
- data/spec/vectors/har.json +500 -0
- data/spec/vectors/mask.json +175 -0
- data/spec/vectors/recovery-slug.json +102 -0
- data/spec/vectors/redact.json +775 -0
- data/spec/vectors/request-id.json +120 -0
- metadata +82 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "text"
|
|
6
|
+
require_relative "request_id"
|
|
7
|
+
|
|
8
|
+
module Restless
|
|
9
|
+
# CONTRACT.md section 10. What the SDK adds to the customer's own responses.
|
|
10
|
+
module Injection
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# INJECT-005. Legible URL slug for the recovery dig-in path, derived from
|
|
14
|
+
# method + route pattern: `GET /car/{id}` becomes `get-car-id`.
|
|
15
|
+
#
|
|
16
|
+
# The server resolves it back to an OpenAPI operation by matching the same
|
|
17
|
+
# scheme, so this MUST stay in sync with the app's `recoverySlug`
|
|
18
|
+
# (INJECT-007).
|
|
19
|
+
def recovery_slug(method = nil, path = nil)
|
|
20
|
+
m = Text.full_lower(method.to_s)
|
|
21
|
+
# JavaScript's `trim` strips the PRIM-002 set; Ruby's `strip` strips a
|
|
22
|
+
# different one in both directions.
|
|
23
|
+
p = Text.ws_trim(path.to_s)
|
|
24
|
+
return "unknown" if m.empty? || p.empty?
|
|
25
|
+
|
|
26
|
+
flat = p.gsub(%r{[/{}:]+}, "-")
|
|
27
|
+
.gsub(/[^a-zA-Z0-9\-]/, "")
|
|
28
|
+
.gsub(/-+/, "-")
|
|
29
|
+
# `\A` / `\z`, not `^` / `$`: Ruby's are line anchors (PRIM-005).
|
|
30
|
+
flat = flat.sub(/\A-/, "").sub(/-\z/, "")
|
|
31
|
+
flat.empty? ? m : "#{m}-#{flat}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# INJECT-001..004, INJECT-006. Returns the headers to set plus the `debug`
|
|
35
|
+
# object to merge into a JSON body, or nil when nothing should be injected.
|
|
36
|
+
def build(status:, request_id:, base_url:, prefix: nil, recovery: nil,
|
|
37
|
+
method: nil, path: nil, docs_url: nil)
|
|
38
|
+
return nil if status < 400 # INJECT-001
|
|
39
|
+
|
|
40
|
+
display = RequestId.format_request_id(request_id, prefix)
|
|
41
|
+
# INJECT-006: the server-supplied docsUrl when one has been learned,
|
|
42
|
+
# else the configured base URL. One batch of staleness after a
|
|
43
|
+
# docs-domain change is accepted.
|
|
44
|
+
log_host = docs_url.nil? || docs_url.empty? ? base_url : docs_url
|
|
45
|
+
log_url = "#{log_host}/logs/#{request_id}"
|
|
46
|
+
debug_cmd = "npx api debug #{display}"
|
|
47
|
+
|
|
48
|
+
# Per-request "dig-in" URL the calling agent (often an AI) can fetch for
|
|
49
|
+
# concrete next steps. Deliberately LEGIBLE: it ends in `<slug>.md` so it
|
|
50
|
+
# reads as documentation rather than a tracking blob. The first segment
|
|
51
|
+
# is the same public request id already in `debug.log`, so the dashboard
|
|
52
|
+
# can correlate the follow-up without any new tracking token.
|
|
53
|
+
slug = recovery_slug(method, path)
|
|
54
|
+
dig_in = "For the accepted parameters and next steps, " \
|
|
55
|
+
"fetch #{log_host}/p/#{request_id}/#{slug}.md"
|
|
56
|
+
# INJECT-004: a cached recovery message precedes the dig-in line,
|
|
57
|
+
# separated by a blank line.
|
|
58
|
+
recovery_text =
|
|
59
|
+
if recovery.nil? || recovery.empty?
|
|
60
|
+
dig_in
|
|
61
|
+
else
|
|
62
|
+
"#{recovery}\n\n#{dig_in}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
headers: {
|
|
67
|
+
"x-log-url" => log_url, # INJECT-002
|
|
68
|
+
"x-debug" => debug_cmd
|
|
69
|
+
},
|
|
70
|
+
debug: {
|
|
71
|
+
"log" => log_url,
|
|
72
|
+
"cli" => debug_cmd,
|
|
73
|
+
"recovery" => recovery_text
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# INJECT-003. Merge `debug` into the response body ONLY when the body is a
|
|
79
|
+
# JSON OBJECT (not an array, not a scalar) and the content type says JSON.
|
|
80
|
+
#
|
|
81
|
+
# Returns the original body untouched on any parse failure: SAFETY-001
|
|
82
|
+
# outranks everything here.
|
|
83
|
+
def apply_body(body, content_type, debug)
|
|
84
|
+
return body if body.nil? || body.empty? || debug.nil?
|
|
85
|
+
return body unless Text.full_lower(content_type.to_s).include?("application/json")
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
parsed = JSON.parse(body, max_nesting: false)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
return body
|
|
91
|
+
end
|
|
92
|
+
return body unless parsed.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
JSON.generate(parsed.merge("debug" => debug))
|
|
95
|
+
rescue StandardError
|
|
96
|
+
body
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
require_relative "text"
|
|
7
|
+
|
|
8
|
+
module Restless
|
|
9
|
+
# CONTRACT.md section 3. The masked end-user API key.
|
|
10
|
+
#
|
|
11
|
+
# sha512-<base64(sha512(utf8(key)))>?<last4>
|
|
12
|
+
#
|
|
13
|
+
# This is the lookup key the ingest and the dashboard index on, so it has to
|
|
14
|
+
# be byte-identical across every SDK and the server. Server-side reference:
|
|
15
|
+
# `logs/src/clickhouse/models/request.ts`.
|
|
16
|
+
module Mask
|
|
17
|
+
# MASK-011. Setup-time placeholders the CLI (or a copy-pasted doc) leaves
|
|
18
|
+
# in curl examples. Hashing them would cluster unrelated requests under an
|
|
19
|
+
# arbitrary tail. Compared case-sensitively and exactly.
|
|
20
|
+
PLACEHOLDER_KEYS = %w[
|
|
21
|
+
API_KEY_HERE
|
|
22
|
+
YOUR_API_KEY
|
|
23
|
+
YOUR_KEY
|
|
24
|
+
REPLACE_ME
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# MASK-013. Redaction may run before masking in some call paths; hashing a
|
|
28
|
+
# sentinel produces a meaningless key.
|
|
29
|
+
#
|
|
30
|
+
# `\A...\z` rather than `^...$`: PRIM-005. Ruby's `$` also matches before a
|
|
31
|
+
# trailing newline, so `"<REDACTED:5>\n"` would pass through unmasked here
|
|
32
|
+
# and be hashed in JavaScript.
|
|
33
|
+
REDACTED_RE = /\A<REDACTED:[0-9]+(?::[^>]*)?>\z/.freeze
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# MASK-001. Returns nil for "no key" (MASK-010).
|
|
38
|
+
def mask(api_key)
|
|
39
|
+
return nil if api_key.nil? || api_key == ""
|
|
40
|
+
return nil if PLACEHOLDER_KEYS.include?(api_key)
|
|
41
|
+
|
|
42
|
+
# MASK-012. Idempotent for its own output.
|
|
43
|
+
return api_key if api_key.start_with?("sha512-")
|
|
44
|
+
return api_key if REDACTED_RE.match?(api_key)
|
|
45
|
+
|
|
46
|
+
# MASK-004: hash the UTF-8 encoding. MASK-002: SHA-512.
|
|
47
|
+
# MASK-003: STANDARD base64 with padding, not base64url.
|
|
48
|
+
digest = Base64.strict_encode64(Digest::SHA512.digest(Text.to_utf8(api_key)))
|
|
49
|
+
|
|
50
|
+
# MASK-006: last 4 CODE POINTS of the plaintext, never a byte or
|
|
51
|
+
# UTF-16 code-unit slice. Keys shorter than 4 contribute all of theirs.
|
|
52
|
+
last4 = api_key.chars.last(4).join
|
|
53
|
+
|
|
54
|
+
"sha512-#{digest}?#{last4}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "capture"
|
|
6
|
+
require_relative "injection"
|
|
7
|
+
require_relative "request_id"
|
|
8
|
+
require_relative "stack_frames"
|
|
9
|
+
require_relative "text"
|
|
10
|
+
|
|
11
|
+
module Restless
|
|
12
|
+
# Rack middleware. One interface covers Rails, Sinatra, Hanami, Grape,
|
|
13
|
+
# Roda and anything else that speaks Rack.
|
|
14
|
+
#
|
|
15
|
+
# CONTRACT.md section 14 is explicit that adapters are per-language and not
|
|
16
|
+
# standardised, so this does NOT reproduce the Node SDK's duck-typed
|
|
17
|
+
# universal middleware or its framework list. Rack is the one interface
|
|
18
|
+
# worth having in Ruby.
|
|
19
|
+
#
|
|
20
|
+
# SAFETY-001 governs everything below: no code path here may propagate an
|
|
21
|
+
# exception into the customer's app or response lifecycle. The only
|
|
22
|
+
# exception deliberately re-raised is the customer's own.
|
|
23
|
+
class Middleware
|
|
24
|
+
# SAFETY-007. Bodies over this are recorded WITHOUT a body; headers are
|
|
25
|
+
# still stamped. Capture must never buffer unboundedly.
|
|
26
|
+
MAX_CAPTURE_BYTES = 1024 * 1024
|
|
27
|
+
|
|
28
|
+
# SAFETY-007. Streaming responses are passed straight through, never
|
|
29
|
+
# buffered.
|
|
30
|
+
STREAMING_TYPES = %w[text/event-stream].freeze
|
|
31
|
+
|
|
32
|
+
# SAFETY-006. A serialized parse of multipart is meaningless.
|
|
33
|
+
SKIPPED_REQUEST_TYPES = %w[multipart/form-data].freeze
|
|
34
|
+
|
|
35
|
+
# Framework hooks that carry the matched route template, most specific
|
|
36
|
+
# first. Override wholesale with the `route:` lambda.
|
|
37
|
+
ROUTE_ENV_KEYS = %w[
|
|
38
|
+
restless.route
|
|
39
|
+
sinatra.route
|
|
40
|
+
action_dispatch.route_uri_pattern
|
|
41
|
+
grape.routing_args
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# Frameworks that catch the exception themselves still leave it in the
|
|
45
|
+
# env, which is the only way to reach the `stack` fingerprint strategy
|
|
46
|
+
# for a handled 500.
|
|
47
|
+
EXCEPTION_ENV_KEYS = %w[
|
|
48
|
+
restless.exception
|
|
49
|
+
sinatra.error
|
|
50
|
+
action_dispatch.exception
|
|
51
|
+
rack.exception
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# `use client.rack` hands Rack::Builder something that responds to
|
|
55
|
+
# `new(app)`; this is that something.
|
|
56
|
+
class Factory
|
|
57
|
+
def initialize(client, options)
|
|
58
|
+
@client = client
|
|
59
|
+
@options = options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def new(app)
|
|
63
|
+
Middleware.new(app, @client, @options)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.factory(client, **options)
|
|
68
|
+
Factory.new(client, options)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Read-only view of the request, handed to the setup callback.
|
|
72
|
+
class RequestInfo
|
|
73
|
+
attr_reader :env
|
|
74
|
+
|
|
75
|
+
def initialize(env)
|
|
76
|
+
@env = env
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Case-insensitive, accepts either `Authorization` or `authorization`.
|
|
80
|
+
def header(name)
|
|
81
|
+
key = "HTTP_#{name.to_s.upcase.tr('-', '_')}"
|
|
82
|
+
return @env[key] if @env.key?(key)
|
|
83
|
+
return @env["CONTENT_TYPE"] if key == "HTTP_CONTENT_TYPE"
|
|
84
|
+
return @env["CONTENT_LENGTH"] if key == "HTTP_CONTENT_LENGTH"
|
|
85
|
+
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
alias [] header
|
|
89
|
+
|
|
90
|
+
def request_method
|
|
91
|
+
@env["REQUEST_METHOD"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def path
|
|
95
|
+
"#{@env['SCRIPT_NAME']}#{@env['PATH_INFO']}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def query_string
|
|
99
|
+
@env["QUERY_STRING"].to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def url
|
|
103
|
+
Middleware.full_url(@env)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def initialize(app, client, options = {})
|
|
108
|
+
@app = app
|
|
109
|
+
@client = client
|
|
110
|
+
@engine = client.engine
|
|
111
|
+
@route_resolver = options[:route]
|
|
112
|
+
@capture_request_body =
|
|
113
|
+
options.key?(:capture_request_body) ? options[:capture_request_body] : true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def call(env)
|
|
117
|
+
started_at = Time.now
|
|
118
|
+
clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
119
|
+
request = RequestInfo.new(env)
|
|
120
|
+
|
|
121
|
+
setup = safely({}) { @engine.resolve(request) }
|
|
122
|
+
our_id = RequestId.new_request_id
|
|
123
|
+
|
|
124
|
+
block = safely(nil) { CaptureEngine.resolve_block(setup["block"]) }
|
|
125
|
+
if block
|
|
126
|
+
# SETUP-004: reject before the handler runs.
|
|
127
|
+
return finish(env, setup, our_id, started_at, clock, block_response(block),
|
|
128
|
+
request_body: capture_request_body(env), stack_frame: nil)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
request_body = capture_request_body(env)
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
status, headers, body = @app.call(env)
|
|
135
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
136
|
+
# The customer's exception. Capture it with the stack so the
|
|
137
|
+
# fingerprint keys on the RAISING method (FP-043), then re-raise so
|
|
138
|
+
# their own error handling is completely unaffected.
|
|
139
|
+
frame = safely(nil) { StackFrames.from_exception(e) }
|
|
140
|
+
safely(nil) do
|
|
141
|
+
finish(env, setup, our_id, started_at, clock,
|
|
142
|
+
[500, { "content-type" => "application/json" },
|
|
143
|
+
[JSON.generate({ "error" => "Internal Server Error" })]],
|
|
144
|
+
request_body: request_body, stack_frame: frame, inject: false)
|
|
145
|
+
end
|
|
146
|
+
raise
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A framework that handled the exception itself still left it here.
|
|
150
|
+
frame = nil
|
|
151
|
+
if status.to_i >= 500
|
|
152
|
+
error = EXCEPTION_ENV_KEYS.map { |k| env[k] }.find { |v| v.respond_to?(:backtrace) }
|
|
153
|
+
frame = safely(nil) { StackFrames.from_exception(error) } if error
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
finish(env, setup, our_id, started_at, clock, [status, headers, body],
|
|
157
|
+
request_body: request_body, stack_frame: frame)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.full_url(env)
|
|
161
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
162
|
+
host = env["HTTP_HOST"] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"
|
|
163
|
+
url = +"#{scheme}://#{host}#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
164
|
+
query = env["QUERY_STRING"].to_s
|
|
165
|
+
url << "?" << query unless query.empty?
|
|
166
|
+
url
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# `GET /pets/:id` (Sinatra) and `/pets/:id(.:format)` (Rails) both become
|
|
170
|
+
# `/pets/{id}`, which is what every other Restless SDK reports for the
|
|
171
|
+
# same endpoint. Without that the same API produces two different
|
|
172
|
+
# `routePattern` values depending on the language it was written in.
|
|
173
|
+
def self.normalize_route_pattern(pattern)
|
|
174
|
+
route = Text.ws_trim(pattern.to_s)
|
|
175
|
+
return nil if route.empty?
|
|
176
|
+
|
|
177
|
+
route = route.sub(/\A[A-Z]+[ \t]+/, "") # strip the leading method
|
|
178
|
+
route = route.sub(/\(\.:format\)\z/, "") # Rails' optional format
|
|
179
|
+
route = route.gsub(/:([A-Za-z_][A-Za-z0-9_]*)/, '{\1}')
|
|
180
|
+
route.empty? ? nil : route
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def route_pattern(env)
|
|
186
|
+
if @route_resolver
|
|
187
|
+
resolved = safely(nil) { @route_resolver.call(env) }
|
|
188
|
+
return Middleware.normalize_route_pattern(resolved) if resolved
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
ROUTE_ENV_KEYS.each do |key|
|
|
192
|
+
value = env[key]
|
|
193
|
+
next unless value.is_a?(String) && !value.empty?
|
|
194
|
+
|
|
195
|
+
normalized = Middleware.normalize_route_pattern(value)
|
|
196
|
+
return normalized if normalized
|
|
197
|
+
end
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def block_response(block)
|
|
202
|
+
body = JSON.generate({ "error" => block[:message] })
|
|
203
|
+
[block[:status],
|
|
204
|
+
{ "content-type" => "application/json",
|
|
205
|
+
"content-length" => body.bytesize.to_s },
|
|
206
|
+
[body]]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Assemble the capture, inject, record, and hand the response back.
|
|
210
|
+
def finish(env, setup, our_id, started_at, clock, response,
|
|
211
|
+
request_body:, stack_frame:, inject: true)
|
|
212
|
+
status, headers, body = response
|
|
213
|
+
headers = normalize_headers(headers)
|
|
214
|
+
|
|
215
|
+
body_text, body = read_response_body(body, headers)
|
|
216
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - clock) * 1000).round
|
|
217
|
+
|
|
218
|
+
captured = {
|
|
219
|
+
"requestId" => our_id,
|
|
220
|
+
"startedAt" => Text.iso8601_millis(started_at),
|
|
221
|
+
"duration" => duration,
|
|
222
|
+
"routePattern" => route_pattern(env),
|
|
223
|
+
"request" => {
|
|
224
|
+
"method" => env["REQUEST_METHOD"],
|
|
225
|
+
"url" => Middleware.full_url(env),
|
|
226
|
+
"headers" => request_headers(env),
|
|
227
|
+
"body" => request_body
|
|
228
|
+
},
|
|
229
|
+
"response" => {
|
|
230
|
+
"status" => status.to_i,
|
|
231
|
+
"headers" => headers,
|
|
232
|
+
"body" => body_text
|
|
233
|
+
},
|
|
234
|
+
"user" => setup
|
|
235
|
+
}
|
|
236
|
+
captured.delete("routePattern") if captured["routePattern"].nil?
|
|
237
|
+
|
|
238
|
+
options = @engine.uploader.options
|
|
239
|
+
# REQID-010, REQID-011.
|
|
240
|
+
headers.merge!(RequestId.response_headers(
|
|
241
|
+
our_id, request_headers(env),
|
|
242
|
+
options[:request_id_prefix], options[:has_api_key]
|
|
243
|
+
))
|
|
244
|
+
|
|
245
|
+
if inject && status.to_i >= 400
|
|
246
|
+
# INJECT-009: the fingerprint is computed against the customer's RAW
|
|
247
|
+
# response, snapshotted before any injected header or body field.
|
|
248
|
+
fingerprint = @engine.compute_fingerprint(captured, stack_frame)
|
|
249
|
+
if fingerprint
|
|
250
|
+
captured["errorFingerprint"] = CaptureEngine.wire_fingerprint(fingerprint)
|
|
251
|
+
# INJECT-010: computed once and reused for both the recovery lookup
|
|
252
|
+
# and the upload payload.
|
|
253
|
+
recovery = @engine.lookup_recovery(fingerprint.key)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
injection = safely(nil) do
|
|
257
|
+
Injection.build(
|
|
258
|
+
status: status.to_i,
|
|
259
|
+
request_id: our_id,
|
|
260
|
+
base_url: options[:base_url],
|
|
261
|
+
prefix: options[:request_id_prefix],
|
|
262
|
+
recovery: recovery,
|
|
263
|
+
method: env["REQUEST_METHOD"],
|
|
264
|
+
path: captured["routePattern"],
|
|
265
|
+
docs_url: @engine.docs_url
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if injection
|
|
270
|
+
headers.merge!(injection[:headers]) # INJECT-002
|
|
271
|
+
rewritten = Injection.apply_body(body_text, headers["content-type"],
|
|
272
|
+
injection[:debug]) # INJECT-003
|
|
273
|
+
if rewritten && !rewritten.equal?(body_text) && rewritten != body_text
|
|
274
|
+
body = [rewritten]
|
|
275
|
+
# INJECT-008: recompute Content-Length or the client truncates
|
|
276
|
+
# mid-JSON.
|
|
277
|
+
headers["content-length"] = rewritten.bytesize.to_s if headers.key?("content-length")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
safely(nil) { @engine.record(captured, stack_frame: stack_frame) }
|
|
283
|
+
|
|
284
|
+
[status, headers, body]
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
Env.debug_log("middleware finish failed: #{e.class}: #{e.message}")
|
|
287
|
+
response
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Rack 2 hands out `Content-Type`, Rack 3 requires `content-type`.
|
|
291
|
+
# Downcasing is safe in both directions: header names are
|
|
292
|
+
# case-insensitive on the wire, and every lookup below assumes lowercase.
|
|
293
|
+
def normalize_headers(headers)
|
|
294
|
+
out = {}
|
|
295
|
+
(headers || {}).each do |name, value|
|
|
296
|
+
out[name.to_s.downcase] = value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
297
|
+
end
|
|
298
|
+
out
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def request_headers(env)
|
|
302
|
+
out = {}
|
|
303
|
+
env.each do |key, value|
|
|
304
|
+
next unless key.is_a?(String)
|
|
305
|
+
|
|
306
|
+
if key.start_with?("HTTP_") && key != "HTTP_VERSION"
|
|
307
|
+
# HAR-004: duplicate values arrive already joined by the server.
|
|
308
|
+
out[key[5..-1].downcase.tr("_", "-")] = value.to_s
|
|
309
|
+
elsif key == "CONTENT_TYPE" || key == "CONTENT_LENGTH"
|
|
310
|
+
out[key.downcase.tr("_", "-")] = value.to_s
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
out
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def capture_request_body(env)
|
|
317
|
+
return nil unless @capture_request_body
|
|
318
|
+
|
|
319
|
+
content_type = env["CONTENT_TYPE"].to_s.downcase
|
|
320
|
+
# SAFETY-006.
|
|
321
|
+
return nil if SKIPPED_REQUEST_TYPES.any? { |t| content_type.include?(t) }
|
|
322
|
+
|
|
323
|
+
input = env["rack.input"]
|
|
324
|
+
return nil if input.nil?
|
|
325
|
+
# Reading a non-rewindable input would consume the customer's body.
|
|
326
|
+
# Rack 3 does not guarantee rewindability, so skip rather than break
|
|
327
|
+
# the app -- SAFETY-001 outranks capture completeness.
|
|
328
|
+
return nil unless input.respond_to?(:rewind) && input.respond_to?(:read)
|
|
329
|
+
|
|
330
|
+
raw = input.read(MAX_CAPTURE_BYTES + 1)
|
|
331
|
+
input.rewind
|
|
332
|
+
return nil if raw.nil? || raw.empty?
|
|
333
|
+
return nil if raw.bytesize > MAX_CAPTURE_BYTES
|
|
334
|
+
|
|
335
|
+
Text.to_utf8(raw)
|
|
336
|
+
rescue StandardError => e
|
|
337
|
+
Env.debug_log("request body capture failed: #{e.class}: #{e.message}")
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Returns [captured_text_or_nil, body_to_return].
|
|
342
|
+
def read_response_body(body, headers)
|
|
343
|
+
content_type = headers["content-type"].to_s.downcase
|
|
344
|
+
# SAFETY-007: never buffer a stream.
|
|
345
|
+
return [nil, body] if STREAMING_TYPES.any? { |t| content_type.include?(t) }
|
|
346
|
+
# Rack 3 streaming bodies respond to `call`, not `each`.
|
|
347
|
+
return [nil, body] unless body.respond_to?(:each)
|
|
348
|
+
|
|
349
|
+
chunks = []
|
|
350
|
+
size = 0
|
|
351
|
+
body.each do |chunk|
|
|
352
|
+
chunks << chunk
|
|
353
|
+
size += chunk.to_s.bytesize
|
|
354
|
+
end
|
|
355
|
+
body.close if body.respond_to?(:close)
|
|
356
|
+
|
|
357
|
+
return [nil, chunks] if size > MAX_CAPTURE_BYTES
|
|
358
|
+
|
|
359
|
+
joined = chunks.join
|
|
360
|
+
[joined.empty? ? nil : Text.to_utf8(joined), chunks]
|
|
361
|
+
rescue StandardError => e
|
|
362
|
+
Env.debug_log("response body capture failed: #{e.class}: #{e.message}")
|
|
363
|
+
[nil, body]
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# SAFETY-001.
|
|
367
|
+
def safely(fallback)
|
|
368
|
+
yield
|
|
369
|
+
rescue StandardError => e
|
|
370
|
+
Env.debug_log("swallowed: #{e.class}: #{e.message}")
|
|
371
|
+
fallback
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|