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.
- checksums.yaml +4 -4
- data/CONFORMANCE.md +13 -8
- data/LICENSE +18 -0
- data/README.md +66 -6
- data/exe/restless-conformance +1 -1
- data/install.md +35 -9
- data/lib/restless/capture.rb +86 -5
- data/lib/restless/client.rb +29 -1
- data/lib/restless/conformance.rb +43 -1
- data/lib/restless/injection.rb +31 -16
- data/lib/restless/rack.rb +54 -8
- data/lib/restless/sampling.rb +225 -0
- data/lib/restless/settings.rb +7 -4
- data/lib/restless/uploader.rb +53 -1
- data/lib/restless/version.rb +2 -2
- data/lib/restless.rb +2 -1
- data/spec/VECTORS_VERSION +1 -1
- data/spec/vectors/debug-injection.json +219 -0
- data/spec/vectors/fingerprint.json +1 -1
- data/spec/vectors/har.json +1 -1
- data/spec/vectors/mask.json +1 -1
- data/spec/vectors/recovery-slug.json +1 -1
- data/spec/vectors/redact.json +1 -1
- data/spec/vectors/request-id.json +1 -1
- data/spec/vectors/sampling.json +738 -0
- metadata +10 -10
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
|
-
|
|
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
|
-
|
|
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" =>
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/restless/settings.rb
CHANGED
|
@@ -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 `
|
|
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
|
|
data/lib/restless/uploader.rb
CHANGED
|
@@ -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",
|
data/lib/restless/version.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
1
|
+
1.1.0
|