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