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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Restless
|
|
4
|
+
# Shared text primitives: CONTRACT.md section 2.
|
|
5
|
+
#
|
|
6
|
+
# Everything in here exists because the obvious Ruby spelling silently
|
|
7
|
+
# disagrees with the reference implementation. Read the comments before
|
|
8
|
+
# replacing any of it with a one-liner.
|
|
9
|
+
module Text
|
|
10
|
+
# PRIM-002. The whitespace set, enumerated.
|
|
11
|
+
#
|
|
12
|
+
# Written as code points rather than literal characters because most of
|
|
13
|
+
# them are invisible and several are indistinguishable from a plain space
|
|
14
|
+
# in an editor. This is the JavaScript `\s` set. Ruby's `\s` is ASCII-only
|
|
15
|
+
# and NARROWER (no NBSP, no Zs category, no LS/PS/ZWNBSP), so using it
|
|
16
|
+
# would split an auth-scheme header at a different place and collapse a
|
|
17
|
+
# different set of runs in `normalize_message`.
|
|
18
|
+
WS_CODEPOINTS = [
|
|
19
|
+
0x0009, # TAB
|
|
20
|
+
0x000A, # LF
|
|
21
|
+
0x000B, # VT
|
|
22
|
+
0x000C, # FF
|
|
23
|
+
0x000D, # CR
|
|
24
|
+
0x0020, # SPACE
|
|
25
|
+
0x00A0, # NBSP
|
|
26
|
+
0x1680, # OGHAM SPACE MARK
|
|
27
|
+
0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005,
|
|
28
|
+
0x2006, 0x2007, 0x2008, 0x2009, 0x200A,
|
|
29
|
+
0x2028, # LINE SEPARATOR
|
|
30
|
+
0x2029, # PARAGRAPH SEPARATOR
|
|
31
|
+
0x202F, # NARROW NO-BREAK SPACE
|
|
32
|
+
0x205F, # MEDIUM MATHEMATICAL SPACE
|
|
33
|
+
0x3000, # IDEOGRAPHIC SPACE
|
|
34
|
+
0xFEFF # ZERO WIDTH NO-BREAK SPACE
|
|
35
|
+
# Deliberately NOT U+180E, which left the Zs category in Unicode 6.3.
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
WS_CHARS = WS_CODEPOINTS.map { |cp| [cp].pack("U") }.freeze
|
|
39
|
+
WS_SET = WS_CHARS.each_with_object({}) { |c, h| h[c] = true }.freeze
|
|
40
|
+
|
|
41
|
+
# The same set as a regex character-class body, for interpolation.
|
|
42
|
+
WS_CLASS = "\\u0009\\u000A\\u000B\\u000C\\u000D\\u0020" \
|
|
43
|
+
"\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029" \
|
|
44
|
+
"\\u202F\\u205F\\u3000\\uFEFF"
|
|
45
|
+
|
|
46
|
+
# PRIM-001. WORD is exactly [A-Za-z0-9_]. Never `\w`: ASCII-only in Ruby
|
|
47
|
+
# today, which happens to be right, but spelling it out is what keeps it
|
|
48
|
+
# right.
|
|
49
|
+
WORD_CLASS = "A-Za-z0-9_"
|
|
50
|
+
|
|
51
|
+
REPLACEMENT_CHAR = [0xFFFD].pack("U")
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
# PRIM-010. Code points, not UTF-16 code units and not bytes.
|
|
56
|
+
def code_points(str)
|
|
57
|
+
str.chars
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# PRIM-011. UTF-8 bytes.
|
|
61
|
+
def utf8_length(str)
|
|
62
|
+
to_utf8(str).bytesize
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# PRIM-013 / SAFETY-001. Get a valid UTF-8 String out of anything,
|
|
66
|
+
# without raising.
|
|
67
|
+
#
|
|
68
|
+
# Ruby cannot represent an unpaired surrogate at all (see PRIM-035 and
|
|
69
|
+
# CONFORMANCE.md), so the surrogate substitution the contract describes is
|
|
70
|
+
# structurally impossible here. What IS possible is a String tagged UTF-8
|
|
71
|
+
# that holds invalid bytes -- Rack hands those out routinely -- so scrub.
|
|
72
|
+
#
|
|
73
|
+
# BINARY is DECODED, never transcoded. `IO#read(n)` always returns
|
|
74
|
+
# ASCII-8BIT and the Rack spec requires `rack.input` to be opened in binary
|
|
75
|
+
# mode, so every captured request body arrives here tagged BINARY. Ruby
|
|
76
|
+
# transcodes BINARY to UTF-8 byte by byte and has no mapping for anything
|
|
77
|
+
# above 0x7F, so `encode` turns each byte of a multi-byte character into a
|
|
78
|
+
# separate U+FFFD: a body of `{"toKen":...}` with a U+212A KELVIN SIGN came
|
|
79
|
+
# out as `{"to���en":...}`. That destroys every non-ASCII
|
|
80
|
+
# body the SDK captures, and it is a redaction bypass on top of the fold
|
|
81
|
+
# bug in Redact.normalize_name, because a mangled key no longer matches the
|
|
82
|
+
# denylist. Bytes tagged BINARY are UTF-8 bytes; decode them like
|
|
83
|
+
# `bytes_to_utf8` does, which is Node's `Buffer#toString("utf8")`.
|
|
84
|
+
#
|
|
85
|
+
# A String tagged with a REAL other encoding (ISO-8859-1 and friends) is
|
|
86
|
+
# still transcoded, because there the tag is information rather than the
|
|
87
|
+
# absence of one.
|
|
88
|
+
def to_utf8(str)
|
|
89
|
+
return "" if str.nil?
|
|
90
|
+
|
|
91
|
+
s = str.is_a?(String) ? str : str.to_s
|
|
92
|
+
if s.encoding == Encoding::UTF_8
|
|
93
|
+
s.valid_encoding? ? s : s.scrub(REPLACEMENT_CHAR)
|
|
94
|
+
elsif s.encoding == Encoding::ASCII_8BIT
|
|
95
|
+
s.dup.force_encoding(Encoding::UTF_8).scrub(REPLACEMENT_CHAR)
|
|
96
|
+
else
|
|
97
|
+
s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace,
|
|
98
|
+
replace: REPLACEMENT_CHAR)
|
|
99
|
+
end
|
|
100
|
+
rescue StandardError
|
|
101
|
+
""
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Decode raw bytes as UTF-8, replacing anything ill-formed with U+FFFD.
|
|
105
|
+
#
|
|
106
|
+
# Byte-for-byte identical to Node's `Buffer.from(bytes).toString("utf8")`,
|
|
107
|
+
# including the maximal-subpart rule (a truncated 3-byte sequence yields
|
|
108
|
+
# ONE U+FFFD, an overlong pair yields two). Verified differentially.
|
|
109
|
+
def bytes_to_utf8(bytes)
|
|
110
|
+
bytes.pack("C*").force_encoding(Encoding::UTF_8).scrub(REPLACEMENT_CHAR)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# PRIM-020. Unicode FULL, locale-independent lowercase mapping.
|
|
114
|
+
#
|
|
115
|
+
# `String#downcase` is exactly that, and the two traps the contract calls
|
|
116
|
+
# out both come out right: U+0130 becomes `i` + U+0307 (two code points,
|
|
117
|
+
# matching JavaScript and Python, unlike Go's SIMPLE mapping), and U+212A
|
|
118
|
+
# KELVIN SIGN becomes a plain `k`. It is not `casefold`, and it applies no
|
|
119
|
+
# locale tailoring unless one is asked for, so no Turkish dotless-i.
|
|
120
|
+
#
|
|
121
|
+
# This is the ONLY lowercase in the SDK. Section 4 name normalization used
|
|
122
|
+
# to have its own ASCII-only fold for names; that fold was
|
|
123
|
+
# a redaction bypass (see Redact.normalize_name) and is gone.
|
|
124
|
+
#
|
|
125
|
+
# What `String#downcase` does not do is the Final_Sigma contextual rule
|
|
126
|
+
# that `String.prototype.toLowerCase` applies: a word-final capital sigma
|
|
127
|
+
# lowercases to U+03C2 in the reference and to U+03C3 here. PRIM-021
|
|
128
|
+
# records that as a real difference between the languages and explicitly
|
|
129
|
+
# does NOT require it, because nothing in this contract can observe it.
|
|
130
|
+
# Greek is outside WORD, so FP-020 step 6 replaces both sigma forms with a
|
|
131
|
+
# space before either can reach a token (`"ΑΣ failed"` and `"ΑΣΑ failed"`
|
|
132
|
+
# both normalize to `failed` either way), and the REDACT-010 denylists are
|
|
133
|
+
# ASCII. A hand-rolled \p{Cased} / \p{Case_Ignorable} implementation used
|
|
134
|
+
# to live here; it was removed rather than carried as code no vector,
|
|
135
|
+
# fuzzer or caller can distinguish from this one-liner.
|
|
136
|
+
def full_lower(str)
|
|
137
|
+
str.downcase
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def ws?(char)
|
|
141
|
+
WS_SET.key?(char)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `String.prototype.trim` semantics: strip exactly the PRIM-002 set.
|
|
145
|
+
#
|
|
146
|
+
# Ruby's `String#strip` removes ASCII whitespace plus NUL and leaves NBSP
|
|
147
|
+
# and friends in place, which is a different set in both directions.
|
|
148
|
+
def ws_trim(str)
|
|
149
|
+
chars = str.chars
|
|
150
|
+
first = 0
|
|
151
|
+
first += 1 while first < chars.length && ws?(chars[first])
|
|
152
|
+
last = chars.length - 1
|
|
153
|
+
last -= 1 while last >= first && ws?(chars[last])
|
|
154
|
+
return "" if last < first
|
|
155
|
+
|
|
156
|
+
chars[first..last].join
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# PRIM-040. `YYYY-MM-DDTHH:MM:SS.sssZ`, exactly three fractional digits
|
|
160
|
+
# and a literal Z.
|
|
161
|
+
#
|
|
162
|
+
# Hand-built: `Time#iso8601` needs `require "time"` plus an explicit digit
|
|
163
|
+
# count, and emits `+00:00` rather than `Z` unless the receiver is already
|
|
164
|
+
# UTC. The ingest parses this field permissively and silently falls back to
|
|
165
|
+
# server time when it cannot, so a wrong format loses real request timing
|
|
166
|
+
# with no error anywhere in the system.
|
|
167
|
+
def iso8601_millis(time)
|
|
168
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "env"
|
|
8
|
+
require_relative "har"
|
|
9
|
+
require_relative "version"
|
|
10
|
+
|
|
11
|
+
module Restless
|
|
12
|
+
# CONTRACT.md sections 8 (wire format) and 9 (batching).
|
|
13
|
+
#
|
|
14
|
+
# Never raises to callers. Upload failures go to stderr under the debug flag
|
|
15
|
+
# and are otherwise swallowed: observability must not break the request path
|
|
16
|
+
# (SAFETY-004, SAFETY-008).
|
|
17
|
+
class Uploader
|
|
18
|
+
BATCH_SIZE = 10 # BATCH-001
|
|
19
|
+
FLUSH_INTERVAL_MS = 5000 # BATCH-002
|
|
20
|
+
MAX_QUEUE = 1000 # BATCH-004
|
|
21
|
+
HTTP_TIMEOUT_S = 10
|
|
22
|
+
|
|
23
|
+
attr_reader :base_url, :request_id_prefix
|
|
24
|
+
|
|
25
|
+
def initialize(api_key:, base_url:, request_id_prefix: nil, on_response: nil,
|
|
26
|
+
transport: nil)
|
|
27
|
+
@api_key = api_key.to_s
|
|
28
|
+
@base_url = base_url.to_s
|
|
29
|
+
@request_id_prefix = request_id_prefix
|
|
30
|
+
@on_response = on_response
|
|
31
|
+
# Test hook. Anything responding to
|
|
32
|
+
# `call(url, headers, body) -> [status, body]`.
|
|
33
|
+
@transport = transport
|
|
34
|
+
|
|
35
|
+
@queue = []
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@timer = nil
|
|
38
|
+
@inflight = []
|
|
39
|
+
|
|
40
|
+
warn_if_insecure
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def api_key?
|
|
44
|
+
!@api_key.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# WIRE-006. The project key and every captured header would otherwise ship
|
|
48
|
+
# in the clear with no signal anywhere.
|
|
49
|
+
def warn_if_insecure
|
|
50
|
+
uri = URI.parse(@base_url)
|
|
51
|
+
return unless uri.scheme == "http"
|
|
52
|
+
return if %w[localhost 127.0.0.1].include?(uri.host)
|
|
53
|
+
|
|
54
|
+
warn("[restless-sdk] RESTLESS_BASE_URL=#{@base_url} is plain HTTP -- your API " \
|
|
55
|
+
"key and every captured header will be transmitted unencrypted. " \
|
|
56
|
+
"Use https:// or localhost.")
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def push(captured)
|
|
62
|
+
# BATCH-008.
|
|
63
|
+
return if Env.test_run? && !Env.setup_mode?
|
|
64
|
+
|
|
65
|
+
batch = nil
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
if @queue.length >= MAX_QUEUE
|
|
68
|
+
# Drop the OLDEST. The newest entries are the ones an operator is
|
|
69
|
+
# actively debugging.
|
|
70
|
+
@queue.shift
|
|
71
|
+
Env.debug_log("queue at #{MAX_QUEUE} -- dropping oldest captured request")
|
|
72
|
+
end
|
|
73
|
+
@queue << captured
|
|
74
|
+
|
|
75
|
+
if flush_immediately? || @queue.length >= BATCH_SIZE
|
|
76
|
+
batch = take_batch
|
|
77
|
+
else
|
|
78
|
+
start_timer
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
upload_async(batch) if batch
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# BATCH-005, BATCH-006, BATCH-007. Synchronous: resolves when the attempt
|
|
87
|
+
# completes.
|
|
88
|
+
def flush
|
|
89
|
+
batch = @mutex.synchronize { take_batch }
|
|
90
|
+
upload(batch) unless batch.nil? || batch.empty?
|
|
91
|
+
join_inflight
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def options
|
|
96
|
+
{
|
|
97
|
+
base_url: @base_url,
|
|
98
|
+
request_id_prefix: @request_id_prefix,
|
|
99
|
+
has_api_key: api_key?
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# BATCH-003. Flush every push when the app is not in production, or when
|
|
106
|
+
# the ingest is on localhost. Both keep the customer dev loop and
|
|
107
|
+
# self-hosted setups low-latency.
|
|
108
|
+
def flush_immediately?
|
|
109
|
+
return true unless Env.production?
|
|
110
|
+
return true if Env.localhost?(@base_url)
|
|
111
|
+
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Caller holds @mutex.
|
|
116
|
+
def take_batch
|
|
117
|
+
cancel_timer
|
|
118
|
+
return nil if @queue.empty?
|
|
119
|
+
|
|
120
|
+
batch = @queue
|
|
121
|
+
@queue = []
|
|
122
|
+
batch
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Caller holds @mutex.
|
|
126
|
+
def start_timer
|
|
127
|
+
return if @timer
|
|
128
|
+
|
|
129
|
+
@timer = Thread.new do
|
|
130
|
+
sleep(FLUSH_INTERVAL_MS / 1000.0)
|
|
131
|
+
begin
|
|
132
|
+
batch = @mutex.synchronize do
|
|
133
|
+
@timer = nil
|
|
134
|
+
@queue.empty? ? nil : take_batch_unsafe
|
|
135
|
+
end
|
|
136
|
+
upload(batch) if batch
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
Env.debug_log("flush timer failed: #{e.class}: #{e.message}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
@timer.abort_on_exception = false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Like take_batch but without cancelling the timer, which the timer thread
|
|
145
|
+
# has already cleared.
|
|
146
|
+
def take_batch_unsafe
|
|
147
|
+
batch = @queue
|
|
148
|
+
@queue = []
|
|
149
|
+
batch
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Caller holds @mutex.
|
|
153
|
+
def cancel_timer
|
|
154
|
+
timer = @timer
|
|
155
|
+
@timer = nil
|
|
156
|
+
timer.kill if timer && timer != Thread.current
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# SAFETY-008. The capture path performs no blocking I/O; uploads are
|
|
160
|
+
# asynchronous and fire-and-forget.
|
|
161
|
+
def upload_async(batch)
|
|
162
|
+
thread = Thread.new { upload(batch) }
|
|
163
|
+
thread.abort_on_exception = false
|
|
164
|
+
@mutex.synchronize do
|
|
165
|
+
@inflight.reject! { |t| !t.alive? }
|
|
166
|
+
@inflight << thread
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def join_inflight
|
|
171
|
+
threads = @mutex.synchronize { @inflight.dup }
|
|
172
|
+
threads.each { |t| t.join(HTTP_TIMEOUT_S) }
|
|
173
|
+
@mutex.synchronize { @inflight.reject! { |t| !t.alive? } }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def upload(batch)
|
|
177
|
+
return if batch.nil? || batch.empty?
|
|
178
|
+
|
|
179
|
+
# BATCH-007. No key: drop the batch, never retry or accumulate.
|
|
180
|
+
unless api_key?
|
|
181
|
+
Env.debug_log("no API key -- dropping batch of #{batch.length}")
|
|
182
|
+
return
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
fingerprints = distinct_fingerprints(batch)
|
|
186
|
+
payload = batch.map { |captured| entry_for(captured) }
|
|
187
|
+
url = "#{@base_url}/v1/request" # WIRE-001
|
|
188
|
+
|
|
189
|
+
Env.debug_log("uploading #{batch.length} entr#{batch.length == 1 ? 'y' : 'ies'} to #{url}")
|
|
190
|
+
|
|
191
|
+
headers = {
|
|
192
|
+
"Content-Type" => "application/json", # WIRE-002
|
|
193
|
+
"Authorization" => "Bearer #{@api_key}", # WIRE-003
|
|
194
|
+
"X-Restless-Spec-Version" => SPEC_VERSION, # META-002
|
|
195
|
+
"User-Agent" => "#{SDK_NAME}/#{VERSION}"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# WIRE-004: a JSON ARRAY, even for a single capture.
|
|
199
|
+
status, body = send_request(url, headers, JSON.generate(payload))
|
|
200
|
+
|
|
201
|
+
# WIRE-024: a non-2xx is never retried and never raises. The batch is
|
|
202
|
+
# dropped.
|
|
203
|
+
unless status && status >= 200 && status < 300
|
|
204
|
+
Env.debug_log("upload failed: #{status.inspect}")
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
handle_response(body, fingerprints)
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
# SAFETY-004.
|
|
211
|
+
Env.debug_log("upload error: #{e.class}: #{e.message}")
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def send_request(url, headers, body)
|
|
216
|
+
return @transport.call(url, headers, body) if @transport
|
|
217
|
+
|
|
218
|
+
uri = URI.parse(url)
|
|
219
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
220
|
+
http.use_ssl = uri.scheme == "https"
|
|
221
|
+
http.open_timeout = HTTP_TIMEOUT_S
|
|
222
|
+
http.read_timeout = HTTP_TIMEOUT_S
|
|
223
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
224
|
+
headers.each { |k, v| request[k] = v }
|
|
225
|
+
request.body = body
|
|
226
|
+
response = http.request(request)
|
|
227
|
+
[response.code.to_i, response.body]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# WIRE-020..023.
|
|
231
|
+
def handle_response(raw_body, batch_fingerprints)
|
|
232
|
+
return if @on_response.nil?
|
|
233
|
+
|
|
234
|
+
parsed = begin
|
|
235
|
+
JSON.parse(raw_body.to_s)
|
|
236
|
+
rescue StandardError
|
|
237
|
+
# WIRE-020: a non-JSON or unparseable body is ignored without error.
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
return unless parsed.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
@on_response.call(parsed, batch_fingerprints)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# CACHE-012 feeds off this: every distinct fingerprint key in the batch.
|
|
246
|
+
def distinct_fingerprints(batch)
|
|
247
|
+
seen = {}
|
|
248
|
+
out = []
|
|
249
|
+
batch.each do |captured|
|
|
250
|
+
fingerprint = captured["errorFingerprint"]
|
|
251
|
+
next unless fingerprint.is_a?(Hash)
|
|
252
|
+
|
|
253
|
+
key = fingerprint["key"]
|
|
254
|
+
next if !key.is_a?(String) || key.empty? || seen[key]
|
|
255
|
+
|
|
256
|
+
seen[key] = true
|
|
257
|
+
out << key
|
|
258
|
+
end
|
|
259
|
+
out
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# WIRE-010..019.
|
|
263
|
+
def entry_for(captured)
|
|
264
|
+
har = Har.to_har_entry(captured)
|
|
265
|
+
user = captured["user"] || {}
|
|
266
|
+
owner = user["owner"] || {}
|
|
267
|
+
owner_id = owner["id"]
|
|
268
|
+
masked_key = user["apiKey"]
|
|
269
|
+
|
|
270
|
+
# WIRE-013: `emails` is always an ARRAY. A single-string `email` from
|
|
271
|
+
# enrichment is wrapped; an absent one becomes [].
|
|
272
|
+
raw_email = owner["email"]
|
|
273
|
+
emails = if raw_email.is_a?(Array)
|
|
274
|
+
raw_email
|
|
275
|
+
elsif raw_email.nil? || raw_email == ""
|
|
276
|
+
[]
|
|
277
|
+
else
|
|
278
|
+
[raw_email]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
entry = {
|
|
282
|
+
# WIRE-011: the RAW uuid, with no display prefix applied.
|
|
283
|
+
"_id" => captured["requestId"]
|
|
284
|
+
}
|
|
285
|
+
entry["routePattern"] = captured["routePattern"] if captured["routePattern"]
|
|
286
|
+
# WIRE-017: absent for successful responses.
|
|
287
|
+
entry["errorFingerprint"] = captured["errorFingerprint"] if captured["errorFingerprint"]
|
|
288
|
+
# WIRE-012: owner id, else the masked end-user key, else "anonymous".
|
|
289
|
+
entry["group"] = {
|
|
290
|
+
"id" => (owner_id && !owner_id.empty? ? owner_id : nil) || masked_key || "anonymous",
|
|
291
|
+
"label" => owner["label"] || "",
|
|
292
|
+
"emails" => emails
|
|
293
|
+
}
|
|
294
|
+
# WIRE-014: the individual caller, indexed independently of the group.
|
|
295
|
+
entry["apiKey"] = masked_key if masked_key
|
|
296
|
+
# WIRE-015/WIRE-019: the wire name is `projectId`, the user-facing name
|
|
297
|
+
# is `owner`. The mismatch is deliberate and coupled to the ingest's
|
|
298
|
+
# storage schema.
|
|
299
|
+
entry["projectId"] = owner_id if owner_id
|
|
300
|
+
|
|
301
|
+
# SETUP-005: unknown top-level fields on the setup result ride along.
|
|
302
|
+
(user["extra"] || {}).each do |key, value|
|
|
303
|
+
entry[key] = value unless entry.key?(key)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# WIRE-018: reserved constants.
|
|
307
|
+
entry["clientIPAddress"] = "127.0.0.1"
|
|
308
|
+
entry["development"] = false
|
|
309
|
+
entry["request"] = {
|
|
310
|
+
"log" => {
|
|
311
|
+
"version" => "1.2",
|
|
312
|
+
"creator" => { "name" => SDK_NAME, "version" => VERSION }, # WIRE-016
|
|
313
|
+
"entries" => [har]
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
entry
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Restless
|
|
4
|
+
# Identity of this SDK, and the contract version it implements.
|
|
5
|
+
#
|
|
6
|
+
# META-001: the spec version an SDK implements must be recorded in a
|
|
7
|
+
# machine-readable form alongside its conformance level.
|
|
8
|
+
|
|
9
|
+
VERSION = "0.1.0"
|
|
10
|
+
|
|
11
|
+
# WIRE-016: distinct per implementation, so the ingest can attribute a
|
|
12
|
+
# payload to a language.
|
|
13
|
+
SDK_NAME = "restless-sdk-ruby"
|
|
14
|
+
|
|
15
|
+
# The spec/CONTRACT.md version this SDK is verified against.
|
|
16
|
+
#
|
|
17
|
+
# REDACT-010 requires full Unicode name folding
|
|
18
|
+
# to require full Unicode lowercase. Keep this in step with
|
|
19
|
+
# spec/VECTORS_VERSION.
|
|
20
|
+
SPEC_VERSION = "1.0.0"
|
|
21
|
+
|
|
22
|
+
# CONTRACT.md 1.1: L1 is the pure functions, L2 adds batching, caches,
|
|
23
|
+
# injection and the safety guarantees.
|
|
24
|
+
CONFORMANCE_LEVEL = "L2"
|
|
25
|
+
end
|
data/lib/restless.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "restless/version"
|
|
4
|
+
require_relative "restless/text"
|
|
5
|
+
require_relative "restless/env"
|
|
6
|
+
require_relative "restless/mask"
|
|
7
|
+
require_relative "restless/redact"
|
|
8
|
+
require_relative "restless/fingerprint"
|
|
9
|
+
require_relative "restless/stack_frames"
|
|
10
|
+
require_relative "restless/har"
|
|
11
|
+
require_relative "restless/request_id"
|
|
12
|
+
require_relative "restless/injection"
|
|
13
|
+
require_relative "restless/caches"
|
|
14
|
+
require_relative "restless/settings"
|
|
15
|
+
require_relative "restless/uploader"
|
|
16
|
+
require_relative "restless/capture"
|
|
17
|
+
require_relative "restless/client"
|
|
18
|
+
# Depends on nothing outside the stdlib -- a Rack middleware is just an object
|
|
19
|
+
# with a `call(env)` -- so it is loaded eagerly rather than hidden behind a
|
|
20
|
+
# lazy require.
|
|
21
|
+
require_relative "restless/rack"
|
|
22
|
+
|
|
23
|
+
# Capture your API traffic and send it to Restless.
|
|
24
|
+
#
|
|
25
|
+
# This SDK implements version 1.0.0 of the Restless SDK Contract at level L2.
|
|
26
|
+
# See CONFORMANCE.md.
|
|
27
|
+
#
|
|
28
|
+
# client = Restless.new(ENV["RESTLESS_KEY"])
|
|
29
|
+
#
|
|
30
|
+
# client.setup do |request|
|
|
31
|
+
# {
|
|
32
|
+
# api_key: client.mask(request.header("Authorization")),
|
|
33
|
+
# owner: {
|
|
34
|
+
# id: workspace_id_for(request),
|
|
35
|
+
# enrich: ->(id) { { label: Workspace.find(id).name } },
|
|
36
|
+
# },
|
|
37
|
+
# }
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# use client.rack
|
|
41
|
+
module Restless
|
|
42
|
+
class << self
|
|
43
|
+
# Construct a client. See Restless::Client#initialize.
|
|
44
|
+
def new(api_key = nil, **options)
|
|
45
|
+
Client.new(api_key, **options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# MASK-001, available without a client for scripts and tests.
|
|
49
|
+
def mask(api_key)
|
|
50
|
+
Mask.mask(api_key)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def new_request_id
|
|
54
|
+
RequestId.new_request_id
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|