restless-sdk 0.3.0 → 0.4.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 +6 -1
- data/README.md +10 -1
- data/install.md +11 -2
- data/lib/restless/uploader.rb +228 -96
- data/lib/restless/version.rb +2 -2
- data/lib/restless.rb +1 -1
- data/spec/VECTORS_VERSION +1 -1
- data/spec/vectors/debug-injection.json +1 -1
- 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 +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82907e8d32a7662ffb2e9aa15b19a7866578451c0b14fe1cb60196a04d26703a
|
|
4
|
+
data.tar.gz: 030f27989031f51461208c12cbe9f1c2499303abfb9d74e713db995aea9fd39b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c4ce380faaa258e6d4b91fe557ccd47fcf4731bc6e59ba731cab405e207d52118f00c7d4f59f0c432301a600dd9d2e9aa048fe09194a8e96f89cefee654b3663
|
|
7
|
+
data.tar.gz: c500e8a60dcda664336a64c947d66e113aa422cdafe31b251e13c7f0e26be9c55045d57436a1bf87c90ba01bc13315546284c030dd753273360790d475ea7d06
|
data/CONFORMANCE.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
| | |
|
|
4
4
|
|---|---|
|
|
5
|
-
| **Spec version** | 1.
|
|
5
|
+
| **Spec version** | 1.3.0 |
|
|
6
6
|
| **Level** | L2 (core + batching, caches, injection, safety) |
|
|
7
7
|
| **Reference** | `restlesshq/node` (`@restlessai/sdk`) |
|
|
8
8
|
| **Driver** | `ruby exe/restless-conformance` |
|
|
@@ -79,6 +79,11 @@ the reference. They are the reason this SDK is byte-compatible.
|
|
|
79
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. |
|
|
80
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. |
|
|
81
81
|
| BATCH-008 | Test detection keys on those same variables being `test`, plus `RSpec::Core`, `Minitest::Test` and `Test::Unit::TestCase` being defined. |
|
|
82
|
+
| BATCH-009 | A slot is a worker thread, counted under the uploader's `Mutex`. A worker that finishes takes the next batch itself (up to 100, BATCH-010) instead of handing off, and releases its slot in an `ensure`, so a killed thread cannot leak one. `flush` waits on a `ConditionVariable` that every release broadcasts. |
|
|
83
|
+
| BATCH-009 (forking) | Puma and Unicorn fork after the app loads. The child inherits the slot count and queue but none of the threads behind them, so a stale count would block uploads forever. The uploader records `Process.pid` and starts the child from a clean state (new `Mutex`, empty queue, zero slots). The parent's queue is dropped in the child rather than sent twice. |
|
|
84
|
+
| WIRE-026 | "Never reached the ingest" is `Errno::ECONNREFUSED` and `SocketError` (DNS). `Net::HTTP` re-raises a connect failure with the same class, so the classification holds on a real socket (`test/test_uploader.rb` checks one). `Retry-After` is parsed with `Float()` (seconds) and then `Time.httpdate`. |
|
|
85
|
+
| WIRE-027 | `Net::ReadTimeout`, `Net::OpenTimeout`, `Timeout::Error`, `Errno::ECONNRESET`, `EOFError` and everything else fall through to the drop. `max_retries = 0` keeps `Net::HTTP` from resending on its own. |
|
|
86
|
+
| WIRE-028 | `open_timeout`, `read_timeout` and `write_timeout` are per operation, so a slow-trickling response could outlive them. The whole exchange also runs inside `Timeout.timeout(10)`. |
|
|
82
87
|
| 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. |
|
|
83
88
|
|
|
84
89
|
## Exemption: PRIM-034 (unpaired surrogates)
|
data/README.md
CHANGED
|
@@ -188,6 +188,15 @@ and swallowed (surfaced only under `DEBUG=restless`). Uploads happen on a
|
|
|
188
188
|
background thread and are fire-and-forget. Streaming responses
|
|
189
189
|
(`text/event-stream`) and bodies over 1 MiB are never buffered.
|
|
190
190
|
|
|
191
|
+
It also stays bounded when Restless is slow or busy. At most 4 uploads run at
|
|
192
|
+
once, each with a 10 s timeout; captures queue behind them (up to 1000, oldest
|
|
193
|
+
dropped first) and a freed slot sends up to 100 at a time to catch up. An
|
|
194
|
+
upload Restless turns away with `503` or `429` is retried up to twice, waiting
|
|
195
|
+
for its `Retry-After` (at most 10 s). Anything Restless may already have
|
|
196
|
+
stored, such as a `502`, `504`, timeout or dropped connection, is never resent,
|
|
197
|
+
so a log is never doubled. `flush` waits for all of it, retries included. A
|
|
198
|
+
worker forked by Puma or Unicorn starts with its own empty queue.
|
|
199
|
+
|
|
191
200
|
An exception your app raises is captured with its stack - so the error
|
|
192
201
|
fingerprint keys on the method that raised, not on the middleware - and then
|
|
193
202
|
re-raised untouched, leaving your own error handling completely unaffected.
|
|
@@ -203,7 +212,7 @@ re-raised untouched, leaving your own error handling completely unaffected.
|
|
|
203
212
|
|
|
204
213
|
## Conformance
|
|
205
214
|
|
|
206
|
-
This SDK implements version 1.
|
|
215
|
+
This SDK implements version 1.3.0 of the Restless SDK Contract at level L2,
|
|
207
216
|
and is verified against the shared cross-language conformance vectors and a
|
|
208
217
|
differential fuzzer run against the reference implementation. See
|
|
209
218
|
[CONFORMANCE.md](./CONFORMANCE.md).
|
data/install.md
CHANGED
|
@@ -50,7 +50,7 @@ The client exposes four things:
|
|
|
50
50
|
| `setup(&block)` | Register the per-request callback. Takes a block or any callable. |
|
|
51
51
|
| `mask(key)` | Hash an end-user API key for safe logging. |
|
|
52
52
|
| `rack` | The middleware factory to mount. |
|
|
53
|
-
| `flush` |
|
|
53
|
+
| `flush` | Upload everything queued and wait for it, retries included (e.g. before exit). |
|
|
54
54
|
|
|
55
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
56
|
|
|
@@ -299,7 +299,16 @@ The handler never runs. The response body is JSON `{"error": "<message>"}`. `enr
|
|
|
299
299
|
| `DEBUG=restless` | Print upload diagnostics to stderr |
|
|
300
300
|
| `RESTLESS_SETUP_MODE=1` | Upload even under a test runner (see below) |
|
|
301
301
|
|
|
302
|
-
Batching is fixed
|
|
302
|
+
Batching is fixed, with no config:
|
|
303
|
+
|
|
304
|
+
- 10 requests per batch, a 5000 ms flush interval, and immediate flushing outside production or against a localhost base URL
|
|
305
|
+
- A 1000-entry queue that drops oldest on overflow
|
|
306
|
+
- At most 4 uploads in flight; while capped, each freed slot sends up to 100 queued entries
|
|
307
|
+
- Upload timeout: 10 s
|
|
308
|
+
- Retries: only `503`/`429` (honouring `Retry-After`, max 2 retries, max 10 s wait) and connections that never reached the ingest (refused, DNS failure). Never `502`/`504`, timeouts or dropped connections, which the ingest may already have stored.
|
|
309
|
+
- Safe under forking servers (Puma, Unicorn): a forked worker starts with an empty queue and its own uploads.
|
|
310
|
+
|
|
311
|
+
Uploads run on background threads and never block a response. Failures are swallowed unless `DEBUG=restless`. There is nothing to configure here and nothing to add to the user's code.
|
|
303
312
|
|
|
304
313
|
**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.
|
|
305
314
|
|
data/lib/restless/uploader.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
|
+
require "time"
|
|
6
|
+
require "timeout"
|
|
5
7
|
require "uri"
|
|
6
8
|
|
|
7
9
|
require_relative "env"
|
|
@@ -9,37 +11,40 @@ require_relative "har"
|
|
|
9
11
|
require_relative "version"
|
|
10
12
|
|
|
11
13
|
module Restless
|
|
12
|
-
# CONTRACT.md sections 8 (wire format) and 9 (batching).
|
|
13
|
-
#
|
|
14
|
-
# Never raises to callers. Upload failures go to stderr under the debug flag
|
|
15
|
-
# and are otherwise swallowed: observability must not break the request path
|
|
16
|
-
# (SAFETY-004, SAFETY-008).
|
|
14
|
+
# CONTRACT.md sections 8 (wire format) and 9 (batching). Never raises to
|
|
15
|
+
# callers; failures surface only under the debug flag (SAFETY-004, SAFETY-008).
|
|
17
16
|
class Uploader
|
|
18
17
|
BATCH_SIZE = 10 # BATCH-001
|
|
19
18
|
FLUSH_INTERVAL_MS = 5000 # BATCH-002
|
|
20
19
|
MAX_QUEUE = 1000 # BATCH-004
|
|
21
|
-
|
|
20
|
+
MAX_IN_FLIGHT = 4 # BATCH-009
|
|
21
|
+
MAX_UPLOAD_ENTRIES = 100 # BATCH-010
|
|
22
|
+
HTTP_TIMEOUT_S = 10 # WIRE-028
|
|
23
|
+
MAX_RETRIES = 2 # WIRE-026
|
|
24
|
+
RETRY_BASE_S = 1.0
|
|
25
|
+
MAX_RETRY_DELAY_S = 10.0
|
|
26
|
+
|
|
27
|
+
# Failures where the request never reached the ingest, so a resend cannot
|
|
28
|
+
# duplicate (WIRE-026). SocketError is a DNS lookup failure.
|
|
29
|
+
NOT_SENT_ERRORS = [Errno::ECONNREFUSED, SocketError].freeze
|
|
22
30
|
|
|
23
31
|
attr_reader :base_url, :request_id_prefix
|
|
24
32
|
|
|
25
|
-
# `sampling_hash` is the SAMPLE-040 hash
|
|
26
|
-
#
|
|
27
|
-
# sampling is not configured, and then no header is sent at all.
|
|
33
|
+
# `sampling_hash` is the SAMPLE-040 hash stamped on every upload as
|
|
34
|
+
# `x-restless-sampling` (SAMPLE-052); nil sends no header at all.
|
|
28
35
|
def initialize(api_key:, base_url:, request_id_prefix: nil, on_response: nil,
|
|
29
|
-
sampling_hash: nil, transport: nil)
|
|
36
|
+
sampling_hash: nil, transport: nil, sleeper: nil)
|
|
30
37
|
@api_key = api_key.to_s
|
|
31
38
|
@sampling_hash = sampling_hash
|
|
32
39
|
@base_url = base_url.to_s
|
|
33
40
|
@request_id_prefix = request_id_prefix
|
|
34
41
|
@on_response = on_response
|
|
35
|
-
# Test
|
|
36
|
-
# `call(
|
|
42
|
+
# Test hooks: `transport.call(url, headers, body) -> [status, body, headers]`
|
|
43
|
+
# and `sleeper.call(seconds)` in place of a real retry wait.
|
|
37
44
|
@transport = transport
|
|
45
|
+
@sleeper = sleeper
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
@mutex = Mutex.new
|
|
41
|
-
@timer = nil
|
|
42
|
-
@inflight = []
|
|
47
|
+
reset_state
|
|
43
48
|
|
|
44
49
|
warn_if_insecure
|
|
45
50
|
end
|
|
@@ -66,41 +71,63 @@ module Restless
|
|
|
66
71
|
# BATCH-008.
|
|
67
72
|
return if Env.test_run? && !Env.setup_mode?
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
@mutex.synchronize do
|
|
74
|
+
reset_after_fork
|
|
75
|
+
batches = @mutex.synchronize do
|
|
71
76
|
if @queue.length >= MAX_QUEUE
|
|
72
|
-
# Drop the OLDEST
|
|
73
|
-
# actively debugging.
|
|
77
|
+
# Drop the OLDEST: the newest are what an operator is debugging.
|
|
74
78
|
@queue.shift
|
|
75
79
|
Env.debug_log("queue at #{MAX_QUEUE} -- dropping oldest captured request")
|
|
76
80
|
end
|
|
77
81
|
@queue << captured
|
|
78
82
|
|
|
79
|
-
if flush_immediately?
|
|
80
|
-
|
|
83
|
+
if flush_immediately?
|
|
84
|
+
take_batches(true)
|
|
85
|
+
elsif @queue.length >= BATCH_SIZE
|
|
86
|
+
take_batches(false)
|
|
81
87
|
else
|
|
82
88
|
start_timer
|
|
89
|
+
[]
|
|
83
90
|
end
|
|
84
91
|
end
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
start_workers(batches)
|
|
94
|
+
nil
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Env.debug_log("push failed: #{e.class}: #{e.message}")
|
|
87
97
|
nil
|
|
88
98
|
end
|
|
89
99
|
|
|
90
|
-
# BATCH-005
|
|
91
|
-
#
|
|
100
|
+
# BATCH-005 to BATCH-007. Uploads everything queued, at most MAX_IN_FLIGHT
|
|
101
|
+
# at a time, and returns once all of it has settled, retries included.
|
|
92
102
|
def flush
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
reset_after_fork
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
cancel_timer
|
|
106
|
+
@draining += 1
|
|
107
|
+
begin
|
|
108
|
+
loop do
|
|
109
|
+
start_workers(take_batches(true))
|
|
110
|
+
break if @queue.empty? && @active.zero?
|
|
111
|
+
|
|
112
|
+
# Timed only as a guard against a worker that vanished without
|
|
113
|
+
# releasing its slot; every real release broadcasts.
|
|
114
|
+
@cond.wait(@mutex, 1.0)
|
|
115
|
+
end
|
|
116
|
+
ensure
|
|
117
|
+
@draining -= 1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
join_reports
|
|
121
|
+
nil
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Env.debug_log("flush failed: #{e.class}: #{e.message}")
|
|
96
124
|
nil
|
|
97
125
|
end
|
|
98
126
|
|
|
99
|
-
# SAMPLE-050, SAMPLE-051. POST the sampling report on a background
|
|
100
|
-
#
|
|
101
|
-
# upload: nothing under a test runner or without a project key. Never
|
|
102
|
-
# raises, never retries.
|
|
127
|
+
# SAMPLE-050, SAMPLE-051. POST the sampling report on a background thread,
|
|
128
|
+
# then yield on that thread. Same gates as an upload; never raises or retries.
|
|
103
129
|
def send_sampling_report_async(report, &on_done)
|
|
130
|
+
reset_after_fork
|
|
104
131
|
thread = Thread.new do
|
|
105
132
|
begin
|
|
106
133
|
send_sampling_report(report)
|
|
@@ -113,7 +140,10 @@ module Restless
|
|
|
113
140
|
end
|
|
114
141
|
end
|
|
115
142
|
thread.abort_on_exception = false
|
|
116
|
-
|
|
143
|
+
@mutex.synchronize do
|
|
144
|
+
@reports.reject! { |t| !t.alive? }
|
|
145
|
+
@reports << thread
|
|
146
|
+
end
|
|
117
147
|
nil
|
|
118
148
|
end
|
|
119
149
|
|
|
@@ -127,9 +157,25 @@ module Restless
|
|
|
127
157
|
|
|
128
158
|
private
|
|
129
159
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
160
|
+
def reset_state
|
|
161
|
+
@pid = Process.pid
|
|
162
|
+
@mutex = Mutex.new
|
|
163
|
+
@cond = ConditionVariable.new
|
|
164
|
+
@queue = []
|
|
165
|
+
@active = 0 # uploads holding a BATCH-009 slot, including retry waits
|
|
166
|
+
@draining = 0 # flushes in progress, which let partial batches through
|
|
167
|
+
@timer = nil
|
|
168
|
+
@reports = []
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Puma and Unicorn fork after the app loads: the child inherits this state
|
|
172
|
+
# but none of its threads, so it starts clean rather than count dead slots.
|
|
173
|
+
def reset_after_fork
|
|
174
|
+
reset_state unless @pid == Process.pid
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# BATCH-003. Outside production, or against a localhost ingest, every push
|
|
178
|
+
# flushes, keeping the dev loop and self-hosted setups low-latency.
|
|
133
179
|
def flush_immediately?
|
|
134
180
|
return true unless Env.production?
|
|
135
181
|
return true if Env.localhost?(@base_url)
|
|
@@ -137,16 +183,68 @@ module Restless
|
|
|
137
183
|
false
|
|
138
184
|
end
|
|
139
185
|
|
|
140
|
-
# Caller holds @mutex.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
# Caller holds @mutex. Claims a slot per batch; `partial` lets a batch
|
|
187
|
+
# under BATCH_SIZE go (BATCH-010).
|
|
188
|
+
def take_batches(partial)
|
|
189
|
+
# BATCH-007. No key: drop everything, never retry or accumulate.
|
|
190
|
+
unless api_key?
|
|
191
|
+
Env.debug_log("no API key -- dropping batch of #{@queue.length}") unless @queue.empty?
|
|
192
|
+
@queue.clear
|
|
193
|
+
cancel_timer
|
|
194
|
+
return []
|
|
195
|
+
end
|
|
144
196
|
|
|
145
|
-
|
|
146
|
-
@queue
|
|
197
|
+
batches = []
|
|
198
|
+
while @active < MAX_IN_FLIGHT && !@queue.empty? && (partial || @queue.length >= BATCH_SIZE)
|
|
199
|
+
batches << @queue.shift(MAX_UPLOAD_ENTRIES)
|
|
200
|
+
@active += 1
|
|
201
|
+
end
|
|
202
|
+
cancel_timer if @queue.empty?
|
|
203
|
+
batches
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Caller holds @mutex. The next batch for a worker whose upload just
|
|
207
|
+
# finished, or nil when it should give its slot back.
|
|
208
|
+
def next_batch
|
|
209
|
+
return nil if @queue.empty? || !api_key?
|
|
210
|
+
return nil unless flush_immediately? || @draining.positive? || @queue.length >= BATCH_SIZE
|
|
211
|
+
|
|
212
|
+
batch = @queue.shift(MAX_UPLOAD_ENTRIES)
|
|
213
|
+
cancel_timer if @queue.empty?
|
|
147
214
|
batch
|
|
148
215
|
end
|
|
149
216
|
|
|
217
|
+
# Caller holds @mutex. A remainder under BATCH_SIZE waits for the timer.
|
|
218
|
+
def release_slot
|
|
219
|
+
@active -= 1
|
|
220
|
+
start_timer unless @queue.empty?
|
|
221
|
+
@cond.broadcast
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# SAFETY-008. Uploads run on background threads, never the request thread.
|
|
225
|
+
def start_workers(batches)
|
|
226
|
+
batches.each do |batch|
|
|
227
|
+
thread = Thread.new(batch) { |b| run_worker(b) }
|
|
228
|
+
thread.abort_on_exception = false
|
|
229
|
+
thread.report_on_exception = false
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# One slot. It keeps sending while a backlog is waiting, then frees itself.
|
|
234
|
+
def run_worker(batch)
|
|
235
|
+
holding = true
|
|
236
|
+
while holding
|
|
237
|
+
send_batch(batch)
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
batch = next_batch
|
|
240
|
+
holding = !batch.nil?
|
|
241
|
+
release_slot unless holding
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
ensure
|
|
245
|
+
@mutex.synchronize { release_slot } if holding
|
|
246
|
+
end
|
|
247
|
+
|
|
150
248
|
# Caller holds @mutex.
|
|
151
249
|
def start_timer
|
|
152
250
|
return if @timer
|
|
@@ -154,11 +252,11 @@ module Restless
|
|
|
154
252
|
@timer = Thread.new do
|
|
155
253
|
sleep(FLUSH_INTERVAL_MS / 1000.0)
|
|
156
254
|
begin
|
|
157
|
-
|
|
255
|
+
batches = @mutex.synchronize do
|
|
158
256
|
@timer = nil
|
|
159
|
-
|
|
257
|
+
take_batches(true)
|
|
160
258
|
end
|
|
161
|
-
|
|
259
|
+
start_workers(batches)
|
|
162
260
|
rescue StandardError => e
|
|
163
261
|
Env.debug_log("flush timer failed: #{e.class}: #{e.message}")
|
|
164
262
|
end
|
|
@@ -166,14 +264,6 @@ module Restless
|
|
|
166
264
|
@timer.abort_on_exception = false
|
|
167
265
|
end
|
|
168
266
|
|
|
169
|
-
# Like take_batch but without cancelling the timer, which the timer thread
|
|
170
|
-
# has already cleared.
|
|
171
|
-
def take_batch_unsafe
|
|
172
|
-
batch = @queue
|
|
173
|
-
@queue = []
|
|
174
|
-
batch
|
|
175
|
-
end
|
|
176
|
-
|
|
177
267
|
# Caller holds @mutex.
|
|
178
268
|
def cancel_timer
|
|
179
269
|
timer = @timer
|
|
@@ -181,20 +271,10 @@ module Restless
|
|
|
181
271
|
timer.kill if timer && timer != Thread.current
|
|
182
272
|
end
|
|
183
273
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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)
|
|
194
|
-
@mutex.synchronize do
|
|
195
|
-
@inflight.reject! { |t| !t.alive? }
|
|
196
|
-
@inflight << thread
|
|
197
|
-
end
|
|
274
|
+
def join_reports
|
|
275
|
+
threads = @mutex.synchronize { @reports.dup }
|
|
276
|
+
threads.each { |t| t.join(HTTP_TIMEOUT_S) }
|
|
277
|
+
@mutex.synchronize { @reports.reject! { |t| !t.alive? } }
|
|
198
278
|
end
|
|
199
279
|
|
|
200
280
|
def send_sampling_report(report)
|
|
@@ -216,27 +296,13 @@ module Restless
|
|
|
216
296
|
nil
|
|
217
297
|
end
|
|
218
298
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
@mutex.synchronize { @inflight.reject! { |t| !t.alive? } }
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def upload(batch)
|
|
226
|
-
return if batch.nil? || batch.empty?
|
|
227
|
-
|
|
228
|
-
# BATCH-007. No key: drop the batch, never retry or accumulate.
|
|
229
|
-
unless api_key?
|
|
230
|
-
Env.debug_log("no API key -- dropping batch of #{batch.length}")
|
|
231
|
-
return
|
|
232
|
-
end
|
|
233
|
-
|
|
299
|
+
# One upload, holding its slot through any retries. Only what the ingest
|
|
300
|
+
# certainly did not store is resent (WIRE-024, WIRE-026, WIRE-027).
|
|
301
|
+
def send_batch(batch)
|
|
234
302
|
fingerprints = distinct_fingerprints(batch)
|
|
235
|
-
|
|
303
|
+
# WIRE-004: a JSON ARRAY, even for a single capture.
|
|
304
|
+
body = JSON.generate(batch.map { |captured| entry_for(captured) })
|
|
236
305
|
url = "#{@base_url}/v1/request" # WIRE-001
|
|
237
|
-
|
|
238
|
-
Env.debug_log("uploading #{batch.length} entr#{batch.length == 1 ? 'y' : 'ies'} to #{url}")
|
|
239
|
-
|
|
240
306
|
headers = {
|
|
241
307
|
"Content-Type" => "application/json", # WIRE-002
|
|
242
308
|
"Authorization" => "Bearer #{@api_key}", # WIRE-003
|
|
@@ -245,23 +311,85 @@ module Restless
|
|
|
245
311
|
}
|
|
246
312
|
headers["x-restless-sampling"] = @sampling_hash if @sampling_hash # SAMPLE-052
|
|
247
313
|
|
|
248
|
-
|
|
249
|
-
|
|
314
|
+
attempt = 0
|
|
315
|
+
loop do
|
|
316
|
+
Env.debug_log("uploading #{batch.length} entr#{batch.length == 1 ? 'y' : 'ies'} to #{url}" \
|
|
317
|
+
"#{attempt.positive? ? " (retry #{attempt})" : ''}")
|
|
318
|
+
begin
|
|
319
|
+
status, response_body, response_headers = send_request(url, headers, body)
|
|
320
|
+
rescue *NOT_SENT_ERRORS => e
|
|
321
|
+
raise unless attempt < MAX_RETRIES
|
|
322
|
+
|
|
323
|
+
Env.debug_log("upload not sent (#{e.class}); retrying")
|
|
324
|
+
pause(retry_delay(nil, attempt))
|
|
325
|
+
attempt += 1
|
|
326
|
+
next
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
if status.is_a?(Integer) && status >= 200 && status < 300
|
|
330
|
+
handle_response(response_body, fingerprints)
|
|
331
|
+
return
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
if [503, 429].include?(status) && attempt < MAX_RETRIES
|
|
335
|
+
wait = retry_delay(header_value(response_headers, "retry-after"), attempt)
|
|
336
|
+
Env.debug_log("upload refused (#{status}); retrying in #{(wait * 1000).round}ms")
|
|
337
|
+
pause(wait)
|
|
338
|
+
attempt += 1
|
|
339
|
+
next
|
|
340
|
+
end
|
|
250
341
|
|
|
251
|
-
# WIRE-024: a non-2xx is never retried and never raises. The batch is
|
|
252
|
-
# dropped.
|
|
253
|
-
unless status && status >= 200 && status < 300
|
|
254
342
|
Env.debug_log("upload failed: #{status.inspect}")
|
|
255
343
|
return
|
|
256
344
|
end
|
|
257
|
-
|
|
258
|
-
handle_response(body, fingerprints)
|
|
259
345
|
rescue StandardError => e
|
|
260
|
-
# SAFETY-004.
|
|
346
|
+
# SAFETY-004. Timeouts and resets land here and are never retried.
|
|
261
347
|
Env.debug_log("upload error: #{e.class}: #{e.message}")
|
|
262
348
|
nil
|
|
263
349
|
end
|
|
264
350
|
|
|
351
|
+
# WIRE-026: the server's Retry-After (seconds or an HTTP date), else
|
|
352
|
+
# jittered exponential backoff; either way capped.
|
|
353
|
+
def retry_delay(retry_after, attempt)
|
|
354
|
+
seconds = parse_retry_after(retry_after)
|
|
355
|
+
if seconds.nil? || !seconds.finite? || seconds.negative?
|
|
356
|
+
seconds = RETRY_BASE_S * (2**attempt) * (0.5 + rand)
|
|
357
|
+
end
|
|
358
|
+
[seconds, MAX_RETRY_DELAY_S].min
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def parse_retry_after(value)
|
|
362
|
+
text = value.to_s.strip
|
|
363
|
+
return nil if text.empty?
|
|
364
|
+
|
|
365
|
+
begin
|
|
366
|
+
Float(text)
|
|
367
|
+
rescue ArgumentError
|
|
368
|
+
begin
|
|
369
|
+
Time.httpdate(text) - Time.now
|
|
370
|
+
rescue ArgumentError
|
|
371
|
+
nil
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def header_value(headers, name)
|
|
377
|
+
return nil unless headers.respond_to?(:each_pair)
|
|
378
|
+
|
|
379
|
+
headers.each_pair do |key, value|
|
|
380
|
+
next unless key.to_s.casecmp(name).zero?
|
|
381
|
+
|
|
382
|
+
return value.is_a?(Array) ? value.first : value
|
|
383
|
+
end
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def pause(seconds)
|
|
388
|
+
return @sleeper.call(seconds) if @sleeper
|
|
389
|
+
|
|
390
|
+
sleep(seconds) if seconds.positive?
|
|
391
|
+
end
|
|
392
|
+
|
|
265
393
|
def send_request(url, headers, body)
|
|
266
394
|
return @transport.call(url, headers, body) if @transport
|
|
267
395
|
|
|
@@ -270,11 +398,15 @@ module Restless
|
|
|
270
398
|
http.use_ssl = uri.scheme == "https"
|
|
271
399
|
http.open_timeout = HTTP_TIMEOUT_S
|
|
272
400
|
http.read_timeout = HTTP_TIMEOUT_S
|
|
401
|
+
http.write_timeout = HTTP_TIMEOUT_S if http.respond_to?(:write_timeout=)
|
|
402
|
+
# Net::HTTP never resends a POST anyway; zero keeps WIRE-027 explicit.
|
|
403
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
273
404
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
274
405
|
headers.each { |k, v| request[k] = v }
|
|
275
406
|
request.body = body
|
|
276
|
-
|
|
277
|
-
|
|
407
|
+
# The per-operation timeouts above do not bound the whole exchange.
|
|
408
|
+
response = Timeout.timeout(HTTP_TIMEOUT_S) { http.request(request) }
|
|
409
|
+
[response.code.to_i, response.body, response.to_hash]
|
|
278
410
|
end
|
|
279
411
|
|
|
280
412
|
# WIRE-020..023.
|
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.4.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.3.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
|
@@ -23,7 +23,7 @@ require_relative "restless/rack"
|
|
|
23
23
|
|
|
24
24
|
# Capture your API traffic and send it to Restless.
|
|
25
25
|
#
|
|
26
|
-
# This SDK implements version 1.
|
|
26
|
+
# This SDK implements version 1.3.0 of the Restless SDK Contract at level L2.
|
|
27
27
|
# See CONFORMANCE.md.
|
|
28
28
|
#
|
|
29
29
|
# client = Restless.new(ENV["RESTLESS_KEY"])
|
data/spec/VECTORS_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.3.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.
|
|
4
|
+
"specVersion": "1.3.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.
|
|
4
|
+
"specVersion": "1.3.0",
|
|
5
5
|
"cases": [
|
|
6
6
|
{
|
|
7
7
|
"id": "fp/404-parameterized",
|
data/spec/vectors/har.json
CHANGED
data/spec/vectors/mask.json
CHANGED
data/spec/vectors/redact.json
CHANGED
|
@@ -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.
|
|
4
|
+
"specVersion": "1.3.0",
|
|
5
5
|
"cases": [
|
|
6
6
|
{
|
|
7
7
|
"id": "reqid/format-with-prefix",
|
data/spec/vectors/sampling.json
CHANGED
|
@@ -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.
|
|
4
|
+
"specVersion": "1.3.0",
|
|
5
5
|
"cases": [
|
|
6
6
|
{
|
|
7
7
|
"id": "sampling/config-absent",
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: restless-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Restless
|
|
@@ -15,7 +15,7 @@ description: |
|
|
|
15
15
|
recovered from, and uploads the result to Restless. Works with Rails,
|
|
16
16
|
Sinatra, Hanami, Grape, Roda and anything else that speaks Rack.
|
|
17
17
|
|
|
18
|
-
Implements version 1.
|
|
18
|
+
Implements version 1.3.0 of the Restless SDK Contract at
|
|
19
19
|
level L2.
|
|
20
20
|
executables: []
|
|
21
21
|
extensions: []
|
|
@@ -60,7 +60,7 @@ licenses:
|
|
|
60
60
|
metadata:
|
|
61
61
|
source_code_uri: https://github.com/restlesshq/ruby
|
|
62
62
|
bug_tracker_uri: https://github.com/restlesshq/ruby/issues
|
|
63
|
-
restless_spec_version: 1.
|
|
63
|
+
restless_spec_version: 1.3.0
|
|
64
64
|
restless_conformance_level: L2
|
|
65
65
|
rdoc_options: []
|
|
66
66
|
require_paths:
|