restless-sdk 0.1.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 +7 -0
- data/CONFORMANCE.md +112 -0
- data/README.md +148 -0
- data/exe/restless-conformance +108 -0
- data/install.md +299 -0
- data/lib/restless/caches.rb +147 -0
- data/lib/restless/capture.rb +289 -0
- data/lib/restless/client.rb +111 -0
- data/lib/restless/conformance.rb +164 -0
- data/lib/restless/env.rb +82 -0
- data/lib/restless/fingerprint.rb +287 -0
- data/lib/restless/har.rb +113 -0
- data/lib/restless/injection.rb +99 -0
- data/lib/restless/mask.rb +57 -0
- data/lib/restless/rack.rb +374 -0
- data/lib/restless/redact.rb +342 -0
- data/lib/restless/request_id.rb +63 -0
- data/lib/restless/settings.rb +101 -0
- data/lib/restless/stack_frames.rb +146 -0
- data/lib/restless/text.rb +171 -0
- data/lib/restless/uploader.rb +319 -0
- data/lib/restless/version.rb +25 -0
- data/lib/restless.rb +57 -0
- data/spec/VECTORS_VERSION +1 -0
- data/spec/vectors/fingerprint.json +1119 -0
- data/spec/vectors/har.json +500 -0
- data/spec/vectors/mask.json +175 -0
- data/spec/vectors/recovery-slug.json +102 -0
- data/spec/vectors/redact.json +775 -0
- data/spec/vectors/request-id.json +120 -0
- metadata +82 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Restless
|
|
4
|
+
# CONTRACT.md section 11.
|
|
5
|
+
#
|
|
6
|
+
# Both caches are Mutex-guarded. Puma, Falcon and Unicorn-with-threads all
|
|
7
|
+
# serve requests on many threads at once, so an unsynchronized Hash here is a
|
|
8
|
+
# real data race rather than a theoretical one.
|
|
9
|
+
|
|
10
|
+
# CACHE-001..007. Enriched owner metadata, keyed by owner id (or the masked
|
|
11
|
+
# end-user key when there is no id).
|
|
12
|
+
#
|
|
13
|
+
# The point is that `enrich` runs once per key per TTL window rather than
|
|
14
|
+
# once per request, AND that the enriched VALUE is stored -- not merely a
|
|
15
|
+
# freshness flag. Every upload has to carry owner metadata, including the
|
|
16
|
+
# ones that skipped the callback, because the ingest cannot backfill: without
|
|
17
|
+
# it every request after the first lands in the dashboard as unauthenticated.
|
|
18
|
+
class EnrichCache
|
|
19
|
+
DEFAULT_TTL_MS = 3_600_000 # CACHE-004: 1 hour
|
|
20
|
+
|
|
21
|
+
def initialize(ttl_ms = DEFAULT_TTL_MS)
|
|
22
|
+
@ttl_ms = ttl_ms
|
|
23
|
+
@entries = {}
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get(key)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
entry = @entries[key]
|
|
30
|
+
next nil if entry.nil?
|
|
31
|
+
|
|
32
|
+
if now_ms - entry[:ts] > @ttl_ms # CACHE-015
|
|
33
|
+
@entries.delete(key)
|
|
34
|
+
next nil
|
|
35
|
+
end
|
|
36
|
+
entry[:value]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def set(key, value)
|
|
41
|
+
@mutex.synchronize { @entries[key] = { value: value, ts: now_ms } }
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# CACHE-006. Server-driven, via `needsEnrichment` on an upload response.
|
|
46
|
+
def invalidate(key)
|
|
47
|
+
@mutex.synchronize { @entries.delete(key) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear
|
|
51
|
+
@mutex.synchronize { @entries.clear }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def size
|
|
55
|
+
@mutex.synchronize { @entries.size }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def now_ms
|
|
61
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# CACHE-010..015. Agent Recovery messages keyed by error fingerprint.
|
|
66
|
+
#
|
|
67
|
+
# Performance is the whole point. The lookup sits on the hot path of every
|
|
68
|
+
# 4xx/5xx, so it is synchronous, in-process and never does I/O. A cold miss
|
|
69
|
+
# injects nothing and returns immediately; the server piggybacks the message
|
|
70
|
+
# onto the next upload response, so the SECOND occurrence hits.
|
|
71
|
+
#
|
|
72
|
+
# "No message for this fingerprint" is itself a cacheable answer, stored as
|
|
73
|
+
# nil, so a cold miss does not stay cold on every request. The negative TTL
|
|
74
|
+
# is shorter so a freshly-attached dashboard message starts working within
|
|
75
|
+
# minutes.
|
|
76
|
+
class RecoveryCache
|
|
77
|
+
DEFAULT_TTL_MS = 3_600_000 # CACHE-014: 1 hour, positive
|
|
78
|
+
DEFAULT_NEGATIVE_TTL_MS = 300_000 # CACHE-014: 5 minutes, negative
|
|
79
|
+
|
|
80
|
+
# Distinguishes "cached as absent" (nil) from "never seen" (MISS).
|
|
81
|
+
MISS = Object.new.freeze
|
|
82
|
+
|
|
83
|
+
def initialize(ttl_ms = DEFAULT_TTL_MS, negative_ttl_ms = DEFAULT_NEGATIVE_TTL_MS)
|
|
84
|
+
@ttl_ms = ttl_ms
|
|
85
|
+
@negative_ttl_ms = negative_ttl_ms
|
|
86
|
+
@entries = {}
|
|
87
|
+
@mutex = Mutex.new
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns a String (inject it), nil (the server confirmed there is none),
|
|
91
|
+
# or MISS (never seen, or expired).
|
|
92
|
+
def get(key)
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
entry = @entries[key]
|
|
95
|
+
next MISS if entry.nil?
|
|
96
|
+
|
|
97
|
+
ttl = entry[:message].nil? ? @negative_ttl_ms : @ttl_ms
|
|
98
|
+
if now_ms - entry[:ts] > ttl # CACHE-015
|
|
99
|
+
@entries.delete(key)
|
|
100
|
+
next MISS
|
|
101
|
+
end
|
|
102
|
+
entry[:message]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Convenience for the hot path: the message to inject, or nil.
|
|
107
|
+
def lookup(key)
|
|
108
|
+
value = get(key)
|
|
109
|
+
value.is_a?(String) ? value : nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def set(key, message)
|
|
113
|
+
@mutex.synchronize { @entries[key] = { message: message, ts: now_ms } }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# CACHE-013. A negative entry must never overwrite a positive one.
|
|
117
|
+
def set_negative_unless_present(key)
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
entry = @entries[key]
|
|
120
|
+
if entry
|
|
121
|
+
ttl = entry[:message].nil? ? @negative_ttl_ms : @ttl_ms
|
|
122
|
+
fresh = now_ms - entry[:ts] <= ttl
|
|
123
|
+
next if fresh && !entry[:message].nil?
|
|
124
|
+
end
|
|
125
|
+
@entries[key] = { message: nil, ts: now_ms }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def invalidate(key)
|
|
130
|
+
@mutex.synchronize { @entries.delete(key) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def clear
|
|
134
|
+
@mutex.synchronize { @entries.clear }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def size
|
|
138
|
+
@mutex.synchronize { @entries.size }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def now_ms
|
|
144
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "caches"
|
|
6
|
+
require_relative "fingerprint"
|
|
7
|
+
require_relative "redact"
|
|
8
|
+
require_relative "uploader"
|
|
9
|
+
|
|
10
|
+
module Restless
|
|
11
|
+
# The capture engine: redaction choke point (section 4), the two caches
|
|
12
|
+
# (section 11), fingerprinting (section 5) and the hand-off to the uploader
|
|
13
|
+
# (sections 8 and 9).
|
|
14
|
+
#
|
|
15
|
+
# Every adapter goes through here. No adapter may bypass `record`, which is
|
|
16
|
+
# the single point where redaction runs.
|
|
17
|
+
class CaptureEngine
|
|
18
|
+
# REDACT-030.
|
|
19
|
+
MAX_BODY_BYTES = Redact::MAX_BODY_BYTES
|
|
20
|
+
|
|
21
|
+
attr_reader :uploader, :enrich_cache, :recovery_cache
|
|
22
|
+
|
|
23
|
+
def initialize(api_key:, base_url:, request_id_prefix: nil, redact: nil,
|
|
24
|
+
transport: nil)
|
|
25
|
+
@redact = redact || {}
|
|
26
|
+
@enrich_cache = EnrichCache.new
|
|
27
|
+
@recovery_cache = RecoveryCache.new
|
|
28
|
+
@docs_url = nil
|
|
29
|
+
@docs_mutex = Mutex.new
|
|
30
|
+
@callback = nil
|
|
31
|
+
@uploader = Uploader.new(
|
|
32
|
+
api_key: api_key,
|
|
33
|
+
base_url: base_url,
|
|
34
|
+
request_id_prefix: request_id_prefix,
|
|
35
|
+
transport: transport,
|
|
36
|
+
on_response: method(:handle_server_response)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def callback=(callback)
|
|
41
|
+
@callback = callback
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# INJECT-006. The latest server-resolved docs origin, or nil when no batch
|
|
45
|
+
# has round-tripped yet.
|
|
46
|
+
def docs_url
|
|
47
|
+
@docs_mutex.synchronize { @docs_url }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def flush
|
|
51
|
+
@uploader.flush
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# WIRE-020..023, CACHE-006, CACHE-011..013.
|
|
55
|
+
def handle_server_response(body, batch_fingerprints)
|
|
56
|
+
needs = body["needsEnrichment"]
|
|
57
|
+
if needs.is_a?(Array)
|
|
58
|
+
needs.each { |key| @enrich_cache.invalidate(key) if key.is_a?(String) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
docs = body["docsUrl"]
|
|
62
|
+
if docs.is_a?(String) && !docs.empty?
|
|
63
|
+
# Origin only; strip trailing slashes so the server can be lax.
|
|
64
|
+
@docs_mutex.synchronize { @docs_url = docs.sub(%r{/+\z}, "") }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
messages = body["recoveryMessages"].is_a?(Hash) ? body["recoveryMessages"] : {}
|
|
68
|
+
batch_fingerprints.each do |key|
|
|
69
|
+
value = messages[key]
|
|
70
|
+
if value.is_a?(String)
|
|
71
|
+
@recovery_cache.set(key, value) # CACHE-011
|
|
72
|
+
elsif messages.key?(key)
|
|
73
|
+
@recovery_cache.set(key, nil)
|
|
74
|
+
else
|
|
75
|
+
# CACHE-012 + CACHE-013: negative-cache anything the server did not
|
|
76
|
+
# answer for, without clobbering an existing positive entry. This is
|
|
77
|
+
# what guarantees the SECOND occurrence of any error is a cache hit.
|
|
78
|
+
@recovery_cache.set_negative_unless_present(key)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
Env.debug_log("server response handling failed: #{e.class}: #{e.message}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# CACHE-010. Synchronous, in-process, no I/O, never blocks the response.
|
|
86
|
+
def lookup_recovery(fingerprint_key)
|
|
87
|
+
@recovery_cache.lookup(fingerprint_key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# FP-002. Errors only; the ingest treats an absent fingerprint as success.
|
|
91
|
+
def compute_fingerprint(captured, stack_frame = nil)
|
|
92
|
+
response = captured["response"] || {}
|
|
93
|
+
status = response["status"].to_i
|
|
94
|
+
return nil if status < 400
|
|
95
|
+
|
|
96
|
+
body = response["body"]
|
|
97
|
+
if body.is_a?(String)
|
|
98
|
+
begin
|
|
99
|
+
body = JSON.parse(body, max_nesting: false)
|
|
100
|
+
rescue StandardError
|
|
101
|
+
# Leave it as a string; `extract_message` handles both shapes.
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
request = captured["request"] || {}
|
|
106
|
+
Fingerprint.compute(
|
|
107
|
+
status: status,
|
|
108
|
+
method: request["method"],
|
|
109
|
+
route: captured["routePattern"],
|
|
110
|
+
response_headers: response["headers"],
|
|
111
|
+
response_body: body,
|
|
112
|
+
stack_frame: stack_frame
|
|
113
|
+
)
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
Env.debug_log("fingerprint failed: #{e.class}: #{e.message}")
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# WIRE-017. The serialized form of a fingerprint.
|
|
120
|
+
#
|
|
121
|
+
# `Fingerprint::Result` is a Struct with symbol members, and the wire wants
|
|
122
|
+
# string keys, so the mapping has to be written somewhere. It is written
|
|
123
|
+
# once: the Rack middleware computes the fingerprint early (INJECT-009) and
|
|
124
|
+
# must serialize it identically to `record` below.
|
|
125
|
+
def self.wire_fingerprint(fingerprint)
|
|
126
|
+
{
|
|
127
|
+
"strategy" => fingerprint.strategy,
|
|
128
|
+
"key" => fingerprint.key,
|
|
129
|
+
"reason" => fingerprint.reason
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Run the user's setup callback and resolve owner metadata.
|
|
134
|
+
#
|
|
135
|
+
# SAFETY-002: a callback that raises is caught and the request proceeds
|
|
136
|
+
# with no user context attached.
|
|
137
|
+
def resolve(request)
|
|
138
|
+
return {} if @callback.nil?
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
raw = @callback.call(request)
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
Env.debug_log("setup callback raised: #{e.class}: #{e.message}")
|
|
144
|
+
return {}
|
|
145
|
+
end
|
|
146
|
+
return {} unless raw.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
result = normalize_setup(raw)
|
|
149
|
+
owner = result[:owner]
|
|
150
|
+
return { "apiKey" => result[:api_key], "block" => result[:block],
|
|
151
|
+
"extra" => result[:extra] }.compact if owner.nil?
|
|
152
|
+
|
|
153
|
+
owner_id = owner[:id]
|
|
154
|
+
enrich = owner[:enrich]
|
|
155
|
+
# CACHE-002: key on owner id when present, else the masked end-user key,
|
|
156
|
+
# so multiple end-users in one workspace share a slot.
|
|
157
|
+
cache_key = (owner_id if owner_id && !owner_id.empty?) || result[:api_key]
|
|
158
|
+
|
|
159
|
+
resolved_owner = { "id" => owner_id }.compact
|
|
160
|
+
|
|
161
|
+
if enrich.respond_to?(:call) && owner_id && !owner_id.empty? && cache_key
|
|
162
|
+
cached = @enrich_cache.get(cache_key)
|
|
163
|
+
if cached
|
|
164
|
+
# CACHE-003: the VALUE is cached, not merely a freshness flag, so
|
|
165
|
+
# every upload carries owner metadata even when the callback was
|
|
166
|
+
# skipped. The ingest cannot backfill.
|
|
167
|
+
resolved_owner = resolved_owner.merge(cached)
|
|
168
|
+
else
|
|
169
|
+
enriched = begin
|
|
170
|
+
enrich.call(owner_id)
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
# CACHE-005 / SAFETY-003: swallowed, and NOT cached, so the next
|
|
173
|
+
# request retries.
|
|
174
|
+
Env.debug_log("enrich raised: #{e.class}: #{e.message}")
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
if enriched.is_a?(Hash)
|
|
178
|
+
stringified = stringify_keys(enriched)
|
|
179
|
+
@enrich_cache.set(cache_key, stringified)
|
|
180
|
+
resolved_owner = resolved_owner.merge(stringified)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# CACHE-007: when enrichment did not run or produced nothing, the upload
|
|
186
|
+
# still carries the bare owner id so the dashboard can group by it.
|
|
187
|
+
{
|
|
188
|
+
"apiKey" => result[:api_key],
|
|
189
|
+
"owner" => resolved_owner,
|
|
190
|
+
"block" => result[:block],
|
|
191
|
+
"extra" => result[:extra]
|
|
192
|
+
}.compact
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# SETUP-004.
|
|
196
|
+
def self.resolve_block(block)
|
|
197
|
+
return nil if block.nil? || block == false
|
|
198
|
+
return { status: 403, message: "Forbidden" } if block == true
|
|
199
|
+
return nil unless block.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
status = block[:status] || block["status"] || 403
|
|
202
|
+
message = block[:message] || block["message"] || "Forbidden"
|
|
203
|
+
{ status: status.to_i, message: message.to_s }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The single redaction choke point. Redact, truncate, fingerprint, enqueue.
|
|
207
|
+
def record(captured, stack_frame: nil)
|
|
208
|
+
request = captured["request"] || {}
|
|
209
|
+
response = captured["response"] || {}
|
|
210
|
+
request_headers = request["headers"] || {}
|
|
211
|
+
response_headers = response["headers"] || {}
|
|
212
|
+
|
|
213
|
+
sanitized = captured.dup
|
|
214
|
+
sanitized["request"] = request.merge(
|
|
215
|
+
"url" => Redact.redact_url(request["url"].to_s, @redact[:query_params] || []),
|
|
216
|
+
"headers" => Redact.redact_headers(request_headers, @redact[:headers] || []),
|
|
217
|
+
# REDACT-033: truncation runs AFTER redaction, so a secret cannot
|
|
218
|
+
# survive by sitting past the byte limit.
|
|
219
|
+
"body" => Redact.truncate_body(
|
|
220
|
+
Redact.redact_body(request["body"], request_headers["content-type"],
|
|
221
|
+
@redact[:body_keys] || []),
|
|
222
|
+
MAX_BODY_BYTES
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
sanitized["response"] = response.merge(
|
|
226
|
+
"headers" => Redact.redact_headers(response_headers, @redact[:headers] || []),
|
|
227
|
+
"body" => Redact.truncate_body(
|
|
228
|
+
Redact.redact_body(response["body"], response_headers["content-type"],
|
|
229
|
+
@redact[:body_keys] || []),
|
|
230
|
+
MAX_BODY_BYTES
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if sanitized["errorFingerprint"].nil? && response["status"].to_i >= 400
|
|
235
|
+
fingerprint = compute_fingerprint(sanitized, stack_frame)
|
|
236
|
+
sanitized["errorFingerprint"] = self.class.wire_fingerprint(fingerprint) if fingerprint
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
@uploader.push(sanitized)
|
|
240
|
+
nil
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
# SAFETY-001. Nothing in here may reach customer handler code.
|
|
243
|
+
Env.debug_log("record failed: #{e.class}: #{e.message}")
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
# Accept both `:api_key`/`"apiKey"` spellings so the callback reads
|
|
250
|
+
# naturally in Ruby without losing the documented wire names (SETUP-001).
|
|
251
|
+
def normalize_setup(raw)
|
|
252
|
+
api_key = fetch_any(raw, :api_key, :apiKey, "api_key", "apiKey")
|
|
253
|
+
owner_raw = fetch_any(raw, :owner, "owner")
|
|
254
|
+
block = fetch_any(raw, :block, "block")
|
|
255
|
+
|
|
256
|
+
known = %i[api_key apiKey owner block] + %w[api_key apiKey owner block]
|
|
257
|
+
extra = {}
|
|
258
|
+
raw.each do |key, value|
|
|
259
|
+
next if known.include?(key)
|
|
260
|
+
|
|
261
|
+
extra[key.to_s] = value
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
owner = nil
|
|
265
|
+
if owner_raw.is_a?(Hash)
|
|
266
|
+
owner = {
|
|
267
|
+
id: fetch_any(owner_raw, :id, "id"),
|
|
268
|
+
# SETUP-003: `enrich` is the ONLY channel for owner metadata.
|
|
269
|
+
# Anything else inline on `owner` is dropped.
|
|
270
|
+
enrich: fetch_any(owner_raw, :enrich, "enrich")
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
{ api_key: api_key, owner: owner, block: block,
|
|
275
|
+
extra: extra.empty? ? nil : extra }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def fetch_any(hash, *keys)
|
|
279
|
+
keys.each { |key| return hash[key] if hash.key?(key) }
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def stringify_keys(hash)
|
|
284
|
+
out = {}
|
|
285
|
+
hash.each { |k, v| out[k.to_s] = v }
|
|
286
|
+
out
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "capture"
|
|
4
|
+
require_relative "env"
|
|
5
|
+
require_relative "mask"
|
|
6
|
+
require_relative "request_id"
|
|
7
|
+
require_relative "settings"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module Restless
|
|
11
|
+
# The public entry point.
|
|
12
|
+
#
|
|
13
|
+
# client = Restless::Client.new(ENV["RESTLESS_KEY"])
|
|
14
|
+
# client.setup { |request| { api_key: client.mask(request.header("authorization")) } }
|
|
15
|
+
# use client.rack
|
|
16
|
+
class Client
|
|
17
|
+
attr_reader :engine
|
|
18
|
+
|
|
19
|
+
# CONFIG-001..003, CONFIG-010..015.
|
|
20
|
+
#
|
|
21
|
+
# `redact` extends the built-in denylists; it can never shrink or replace
|
|
22
|
+
# them (REDACT-014). Both the settings file and this option are additive
|
|
23
|
+
# on top of the defaults (REDACT-015).
|
|
24
|
+
#
|
|
25
|
+
# `transport` is an internal test hook -- anything responding to
|
|
26
|
+
# `call(url, headers, body) -> [status, body]` -- and is not public API.
|
|
27
|
+
def initialize(api_key = nil, base_url: nil, api: nil, redact: nil,
|
|
28
|
+
transport: nil)
|
|
29
|
+
resolved_key = Env.resolve_api_key(api_key)
|
|
30
|
+
|
|
31
|
+
# CONFIG-002. Construction still succeeds and capture still runs; only
|
|
32
|
+
# upload is disabled.
|
|
33
|
+
if resolved_key.empty? && !Env.test_run?
|
|
34
|
+
warn("[restless-sdk] no API key found -- set RESTLESS_KEY in your " \
|
|
35
|
+
"environment or pass it to Restless::Client.new. Captured requests " \
|
|
36
|
+
"will not be uploaded.")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# CONFIG-013/CONFIG-014 raise here, deliberately: guessing which API
|
|
40
|
+
# entry to use would silently apply the wrong redaction list.
|
|
41
|
+
entry = Settings.resolve_api(Settings.load, api)
|
|
42
|
+
settings_redact = entry && entry[:redact]
|
|
43
|
+
|
|
44
|
+
@engine = CaptureEngine.new(
|
|
45
|
+
api_key: resolved_key,
|
|
46
|
+
base_url: Env.resolve_base_url(base_url),
|
|
47
|
+
request_id_prefix: entry && entry[:request_id_prefix],
|
|
48
|
+
redact: merge_redact(settings_redact, redact),
|
|
49
|
+
transport: transport
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Register the per-request callback. Accepts a block or any callable.
|
|
54
|
+
#
|
|
55
|
+
# client.setup do |request|
|
|
56
|
+
# { api_key: client.mask(request.header("authorization")),
|
|
57
|
+
# owner: { id: workspace_id, enrich: ->(id) { load_workspace(id) } } }
|
|
58
|
+
# end
|
|
59
|
+
def setup(callable = nil, &block)
|
|
60
|
+
@engine.callback = callable || block
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# MASK-001. Pass the RAW header value through; never substitute a
|
|
65
|
+
# placeholder like "anonymous", whose last 4 characters would become the
|
|
66
|
+
# mask tail and cluster unrelated callers together (SETUP-001).
|
|
67
|
+
def mask(api_key)
|
|
68
|
+
Mask.mask(api_key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# BATCH-005.
|
|
72
|
+
def flush
|
|
73
|
+
@engine.flush
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def new_request_id
|
|
77
|
+
RequestId.new_request_id
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The Rack middleware factory. `use client.rack` in a `config.ru`, or
|
|
81
|
+
# `app = client.rack.new(app)` by hand.
|
|
82
|
+
def rack(**options)
|
|
83
|
+
Middleware.factory(self, **options)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def spec_version
|
|
87
|
+
SPEC_VERSION
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def conformance_level
|
|
91
|
+
CONFORMANCE_LEVEL
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def merge_redact(from_settings, from_options)
|
|
97
|
+
settings = from_settings || {}
|
|
98
|
+
options = from_options || {}
|
|
99
|
+
{
|
|
100
|
+
headers: list(settings["headers"]) + list(options[:headers] || options["headers"]),
|
|
101
|
+
body_keys: list(settings["bodyKeys"]) + list(options[:body_keys] || options["bodyKeys"]),
|
|
102
|
+
query_params: list(settings["queryParams"]) +
|
|
103
|
+
list(options[:query_params] || options["queryParams"])
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def list(value)
|
|
108
|
+
value.is_a?(Array) ? value.map(&:to_s) : []
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|