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,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mask"
|
|
4
|
+
require_relative "redact"
|
|
5
|
+
require_relative "fingerprint"
|
|
6
|
+
require_relative "stack_frames"
|
|
7
|
+
require_relative "har"
|
|
8
|
+
require_relative "request_id"
|
|
9
|
+
require_relative "injection"
|
|
10
|
+
|
|
11
|
+
module Restless
|
|
12
|
+
# The shared operation table behind the conformance driver.
|
|
13
|
+
#
|
|
14
|
+
# Both `exe/restless-conformance` (the stdio driver the cross-language
|
|
15
|
+
# harness drives) and `test/test_vectors.rb` (the in-process replay) go
|
|
16
|
+
# through this one file, so it is impossible for the vectors to describe
|
|
17
|
+
# behaviour the driver does not exhibit.
|
|
18
|
+
#
|
|
19
|
+
# See node-sdk/spec/driver/PROTOCOL.md. Internal: never part of the public
|
|
20
|
+
# API, never shipped behaviour a customer depends on.
|
|
21
|
+
module Conformance
|
|
22
|
+
# An input this implementation's language cannot represent or parse. The
|
|
23
|
+
# harness records it as a SKIP, not a failure. See CONTRACT.md FP-046 and
|
|
24
|
+
# PRIM-035.
|
|
25
|
+
class UnsupportedDialect < StandardError; end
|
|
26
|
+
|
|
27
|
+
class UnknownOp < StandardError; end
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def dispatch(op, input)
|
|
32
|
+
input ||= {}
|
|
33
|
+
case op
|
|
34
|
+
# --- masking (section 3) ---
|
|
35
|
+
when "mask"
|
|
36
|
+
Mask.mask(str_or_nil(input["apiKey"]))
|
|
37
|
+
|
|
38
|
+
# --- redaction (section 4) ---
|
|
39
|
+
when "redactValue"
|
|
40
|
+
Redact.redact_value(str(input["value"]))
|
|
41
|
+
when "redactHeaders"
|
|
42
|
+
Redact.redact_headers(hash(input["headers"]), strs(input["extra"]))
|
|
43
|
+
when "redactUrl"
|
|
44
|
+
Redact.redact_url(str(input["url"]), strs(input["extra"]))
|
|
45
|
+
when "redactBody"
|
|
46
|
+
body = str_or_nil(input["body"])
|
|
47
|
+
# An empty string is a VALUE here, distinct from an absent body, so
|
|
48
|
+
# this is deliberately not collapsed to nil.
|
|
49
|
+
body.nil? ? nil : Redact.redact_body(body, str_or_nil(input["contentType"]), strs(input["extra"]))
|
|
50
|
+
when "truncateBody"
|
|
51
|
+
body = str_or_nil(input["body"])
|
|
52
|
+
body.nil? ? nil : Redact.truncate_body(body, int(input["maxBytes"]))
|
|
53
|
+
|
|
54
|
+
# --- fingerprinting (section 5) ---
|
|
55
|
+
when "fingerprint"
|
|
56
|
+
fingerprint(input)
|
|
57
|
+
when "normalizeRoute"
|
|
58
|
+
Fingerprint.normalize_route(str_or_nil(input["route"]))
|
|
59
|
+
when "normalizeMessage"
|
|
60
|
+
Fingerprint.normalize_message(str(input["message"]))
|
|
61
|
+
when "projectRelative"
|
|
62
|
+
# FP-042 is shared across every SDK even though frame PARSING is not
|
|
63
|
+
# (FP-044/FP-046), so path normalization gets its own dialect-free op.
|
|
64
|
+
Fingerprint.project_relative(str(input["file"]))
|
|
65
|
+
|
|
66
|
+
# --- request ids (section 6) ---
|
|
67
|
+
when "formatRequestId"
|
|
68
|
+
RequestId.format_request_id(str(input["rawId"]), str_or_nil(input["prefix"]))
|
|
69
|
+
when "stripRequestIdPrefix"
|
|
70
|
+
RequestId.strip_request_id_prefix(str(input["requestId"]))
|
|
71
|
+
when "requestIdHeaders"
|
|
72
|
+
RequestId.response_headers(
|
|
73
|
+
str(input["ourId"]),
|
|
74
|
+
hash(input["incomingHeaders"]),
|
|
75
|
+
str_or_nil(input["prefix"]),
|
|
76
|
+
input.key?("hasApiKey") ? input["hasApiKey"] == true : true
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# --- injection (section 10) ---
|
|
80
|
+
when "recoverySlug"
|
|
81
|
+
Injection.recovery_slug(str_or_nil(input["method"]), str_or_nil(input["path"]))
|
|
82
|
+
|
|
83
|
+
# --- HAR (section 7) ---
|
|
84
|
+
when "harEntry"
|
|
85
|
+
Har.to_har_entry(hash(input["captured"]))
|
|
86
|
+
|
|
87
|
+
else
|
|
88
|
+
raise UnknownOp, "unknown op: #{op}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def fingerprint(input)
|
|
93
|
+
stack = stack_text(input["stackTrace"])
|
|
94
|
+
if !stack.empty? && v8_dialect?(stack)
|
|
95
|
+
raise UnsupportedDialect,
|
|
96
|
+
"unsupported stack dialect: v8 (this SDK parses Ruby backtraces; FP-044/FP-046)"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Parse the stack the way the Rack adapter does. This used to pass
|
|
100
|
+
# nil, which made the driver structurally incapable of reaching the
|
|
101
|
+
# stack strategy: the whole path was dead through the harness while
|
|
102
|
+
# the SDK's own tests, which call StackFrames directly, still passed.
|
|
103
|
+
# Nothing caught it, because every v8-shaped stack vector is skipped
|
|
104
|
+
# under FP-046 so the harness never exercises this branch. A driver
|
|
105
|
+
# that short-circuits the SDK is testing itself.
|
|
106
|
+
result = Fingerprint.compute(
|
|
107
|
+
status: int(input["status"]),
|
|
108
|
+
method: str_or_nil(input["method"]),
|
|
109
|
+
route: str_or_nil(input["route"]),
|
|
110
|
+
response_headers: input["responseHeaders"].is_a?(Hash) ? input["responseHeaders"] : nil,
|
|
111
|
+
response_body: input["responseBody"],
|
|
112
|
+
stack_frame: stack.empty? ? nil : StackFrames.top_user_frame(stack.split("\n"))
|
|
113
|
+
)
|
|
114
|
+
# FP-003: `reason` is human-facing prose, explicitly not contract
|
|
115
|
+
# surface, so drivers must not emit it.
|
|
116
|
+
{ "strategy" => result.strategy, "key" => result.key }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def stack_text(raw)
|
|
120
|
+
case raw
|
|
121
|
+
when String then raw
|
|
122
|
+
when Array then raw.map(&:to_s).join("\n")
|
|
123
|
+
else ""
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A v8 frame is ` at fn (/file.js:12:34)`. Ruby backtraces are
|
|
128
|
+
# `/file.rb:12:in 'fn'`, so anything shaped like the former is out of
|
|
129
|
+
# dialect and the driver says so rather than guessing.
|
|
130
|
+
def v8_dialect?(stack)
|
|
131
|
+
stack.split("\n").any? { |line| line.strip.start_with?("at ") }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- input coercion -----------------------------------------------------
|
|
135
|
+
# The protocol represents absence as JSON null.
|
|
136
|
+
|
|
137
|
+
def str(value)
|
|
138
|
+
value.is_a?(String) ? value : ""
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def str_or_nil(value)
|
|
142
|
+
value.is_a?(String) ? value : nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def strs(value)
|
|
146
|
+
return [] unless value.is_a?(Array)
|
|
147
|
+
|
|
148
|
+
value.select { |v| v.is_a?(String) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def hash(value)
|
|
152
|
+
value.is_a?(Hash) ? value : {}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def int(value)
|
|
156
|
+
case value
|
|
157
|
+
when Integer then value
|
|
158
|
+
when Float then value.to_i
|
|
159
|
+
when String then value.to_i
|
|
160
|
+
else 0
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
data/lib/restless/env.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Restless
|
|
4
|
+
# Environment probing: CONFIG-003, CONFIG-004, BATCH-003, BATCH-008.
|
|
5
|
+
#
|
|
6
|
+
# All of it is per-language by design (CONTRACT.md section 14); what is
|
|
7
|
+
# normative is only the effect.
|
|
8
|
+
module Env
|
|
9
|
+
# WIRE-005.
|
|
10
|
+
DEFAULT_BASE_URL = "https://ingress.restless.ai"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# BATCH-003's "language equivalent environment indicator". Ruby has three
|
|
15
|
+
# spellings in the wild and no single winner, so all three are consulted,
|
|
16
|
+
# most specific first.
|
|
17
|
+
def app_env
|
|
18
|
+
%w[RESTLESS_ENV RACK_ENV RAILS_ENV].each do |name|
|
|
19
|
+
value = ENV[name]
|
|
20
|
+
return value if value && !value.empty?
|
|
21
|
+
end
|
|
22
|
+
""
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def production?
|
|
26
|
+
app_env == "production"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# BATCH-008. Under a test runner, captures are dropped rather than
|
|
30
|
+
# uploaded, unless RESTLESS_SETUP_MODE=1. Test suites must not hammer
|
|
31
|
+
# production ingest.
|
|
32
|
+
#
|
|
33
|
+
# The constants are checked rather than merely `defined?(Minitest)` at the
|
|
34
|
+
# top level, because a library can pull minitest in without the process
|
|
35
|
+
# actually being a test run.
|
|
36
|
+
def test_run?
|
|
37
|
+
return true if app_env == "test"
|
|
38
|
+
return true if defined?(::RSpec::Core)
|
|
39
|
+
return true if defined?(::Minitest::Test)
|
|
40
|
+
return true if defined?(::Test::Unit::TestCase)
|
|
41
|
+
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def setup_mode?
|
|
46
|
+
ENV["RESTLESS_SETUP_MODE"] == "1"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# CONFIG-004. `DEBUG` containing `restless` as a whitespace- or
|
|
50
|
+
# comma-delimited token, or the literal `*`.
|
|
51
|
+
def debug?
|
|
52
|
+
flag = ENV["DEBUG"].to_s
|
|
53
|
+
return true if flag == "*" || flag == "restless"
|
|
54
|
+
|
|
55
|
+
flag.split(/[\s,]+/).include?("restless")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def debug_log(message)
|
|
59
|
+
warn("[restless] #{message}") if debug?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# CONFIG-001. Explicit key, then RESTLESS_KEY, then README_API_KEY.
|
|
63
|
+
def resolve_api_key(explicit = nil)
|
|
64
|
+
[explicit, ENV["RESTLESS_KEY"], ENV["README_API_KEY"]]
|
|
65
|
+
.find { |v| v && !v.empty? } || ""
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# CONFIG-003.
|
|
69
|
+
def resolve_base_url(explicit = nil)
|
|
70
|
+
return explicit if explicit && !explicit.empty?
|
|
71
|
+
|
|
72
|
+
from_env = ENV["RESTLESS_BASE_URL"]
|
|
73
|
+
return from_env if from_env && !from_env.empty?
|
|
74
|
+
|
|
75
|
+
DEFAULT_BASE_URL
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def localhost?(base_url)
|
|
79
|
+
base_url.include?("//localhost") || base_url.include?("//127.0.0.1")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text"
|
|
4
|
+
|
|
5
|
+
module Restless
|
|
6
|
+
# CONTRACT.md section 5. A stable identifier for an HTTP error response,
|
|
7
|
+
# computed at capture time and shipped with the log.
|
|
8
|
+
#
|
|
9
|
+
# The ingest stores it, the dashboard groups by it, a customer attaches a
|
|
10
|
+
# recovery message to a group, and the SDK injects that message into
|
|
11
|
+
# matching responses. Nothing re-derives it, so every SDK must agree.
|
|
12
|
+
module Fingerprint
|
|
13
|
+
# FP-016. Checked in this order, matched EXACTLY (case-sensitively, no
|
|
14
|
+
# REDACT-010 normalization).
|
|
15
|
+
CODE_FIELDS = %w[code error_code errorCode type].freeze
|
|
16
|
+
NESTED_PATHS = [
|
|
17
|
+
%w[error code],
|
|
18
|
+
%w[error type],
|
|
19
|
+
%w[error error_code]
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# FP-015. 1 to 64 code points, identifier-shaped. Keeps `card_declined`
|
|
23
|
+
# and `AUTH_MISMATCH`, rejects `Your card was declined.` and bare UUIDs.
|
|
24
|
+
#
|
|
25
|
+
# `\A...\z` rather than `^...$` (PRIM-005): Ruby's `$` also matches before
|
|
26
|
+
# a trailing newline, so `"boom\n"` would look like a code here and not in
|
|
27
|
+
# JavaScript.
|
|
28
|
+
CODE_RE = /\A[a-zA-Z][a-zA-Z0-9_.\-]*\z/.freeze
|
|
29
|
+
|
|
30
|
+
# FP-030. A single path segment that collapses to `:id`. Each pattern is
|
|
31
|
+
# fully anchored, so this is a whole-segment test rather than a scan.
|
|
32
|
+
#
|
|
33
|
+
# The hex ranges are written out rather than using the `i` flag: Ruby's
|
|
34
|
+
# case-insensitive matching is Unicode case folding, which is a wider
|
|
35
|
+
# relation than the ASCII-only canonicalization a non-`u` JavaScript regex
|
|
36
|
+
# performs.
|
|
37
|
+
SEG_UUID = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/.freeze
|
|
38
|
+
SEG_NUMERIC = /\A[0-9]+\z/.freeze
|
|
39
|
+
SEG_LONG_HEX = /\A[0-9a-fA-F]{16,}\z/.freeze
|
|
40
|
+
|
|
41
|
+
# FP-042. Project-directory markers.
|
|
42
|
+
PROJECT_DIRS = %w[src lib app api routes controllers handlers].freeze
|
|
43
|
+
|
|
44
|
+
# FP-020. The classes are enumerated (PRIM-001, PRIM-002) rather than
|
|
45
|
+
# spelled `\w` / `\s`, because those mean different things in different
|
|
46
|
+
# engines and this key has to be byte-identical in every SDK.
|
|
47
|
+
WS = Text::WS_CLASS
|
|
48
|
+
WORD = Text::WORD_CLASS
|
|
49
|
+
|
|
50
|
+
RE_URL = Regexp.new("https?://[^#{WS}]+").freeze
|
|
51
|
+
RE_EMAIL = Regexp.new("[^#{WS}]+@[^#{WS}]+\\.[^#{WS}]+").freeze
|
|
52
|
+
RE_QUOTED = /['"`][^'"`]*['"`]/.freeze
|
|
53
|
+
RE_PUNCT = Regexp.new("[^#{WORD}#{WS}\\-]").freeze
|
|
54
|
+
RE_WS_RUN = Regexp.new("[#{WS}]+").freeze
|
|
55
|
+
|
|
56
|
+
# FP-020 step 5, and the one regex in this file that cannot be written
|
|
57
|
+
# against a UTF-8 string.
|
|
58
|
+
#
|
|
59
|
+
# Ruby's `\w` is ASCII-only (correct here) but its `\b` is NOT: Onigmo
|
|
60
|
+
# defines the boundary against the Unicode word property, so `"eA1"` with
|
|
61
|
+
# a leading accented letter has no boundary before the `A` and the whole
|
|
62
|
+
# digit-word survives, where JavaScript, Go and Python-with-re.ASCII all
|
|
63
|
+
# strip it. Applying the match to the UTF-8 BYTES restores JavaScript's
|
|
64
|
+
# semantics exactly: every non-ASCII character becomes a run of non-word
|
|
65
|
+
# bytes, which is what a non-`u` JS regex sees in UTF-16 too. The pattern
|
|
66
|
+
# can only ever match ASCII bytes, so byte slicing and character slicing
|
|
67
|
+
# coincide and the result is still well-formed UTF-8.
|
|
68
|
+
RE_DIGIT_WORD = Regexp.new("\\b[#{WORD}\\-]*[0-9][#{WORD}\\-]*\\b").freeze
|
|
69
|
+
|
|
70
|
+
Result = Struct.new(:strategy, :key, :reason)
|
|
71
|
+
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
# FP-010. Strategies are tried in this exact order; the first that yields
|
|
75
|
+
# a key wins.
|
|
76
|
+
#
|
|
77
|
+
# `stack_frame` is the already-extracted `{file:, fn:}` for the frame
|
|
78
|
+
# nearest the throw site (FP-043). Frame PARSING is per-language
|
|
79
|
+
# (FP-044) and lives in `Restless::StackFrames`, so this function stays
|
|
80
|
+
# dialect-free.
|
|
81
|
+
def compute(status:, method: nil, route: nil, response_headers: nil,
|
|
82
|
+
response_body: nil, stack_frame: nil)
|
|
83
|
+
method = "GET" if method.nil? || method.empty? # FP-011
|
|
84
|
+
|
|
85
|
+
# FP-012. 404 is intercepted BEFORE the code-based strategies: a generic
|
|
86
|
+
# `not_found` code is the same on every route, so grouping 404s by code
|
|
87
|
+
# is useless for recovery.
|
|
88
|
+
if status == 404
|
|
89
|
+
normalized = route.nil? || route.empty? ? "" : normalize_route(route)
|
|
90
|
+
# FP-014: the parameter test runs on the NORMALIZED route.
|
|
91
|
+
if normalized.include?(":") || normalized.include?("{")
|
|
92
|
+
return Result.new(
|
|
93
|
+
"resource", "404:resource",
|
|
94
|
+
"404 on a parameterized route (#{method} #{normalized}); " \
|
|
95
|
+
"the addressed resource was not found"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
reason = if normalized.empty?
|
|
99
|
+
"404 on a path that matched no route; the endpoint does not exist"
|
|
100
|
+
else
|
|
101
|
+
"404 on #{method} #{normalized}; no resource at this path"
|
|
102
|
+
end
|
|
103
|
+
return Result.new("endpoint", "404:endpoint", reason)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 1. Explicit header. Fully deterministic; the customer opted in.
|
|
107
|
+
header_code = read_header_code(response_headers)
|
|
108
|
+
if header_code
|
|
109
|
+
return Result.new("header", "#{status}:#{header_code}",
|
|
110
|
+
%(x-restless-error-code header: "#{header_code}"))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 2. Code-like field in the body. Stripe/AWS/Twilio-shaped APIs land here.
|
|
114
|
+
body_code = read_body_code(response_body)
|
|
115
|
+
if body_code
|
|
116
|
+
return Result.new("body-code", "#{status}:#{body_code}",
|
|
117
|
+
%(code field in body: "#{body_code}"))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# 3. Stack trace (5xx with a thrown exception). File + function only:
|
|
121
|
+
# FP-041 forbids a line number, so adding a comment above a `raise`
|
|
122
|
+
# cannot split an error group.
|
|
123
|
+
if status >= 500 && stack_frame
|
|
124
|
+
return Result.new(
|
|
125
|
+
"stack", "#{status}:#{stack_frame[:file]}:#{stack_frame[:fn]}",
|
|
126
|
+
"top user frame: #{stack_frame[:fn]} in #{stack_frame[:file]}"
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 4. Normalized message + templated route.
|
|
131
|
+
normalized_route = normalize_route(route)
|
|
132
|
+
msg = normalize_message(extract_message(response_body))
|
|
133
|
+
unless msg.empty?
|
|
134
|
+
return Result.new("message", "#{status}:#{method}:#{normalized_route}:#{msg}",
|
|
135
|
+
%(message normalized to "#{msg}"))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# 5. Status + route only. Coarse, but it groups all unhandled responses.
|
|
139
|
+
Result.new("route-only", "#{status}:#{method}:#{normalized_route}",
|
|
140
|
+
"no usable code or message; falling back to status + route")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# FP-017. The header name is matched case-insensitively.
|
|
144
|
+
def read_header_code(headers)
|
|
145
|
+
return nil unless headers.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
lower = {}
|
|
148
|
+
headers.each { |k, v| lower[Text.full_lower(k.to_s)] = v }
|
|
149
|
+
value = lower["x-restless-error-code"]
|
|
150
|
+
looks_like_code?(value) ? value : nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def read_body_code(body)
|
|
154
|
+
return nil unless body.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
CODE_FIELDS.each do |field|
|
|
157
|
+
value = body[field]
|
|
158
|
+
return value if looks_like_code?(value)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
NESTED_PATHS.each do |path|
|
|
162
|
+
value = body
|
|
163
|
+
path.each { |segment| value = value.is_a?(Hash) ? value[segment] : nil }
|
|
164
|
+
return value if looks_like_code?(value)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# FP-015.
|
|
171
|
+
def looks_like_code?(value)
|
|
172
|
+
value.is_a?(String) &&
|
|
173
|
+
!value.empty? &&
|
|
174
|
+
value.length <= 64 &&
|
|
175
|
+
CODE_RE.match?(value)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# FP-018. Body itself if it is a string; then `message`; then `error` if
|
|
179
|
+
# it is a string; then `error.message`. Anything else yields no message.
|
|
180
|
+
def extract_message(body)
|
|
181
|
+
return "" if body.nil? || body == false
|
|
182
|
+
return body if body.is_a?(String)
|
|
183
|
+
return "" unless body.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
message = body["message"]
|
|
186
|
+
return message if message.is_a?(String)
|
|
187
|
+
|
|
188
|
+
nested = body["error"]
|
|
189
|
+
return nested if nested.is_a?(String)
|
|
190
|
+
if nested.is_a?(Hash) && nested["message"].is_a?(String)
|
|
191
|
+
return nested["message"]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
""
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# FP-020. The 12 steps, in exactly this order.
|
|
198
|
+
def normalize_message(msg)
|
|
199
|
+
return "" if msg.nil? || msg.empty?
|
|
200
|
+
|
|
201
|
+
s = Text.full_lower(msg) # 1
|
|
202
|
+
s = s.gsub(RE_URL, " ") # 2
|
|
203
|
+
s = s.gsub(RE_EMAIL, " ") # 3
|
|
204
|
+
s = s.gsub(RE_QUOTED, " ") # 4
|
|
205
|
+
s = strip_digit_words(s) # 5
|
|
206
|
+
s = s.gsub(RE_PUNCT, " ") # 6
|
|
207
|
+
s = s.gsub(RE_WS_RUN, " ") # 7
|
|
208
|
+
s = Text.ws_trim(s) # 8
|
|
209
|
+
s.split(/ /, -1) # 9 (never `split(" ")`: awk mode)
|
|
210
|
+
.reject { |w| w.length <= 1 } # 10
|
|
211
|
+
.first(6) # 11
|
|
212
|
+
.join("-") # 12
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# FP-021. Whole words containing a digit are stripped, not bare digits:
|
|
216
|
+
# `abc123` reduced to `abc` would still influence the key, so grouping
|
|
217
|
+
# would break whenever the surrounding id changed.
|
|
218
|
+
#
|
|
219
|
+
# See RE_DIGIT_WORD for why this runs against the byte string.
|
|
220
|
+
def strip_digit_words(str)
|
|
221
|
+
str.dup
|
|
222
|
+
.force_encoding(Encoding::ASCII_8BIT)
|
|
223
|
+
.gsub(RE_DIGIT_WORD, " ")
|
|
224
|
+
.force_encoding(Encoding::UTF_8)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# FP-030..FP-032.
|
|
228
|
+
def normalize_route(route)
|
|
229
|
+
return "/" if route.nil? || route.empty?
|
|
230
|
+
|
|
231
|
+
# `split("/", -1)`: Ruby drops trailing empty fields without the
|
|
232
|
+
# negative limit, so `/users/` would lose its trailing segment and stop
|
|
233
|
+
# matching JavaScript.
|
|
234
|
+
segments = route.split("/", -1)
|
|
235
|
+
# FP-031: index 0 is the text BEFORE the first "/" and is never
|
|
236
|
+
# normalized, which keeps a bare "123" route untouched.
|
|
237
|
+
(1...segments.length).each do |i|
|
|
238
|
+
segments[i] = ":id" if id_segment?(segments[i])
|
|
239
|
+
end
|
|
240
|
+
segments.join("/")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def id_segment?(segment)
|
|
244
|
+
SEG_UUID.match?(segment) ||
|
|
245
|
+
SEG_NUMERIC.match?(segment) ||
|
|
246
|
+
SEG_LONG_HEX.match?(segment)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# FP-042. Strip the machine-specific path prefix down to a project-relative
|
|
250
|
+
# path, so the same source file produces the same key on a laptop and in
|
|
251
|
+
# production.
|
|
252
|
+
#
|
|
253
|
+
# The LAST project directory wins, not the first, and the difference is the
|
|
254
|
+
# whole point:
|
|
255
|
+
#
|
|
256
|
+
# /Users/dev/proj/src/db/users.rb -> src/db/users.rb
|
|
257
|
+
# /app/src/db/users.rb -> src/db/users.rb
|
|
258
|
+
# /opt/render/project/src/db/users.rb -> src/db/users.rb
|
|
259
|
+
#
|
|
260
|
+
# A first-match rule returns `app/src/db/users.rb` for the middle one,
|
|
261
|
+
# because the deployment root IS the first match. Docker's conventional
|
|
262
|
+
# `WORKDIR /app` and Heroku both root there, so first-match made production
|
|
263
|
+
# disagree with a laptop for the same file across the most common
|
|
264
|
+
# containerized layout there is, defeating the only thing this function
|
|
265
|
+
# exists to do.
|
|
266
|
+
#
|
|
267
|
+
# The trade is that a nested layout (`/proj/src/a/src/x.rb`) collapses to
|
|
268
|
+
# `src/x.rb` rather than `src/a/src/x.rb`. That is far rarer than an `/app`
|
|
269
|
+
# root and is still machine-independent, which is the property being
|
|
270
|
+
# protected.
|
|
271
|
+
def project_relative(file)
|
|
272
|
+
# `split("/", -1)`: Ruby drops trailing empty fields without the negative
|
|
273
|
+
# limit, so `/proj/src/` would come back as ["", "proj", "src"] and take
|
|
274
|
+
# the fallback branch where JavaScript, which keeps the empty field,
|
|
275
|
+
# matches `src` and returns "src/".
|
|
276
|
+
segments = file.split("/", -1)
|
|
277
|
+
# Stop before the final component: a project dir has to have something
|
|
278
|
+
# after it to be a directory at all. A negative start makes `downto`
|
|
279
|
+
# yield nothing, which is correct for a bare filename.
|
|
280
|
+
(segments.length - 2).downto(0) do |i|
|
|
281
|
+
return segments[i..-1].join("/") if PROJECT_DIRS.include?(segments[i])
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
segments.last(2).join("/")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
data/lib/restless/har.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text"
|
|
4
|
+
require_relative "redact"
|
|
5
|
+
|
|
6
|
+
module Restless
|
|
7
|
+
# CONTRACT.md section 7. Captured traffic rides inside a HAR 1.2 envelope.
|
|
8
|
+
module Har
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# HAR-005. Parse the query into ordered name/value pairs.
|
|
12
|
+
#
|
|
13
|
+
# Hand-rolled rather than delegating to a URI library so it shares the
|
|
14
|
+
# exact decoding rule with `redact_url`, and so a port does not have to
|
|
15
|
+
# reproduce WHATWG URL parsing to agree with us. Duplicate names are
|
|
16
|
+
# preserved, which a hash-based parse would lose.
|
|
17
|
+
def parse_query_string(url)
|
|
18
|
+
q = url.index("?")
|
|
19
|
+
return [] if q.nil?
|
|
20
|
+
|
|
21
|
+
rest = url[(q + 1)..-1] || ""
|
|
22
|
+
hash = rest.index("#")
|
|
23
|
+
query = hash.nil? ? rest : rest[0, hash]
|
|
24
|
+
return [] if query.empty?
|
|
25
|
+
|
|
26
|
+
out = []
|
|
27
|
+
query.split("&", -1).each do |pair|
|
|
28
|
+
next if pair.empty?
|
|
29
|
+
|
|
30
|
+
eq = pair.index("=")
|
|
31
|
+
if eq.nil?
|
|
32
|
+
out << { "name" => Redact.percent_decode(pair), "value" => "" }
|
|
33
|
+
else
|
|
34
|
+
out << {
|
|
35
|
+
"name" => Redact.percent_decode(pair[0, eq]),
|
|
36
|
+
"value" => Redact.percent_decode(pair[(eq + 1)..-1] || "")
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
out
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def headers_to_list(headers)
|
|
44
|
+
headers.map { |name, value| { "name" => name, "value" => value } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `captured` is a hash with string keys:
|
|
48
|
+
# requestId, startedAt, duration, routePattern?,
|
|
49
|
+
# request: {method, url, headers, body?}, response: {status, headers, body?}
|
|
50
|
+
#
|
|
51
|
+
# A nil body means "no body captured" and is distinct from an empty one:
|
|
52
|
+
# HAR-011 makes the first report -1 and the second 0.
|
|
53
|
+
def to_har_entry(captured)
|
|
54
|
+
request = captured["request"] || {}
|
|
55
|
+
response = captured["response"] || {}
|
|
56
|
+
request_headers = request["headers"] || {}
|
|
57
|
+
response_headers = response["headers"] || {}
|
|
58
|
+
|
|
59
|
+
request_body = request["body"]
|
|
60
|
+
response_body = response["body"]
|
|
61
|
+
|
|
62
|
+
req_content_type = request_headers["content-type"]
|
|
63
|
+
req_content_type = "" if req_content_type.nil? || req_content_type == ""
|
|
64
|
+
# HAR-007: mimeType falls back when the response has no content type.
|
|
65
|
+
res_content_type = response_headers["content-type"]
|
|
66
|
+
if res_content_type.nil? || res_content_type == ""
|
|
67
|
+
res_content_type = "application/octet-stream"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
duration = captured["duration"] || 0
|
|
71
|
+
|
|
72
|
+
har_request = {
|
|
73
|
+
"method" => request["method"],
|
|
74
|
+
"url" => request["url"],
|
|
75
|
+
"httpVersion" => "HTTP/1.1", # HAR-003 (reserved)
|
|
76
|
+
"headers" => headers_to_list(request_headers), # HAR-004
|
|
77
|
+
"queryString" => parse_query_string(request["url"].to_s)
|
|
78
|
+
}
|
|
79
|
+
# HAR-006: postData is present ONLY when a request body was captured.
|
|
80
|
+
# The reference tests truthiness, so an empty-string body produces no
|
|
81
|
+
# postData but still reports bodySize 0.
|
|
82
|
+
unless request_body.nil? || request_body.empty?
|
|
83
|
+
har_request["postData"] = {
|
|
84
|
+
"mimeType" => req_content_type,
|
|
85
|
+
"text" => request_body
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
har_request["headersSize"] = -1 # HAR-012
|
|
89
|
+
har_request["bodySize"] =
|
|
90
|
+
request_body.nil? ? -1 : Text.utf8_length(request_body) # HAR-010/011
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
"startedDateTime" => captured["startedAt"], # HAR-001
|
|
94
|
+
"time" => duration, # HAR-002
|
|
95
|
+
"request" => har_request,
|
|
96
|
+
"response" => {
|
|
97
|
+
"status" => response["status"],
|
|
98
|
+
"statusText" => "", # HAR-008 (reserved)
|
|
99
|
+
"httpVersion" => "HTTP/1.1",
|
|
100
|
+
"headers" => headers_to_list(response_headers),
|
|
101
|
+
"content" => {
|
|
102
|
+
"size" => response_body.nil? ? 0 : Text.utf8_length(response_body),
|
|
103
|
+
"mimeType" => res_content_type,
|
|
104
|
+
"text" => response_body.nil? ? "" : response_body
|
|
105
|
+
},
|
|
106
|
+
"headersSize" => -1,
|
|
107
|
+
"bodySize" => response_body.nil? ? -1 : Text.utf8_length(response_body)
|
|
108
|
+
},
|
|
109
|
+
"timings" => { "send" => 0, "wait" => duration, "receive" => 0 }
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|