Class: Restless::CaptureEngine
- Inherits:
-
Object
- Object
- Restless::CaptureEngine
- Defined in:
- lib/restless/capture.rb
Overview
The capture engine: redaction choke point (section 4), the two caches (section 11), fingerprinting (section 5) and the hand-off to the uploader (sections 8 and 9).
Every adapter goes through here. No adapter may bypass record, which is
the single point where redaction runs.
Constant Summary collapse
- MAX_BODY_BYTES =
REDACT-030.
Redact::MAX_BODY_BYTES
Instance Attribute Summary collapse
-
#enrich_cache ⇒ Object
readonly
Returns the value of attribute enrich_cache.
-
#recovery_cache ⇒ Object
readonly
Returns the value of attribute recovery_cache.
-
#uploader ⇒ Object
readonly
Returns the value of attribute uploader.
Class Method Summary collapse
-
.resolve_block(block) ⇒ Object
SETUP-004.
-
.wire_fingerprint(fingerprint) ⇒ Object
WIRE-017.
Instance Method Summary collapse
- #callback=(callback) ⇒ Object
-
#compute_fingerprint(captured, stack_frame = nil) ⇒ Object
FP-002.
-
#docs_url ⇒ Object
INJECT-006.
- #flush ⇒ Object
-
#handle_server_response(body, batch_fingerprints) ⇒ Object
WIRE-020..023, CACHE-006, CACHE-011..013.
-
#initialize(api_key:, base_url:, request_id_prefix: nil, redact: nil, transport: nil) ⇒ CaptureEngine
constructor
A new instance of CaptureEngine.
-
#lookup_recovery(fingerprint_key) ⇒ Object
CACHE-010.
-
#record(captured, stack_frame: nil) ⇒ Object
The single redaction choke point.
-
#resolve(request) ⇒ Object
Run the user's setup callback and resolve owner metadata.
Constructor Details
#initialize(api_key:, base_url:, request_id_prefix: nil, redact: nil, transport: nil) ⇒ CaptureEngine
Returns a new instance of CaptureEngine.
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/restless/capture.rb', line 23 def initialize(api_key:, base_url:, request_id_prefix: nil, redact: nil, transport: nil) @redact = redact || {} @enrich_cache = EnrichCache.new @recovery_cache = RecoveryCache.new @docs_url = nil @docs_mutex = Mutex.new @callback = nil @uploader = Uploader.new( api_key: api_key, base_url: base_url, request_id_prefix: request_id_prefix, transport: transport, on_response: method(:handle_server_response) ) end |
Instance Attribute Details
#enrich_cache ⇒ Object (readonly)
Returns the value of attribute enrich_cache.
21 22 23 |
# File 'lib/restless/capture.rb', line 21 def enrich_cache @enrich_cache end |
#recovery_cache ⇒ Object (readonly)
Returns the value of attribute recovery_cache.
21 22 23 |
# File 'lib/restless/capture.rb', line 21 def recovery_cache @recovery_cache end |
#uploader ⇒ Object (readonly)
Returns the value of attribute uploader.
21 22 23 |
# File 'lib/restless/capture.rb', line 21 def uploader @uploader end |
Class Method Details
.resolve_block(block) ⇒ Object
SETUP-004.
196 197 198 199 200 201 202 203 204 |
# File 'lib/restless/capture.rb', line 196 def self.resolve_block(block) return nil if block.nil? || block == false return { status: 403, message: "Forbidden" } if block == true return nil unless block.is_a?(Hash) status = block[:status] || block["status"] || 403 = block[:message] || block["message"] || "Forbidden" { status: status.to_i, message: .to_s } end |
.wire_fingerprint(fingerprint) ⇒ Object
WIRE-017. The serialized form of a fingerprint.
Fingerprint::Result is a Struct with symbol members, and the wire wants
string keys, so the mapping has to be written somewhere. It is written
once: the Rack middleware computes the fingerprint early (INJECT-009) and
must serialize it identically to record below.
125 126 127 128 129 130 131 |
# File 'lib/restless/capture.rb', line 125 def self.wire_fingerprint(fingerprint) { "strategy" => fingerprint.strategy, "key" => fingerprint.key, "reason" => fingerprint.reason } end |
Instance Method Details
#callback=(callback) ⇒ Object
40 41 42 |
# File 'lib/restless/capture.rb', line 40 def callback=(callback) @callback = callback end |
#compute_fingerprint(captured, stack_frame = nil) ⇒ Object
FP-002. Errors only; the ingest treats an absent fingerprint as success.
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/restless/capture.rb', line 91 def compute_fingerprint(captured, stack_frame = nil) response = captured["response"] || {} status = response["status"].to_i return nil if status < 400 body = response["body"] if body.is_a?(String) begin body = JSON.parse(body, max_nesting: false) rescue StandardError # Leave it as a string; `extract_message` handles both shapes. end end request = captured["request"] || {} Fingerprint.compute( status: status, method: request["method"], route: captured["routePattern"], response_headers: response["headers"], response_body: body, stack_frame: stack_frame ) rescue StandardError => e Env.debug_log("fingerprint failed: #{e.class}: #{e.}") nil end |
#docs_url ⇒ Object
INJECT-006. The latest server-resolved docs origin, or nil when no batch has round-tripped yet.
46 47 48 |
# File 'lib/restless/capture.rb', line 46 def docs_url @docs_mutex.synchronize { @docs_url } end |
#flush ⇒ Object
50 51 52 |
# File 'lib/restless/capture.rb', line 50 def flush @uploader.flush end |
#handle_server_response(body, batch_fingerprints) ⇒ Object
WIRE-020..023, CACHE-006, CACHE-011..013.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/restless/capture.rb', line 55 def handle_server_response(body, batch_fingerprints) needs = body["needsEnrichment"] if needs.is_a?(Array) needs.each { |key| @enrich_cache.invalidate(key) if key.is_a?(String) } end docs = body["docsUrl"] if docs.is_a?(String) && !docs.empty? # Origin only; strip trailing slashes so the server can be lax. @docs_mutex.synchronize { @docs_url = docs.sub(%r{/+\z}, "") } end = body["recoveryMessages"].is_a?(Hash) ? body["recoveryMessages"] : {} batch_fingerprints.each do |key| value = [key] if value.is_a?(String) @recovery_cache.set(key, value) # CACHE-011 elsif .key?(key) @recovery_cache.set(key, nil) else # CACHE-012 + CACHE-013: negative-cache anything the server did not # answer for, without clobbering an existing positive entry. This is # what guarantees the SECOND occurrence of any error is a cache hit. @recovery_cache.set_negative_unless_present(key) end end rescue StandardError => e Env.debug_log("server response handling failed: #{e.class}: #{e.}") end |
#lookup_recovery(fingerprint_key) ⇒ Object
CACHE-010. Synchronous, in-process, no I/O, never blocks the response.
86 87 88 |
# File 'lib/restless/capture.rb', line 86 def lookup_recovery(fingerprint_key) @recovery_cache.lookup(fingerprint_key) end |
#record(captured, stack_frame: nil) ⇒ Object
The single redaction choke point. Redact, truncate, fingerprint, enqueue.
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/restless/capture.rb', line 207 def record(captured, stack_frame: nil) request = captured["request"] || {} response = captured["response"] || {} request_headers = request["headers"] || {} response_headers = response["headers"] || {} sanitized = captured.dup sanitized["request"] = request.merge( "url" => Redact.redact_url(request["url"].to_s, @redact[:query_params] || []), "headers" => Redact.redact_headers(request_headers, @redact[:headers] || []), # REDACT-033: truncation runs AFTER redaction, so a secret cannot # survive by sitting past the byte limit. "body" => Redact.truncate_body( Redact.redact_body(request["body"], request_headers["content-type"], @redact[:body_keys] || []), MAX_BODY_BYTES ) ) sanitized["response"] = response.merge( "headers" => Redact.redact_headers(response_headers, @redact[:headers] || []), "body" => Redact.truncate_body( Redact.redact_body(response["body"], response_headers["content-type"], @redact[:body_keys] || []), MAX_BODY_BYTES ) ) if sanitized["errorFingerprint"].nil? && response["status"].to_i >= 400 fingerprint = compute_fingerprint(sanitized, stack_frame) sanitized["errorFingerprint"] = self.class.wire_fingerprint(fingerprint) if fingerprint end @uploader.push(sanitized) nil rescue StandardError => e # SAFETY-001. Nothing in here may reach customer handler code. Env.debug_log("record failed: #{e.class}: #{e.}") nil end |
#resolve(request) ⇒ Object
Run the user's setup callback and resolve owner metadata.
SAFETY-002: a callback that raises is caught and the request proceeds with no user context attached.
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/restless/capture.rb', line 137 def resolve(request) return {} if @callback.nil? begin raw = @callback.call(request) rescue StandardError => e Env.debug_log("setup callback raised: #{e.class}: #{e.}") return {} end return {} unless raw.is_a?(Hash) result = normalize_setup(raw) owner = result[:owner] return { "apiKey" => result[:api_key], "block" => result[:block], "extra" => result[:extra] }.compact if owner.nil? owner_id = owner[:id] enrich = owner[:enrich] # CACHE-002: key on owner id when present, else the masked end-user key, # so multiple end-users in one workspace share a slot. cache_key = (owner_id if owner_id && !owner_id.empty?) || result[:api_key] resolved_owner = { "id" => owner_id }.compact if enrich.respond_to?(:call) && owner_id && !owner_id.empty? && cache_key cached = @enrich_cache.get(cache_key) if cached # CACHE-003: the VALUE is cached, not merely a freshness flag, so # every upload carries owner metadata even when the callback was # skipped. The ingest cannot backfill. resolved_owner = resolved_owner.merge(cached) else enriched = begin enrich.call(owner_id) rescue StandardError => e # CACHE-005 / SAFETY-003: swallowed, and NOT cached, so the next # request retries. Env.debug_log("enrich raised: #{e.class}: #{e.}") nil end if enriched.is_a?(Hash) stringified = stringify_keys(enriched) @enrich_cache.set(cache_key, stringified) resolved_owner = resolved_owner.merge(stringified) end end end # CACHE-007: when enrichment did not run or produced nothing, the upload # still carries the bare owner id so the dashboard can group by it. { "apiKey" => result[:api_key], "owner" => resolved_owner, "block" => result[:block], "extra" => result[:extra] }.compact end |