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.
@@ -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 `restless` CLI (`npx restless init`); 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.1"
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.1"
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.1
1
+ 1.1.0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.0.1",
4
+ "specVersion": "1.1.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "inject/2xx-headers-only",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.0.1",
4
+ "specVersion": "1.1.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "fp/404-parameterized",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.0.1",
4
+ "specVersion": "1.1.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "har/basic",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.0.1",
4
+ "specVersion": "1.1.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "mask/ascii",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.0.1",
4
+ "specVersion": "1.1.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "slug/basic",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.0.1",
4
+ "specVersion": "1.1.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "redactValue/long",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.0.1",
4
+ "specVersion": "1.1.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "reqid/format-with-prefix",