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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1147b0604f0fdb6ca8b2cdd39ffe03819391d0e5d663b70fa845fbe4b4b72b27
4
+ data.tar.gz: f1bd487a972936ae678151d03f27edecb476b8c1f7b755ff5a88157f5d015e4e
5
+ SHA512:
6
+ metadata.gz: f7508f1e2f878289a16930cd56db0a0c5e150dc6fd5772a0c221cb97792a703f130f15d6a99e401b81bb713edbceba5adc835da4d994600248911f4d7f86b558
7
+ data.tar.gz: c6e94661d61836cdb2dc408beefc0e5323afd17ffb3c5ec2e68beb2ec5ab1687af64ae72a6a8726d97e265d4a1343b04dea320687e0b952a7c392f279851e71d
data/CONFORMANCE.md ADDED
@@ -0,0 +1,112 @@
1
+ # Conformance
2
+
3
+ | | |
4
+ |---|---|
5
+ | **Spec version** | 1.0.0 |
6
+ | **Level** | L2 (core + batching, caches, injection, safety) |
7
+ | **Reference** | `restlesshq/node` (`@restlessai/sdk`) |
8
+ | **Driver** | `ruby exe/restless-conformance` |
9
+
10
+ Declared in `lib/restless/version.rb` (META-001).
11
+
12
+ ## Verifying
13
+
14
+ The harness and vectors live in the reference SDK, so the commands below
15
+ assume it is checked out as a sibling (`../node-sdk`), which is how
16
+ `setup.sh` in the install repo arranges things. The vectors in `spec/` here
17
+ are a pinned copy, so `ruby -Ilib -Itest test/all.rb` alone works without it.
18
+
19
+
20
+ ```sh
21
+ # in-process, no Node and no bundler needed
22
+ ruby -Ilib -Itest test/all.rb
23
+
24
+ # the shared cross-language harness
25
+ node ../node-sdk/spec/harness/run-vectors.mjs -- ruby exe/restless-conformance
26
+
27
+ # differential fuzz against the reference implementation
28
+ node ../node-sdk/spec/harness/fuzz.mjs \
29
+ --ref "node ../node-sdk/spec/driver/.build/node.js" \
30
+ --test "ruby exe/restless-conformance" \
31
+ --iterations 8000 --seed 24301
32
+ ```
33
+
34
+ Current status: **206 vectors, 198 passed, 0 failed, 8 skipped.** Zero
35
+ divergence across 34,918 fuzz comparisons on five seeds (24301, 1, 4242,
36
+ 99991, 777).
37
+
38
+ The 8 skips are cases outside this implementation's dialect, not gaps:
39
+
40
+ - 7 `fp/stack-*` cases feed a v8-shaped stack into `fingerprint`. FP-044
41
+ makes frame parsing per-language and FP-046 requires the driver to say so
42
+ rather than guess. Covered natively in `test/test_stack_frames.rb`. This
43
+ includes `fp/stack-not-used-for-4xx`, where the stack is never consulted:
44
+ the driver declares the dialect on the INPUT rather than on whether the
45
+ strategy happens to fire, which is what the Python and Go drivers do too.
46
+ - 1 `redactBody/lone-surrogate` case. See the exemption below.
47
+
48
+ ## Ruby-specific decisions
49
+
50
+ Each of these is a place where the obvious Ruby code silently disagrees with
51
+ the reference. They are the reason this SDK is byte-compatible.
52
+
53
+ | Contract | What Ruby needs |
54
+ |---|---|
55
+ | PRIM-002 | `WS` is enumerated as code points in `text.rb`. Ruby's `\s` is ASCII-only and NARROWER than the contract's set: no NBSP, no Zs category, no LS/PS/ZWNBSP. |
56
+ | PRIM-003 | **Ruby's `\b` is Unicode-aware even though its `\w` is not.** Onigmo defines the boundary against the Unicode word property, so `\b[\w-]*[0-9][\w-]*\b` leaves `éa1` untouched where JavaScript, Go and Python-with-`re.ASCII` all strip the `a1`. `Fingerprint.strip_digit_words` applies that one regex to the UTF-8 BYTES, which restores JavaScript's semantics exactly (every non-ASCII character becomes a run of non-word bytes, which is what a non-`u` JS regex sees in UTF-16 too). The pattern can only match ASCII bytes, so the result is still well-formed UTF-8. |
57
+ | PRIM-005 | `\A...\z` everywhere, never `^...$`. Ruby's `$` also matches before a trailing newline, so a route segment of `"5\n"` would normalize to `:id` here and not in JavaScript. |
58
+ | PRIM-006 | Hex digits validated explicitly against `[0-9a-fA-F]`. `String#to_i(16)` returns 0 for garbage rather than failing, so `%zz` would silently decode as a NUL byte. |
59
+ | PRIM-010 vs PRIM-011 | `String#length` is code points (what REDACT-002 and MASK-006 want) and `String#bytesize` is UTF-8 bytes (what HAR-010 and REDACT-030 want). They are never interchangeable. |
60
+ | PRIM-013 | `Text.to_utf8` scrubs invalid bytes to U+FFFD instead of raising. Rack hands out header and body strings tagged UTF-8 that are not valid UTF-8. It also DECODES a BINARY string rather than transcoding it: `IO#read(n)` always returns ASCII-8BIT and the Rack spec requires `rack.input` to be binary, and Ruby's BINARY-to-UTF-8 transcode has no mapping above `0x7F`, so `encode` turns every byte of every multi-byte character into its own U+FFFD. That corrupted every non-ASCII body the SDK captured and was a second redaction bypass on top of REDACT-010, since a mangled key no longer matches the denylist. |
61
+ | PRIM-020 | `String#downcase` is FULL case mapping and is used unmodified everywhere, including for REDACT-010 names. U+0130 becomes two code points (matching JS and Python, unlike Go's simple mapping) and U+212A KELVIN SIGN becomes `k`. It is not `casefold` and applies no locale tailoring. The one thing it does not do is the Final_Sigma contextual rule that `String.prototype.toLowerCase` applies (word-final Σ to ς rather than σ), and per the PRIM-021 note that is **not required**: nothing in the contract can observe it, because Greek is outside `WORD` so FP-020 step 6 replaces both sigma forms with a space before either reaches a token, and the REDACT-010 denylists are ASCII. An earlier `\p{Cased}` / `\p{Case_Ignorable}` implementation of the rule was removed as untestable. |
62
+ | PRIM-030..032 | Ruby's `JSON.generate` is already compact, insertion-ordered and literal-UTF-8, and does not HTML-escape - so the `<REDACTED:...>` sentinel the dashboard pattern-matches on survives. Verified byte-identical to `JSON.stringify` for control characters, quotes, backslashes and astral characters. |
63
+ | PRIM-040 | Hand-built with `strftime("%Y-%m-%dT%H:%M:%S.%LZ")`. `Time#iso8601` needs `require "time"` plus an explicit fractional-digit count and emits `+00:00` unless the receiver is already UTC. |
64
+ | REDACT-010 | Name normalization uses **full Unicode** lowercase (PRIM-020), not an ASCII fold, and this is a security requirement rather than a cosmetic one. Normalization decides whether a value is redacted, so it must fold more aggressively rather than less: a header `x-api-Key` or a body key `toKen` whose `K` is U+212A KELVIN SIGN lowercases to a denylisted name and must be redacted. A hand-written ASCII-only fold is the easy mistake here, and it ships such values to the dashboard in plaintext. Pinned by `redactHeaders/unicode-fold-kelvin`, `redactBody/unicode-fold-kelvin` and `test_middleware.rb#test_kelvin_sign_does_not_bypass_the_denylist`. |
65
+ | REDACT-016 | An explicit code-point scan, never `^(\S+)(\s+)(\S.*)$`. Ruby's `.` excludes only LF while JavaScript's also excludes CR, LS and PS, so a credential containing a stray CR would take the scheme-preserving branch here and the redact-whole branch there. |
66
+ | REDACT-025 | `split("&", -1)` and `split("/", -1)`. Ruby drops trailing empty fields without the negative limit, so `?a=1&` would come back as `?a=1` and `/users/` would lose its trailing segment. |
67
+ | REDACT-028 | The unreserved set is spelled out. `CGI.escape` turns a space into `+` and escapes `~`; `URI.encode_www_form_component` differs again. |
68
+ | REDACT-029 | `bytes.pack("C*").force_encoding("UTF-8").scrub` is byte-identical to Node's `Buffer#toString("utf8")`, including the maximal-subpart replacement rule. Verified differentially. |
69
+ | FP-020 step 9 | `split(/ /, -1)`, never `split(" ")`. A literal single-space argument triggers Ruby's awk-mode split, which collapses runs and drops leading empties. |
70
+ | FP-030 | Hex classes written as `[0-9a-fA-F]` with no `i` flag. Ruby's case-insensitive matching is Unicode case folding, a wider relation than the ASCII-only canonicalization a non-`u` JavaScript regex performs. |
71
+ | FP-042 | `project_relative` splits on `/` and scans BACKWARDS from the second-to-last segment, so the LAST project dir wins and a deployment root named `/app` (Docker `WORKDIR`, Heroku) cannot survive into the key. `split("/", -1)`: without the negative limit Ruby drops the trailing empty field, so `/proj/src/` would miss `src` here and match it in JavaScript. |
72
+ | FP-043 | Ruby's `Exception#backtrace` is innermost-FIRST, like v8 and Go and unlike a Python traceback, so the walk goes forwards. Verified empirically in `test/test_stack_frames.rb` rather than assumed. |
73
+ | FP-044 | Frames are `path:line:in \`method'` (`'method'` from Ruby 3.4); both quote styles parse. `block (N levels) in foo` is normalized to `block in foo`, since the nesting count behaves like a line number. Skips are matched by FILE PATH: the SDK's own directory (resolved from `__dir__`), `RbConfig`'s stdlib dirs, `Gem.path`, any `/gems/` segment, and `<internal:` frames. Never by module or class name - a name check would also skip a customer's own Restless-flavoured code and would fail to skip this gem when vendored under another constant. |
74
+ | INJECT-005 | `Text.ws_trim`, not `String#strip`. Ruby's strip removes NUL and leaves NBSP; the contract wants exactly the PRIM-002 set. |
75
+ | BATCH-003 | The environment indicator is `RESTLESS_ENV`, then `RACK_ENV`, then `RAILS_ENV`. Ruby has three spellings in the wild and no single winner. |
76
+ | BATCH-008 | Test detection keys on those same variables being `test`, plus `RSpec::Core`, `Minitest::Test` and `Test::Unit::TestCase` being defined. |
77
+ | CACHE-* | Both caches are Mutex-guarded. Puma, Falcon and threaded Unicorn all serve requests on many threads, so an unsynchronized Hash is a real race. |
78
+
79
+ ## Exemption: PRIM-034 (unpaired surrogates)
80
+
81
+ This SDK claims the **PRIM-035 exemption**, on stronger grounds than Go's.
82
+
83
+ A Go string merely *replaces* an unpaired surrogate with U+FFFD while
84
+ decoding. Ruby is more restrictive in both directions:
85
+
86
+ - `JSON.parse('"\ud83d"')` **raises** `JSON::ParserError: incomplete
87
+ surrogate pair`. The value never reaches SDK code at all.
88
+ - `"\u{d83d}"` is not a legal Ruby literal - the parser rejects it with
89
+ `invalid Unicode codepoint`. `[0xd83d].pack("U")` produces the CESU-8
90
+ bytes `ED A0 BD`, which `String#valid_encoding?` reports as false.
91
+
92
+ So a Ruby `String` has no representation for the value, and per PRIM-035 the
93
+ SDK never raises on such input and substitutes U+FFFD (`Text.to_utf8`).
94
+
95
+ The conformance driver reports `unsupported` for any input LINE containing a
96
+ lone surrogate escape, checked on the raw line **before** parsing, because
97
+ the limitation is at the transport layer rather than in redaction - parsing
98
+ first would produce a `JSON::ParserError` that looks like a conformance
99
+ failure. `test/test_vectors.rb` does the same, swapping each such escape for
100
+ an ASCII marker so the vector documents can be read at all, then skipping the
101
+ cases that contained one.
102
+
103
+ ## Residual differences, accepted
104
+
105
+ **Number rendering in a re-serialized body.** REDACT-020 means a body is only
106
+ re-serialized when it actually contains a denylisted key. For those bodies,
107
+ Ruby preserves int64 exactly where JavaScript silently truncates above 2^53,
108
+ and Ruby's `Float#to_s` uses a different exponent form than JavaScript for
109
+ very large and very small magnitudes. This matches the Python SDK and is the
110
+ behaviour PRIM-033 describes as the *correct* one; the reference is the odd
111
+ one out. It is unreachable from the fuzzer, because `JSON.stringify` has
112
+ already collapsed such values before the input is generated.
data/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # restless-sdk
2
+
3
+ Capture your API traffic and send it to [Restless](https://restless.ai).
4
+
5
+ One Rack middleware covers **Rails**, **Sinatra**, **Hanami**, **Grape**,
6
+ **Roda** and anything else that speaks Rack. Ruby 2.6+. No dependencies.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ gem install restless-sdk
12
+ ```
13
+
14
+ or in a `Gemfile`:
15
+
16
+ ```ruby
17
+ gem "restless-sdk"
18
+ ```
19
+
20
+ ## Use
21
+
22
+ ```ruby
23
+ require "restless"
24
+
25
+ CLIENT = Restless.new(ENV["RESTLESS_KEY"])
26
+
27
+ CLIENT.setup do |request|
28
+ {
29
+ api_key: CLIENT.mask(request.header("Authorization")),
30
+ owner: {
31
+ # Permanent and immutable. A workspace id or database primary key,
32
+ # never an API key, email, or anything else that rotates: the
33
+ # dashboard pins a project's whole log history to this.
34
+ id: workspace_id_for(request),
35
+ # Runs once per owner id, then caches for an hour. Put the expensive
36
+ # lookup here, not in the fields above.
37
+ enrich: ->(owner_id) {
38
+ workspace = Workspace.find(owner_id)
39
+ { label: workspace.name, email: workspace.admin_emails }
40
+ },
41
+ },
42
+ }
43
+ end
44
+ ```
45
+
46
+ Then mount it.
47
+
48
+ ```ruby
49
+ # config.ru (Sinatra, Roda, Hanami, plain Rack)
50
+ use CLIENT.rack
51
+ run App
52
+
53
+ # config/application.rb (Rails)
54
+ config.middleware.insert_before 0, CLIENT.rack
55
+ ```
56
+
57
+ `CLIENT.rack` returns a middleware factory: anything that calls `.new(app)`
58
+ on it works. Mount it as far **out** as you can, so it sees the real status
59
+ and body your app produced rather than an inner layer's.
60
+
61
+ ## What you get
62
+
63
+ - **Lazy owner enrichment.** The `enrich` callback runs on the first request
64
+ from each owner id and then caches, so 100 requests from one workspace do
65
+ not mean 100 database lookups. The cached value still rides on every
66
+ upload, so no request lands in the dashboard as unauthenticated.
67
+ - **Safe by default.** `Authorization`, `Cookie`, `password`, `token`, `ssn`
68
+ and friends are redacted before anything leaves your process. Bodies with
69
+ nothing to redact are passed through byte for byte, so your payloads are
70
+ never re-serialized on the way out.
71
+ - **Error triage.** 4xx/5xx responses get `x-log-url` and `x-debug` headers
72
+ and a `debug` block in the JSON body. If someone attaches a "next steps"
73
+ message to an error in the dashboard, the SDK injects it as
74
+ `debug.recovery` - read synchronously from an in-process cache, never
75
+ blocking the response on a network call. A 5xx that groups by its throw
76
+ site also reports the key it would have grouped under otherwise, so a
77
+ message you attached before that grouping existed keeps being injected.
78
+ - **Blocking.** Return `{ block: true }` from the setup callback to reject a
79
+ request before your handler runs, or
80
+ `{ block: { status: 402, message: "Payment required" } }` to pick the
81
+ status.
82
+
83
+ ## The setup callback
84
+
85
+ It receives a small read-only request view and returns a hash:
86
+
87
+ | field | what |
88
+ |---|---|
89
+ | `api_key` | The **masked** end-user key. Always `CLIENT.mask(raw_header)` - never substitute `"anonymous"`, whose last 4 characters would become the mask tail. |
90
+ | `owner[:id]` | The permanent, immutable workspace/tenant identifier. |
91
+ | `owner[:enrich]` | `->(id) { ... }` returning owner metadata (`label`, `email`, extras). The only channel for owner metadata; anything else inline on `owner` is dropped. |
92
+ | `block` | `true`, or `{ status:, message: }`. |
93
+ | anything else | Carried through onto the log as-is. Keep it cheap - read straight off the request. Anything expensive belongs in `enrich`. |
94
+
95
+ The request view exposes `header(name)` (case-insensitive),
96
+ `request_method`, `path`, `query_string`, `url`, and the raw Rack `env`.
97
+
98
+ ## Route patterns
99
+
100
+ The middleware reads the matched route template from the framework so your
101
+ handlers do not have to report it:
102
+
103
+ | framework | source | reported as |
104
+ |---|---|---|
105
+ | Sinatra | `env["sinatra.route"]` (`GET /pets/:id`) | `/pets/{id}` |
106
+ | Rails | `env["action_dispatch.route_uri_pattern"]` | `/pets/{id}` |
107
+ | anything | `env["restless.route"]`, set by you | as given |
108
+
109
+ The method prefix is stripped and `:id`-style params are rewritten to
110
+ `{id}`, so the same endpoint produces the same `routePattern` here as it
111
+ does in the Node, Python and Go SDKs. Override the whole thing with a
112
+ lambda:
113
+
114
+ ```ruby
115
+ use CLIENT.rack(route: ->(env) { env["my_router.pattern"] })
116
+ ```
117
+
118
+ ## Never breaks your API
119
+
120
+ Observability must not take down a production request path. Upload failures,
121
+ callback exceptions, unserializable bodies and malformed input are all caught
122
+ and swallowed (surfaced only under `DEBUG=restless`). Uploads happen on a
123
+ background thread and are fire-and-forget. Streaming responses
124
+ (`text/event-stream`) and bodies over 1 MiB are never buffered.
125
+
126
+ An exception your app raises is captured with its stack - so the error
127
+ fingerprint keys on the method that raised, not on the middleware - and then
128
+ re-raised untouched, leaving your own error handling completely unaffected.
129
+
130
+ ## Environment variables
131
+
132
+ | variable | purpose |
133
+ |---|---|
134
+ | `RESTLESS_KEY` | Your project API key, if you do not pass one explicitly. |
135
+ | `RESTLESS_BASE_URL` | Override the ingest URL (self-hosted or staging). |
136
+ | `RESTLESS_ENV` / `RACK_ENV` / `RAILS_ENV` | `production` enables batching; anything else flushes every request. `test` disables uploads entirely. |
137
+ | `DEBUG=restless` | Print upload diagnostics to stderr. |
138
+
139
+ ## Conformance
140
+
141
+ This SDK implements version 1.0.0 of the Restless SDK Contract at level L2,
142
+ and is verified against the shared cross-language conformance vectors and a
143
+ differential fuzzer run against the reference implementation. See
144
+ [CONFORMANCE.md](./CONFORMANCE.md).
145
+
146
+ ## License
147
+
148
+ ISC
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The Ruby conformance driver. See node-sdk/spec/driver/PROTOCOL.md.
5
+ #
6
+ # Dev-only: it is a thin shell over Restless::Conformance, which
7
+ # test/test_vectors.rb also uses, so the vectors cannot describe behaviour the
8
+ # driver does not exhibit. No customer ever runs it.
9
+ #
10
+ # bundle exec restless-conformance # or just: ruby exe/restless-conformance
11
+ #
12
+ # Reads JSON Lines on stdin, writes JSON Lines on stdout, diagnostics on
13
+ # stderr, and exits 0 when stdin closes.
14
+
15
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
16
+
17
+ require "json"
18
+ require "restless/conformance"
19
+
20
+ UNSUPPORTED_SURROGATE = <<~MSG.tr("\n", " ").strip
21
+ unsupported: input line contains an unpaired surrogate escape. Ruby's JSON
22
+ parser raises on one and a Ruby String has no representation for it, so the
23
+ value is unreachable before any SDK code runs (PRIM-035).
24
+ MSG
25
+
26
+ # PRIM-035 / PROTOCOL.md "Skips".
27
+ #
28
+ # Checked on the RAW LINE, because this is a TRANSPORT limit rather than an SDK
29
+ # one: `JSON.parse('"\ud83d"')` raises `JSON::ParserError: incomplete surrogate
30
+ # pair`, and `"\u{d83d}"` is not even a legal Ruby literal. Reporting
31
+ # `unsupported` for the whole line is what the protocol asks for; reporting a
32
+ # parse error would look like a conformance failure, and silently substituting
33
+ # U+FFFD would compare a value the harness never sent.
34
+ def lone_surrogate_escape?(line)
35
+ hex4 = /\A[0-9a-fA-F]{4}\z/
36
+ index = 0
37
+ while (at = line.index("\\u", index))
38
+ hex = line[at + 2, 4]
39
+ unless hex && hex4.match?(hex)
40
+ index = at + 2
41
+ next
42
+ end
43
+
44
+ code = hex.to_i(16)
45
+ if code >= 0xD800 && code <= 0xDBFF
46
+ # A high surrogate is fine only if a low one follows immediately.
47
+ low_hex = line[at + 8, 4]
48
+ if line[at + 6, 2] == "\\u" && low_hex && hex4.match?(low_hex)
49
+ low = low_hex.to_i(16)
50
+ if low >= 0xDC00 && low <= 0xDFFF
51
+ index = at + 12
52
+ next
53
+ end
54
+ end
55
+ return true
56
+ end
57
+ return true if code >= 0xDC00 && code <= 0xDFFF
58
+
59
+ index = at + 6
60
+ end
61
+ false
62
+ end
63
+
64
+ # Best-effort extraction so the harness can still correlate a response for a
65
+ # line we refuse to fully decode.
66
+ def raw_id_token(line)
67
+ match = /"id"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)/.match(line)
68
+ match ? match[1] : "null"
69
+ end
70
+
71
+ def emit(line)
72
+ $stdout.write("#{line}\n")
73
+ $stdout.flush
74
+ end
75
+
76
+ $stdout.sync = true
77
+ $stdin.set_encoding(Encoding::UTF_8)
78
+ $stdout.set_encoding(Encoding::UTF_8)
79
+
80
+ while (line = $stdin.gets)
81
+ line = line.strip
82
+ next if line.empty?
83
+
84
+ if lone_surrogate_escape?(line)
85
+ emit(%({"id":#{raw_id_token(line)},"error":#{JSON.generate(UNSUPPORTED_SURROGATE)}}))
86
+ next
87
+ end
88
+
89
+ begin
90
+ request = JSON.parse(line, max_nesting: false)
91
+ rescue StandardError => e
92
+ emit(JSON.generate({ "id" => nil, "error" => "malformed request: #{e.message}" }))
93
+ next
94
+ end
95
+
96
+ id = request["id"]
97
+ begin
98
+ result = Restless::Conformance.dispatch(request["op"], request["input"])
99
+ emit(JSON.generate({ "id" => id, "result" => result }))
100
+ rescue Restless::Conformance::UnknownOp,
101
+ Restless::Conformance::UnsupportedDialect => e
102
+ emit(JSON.generate({ "id" => id, "error" => e.message }))
103
+ rescue StandardError => e
104
+ emit(JSON.generate({ "id" => id, "error" => "#{e.class}: #{e.message}" }))
105
+ end
106
+ end
107
+
108
+ exit 0
data/install.md ADDED
@@ -0,0 +1,299 @@
1
+ # install.md: LLM installation reference for restless-sdk (Ruby)
2
+
3
+ This file is the single source of truth for LLM agents installing or configuring the Restless Ruby SDK. Humans should read `README.md` instead.
4
+
5
+ The document is ordered so an agent can stop as soon as enough context has been loaded: package basics → setup call → where to mount → redaction → settings file → common mistakes.
6
+
7
+ ---
8
+
9
+ ## 1. What this package is
10
+
11
+ `restless-sdk` captures HTTP request/response pairs and ships them in batches to the Restless ingest server for dashboard display.
12
+
13
+ - **Runtime:** Ruby 2.6+. **No dependencies.**
14
+ - **Shape:** one Rack middleware. Rails, Sinatra, Hanami, Grape, Roda and anything else that speaks Rack all use the same one; there are no per-framework adapters.
15
+ - **Install name is not the require name:** you install `restless-sdk` and you `require "restless"`.
16
+
17
+ ## 2. Install
18
+
19
+ ```sh
20
+ gem install restless-sdk
21
+ ```
22
+
23
+ or in a `Gemfile`:
24
+
25
+ ```ruby
26
+ gem "restless-sdk"
27
+ ```
28
+
29
+ No other gems are required.
30
+
31
+ ## 3. The one-line setup
32
+
33
+ Construct a client, register a per-request callback, mount the middleware:
34
+
35
+ ```ruby
36
+ require "restless"
37
+
38
+ CLIENT = Restless.new(ENV["RESTLESS_KEY"])
39
+
40
+ CLIENT.setup do |request|
41
+ { api_key: CLIENT.mask(request.header("Authorization")),
42
+ owner: { id: workspace_id_for(request), enrich: method(:load_workspace) } }
43
+ end
44
+ ```
45
+
46
+ The client exposes four things:
47
+
48
+ | member | purpose |
49
+ |---|---|
50
+ | `setup(&block)` | Register the per-request callback. Takes a block or any callable. |
51
+ | `mask(key)` | Hash an end-user API key for safe logging. |
52
+ | `rack` | The middleware factory to mount. |
53
+ | `flush` | Force-upload the current batch (e.g. before exit). |
54
+
55
+ `Restless.new` and `Restless::Client.new` are the same thing. `mask` is also available as `Restless.mask` without a client, for scripts and tests.
56
+
57
+ **Construct the client once**, somewhere loaded at boot - `config/application.rb` under Rails, the top of `config.ru` otherwise. Not per request.
58
+
59
+ ## 4. Where to mount it
60
+
61
+ `CLIENT.rack` returns a middleware factory: anything that calls `.new(app)` on it works.
62
+
63
+ ```ruby
64
+ # config.ru - Sinatra, Roda, Hanami, Grape, plain Rack
65
+ use CLIENT.rack
66
+ run App
67
+ ```
68
+
69
+ ```ruby
70
+ # config/application.rb - Rails
71
+ config.middleware.insert_before 0, CLIENT.rack
72
+ ```
73
+
74
+ ```ruby
75
+ # No config.ru at all - wrap where the app is handed to the server
76
+ handler = CLIENT.rack.new(APP)
77
+ ```
78
+
79
+ **Mount as far OUT as you can.** An inner mount sees a different status and body than the client did.
80
+
81
+ **Rails: `insert_before 0`, not `use`.** `config.middleware.use` appends to the bottom of the stack, below `ActionDispatch::ShowExceptions`. From there the SDK records the rendered error page rather than the real exception, so a 500 loses its raise site and every crash groups by the wording of your error template.
82
+
83
+ **The mount is not in the file with the routes.** Rails routes live in `config/routes.rb` and the middleware does not go there. This trips up installers coming from frameworks where the two are the same file.
84
+
85
+ ### Route patterns
86
+
87
+ The middleware reads the matched route template from the framework, so handlers do not have to report it:
88
+
89
+ | framework | source | reported as |
90
+ |---|---|---|
91
+ | Sinatra | `env["sinatra.route"]` (`GET /pets/:id`) | `/pets/{id}` |
92
+ | Rails | `env["action_dispatch.route_uri_pattern"]` | `/pets/{id}` |
93
+ | Grape | `env["grape.routing_args"]` | as matched |
94
+ | anything | `env["restless.route"]`, set by you | as given |
95
+
96
+ The method prefix is stripped, Rails' `(.:format)` is dropped and `:id`-style params are rewritten to `{id}`, so one endpoint produces the same `routePattern` here as it does in the Node, Python and Go SDKs. Override wholesale with `CLIENT.rack(route: ->(env) { ... })`.
97
+
98
+ ## 5. The setup callback
99
+
100
+ The callback receives a read-only `RequestInfo` and returns a hash.
101
+
102
+ | accessor | what |
103
+ |---|---|
104
+ | `request.header(name)` | One header, case-insensitive. `request["authorization"]` is an alias. |
105
+ | `request.request_method` | `"GET"`, `"POST"`, ... |
106
+ | `request.path` | `SCRIPT_NAME` + `PATH_INFO`. |
107
+ | `request.query_string` | Raw query string, without `?`. |
108
+ | `request.url` | Full URL. |
109
+ | `request.env` | The raw Rack env, for anything the view does not model. |
110
+
111
+ Use `header`, not `env["HTTP_AUTHORIZATION"]`.
112
+
113
+ Result fields:
114
+
115
+ | field | type | required | notes |
116
+ |---|---|---|---|
117
+ | `api_key` | `String` or nil | no | Masked key from `CLIENT.mask`. Never plaintext. |
118
+ | `owner` | `Hash` | yes\* | The workspace / tenant / end-user this request belongs to. |
119
+ | `block` | `true` or `{ status:, message: }` | no | Rejects the request before the handler runs. |
120
+
121
+ \* Technically optional, but omitting it lands every log in the dashboard as "anonymous".
122
+
123
+ Extra top-level keys are carried through onto the log.
124
+
125
+ **The callback runs before your application**, because the middleware is mounted outside everything. `current_user`, Warden, and controller filters have not run yet. Resolve the owner from the credential yourself inside the block.
126
+
127
+ **A callback that raises is silently ignored.** SAFETY-002 requires that observability never breaks the request path, so an exception is caught and the result discarded. That is correct behaviour, and it means a wrong callback does not crash your app - it just quietly attributes nothing. Verify with §12 rather than assuming.
128
+
129
+ ### 5.1 The `owner` hash
130
+
131
+ `owner[:id]` is the **permanent, immutable identifier** the dashboard pins a project's entire log history to. Misconfiguring it is the single biggest setup mistake.
132
+
133
+ | API shape | Use as `id` |
134
+ |---|---|
135
+ | Multi-tenant SaaS (`Account`, `Organization`, `Workspace`) | The tenant's id |
136
+ | Key owned by a project or service (`ApiKey belongs_to :project`) | The project id, not its creator |
137
+ | Per-user API (one key per developer) | The user's id |
138
+ | No identity model | Omit `owner` entirely |
139
+
140
+ **Never** an API key, email, username, JWT, or a placeholder literal like `"anonymous"` / `"none"` / `"guest"`. Anything that can rotate, or is a dummy string, is wrong.
141
+
142
+ For requests with no real owner, omit the key. A Ruby hash literal cannot omit a key conditionally, which is why the idiomatic shape builds the hash and then assigns:
143
+
144
+ ```ruby
145
+ CLIENT.setup do |request|
146
+ result = { api_key: CLIENT.mask(request.header("Authorization")) }
147
+
148
+ workspace_id = resolve_workspace(request.header("Authorization"))
149
+ if workspace_id
150
+ result[:owner] = { id: workspace_id, enrich: method(:load_workspace) }
151
+ end
152
+
153
+ result
154
+ end
155
+ ```
156
+
157
+ ### 5.2 `owner[:enrich]`
158
+
159
+ The **only** channel for owner metadata. Inline `label` / `email` keys on `owner` are dropped; everything except `id` comes back from `enrich`.
160
+
161
+ ```ruby
162
+ enrich: ->(owner_id) {
163
+ workspace = Workspace.find(owner_id)
164
+ { label: workspace.name, email: workspace.admin_emails } # String or Array
165
+ }
166
+ ```
167
+
168
+ A lambda or a `method(:name)` reference both work.
169
+
170
+ Behaviour:
171
+
172
+ - Cached by `owner[:id]`. The first request from each owner runs it; the rest skip it.
173
+ - If an upload comes back with `needsEnrichment`, that owner is invalidated and the next request re-runs it.
174
+ - Exceptions are swallowed; the log still ships with the id.
175
+ - Skipped entirely when the same result also returns `block`, so a banned tenant does not cost a database round-trip per owner id forever.
176
+ - The resolved values ride on every subsequent upload, so each log carries full metadata without re-running the lookup.
177
+
178
+ ## 6. The `mask()` gotcha
179
+
180
+ `CLIENT.mask(value)` produces `sha512-<base64>?<last4>`. The suffix is the LAST 4 CHARACTERS OF THE INPUT, so substituting a placeholder leaks it.
181
+
182
+ ```ruby
183
+ # CORRECT: nil when the header is missing
184
+ api_key: CLIENT.mask(request.header("Authorization"))
185
+
186
+ # WRONG: "mous" ends up as the mask tail
187
+ api_key: CLIENT.mask(request.header("Authorization") || "anonymous")
188
+ ```
189
+
190
+ `mask` returns nil on nil input and the SDK handles it.
191
+
192
+ ## 7. `.restless/settings.json`
193
+
194
+ Read at startup, walking up from the working directory. Created and owned by the `api` CLI (`npx api setup`). Every Restless SDK reads the same file with the same camelCase keys, so a polyglot repo needs only one.
195
+
196
+ ```json
197
+ {
198
+ "version": 1,
199
+ "projectId": "<team/workspace uuid>",
200
+ "apis": [
201
+ {
202
+ "id": "<api uuid>",
203
+ "name": "Public API",
204
+ "rootDir": ".",
205
+ "oasFile": ".restless/openapi.yaml",
206
+ "framework": "rails",
207
+ "language": "ruby",
208
+ "baseUrl": "https://api.example.com",
209
+ "requestIdPrefix": "PUB",
210
+ "redact": { "headers": ["x-company-auth"], "bodyKeys": ["ssh_private_key"] }
211
+ }
212
+ ]
213
+ }
214
+ ```
215
+
216
+ The SDK reads `requestIdPrefix` and `redact` from the matching entry. Pick one when several are defined:
217
+
218
+ ```ruby
219
+ Restless.new(ENV["RESTLESS_KEY"], api: "Public API")
220
+ ```
221
+
222
+ ## 8. Redaction (on by default)
223
+
224
+ Sensitive values are redacted BEFORE anything leaves the process.
225
+
226
+ - **Headers:** `authorization`, `cookie`, `set-cookie`, `proxy-authorization`, `x-api-key`, `x-auth-token`
227
+ - **Body keys and query params:** `password`, `pass`, `pwd`, `token`, `secret`, `apikey`, `accesstoken`, `refreshtoken`, `idtoken`, `sessionid`, `ssn`, `creditcard`, `ccnumber`, `cvv`, `cvc`
228
+
229
+ Matching is case-insensitive and ignores `-`/`_`, so `api_key` / `apiKey` / `API-KEY` all match. For `authorization` and `proxy-authorization` the scheme word survives (`Bearer <REDACTED:...>`).
230
+
231
+ Two additive sources on top of the defaults: `apis[].redact` in the settings file, and the `redact:` option. **The option accepts either casing**, so `body_keys:` and `bodyKeys` both work - snake_case reads better in Ruby, camelCase matches the wire format:
232
+
233
+ ```ruby
234
+ Restless.new(key, redact: { headers: ["x-custom"], body_keys: ["api_secret"] })
235
+ ```
236
+
237
+ Sentinel format, which the dashboard pattern-matches:
238
+
239
+ ```
240
+ <REDACTED:<length>> # length < 8
241
+ <REDACTED:<length>:<last-4-chars>> # length >= 8
242
+ ```
243
+
244
+ Bodies are capped at **256 KiB** (UTF-8 bytes) and truncated with `[...TRUNCATED: original N bytes]`.
245
+
246
+ ## 9. Request IDs and response injection
247
+
248
+ - Request IDs are v4 UUIDs, never time-based, so they leak no ordering.
249
+ - Every response gets `x-restless-id`. `x-request-id` is set only if the caller did not send one, and an incoming value is never reused as ours.
250
+ - On status **>= 400** the SDK adds `x-log-url` and `x-debug` headers, and merges a `debug` key into a JSON body. There is no user-configurable hook for this.
251
+
252
+ ## 10. Blocking
253
+
254
+ ```ruby
255
+ CLIENT.setup do |request|
256
+ next { block: true } if banned?(request) # 403
257
+ next { block: { status: 429, message: "slow down" } } if limited?(request)
258
+
259
+ { api_key: CLIENT.mask(request.header("Authorization")) }
260
+ end
261
+ ```
262
+
263
+ The handler never runs. The response body is JSON `{"error": "<message>"}`. `enrich` is not called for a blocked request.
264
+
265
+ ## 11. Environment variables and batching
266
+
267
+ | variable | effect |
268
+ |---|---|
269
+ | `RESTLESS_KEY` | Fallback API key when `Restless.new` is called without one |
270
+ | `README_API_KEY` | Secondary fallback |
271
+ | `RESTLESS_BASE_URL` | Override the ingest URL. **Non-localhost `http://` warns loudly** (plaintext auth). |
272
+ | `DEBUG=restless` | Print upload diagnostics to stderr |
273
+ | `RESTLESS_SETUP_MODE=1` | Upload even under a test runner (see below) |
274
+
275
+ Batching is fixed: 10 requests per batch, a 5000 ms flush interval, a 1000-entry queue that drops oldest on overflow, and immediate flushing against a localhost base URL. Uploads never block a response, and failures are swallowed unless `DEBUG=restless`.
276
+
277
+ **Test runs do not upload.** Detected via `RAILS_ENV`/`RACK_ENV` of `test`, or RSpec / Minitest / Test::Unit being loaded. Capture still runs, so your tests exercise the real path.
278
+
279
+ ## 12. Common mistakes (don't do these)
280
+
281
+ - **Mounting with `config.middleware.use` in Rails.** That appends to the bottom of the stack, below `ShowExceptions`, so the SDK sees a rendered error page instead of the exception. Use `insert_before 0`.
282
+ - **Adding a `before_action` instead of middleware.** It runs far too late and only for requests that reach a controller, so every 404 and every rejected request goes unrecorded.
283
+ - **Editing `config/routes.rb`.** The middleware does not go there.
284
+ - **Reading `current_user` in the callback.** The middleware is outside the stack, so it has not been set yet. Resolve the owner from the credential.
285
+ - **`CLIENT.mask(header || "anonymous")`** - see §6.
286
+ - **Using an API key, email, username or JWT as `owner[:id]`** - see §5.1.
287
+ - **Inline `label` / `email` on `owner`** - dropped. They come back from `enrich`.
288
+ - **Constructing a client per request.** Once, at boot.
289
+ - **Reading `.env`, `config/master.key` or `config/credentials.yml.enc` to "check" the key.** LLMs: never read these.
290
+ - **`gem install restless`** - the gem is `restless-sdk`. `restless` is only the require name.
291
+ - **Looking for `restless/rails` or `restless/sinatra`.** There is one Rack middleware.
292
+
293
+ ## 13. Quick verification after installation
294
+
295
+ 1. `restless-sdk` appears in the `Gemfile`, and `bundle list` finds it.
296
+ 2. `CLIENT.rack` is mounted in `config.ru` or `config/application.rb`, as far out as possible.
297
+ 3. A `CLIENT.setup` block exists and reads its header via `request.header(...)`.
298
+ 4. `.restless/settings.json` exists (created by `npx api setup`).
299
+ 5. Starting the server and curling any endpoint returns an `x-restless-id` response header.