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.
@@ -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