restless-sdk 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a346b73df699fa4b309117f63c42f284a971d392fff96ac0321510c9b5c06327
4
- data.tar.gz: a57d20173e1a974bc5fde6b9c7dd6c20e4239f4f7c70cd572863f97628d9ddfc
3
+ metadata.gz: f2ab526956a3600f1166a74828a06c9b01b5a0ce2a17808bdba530f6ea344ee9
4
+ data.tar.gz: 4e5776ac3709c2f1e8b5d0a710d45335927a331eddb0cd9b027a0bd8305c42ee
5
5
  SHA512:
6
- metadata.gz: 34762d9a43d3294b0448c75a683a158d5ce241d88961177bfbcc215969bf9b4a6f6e0f362cfdb31973e8d05de827a8c56d243ab224132b0f3f3a96c4f7ba0c1b
7
- data.tar.gz: bbc49f5ffc1a24e563a4cc609cc6f3a95d43b3f3d4090c78826b83c68cdb4ec5d4aab36cfd7b795ce033ed2e5796d56f0a89c3196d40b49b6227affa0ee13487
6
+ metadata.gz: ce0a15152665dba1b04f64377e6b6dc2e773f128b48ae6fdcddedc0f35fc34df3c40716470857f16c35b8409793b03af07e2bda387af9a24ba8636bcc419149c
7
+ data.tar.gz: 395c589c7b35aa121e9612fd0142e32df3c2f0dc75cc3eeb8607912b73d279ef046a3478f4d63e7a548d813ca4a33ae1212a18bef2a7b60f3bdb0ba88d306a55
data/CONFORMANCE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  | | |
4
4
  |---|---|
5
- | **Spec version** | 1.0.1 |
5
+ | **Spec version** | 1.1.0 |
6
6
  | **Level** | L2 (core + batching, caches, injection, safety) |
7
7
  | **Reference** | `restlesshq/node` (`@restlessai/sdk`) |
8
8
  | **Driver** | `ruby exe/restless-conformance` |
@@ -31,9 +31,9 @@ node ../node/spec/harness/fuzz.mjs \
31
31
  --iterations 8000 --seed 24301
32
32
  ```
33
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).
34
+ Current status: **252 vectors, 244 passed, 0 failed, 8 skipped.** Zero
35
+ divergence across 35,926 fuzz comparisons on five seeds (24301, 1, 4242,
36
+ 99991, 777), including the four sampling ops.
37
37
 
38
38
  The 8 skips are cases outside this implementation's dialect, not gaps:
39
39
 
@@ -71,6 +71,11 @@ the reference. They are the reason this SDK is byte-compatible.
71
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
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
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
+ | SAMPLE-002 | `(r.to_f * 10000 + 0.5).floor`, never `round`. JSON hands over `1` as an Integer and `0.5` as a Float, so the value goes through `to_f` first; a quantized rate is a Float, which is why a rate of 0 serializes as `0.0` (the same JSON number). `true` is not `Numeric` in Ruby, so it needs no special case to be invalid. |
75
+ | SAMPLE-010 | Keys are trimmed of U+0020 only (`sub(/\A +/, "")`), never `strip`, which would also eat a trailing tab or NUL and turn the invalid key `"GET /x\t"` into a valid one. The method is uppercased with `upcase(:ascii)`. |
76
+ | SAMPLE-032 | The decision is the first thing `finish` does. Rack returns status and headers together before the server writes anything, so unlike Node there is no `writeHead` to intercept: none of the SDK's headers exist yet, even for a streaming body or a hijack. A drop returns the app's response triple untouched, body unread. |
77
+ | SAMPLE-040 | The rate token is `format("0.%04d", k)` from the integer step count. `String#sort` compares bytes, which is the order the contract asks for. |
78
+ | Section 14 | `sampling:` accepts symbol or string keys (`default:`, `"routes"`) and symbol modes (`:off`). Neither can come from the settings file or the driver, so the vectors are unaffected. |
74
79
  | 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
80
  | 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
81
  | BATCH-008 | Test detection keys on those same variables being `test`, plus `RSpec::Core`, `Minitest::Test` and `Test::Unit::TestCase` being defined. |
data/README.md CHANGED
@@ -1,7 +1,9 @@
1
- <picture>
2
- <source media="(prefers-color-scheme: dark)" srcset="docs/restless-init-dark.svg">
3
- <img width="100%" src="docs/restless-init.svg" alt="Restless">
4
- </picture>
1
+ <a href="https://restless.ai">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/restless-init-dark.svg">
4
+ <img width="100%" src="docs/restless-init.svg" alt="Restless">
5
+ </picture>
6
+ </a>
5
7
 
6
8
  Run in your codebase to get started:
7
9
 
@@ -94,6 +96,50 @@ and body your app produced rather than an inner layer's.
94
96
  request before your handler runs, or
95
97
  `{ block: { status: 402, message: "Payment required" } }` to pick the
96
98
  status.
99
+ - **Sampling.** Send less for noisy endpoints without losing your counts.
100
+ See below.
101
+
102
+ ## Sampling
103
+
104
+ Each endpoint is `"on"` (the default), `"off"`, `"errors"` (only status
105
+ >= 400), or a rate such as `0.1`: that fraction of successful requests is
106
+ logged in full and the rest without bodies, so counts, latency and
107
+ per-customer usage stay exact. Errors are never sampled.
108
+
109
+ ```ruby
110
+ CLIENT = Restless.new(ENV["RESTLESS_KEY"], sampling: {
111
+ default: "on",
112
+ routes: {
113
+ "GET /health" => "off",
114
+ "GET /status" => "errors",
115
+ "GET /pets" => 0.1,
116
+ "* /pets/{id}" => 0.5,
117
+ },
118
+ })
119
+ ```
120
+
121
+ | mode | successes (< 400) | errors (>= 400) |
122
+ |---|---|---|
123
+ | `"on"` (default) | full log | full log |
124
+ | a rate `0` to `1` | that fraction in full, the rest without bodies | full log |
125
+ | `"errors"` | nothing sent | full log |
126
+ | `"off"` | nothing sent | nothing sent |
127
+
128
+ Keys are a method (or `*` for any) and the route pattern your framework
129
+ matched (see [Route patterns](#route-patterns)); `{id}` and `:id` are the
130
+ same, and `HEAD` does not fall back to `GET`. A request with no route
131
+ pattern uses `default`. Symbols work for the modes (`:off`, `:errors`).
132
+
133
+ The same `sampling` block can live under an API entry in
134
+ `.restless/settings.json`, and the option in code wins per route. A request
135
+ that sends nothing (an `"off"` route, or a success on an `"errors"` route)
136
+ also gets no `x-request-id` header and no debug headers, since there is no
137
+ log for them to point at. An invalid value never raises: it falls back to
138
+ `"on"` and says so under `DEBUG=restless`.
139
+
140
+ With sampling configured, the SDK tells Restless the configuration once, on
141
+ the first request it serves (never at boot), so the dashboard can show an
142
+ `"off"` endpoint as off rather than as idle.
97
143
 
98
144
  ## The setup callback
99
145
 
@@ -153,7 +199,7 @@ re-raised untouched, leaving your own error handling completely unaffected.
153
199
 
154
200
  ## Conformance
155
201
 
156
- This SDK implements version 1.0.0 of the Restless SDK Contract at level L2,
202
+ This SDK implements version 1.1.0 of the Restless SDK Contract at level L2,
157
203
  and is verified against the shared cross-language conformance vectors and a
158
204
  differential fuzzer run against the reference implementation. See
159
205
  [CONFORMANCE.md](./CONFORMANCE.md).
data/install.md CHANGED
@@ -196,24 +196,25 @@ Read at startup, walking up from the working directory. Created and owned by the
196
196
  ```json
197
197
  {
198
198
  "version": 1,
199
- "projectId": "<team/workspace uuid>",
200
199
  "apis": [
201
200
  {
202
201
  "id": "<api uuid>",
203
202
  "name": "Public API",
204
203
  "rootDir": ".",
204
+ "projectId": "<restless project uuid>",
205
205
  "oasFile": ".restless/openapi.yaml",
206
206
  "framework": "rails",
207
207
  "language": "ruby",
208
208
  "baseUrl": "https://api.example.com",
209
209
  "requestIdPrefix": "PUB",
210
- "redact": { "headers": ["x-company-auth"], "bodyKeys": ["ssh_private_key"] }
210
+ "redact": { "headers": ["x-company-auth"], "bodyKeys": ["ssh_private_key"] },
211
+ "sampling": { "default": "on", "routes": { "GET /health": "off", "GET /pets": 0.1 } }
211
212
  }
212
213
  ]
213
214
  }
214
215
  ```
215
216
 
216
- The SDK reads `requestIdPrefix` and `redact` from the matching entry. Pick one when several are defined:
217
+ The SDK reads `requestIdPrefix`, `redact` and `sampling` (§8a, merged with the `sampling:` option, code winning per route) from the matching entry. The rest, `projectId` included - which is per-API and never top-level - belongs to the CLI, not the runtime. Pick one when several are defined:
217
218
 
218
219
  ```ruby
219
220
  Restless.new(ENV["RESTLESS_KEY"], api: "Public API")
@@ -243,11 +244,35 @@ Sentinel format, which the dashboard pattern-matches:
243
244
 
244
245
  Bodies are capped at **256 KiB** (UTF-8 bytes) and truncated with `[...TRUNCATED: original N bytes]`.
245
246
 
247
+ ## 8a. Sampling (off by default)
248
+
249
+ Without a `sampling` block every request is logged in full. With one, each endpoint gets a mode:
250
+
251
+ | mode | successes (< 400) | errors (>= 400) |
252
+ |---|---|---|
253
+ | `"on"` (default) | full log | full log |
254
+ | a rate `0` to `1` | that fraction in full, the rest without bodies | full log |
255
+ | `"errors"` | nothing sent | full log |
256
+ | `"off"` | nothing sent | nothing sent |
257
+
258
+ ```ruby
259
+ Restless.new(ENV["RESTLESS_KEY"], sampling: {
260
+ routes: { "GET /health" => "off", "GET /pets" => 0.1, "* /internal/{job}" => "errors" },
261
+ })
262
+ ```
263
+
264
+ Rules an installer should know:
265
+
266
+ - Keys are `"<METHOD> <route pattern>"`, with `*` for any method. Use the pattern the framework matched (`/pets/{id}` or `/pets/:id`, both work; §4 "Route patterns"), never a concrete URL like `/pets/42`. `HEAD` does not fall back to `GET`. A request with no route pattern uses `default`.
267
+ - Modes are the strings `"on"`, `"off"`, `"errors"` (symbols work in code), or a number from 0 to 1. Anything else falls back to `"on"`; it never raises.
268
+ - Only suggest `"off"` or `"errors"` for endpoints the user agrees are noise (health checks, readiness probes, polling). Never sample auth or payment endpoints without asking.
269
+ - A request that sends nothing also gets no `x-request-id` header, so do not use an `"off"` route to verify the install (§13).
270
+
246
271
  ## 9. Request IDs and response injection
247
272
 
248
273
  - 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 **every** status the SDK adds `x-log-url` and `x-debug` headers; on status **>= 400** it also merges a `debug` key into a JSON body. There is no user-configurable hook for this.
274
+ - The SDK sets exactly one id header, always its own: `x-request-id` by default, or `x-restless-id` when the incoming request already carried an `x-request-id` (we never stomp or reuse an existing chain). With no key resolved the value is the literal `missing-key`.
275
+ - On **every** status the SDK adds `x-log-url` and `x-debug` headers; on status **>= 400** it also merges a `debug` key into a JSON body. There is no user-configurable hook for this. The one exception is a request that sampling drops (§8a): it gets no id header and nothing injected.
251
276
  - `x-log-url` points at your project's public docs host, which the server tells the SDK on each upload. Until the first upload round-trips it is omitted rather than guessed: a URL that 404s is worse than no URL. The ingest host is never used for it.
252
277
 
253
278
  ## 10. Blocking
@@ -261,7 +286,7 @@ CLIENT.setup do |request|
261
286
  end
262
287
  ```
263
288
 
264
- The handler never runs. The response body is JSON `{"error": "<message>"}`. `enrich` is not called for a blocked request.
289
+ The handler never runs. The response body is JSON `{"error": "<message>"}`. `enrich` is not called for a blocked request. Sampling (§8a) treats a blocked request like any other response with its status: a 403 or 429 is logged in full, except on an `"off"` route, where it is not logged and gets no id header.
265
290
 
266
291
  ## 11. Environment variables and batching
267
292
 
@@ -297,4 +322,4 @@ Batching is fixed: 10 requests per batch, a 5000 ms flush interval, a 1000-entry
297
322
  2. `CLIENT.rack` is mounted in `config.ru` or `config/application.rb`, as far out as possible.
298
323
  3. A `CLIENT.setup` block exists and reads its header via `request.header(...)`.
299
324
  4. `.restless/settings.json` exists (created by `npx restless init`).
300
- 5. Starting the server and curling any endpoint returns an `x-restless-id` response header.
325
+ 5. Starting the server and curling any endpoint returns an `x-request-id` response header carrying a fresh id - `<prefix>-<uuid>` when the API has a `requestIdPrefix` in `.restless/settings.json` (the CLI sets one on every project), otherwise a bare UUID. If your curl sends its own `x-request-id`, look for `x-restless-id` instead. Plenty of stacks set `x-request-id` themselves, so the unambiguous proof it came through our SDK is the `x-debug` header, which rides every captured response. A value of `missing-key` means the server is up but never loaded `RESTLESS_KEY`; restart it.
@@ -5,6 +5,7 @@ require "json"
5
5
  require_relative "caches"
6
6
  require_relative "fingerprint"
7
7
  require_relative "redact"
8
+ require_relative "sampling"
8
9
  require_relative "uploader"
9
10
 
10
11
  module Restless
@@ -20,9 +21,19 @@ module Restless
20
21
 
21
22
  attr_reader :uploader, :enrich_cache, :recovery_cache
22
23
 
24
+ # `sampling` is a Sampling::Config, already merged and validated. Nil, or
25
+ # the default config, means no sampling at all.
23
26
  def initialize(api_key:, base_url:, request_id_prefix: nil, redact: nil,
24
- transport: nil)
27
+ sampling: nil, transport: nil)
25
28
  @redact = redact || {}
29
+ # Only kept for a non-default config (SAMPLE-041). Without one, `sample`
30
+ # is a constant :full and nothing about sampling touches the wire.
31
+ @sampling = sampling && !sampling.default? ? sampling : nil
32
+ @sampling_hash = @sampling && Sampling.digest(@sampling)
33
+ @sampling_report = :unsent
34
+ @sampling_mutex = Mutex.new
35
+ # Test hook: anything responding to `call` with a Float in [0, 1).
36
+ @random = -> { Random.rand }
26
37
  @enrich_cache = EnrichCache.new
27
38
  @recovery_cache = RecoveryCache.new
28
39
  @portal_url = nil
@@ -33,10 +44,31 @@ module Restless
33
44
  base_url: base_url,
34
45
  request_id_prefix: request_id_prefix,
35
46
  transport: transport,
47
+ sampling_hash: @sampling_hash,
36
48
  on_response: method(:handle_server_response)
37
49
  )
38
50
  end
39
51
 
52
+ attr_writer :random
53
+
54
+ # The sampling outcome for one request (CONTRACT.md SAMPLE-030): :full,
55
+ # :stub or :drop. The middleware calls this exactly once per request, when
56
+ # the status is final and before any SDK header is added (SAMPLE-032),
57
+ # and acts on the answer.
58
+ #
59
+ # Never raises. Anything unexpected sends the request in full
60
+ # (SAMPLE-035): sampling may only ever remove data, never break capture.
61
+ def sample(method, route_pattern, status)
62
+ return :full if @sampling.nil?
63
+
64
+ report_sampling(false)
65
+ mode = Sampling.resolve_mode(@sampling, method, route_pattern)
66
+ Sampling.decide(mode, status.to_i, @random.call)
67
+ rescue StandardError => e
68
+ Env.debug_log("sampling failed, sending in full: #{e.class}: #{e.message}")
69
+ :full
70
+ end
71
+
40
72
  def callback=(callback)
41
73
  @callback = callback
42
74
  end
@@ -56,6 +88,9 @@ module Restless
56
88
 
57
89
  # WIRE-020..023, CACHE-006, CACHE-011..013.
58
90
  def handle_server_response(body, batch_fingerprints)
91
+ # WIRE-025, SAMPLE-053.
92
+ report_sampling(true) if body["needsSamplingConfig"] == true
93
+
59
94
  needs = body["needsEnrichment"]
60
95
  if needs.is_a?(Array)
61
96
  needs.each { |key| @enrich_cache.invalidate(key) if key.is_a?(String) }
@@ -212,6 +247,13 @@ module Restless
212
247
  def record(captured, stack_frame: nil)
213
248
  request = captured["request"] || {}
214
249
  response = captured["response"] || {}
250
+ if captured["sampled"]
251
+ # A stub ships as if no body had been captured (SAMPLE-034). Dropping
252
+ # them here, before redaction, skips the parse work for exactly the
253
+ # requests the customer asked to send less of.
254
+ request = request.merge("body" => nil)
255
+ response = response.merge("body" => nil)
256
+ end
215
257
  request_headers = request["headers"] || {}
216
258
  response_headers = response["headers"] || {}
217
259
 
@@ -251,6 +293,40 @@ module Restless
251
293
 
252
294
  private
253
295
 
296
+ # Tell the ingest which config produced this process's uploads, so an
297
+ # "off" endpoint does not read as a dead one (SAMPLE-050..053). Once per
298
+ # process, on the first decision; again only when the server asks, and
299
+ # never while a report is already in flight.
300
+ def report_sampling(resend)
301
+ return if @sampling.nil?
302
+
303
+ send_now = @sampling_mutex.synchronize do
304
+ if @sampling_report == :inflight || (@sampling_report == :sent && !resend)
305
+ false
306
+ else
307
+ @sampling_report = :inflight
308
+ true
309
+ end
310
+ end
311
+ return unless send_now
312
+
313
+ report = {
314
+ "hash" => @sampling_hash,
315
+ "default" => @sampling.default,
316
+ "routes" => Sampling.sorted_routes(@sampling),
317
+ "creator" => { "name" => SDK_NAME, "version" => VERSION }
318
+ }
319
+ # Fire and forget on a background thread, and a failure is not retried
320
+ # (SAMPLE-051): the `x-restless-sampling` header on the next upload is
321
+ # how the server asks again if it never got this.
322
+ @uploader.send_sampling_report_async(report) do
323
+ @sampling_mutex.synchronize { @sampling_report = :sent }
324
+ end
325
+ rescue StandardError => e
326
+ @sampling_mutex.synchronize { @sampling_report = :sent }
327
+ Env.debug_log("sampling report failed: #{e.class}: #{e.message}")
328
+ end
329
+
254
330
  # Accept both `:api_key`/`"apiKey"` spellings so the callback reads
255
331
  # naturally in Ruby without losing the documented wire names (SETUP-001).
256
332
  def normalize_setup(raw)
@@ -4,6 +4,7 @@ require_relative "capture"
4
4
  require_relative "env"
5
5
  require_relative "mask"
6
6
  require_relative "request_id"
7
+ require_relative "sampling"
7
8
  require_relative "settings"
8
9
  require_relative "version"
9
10
 
@@ -22,10 +23,25 @@ module Restless
22
23
  # them (REDACT-014). Both the settings file and this option are additive
23
24
  # on top of the defaults (REDACT-015).
24
25
  #
26
+ # `sampling` sends less for high-traffic endpoints (CONTRACT.md section
27
+ # 12.1). Each endpoint is "on" (the default), "off", "errors" (only
28
+ # status >= 400) or a rate in [0, 1]: that fraction of successful
29
+ # requests in full and the rest without bodies. Errors are never sampled.
30
+ #
31
+ # sampling: {
32
+ # default: "on",
33
+ # routes: { "GET /health" => "off", "GET /pets" => 0.1, "* /pets/{id}" => "errors" }
34
+ # }
35
+ #
36
+ # Symbols work too (`default: :off`). Merged with `sampling` in
37
+ # `.restless/settings.json`, this option winning per route (SAMPLE-014).
38
+ # An invalid value never raises: it falls back to "on" and says so under
39
+ # DEBUG=restless.
40
+ #
25
41
  # `transport` is an internal test hook -- anything responding to
26
42
  # `call(url, headers, body) -> [status, body]` -- and is not public API.
27
43
  def initialize(api_key = nil, base_url: nil, api: nil, redact: nil,
28
- transport: nil)
44
+ sampling: nil, transport: nil)
29
45
  resolved_key = Env.resolve_api_key(api_key)
30
46
 
31
47
  # CONFIG-002. Construction still succeeds and capture still runs; only
@@ -41,11 +57,23 @@ module Restless
41
57
  entry = Settings.resolve_api(Settings.load, api)
42
58
  settings_redact = entry && entry[:redact]
43
59
 
60
+ sampling_config = begin
61
+ config, warnings = Sampling.normalize(entry && entry[:sampling], sampling)
62
+ warnings.each { |w| Env.debug_log(w) }
63
+ config
64
+ rescue StandardError => e
65
+ # SAMPLE-003: a bad config never raises. Without one, every request
66
+ # is sent in full.
67
+ Env.debug_log("sampling config ignored: #{e.class}: #{e.message}")
68
+ nil
69
+ end
70
+
44
71
  @engine = CaptureEngine.new(
45
72
  api_key: resolved_key,
46
73
  base_url: Env.resolve_base_url(base_url),
47
74
  request_id_prefix: entry && entry[:request_id_prefix],
48
75
  redact: merge_redact(settings_redact, redact),
76
+ sampling: sampling_config,
49
77
  transport: transport
50
78
  )
51
79
  end
@@ -7,6 +7,7 @@ require_relative "stack_frames"
7
7
  require_relative "har"
8
8
  require_relative "request_id"
9
9
  require_relative "injection"
10
+ require_relative "sampling"
10
11
 
11
12
  module Restless
12
13
  # The shared operation table behind the conformance driver.
@@ -86,6 +87,24 @@ module Restless
86
87
  when "harEntry"
87
88
  Har.to_har_entry(hash(input["captured"]))
88
89
 
90
+ # --- sampling (section 12.1) ---
91
+ # Routes come back as sorted [key, mode] pairs so the comparison pins
92
+ # the normalized keys without depending on hash order. Warnings are
93
+ # debug output, not contract surface, so they are not emitted.
94
+ when "samplingConfig"
95
+ config = sampling_config(input)
96
+ { "default" => config.default, "routes" => config.routes.sort_by { |key, _| key } }
97
+ when "samplingMode"
98
+ Sampling.resolve_mode(sampling_config(input), str(input["method"]),
99
+ str_or_nil(input["route"]))
100
+ when "samplingDecide"
101
+ status = input["status"].is_a?(Numeric) ? input["status"] : 0
102
+ u = input["u"].is_a?(Numeric) ? input["u"] : 0
103
+ Sampling.decide(input["mode"], status, u).to_s
104
+ when "samplingHash"
105
+ config = sampling_config(input)
106
+ { "canonical" => Sampling.canonical(config), "hash" => Sampling.digest(config) }
107
+
89
108
  else
90
109
  raise UnknownOp, "unknown op: #{op}"
91
110
  end
@@ -106,6 +125,12 @@ module Restless
106
125
  { "headers" => built[:headers], "debug" => built[:debug] }
107
126
  end
108
127
 
128
+ # `settings` and `code` are passed verbatim, possibly invalid, and every
129
+ # op that takes them merges and normalizes them first (SAMPLE-014).
130
+ def sampling_config(input)
131
+ Sampling.normalize(input["settings"], input["code"]).first
132
+ end
133
+
109
134
  def fingerprint(input)
110
135
  stack = stack_text(input["stackTrace"])
111
136
  if !stack.empty? && v8_dialect?(stack)
data/lib/restless/rack.rb CHANGED
@@ -210,21 +210,45 @@ module Restless
210
210
  def finish(env, setup, our_id, started_at, clock, response,
211
211
  request_body:, stack_frame:, inject: true)
212
212
  status, headers, body = response
213
- headers = normalize_headers(headers)
213
+ route = route_pattern(env)
214
+
215
+ # SAMPLE-032. One decision per request, here: Rack hands over the status
216
+ # and headers together, before the server has written a byte, so the
217
+ # status is final and none of our headers exist yet. That holds for a
218
+ # streaming body and for a hijack too.
219
+ outcome = @engine.sample(env["REQUEST_METHOD"], route, status.to_i)
220
+
221
+ # SAMPLE-033. Dropped: no log, so no id header, no injected headers and
222
+ # no `debug` object pointing at one. The response goes back exactly as
223
+ # the app returned it, body unread.
224
+ return response if outcome == :drop
214
225
 
215
- body_text, body = read_response_body(body, headers)
226
+ # SAMPLE-034. A stub never ships a body, and is always a success, so
227
+ # there is nothing to inject into it either: leave the body unread.
228
+ stub = outcome == :stub
229
+
230
+ headers = normalize_headers(headers)
231
+ # Rack's partial hijack hands the server a callable in the headers. It
232
+ # is not a header: keep it out of the capture and give it back intact.
233
+ hijack = headers.delete("rack.hijack")
234
+
235
+ body_text, body = if stub || hijack
236
+ [nil, body]
237
+ else
238
+ read_response_body(body, headers)
239
+ end
216
240
  duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - clock) * 1000).round
217
241
 
218
242
  captured = {
219
243
  "requestId" => our_id,
220
244
  "startedAt" => Text.iso8601_millis(started_at),
221
245
  "duration" => duration,
222
- "routePattern" => route_pattern(env),
246
+ "routePattern" => route,
223
247
  "request" => {
224
248
  "method" => env["REQUEST_METHOD"],
225
249
  "url" => Middleware.full_url(env),
226
250
  "headers" => request_headers(env),
227
- "body" => request_body
251
+ "body" => stub ? nil : request_body
228
252
  },
229
253
  "response" => {
230
254
  "status" => status.to_i,
@@ -234,6 +258,7 @@ module Restless
234
258
  "user" => setup
235
259
  }
236
260
  captured.delete("routePattern") if captured["routePattern"].nil?
261
+ captured["sampled"] = true if stub # WIRE-030
237
262
 
238
263
  options = @engine.uploader.options
239
264
  # REQID-010, REQID-011.
@@ -293,6 +318,8 @@ module Restless
293
318
 
294
319
  safely(nil) { @engine.record(captured, stack_frame: stack_frame) }
295
320
 
321
+ # After `record`, which copies the headers it keeps.
322
+ headers["rack.hijack"] = hijack if hijack
296
323
  [status, headers, body]
297
324
  rescue StandardError => e
298
325
  Env.debug_log("middleware finish failed: #{e.class}: #{e.message}")
@@ -305,7 +332,14 @@ module Restless
305
332
  def normalize_headers(headers)
306
333
  out = {}
307
334
  (headers || {}).each do |name, value|
308
- out[name.to_s.downcase] = value.is_a?(Array) ? value.join(", ") : value.to_s
335
+ name = name.to_s.downcase
336
+ out[name] = if name == "rack.hijack"
337
+ value # a callable, not a header value
338
+ elsif value.is_a?(Array)
339
+ value.join(", ")
340
+ else
341
+ value.to_s
342
+ end
309
343
  end
310
344
  out
311
345
  end