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.
@@ -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