restless-sdk 0.1.0 → 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.
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.
@@ -242,6 +267,18 @@ module Restless
242
267
  options[:request_id_prefix], options[:has_api_key]
243
268
  ))
244
269
 
270
+ # INJECT-002. The headers ship on every status; only the body work below
271
+ # is 4xx/5xx, and fingerprinting a success would be wasted.
272
+ if inject && status.to_i < 400
273
+ headers.merge!(safely({}) do
274
+ Injection.headers(
275
+ request_id: our_id,
276
+ prefix: options[:request_id_prefix],
277
+ portal_url: @engine.portal_url
278
+ )
279
+ end)
280
+ end
281
+
245
282
  if inject && status.to_i >= 400
246
283
  # INJECT-009: the fingerprint is computed against the customer's RAW
247
284
  # response, snapshotted before any injected header or body field.
@@ -257,18 +294,18 @@ module Restless
257
294
  Injection.build(
258
295
  status: status.to_i,
259
296
  request_id: our_id,
260
- base_url: options[:base_url],
261
297
  prefix: options[:request_id_prefix],
262
298
  recovery: recovery,
263
299
  method: env["REQUEST_METHOD"],
264
300
  path: captured["routePattern"],
265
- docs_url: @engine.docs_url
301
+ portal_url: @engine.portal_url
266
302
  )
267
303
  end
268
304
 
269
305
  if injection
270
306
  headers.merge!(injection[:headers]) # INJECT-002
271
- rewritten = Injection.apply_body(body_text, headers["content-type"],
307
+ rewritten = injection[:debug] &&
308
+ Injection.apply_body(body_text, headers["content-type"],
272
309
  injection[:debug]) # INJECT-003
273
310
  if rewritten && !rewritten.equal?(body_text) && rewritten != body_text
274
311
  body = [rewritten]
@@ -281,6 +318,8 @@ module Restless
281
318
 
282
319
  safely(nil) { @engine.record(captured, stack_frame: stack_frame) }
283
320
 
321
+ # After `record`, which copies the headers it keeps.
322
+ headers["rack.hijack"] = hijack if hijack
284
323
  [status, headers, body]
285
324
  rescue StandardError => e
286
325
  Env.debug_log("middleware finish failed: #{e.class}: #{e.message}")
@@ -293,7 +332,14 @@ module Restless
293
332
  def normalize_headers(headers)
294
333
  out = {}
295
334
  (headers || {}).each do |name, value|
296
- 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
297
343
  end
298
344
  out
299
345
  end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Restless
6
+ # CONTRACT.md section 12.1 (`SAMPLE`). Per-endpoint control over how much
7
+ # of each request is sent.
8
+ #
9
+ # Everything here is pure. The middleware draws `u` once per request and
10
+ # hands it to `decide`; the tests and the conformance driver pass a fixed
11
+ # one.
12
+ #
13
+ # A mode is "on", "off", "errors" or a Float rate already quantized to a
14
+ # multiple of 0.0001 (SAMPLE-002). Outcomes are :full, :stub and :drop.
15
+ module Sampling
16
+ # A merged, validated configuration. `routes` maps normalized keys
17
+ # (`"GET /pets/{id}"`) to modes, in insertion order.
18
+ Config = Struct.new(:default, :routes) do
19
+ # SAMPLE-041. All "on" with no routes is exactly the behaviour without
20
+ # sampling, and has nothing to report.
21
+ def default?
22
+ default == "on" && routes.empty?
23
+ end
24
+ end
25
+
26
+ STRING_MODES = %w[on off errors].freeze
27
+
28
+ # SAMPLE-002.
29
+ RATE_STEPS = 10_000
30
+
31
+ # SAMPLE-010. Printable ASCII only, so every SDK sorts and hashes keys
32
+ # byte for byte the same. `\A...\z`, never `^...$` (PRIM-005).
33
+ PATH_RE = %r{\A/[\x21-\x7e]*\z}.freeze
34
+ METHOD_RE = /\A(?:[A-Za-z]+|\*)\z/.freeze
35
+ # SAMPLE-011. WORD is ASCII (PRIM-001), so no `\w`.
36
+ PARAM_RE = /:([A-Za-z0-9_]+)/.freeze
37
+
38
+ module_function
39
+
40
+ # SAMPLE-001..003. The normalized mode, or nil when `value` is not a
41
+ # valid one (the caller decides what invalid falls back to).
42
+ #
43
+ # Symbols (`:off`) are accepted as the Ruby spelling of the string modes.
44
+ # They can only come from code: JSON has no symbols.
45
+ def normalize_mode(value)
46
+ value = value.to_s if value.is_a?(Symbol)
47
+ return value if value.is_a?(String) && STRING_MODES.include?(value)
48
+ # `true` is not Numeric in Ruby, unlike in some languages. Complex is
49
+ # Numeric but not real.
50
+ return nil unless value.is_a?(Numeric) && value.real?
51
+
52
+ rate = value.to_f
53
+ return nil unless rate.finite? && rate >= 0 && rate <= 1
54
+
55
+ steps = quantize(rate)
56
+ # A rate that rounds to 1 IS "on"; one spelling, so the dashboard and
57
+ # the hash never see two.
58
+ steps >= RATE_STEPS ? "on" : steps / RATE_STEPS.to_f
59
+ end
60
+
61
+ # SAMPLE-002. floor(r * 10000 + 0.5) in double precision. Not `round`,
62
+ # which would make the rounding mode a per-language question.
63
+ def quantize(rate)
64
+ (rate.to_f * RATE_STEPS + 0.5).floor
65
+ end
66
+
67
+ # SAMPLE-011. The normalized path, or nil when it is not a valid one.
68
+ def normalize_path(path)
69
+ path = path.to_s
70
+ # Normalizing only rewrites ASCII, so a non-ASCII path can never become
71
+ # valid. Checking first also keeps a string with invalid bytes away
72
+ # from `gsub`, which would raise on it.
73
+ return nil unless path.ascii_only?
74
+
75
+ normalized = path.gsub(PARAM_RE, '{\1}')
76
+ normalized = normalized[0..-2] while normalized.length > 1 && normalized.end_with?("/")
77
+ PATH_RE.match?(normalized) ? normalized : nil
78
+ end
79
+
80
+ # SAMPLE-010. `"get /pets/:id/"` becomes `"GET /pets/{id}"`. Nil when
81
+ # the key is not valid.
82
+ def normalize_route_key(key)
83
+ key = key.to_s
84
+ # As in normalize_path: never valid, and `sub` would raise on bad bytes.
85
+ return nil unless key.ascii_only?
86
+
87
+ # Only U+0020 is trimmed: a tab is not a separator (SAMPLE-010).
88
+ trimmed = key.sub(/\A +/, "").sub(/ +\z/, "")
89
+ space = trimmed.index(" ")
90
+ return nil if space.nil?
91
+
92
+ method = trimmed[0, space]
93
+ path = normalize_path(trimmed[space..-1].sub(/\A +/, ""))
94
+ return nil if path.nil? || !METHOD_RE.match?(method)
95
+
96
+ "#{method.upcase(:ascii)} #{path}"
97
+ end
98
+
99
+ # SAMPLE-013, SAMPLE-014. Merge the settings-file and code configs and
100
+ # validate every value. Never raises: a bad mode degrades to "on", a bad
101
+ # key is ignored, and either is reported in `warnings` for the debug log.
102
+ #
103
+ # Returns `[Config, warnings]`.
104
+ def normalize(settings = nil, code = nil)
105
+ warnings = []
106
+ config = Config.new("on", {})
107
+
108
+ [["settings", settings], ["code", code]].each do |source, raw|
109
+ next if raw.nil?
110
+ unless raw.is_a?(Hash)
111
+ warnings << "sampling (#{source}): expected a Hash, ignored"
112
+ next
113
+ end
114
+
115
+ if member?(raw, "default")
116
+ value = member(raw, "default")
117
+ mode = normalize_mode(value)
118
+ warnings << "sampling default (#{source}): invalid mode #{value.inspect}, using \"on\"" if mode.nil?
119
+ config.default = mode || "on"
120
+ end
121
+
122
+ routes = member(raw, "routes")
123
+ next if routes.nil?
124
+ unless routes.is_a?(Hash)
125
+ warnings << "sampling routes (#{source}): expected a Hash, ignored"
126
+ next
127
+ end
128
+
129
+ routes.each do |raw_key, raw_mode|
130
+ key = raw_key.is_a?(String) || raw_key.is_a?(Symbol) ? normalize_route_key(raw_key.to_s) : nil
131
+ if key.nil?
132
+ warnings << "sampling routes (#{source}): invalid route key #{raw_key.inspect}, ignored"
133
+ next
134
+ end
135
+
136
+ mode = normalize_mode(raw_mode)
137
+ if mode.nil?
138
+ warnings << "sampling routes[#{raw_key.inspect}] (#{source}): invalid mode " \
139
+ "#{raw_mode.inspect}, using \"on\""
140
+ end
141
+ # Re-insert so a later duplicate takes the later position as well as
142
+ # the value. Nothing contract-visible depends on the position (the
143
+ # hash and the report sort), but insertion order stays honest.
144
+ config.routes.delete(key)
145
+ config.routes[key] = mode || "on"
146
+ end
147
+ end
148
+
149
+ [config, warnings]
150
+ end
151
+
152
+ # SAMPLE-020..022. The mode for one request. `route` is the matched
153
+ # pattern, nil when the framework reported none.
154
+ def resolve_mode(config, method, route)
155
+ if route.is_a?(String) && !route.empty?
156
+ path = normalize_path(route)
157
+ if path
158
+ exact = config.routes["#{method.to_s.upcase(:ascii)} #{path}"]
159
+ return exact unless exact.nil?
160
+
161
+ any = config.routes["* #{path}"]
162
+ return any unless any.nil?
163
+ end
164
+ end
165
+ config.default
166
+ end
167
+
168
+ # SAMPLE-030, SAMPLE-031. `u` is uniform in [0, 1), drawn once per
169
+ # request.
170
+ def decide(mode, status, u)
171
+ return :drop if mode == "off"
172
+
173
+ error = status >= 400
174
+ return(error ? :full : :drop) if mode == "errors"
175
+ return :full if !mode.is_a?(Numeric) || error
176
+
177
+ u < mode ? :full : :stub
178
+ end
179
+
180
+ # SAMPLE-040. The canonical text the hash is computed over, or nil for
181
+ # the default configuration (SAMPLE-041).
182
+ def canonical(config)
183
+ return nil if config.default?
184
+
185
+ # Keys are printable ASCII (SAMPLE-010) and String#<=> compares bytes,
186
+ # so this is the byte order the contract asks for.
187
+ out = +"default\t#{token(config.default)}\n"
188
+ config.routes.keys.sort.each do |key|
189
+ out << "#{key}\t#{token(config.routes[key])}\n"
190
+ end
191
+ out
192
+ end
193
+
194
+ # SAMPLE-040. Lowercase hex SHA-256 of the canonical form, or nil.
195
+ def digest(config)
196
+ text = canonical(config)
197
+ text && Digest::SHA256.hexdigest(text)
198
+ end
199
+
200
+ # SAMPLE-050. The routes in sorted key order, as the report carries them.
201
+ def sorted_routes(config)
202
+ out = {}
203
+ config.routes.keys.sort.each { |key| out[key] = config.routes[key] }
204
+ out
205
+ end
206
+
207
+ # `0.1` -> "0.1000", built from the integer step count, never from float
208
+ # formatting.
209
+ def token(mode)
210
+ return mode unless mode.is_a?(Numeric)
211
+
212
+ format("0.%04d", quantize(mode))
213
+ end
214
+
215
+ # `default:` and `routes:` read naturally as symbols in Ruby code, and
216
+ # arrive as strings from the settings file.
217
+ def member?(hash, name)
218
+ hash.key?(name.to_sym) || hash.key?(name)
219
+ end
220
+
221
+ def member(hash, name)
222
+ hash.key?(name.to_sym) ? hash[name.to_sym] : hash[name]
223
+ end
224
+ end
225
+ end
@@ -4,8 +4,8 @@ require "json"
4
4
 
5
5
  module Restless
6
6
  # CONTRACT.md section 12. `.restless/settings.json` is created and owned by
7
- # the `api` CLI (`npx api setup`); the SDK consumes exactly two fields of it
8
- # at runtime (CONFIG-015).
7
+ # the `restless` CLI (`npx restless init`); the SDK consumes exactly three fields
8
+ # of it at runtime (CONFIG-015).
9
9
  module Settings
10
10
  class ConfigError < StandardError; end
11
11
 
@@ -35,7 +35,7 @@ module Restless
35
35
  end
36
36
 
37
37
  # CONFIG-013, CONFIG-014. Returns {id:, name:, request_id_prefix:,
38
- # redact:} or nil.
38
+ # redact:, sampling:} or nil.
39
39
  def resolve_api(settings, name = nil)
40
40
  return nil if settings.nil?
41
41
 
@@ -68,7 +68,10 @@ module Restless
68
68
  id: api["id"],
69
69
  name: api["name"],
70
70
  request_id_prefix: api["requestIdPrefix"],
71
- redact: api["redact"].is_a?(Hash) ? api["redact"] : nil
71
+ redact: api["redact"].is_a?(Hash) ? api["redact"] : nil,
72
+ # Raw: Sampling.normalize validates it, and warns about a bad one
73
+ # under DEBUG rather than dropping it silently here.
74
+ sampling: api["sampling"]
72
75
  }
73
76
  end
74
77
 
@@ -22,9 +22,13 @@ module Restless
22
22
 
23
23
  attr_reader :base_url, :request_id_prefix
24
24
 
25
+ # `sampling_hash` is the SAMPLE-040 hash of a non-default sampling config,
26
+ # stamped on every upload as `x-restless-sampling` (SAMPLE-052). Nil when
27
+ # sampling is not configured, and then no header is sent at all.
25
28
  def initialize(api_key:, base_url:, request_id_prefix: nil, on_response: nil,
26
- transport: nil)
29
+ sampling_hash: nil, transport: nil)
27
30
  @api_key = api_key.to_s
31
+ @sampling_hash = sampling_hash
28
32
  @base_url = base_url.to_s
29
33
  @request_id_prefix = request_id_prefix
30
34
  @on_response = on_response
@@ -92,6 +96,27 @@ module Restless
92
96
  nil
93
97
  end
94
98
 
99
+ # SAMPLE-050, SAMPLE-051. POST the sampling report on a background
100
+ # thread, then yield (on that thread) however it went. Same gates as an
101
+ # upload: nothing under a test runner or without a project key. Never
102
+ # raises, never retries.
103
+ def send_sampling_report_async(report, &on_done)
104
+ thread = Thread.new do
105
+ begin
106
+ send_sampling_report(report)
107
+ ensure
108
+ begin
109
+ on_done.call if on_done
110
+ rescue StandardError => e
111
+ Env.debug_log("sampling report callback failed: #{e.class}: #{e.message}")
112
+ end
113
+ end
114
+ end
115
+ thread.abort_on_exception = false
116
+ track(thread)
117
+ nil
118
+ end
119
+
95
120
  def options
96
121
  {
97
122
  base_url: @base_url,
@@ -161,12 +186,36 @@ module Restless
161
186
  def upload_async(batch)
162
187
  thread = Thread.new { upload(batch) }
163
188
  thread.abort_on_exception = false
189
+ track(thread)
190
+ end
191
+
192
+ # `flush` joins these, so it also waits for an in-flight sampling report.
193
+ def track(thread)
164
194
  @mutex.synchronize do
165
195
  @inflight.reject! { |t| !t.alive? }
166
196
  @inflight << thread
167
197
  end
168
198
  end
169
199
 
200
+ def send_sampling_report(report)
201
+ return if Env.test_run? && !Env.setup_mode?
202
+ return unless api_key?
203
+
204
+ url = "#{@base_url}/v1/sampling"
205
+ headers = {
206
+ "Content-Type" => "application/json",
207
+ "Authorization" => "Bearer #{@api_key}",
208
+ "X-Restless-Spec-Version" => SPEC_VERSION,
209
+ "User-Agent" => "#{SDK_NAME}/#{VERSION}"
210
+ }
211
+ status, = send_request(url, headers, JSON.generate(report))
212
+ Env.debug_log("sampling report: #{status.inspect}")
213
+ rescue StandardError => e
214
+ # SAFETY-004.
215
+ Env.debug_log("sampling report error: #{e.class}: #{e.message}")
216
+ nil
217
+ end
218
+
170
219
  def join_inflight
171
220
  threads = @mutex.synchronize { @inflight.dup }
172
221
  threads.each { |t| t.join(HTTP_TIMEOUT_S) }
@@ -194,6 +243,7 @@ module Restless
194
243
  "X-Restless-Spec-Version" => SPEC_VERSION, # META-002
195
244
  "User-Agent" => "#{SDK_NAME}/#{VERSION}"
196
245
  }
246
+ headers["x-restless-sampling"] = @sampling_hash if @sampling_hash # SAMPLE-052
197
247
 
198
248
  # WIRE-004: a JSON ARRAY, even for a single capture.
199
249
  status, body = send_request(url, headers, JSON.generate(payload))
@@ -285,6 +335,8 @@ module Restless
285
335
  entry["routePattern"] = captured["routePattern"] if captured["routePattern"]
286
336
  # WIRE-017: absent for successful responses.
287
337
  entry["errorFingerprint"] = captured["errorFingerprint"] if captured["errorFingerprint"]
338
+ # WIRE-030: present, and true, only on a stub. Never `false`.
339
+ entry["sampled"] = true if captured["sampled"]
288
340
  # WIRE-012: owner id, else the masked end-user key, else "anonymous".
289
341
  entry["group"] = {
290
342
  "id" => (owner_id && !owner_id.empty? ? owner_id : nil) || masked_key || "anonymous",
@@ -6,7 +6,7 @@ module Restless
6
6
  # META-001: the spec version an SDK implements must be recorded in a
7
7
  # machine-readable form alongside its conformance level.
8
8
 
9
- VERSION = "0.1.0"
9
+ VERSION = "0.2.0"
10
10
 
11
11
  # WIRE-016: distinct per implementation, so the ingest can attribute a
12
12
  # payload to a language.
@@ -17,7 +17,7 @@ module Restless
17
17
  # REDACT-010 requires full Unicode name folding
18
18
  # to require full Unicode lowercase. Keep this in step with
19
19
  # spec/VECTORS_VERSION.
20
- SPEC_VERSION = "1.0.0"
20
+ SPEC_VERSION = "1.1.0"
21
21
 
22
22
  # CONTRACT.md 1.1: L1 is the pure functions, L2 adds batching, caches,
23
23
  # injection and the safety guarantees.
data/lib/restless.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "restless/redact"
8
8
  require_relative "restless/fingerprint"
9
9
  require_relative "restless/stack_frames"
10
10
  require_relative "restless/har"
11
+ require_relative "restless/sampling"
11
12
  require_relative "restless/request_id"
12
13
  require_relative "restless/injection"
13
14
  require_relative "restless/caches"
@@ -22,7 +23,7 @@ require_relative "restless/rack"
22
23
 
23
24
  # Capture your API traffic and send it to Restless.
24
25
  #
25
- # This SDK implements version 1.0.0 of the Restless SDK Contract at level L2.
26
+ # This SDK implements version 1.1.0 of the Restless SDK Contract at level L2.
26
27
  # See CONFORMANCE.md.
27
28
  #
28
29
  # client = Restless.new(ENV["RESTLESS_KEY"])
data/spec/VECTORS_VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.1.0