camada 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,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require_relative "beacon_js"
6
+ require_relative "challenge/format"
7
+ require_relative "challenge/page"
8
+ require_relative "challenge/verify"
9
+ require_relative "constants"
10
+ require_relative "env"
11
+ require_relative "events/build"
12
+ require_relative "events/queue"
13
+ require_relative "guarded"
14
+ require_relative "ip"
15
+ require_relative "redact"
16
+ require_relative "snapshot/client"
17
+ require_relative "snapshot/match"
18
+ require_relative "version"
19
+
20
+ module Camada
21
+ # What an adapter hands the engine. Header names are lower-cased; the list keeps the order the
22
+ # host gave (the Rack env's order). `route` is the matched route pattern, set by the adapter at
23
+ # finish time when the host knows it.
24
+ class Req
25
+ attr_reader :method, :path, :query, :host, :http_version, :peer, :https, :headers
26
+ attr_accessor :route
27
+
28
+ def initialize(method:, path:, query: "", host: "", http_version: nil, peer: nil, https: false, headers: [], route: nil)
29
+ @method = method
30
+ @path = path # no query
31
+ @query = query # with the leading '?', or ''
32
+ @host = host
33
+ @http_version = http_version
34
+ @peer = peer # the socket peer the host vouches for
35
+ @https = https
36
+ @headers = headers # [[lowercased name, value], ...]
37
+ @route = route
38
+ end
39
+
40
+ # A header the client repeated is joined the way node:http does it: cookies with '; ' (HTTP/2
41
+ # clients split them into several fields; cookie_value looks for '; name='), the rest with ', '.
42
+ def header(name)
43
+ vals = @headers.filter_map { |k, v| v if k == name }
44
+ return nil if vals.empty?
45
+
46
+ vals.join(name == "cookie" ? "; " : ", ")
47
+ end
48
+ end
49
+
50
+ # camada answered the request; the adapter writes exactly this. `headers` is a Hash of
51
+ # lower-cased names (Rack 3 style), `body` a String.
52
+ Answer = Struct.new(:status, :headers, :body, keyword_init: true) do
53
+ # The Rack triple. content-length is stamped except where a body is forbidden (1xx, 204,
54
+ # 304): Rack::Lint — in every `rackup` development stack — rejects it there, and the beacon's
55
+ # 204 would 500 on every page in dev.
56
+ def to_rack
57
+ h = status < 200 || status == 204 || status == 304 ? headers : headers.merge("content-length" => body.bytesize.to_s)
58
+ [status, h, [body]]
59
+ end
60
+ end
61
+
62
+ # Run the app. rid/set_cookie ride the response; ctx is stored on the host request
63
+ # (env["camada"]); on_finish.call(status) is called once at the end. A nil on_finish means inert.
64
+ Passed = Struct.new(:rid, :set_cookie, :ctx, :on_finish, keyword_init: true)
65
+
66
+ INERT = Passed.new(rid: nil, set_cookie: nil, ctx: nil, on_finish: nil).freeze
67
+
68
+ def self.cookie_value(cookie, name)
69
+ src = "; #{cookie}"
70
+ i = src.index("; #{name}=")
71
+ return nil if i.nil?
72
+
73
+ start = i + name.length + 3
74
+ j = src.index(";", start)
75
+ j.nil? ? src[start..] : src[start...j]
76
+ end
77
+
78
+ # The engine: the host-neutral request handling every adapter delegates to (the Ruby twin of
79
+ # camada-python's Camada class). An adapter turns its request into a Req, asks wants_body and
80
+ # reads at most that many bytes, then calls handle: an Answer means camada fully answered the
81
+ # request (block, challenge, verify, beacon endpoints); a Passed means run the app, stamp the
82
+ # rid header and session cookie on its response, and call on_finish(status) once when it is
83
+ # done. Everything runs inside the fail-open envelope: a camada bug must never 5xx the customer,
84
+ # and CAMADA_DISABLED=1 bypasses the SDK entirely.
85
+ class Engine
86
+ CHALLENGE_HEADERS = { "cache-control" => "no-store", "x-camada-challenge" => "1" }.freeze
87
+
88
+ attr_reader :env, :snap, :queue, :kit, :script_path, :fp_path, :challenge_path, :challenge_on
89
+
90
+ def initialize(env: ENV, transport: nil, refresh_s: nil, script_path: SCRIPT_PATH, fp_path: FP_PATH,
91
+ challenge: true, challenge_path: CHALLENGE_PATH, snapshot_version: DEFAULT_SNAPSHOT_VERSION)
92
+ # env: where CAMADA_* are read from (ENV, or a Hash); transport: threaded into the snapshot
93
+ # client and event queue (tests); refresh_s: pinned poll cadence; challenge: enforce
94
+ # `challenge` verdicts with the first-party page (CAMADA_CHALLENGE=0 also off).
95
+ @env_source = env
96
+ @script_path = script_path
97
+ @fp_path = fp_path
98
+ @challenge_path = challenge_path
99
+ @challenge_on = challenge && env["CAMADA_CHALLENGE"] != "0"
100
+ @env = Env.resolve(env)
101
+ @snap = nil
102
+ @queue = nil
103
+ @kit = nil
104
+ return if @env.nil? || env[KILL_SWITCH_ENV] == "1" # unconfigured or killed at boot: no threads, no exit hooks, truly silent
105
+
106
+ @snap = Snapshot::Client.new(
107
+ @env.snapshot_url, @env.snap_token, refresh_s: refresh_s, mode: @env.serverless ? :lazy : :timer,
108
+ transport: transport, sdk: SDK_ID, snapshot_version: snapshot_version
109
+ )
110
+ @queue = Events::Queue.new(@env.ingest_url, @env.ingest_token, transport: transport, sdk: SDK_ID)
111
+ @kit = Challenge.create_challenge(@env.secret)
112
+ @snap.start
113
+ @queue.install_exit_flush
114
+ end
115
+
116
+ def disabled?
117
+ @env.nil? || @env_source[KILL_SWITCH_ENV] == "1"
118
+ end
119
+
120
+ def now_ms = Events.now_ms
121
+
122
+ # ---- the adapter contract ----
123
+
124
+ # The byte cap to read the body under, when camada itself may answer this request.
125
+ def wants_body(method, path)
126
+ return nil if disabled? || method != "POST"
127
+ return FP_MAX if path == @fp_path && beacon_enabled?
128
+ return BODY_MAX if path == @challenge_path && @challenge_on
129
+
130
+ nil
131
+ end
132
+
133
+ # Never raises. `body` is the request body when wants_body asked for one, or nil when the
134
+ # adapter refused to read it (declared or actual size over the cap).
135
+ def handle(req, body = nil)
136
+ decide(req, body)
137
+ rescue StandardError => e # a camada bug costs the join, never the request
138
+ Guarded.log_rate_limited(e)
139
+ INERT
140
+ end
141
+
142
+ # For HTML templates: the first-party beacon tag with the request's rid.
143
+ def script_tag(ctx)
144
+ return "" if disabled? || !beacon_enabled?
145
+
146
+ rid = ctx && ctx["rid"]
147
+ %(<script src="#{@script_path}#{"?r=#{rid}" if rid}" async></script>)
148
+ end
149
+
150
+ # Serve the challenge for this request on demand — for a route the app wants to gate itself.
151
+ # nil when the client already holds a valid _cch (render your own page), or when the client
152
+ # cannot be identified (fail open).
153
+ def serve_challenge(ctx)
154
+ return nil if disabled? || @kit.nil? || ctx.nil?
155
+
156
+ req = ctx["_req"]
157
+ ip = ctx["ip"]
158
+ return nil if req.nil? || ip.nil? || ip.empty? || challenge_passed?(req, ip)
159
+
160
+ ctx["challenged"] = true
161
+ serve_challenge_answer(req, ip, ctx["sid"])
162
+ rescue StandardError => e
163
+ Guarded.log_rate_limited(e)
164
+ nil
165
+ end
166
+
167
+ # App-context outcome events (login failed, signup, ...). The identifier is HMAC-hashed
168
+ # in-process; the raw value never reaches the queue.
169
+ def track(ctx, event, user: nil)
170
+ return if disabled? || @queue.nil?
171
+
172
+ uid = user.nil? || user.to_s.empty? ? nil : Redact.hash_user_id(user.to_s, @env.ingest_token)
173
+ c = ctx || {}
174
+ row = { "tap" => TAP, "et" => event, "uid" => uid, "rid" => c["rid"], "sid" => c["sid"], "ip" => c["ip"], "ts" => now_ms }
175
+ @queue.push(row)
176
+ rescue StandardError => e
177
+ Guarded.log_rate_limited(e)
178
+ end
179
+
180
+ def stop
181
+ @snap&.stop
182
+ @queue&.stop
183
+ end
184
+
185
+ private
186
+
187
+ def trusted_proxy
188
+ return @env.trusted_proxy if @env && !@env.trusted_proxy.nil? # explicit local override wins
189
+
190
+ @snap && (@snap.config || {})["trusted_proxy"]
191
+ end
192
+
193
+ def beacon_enabled?
194
+ !@snap.nil? && (@snap.config || {})["beacon"] != false
195
+ end
196
+
197
+ def client_ip(req)
198
+ Ip.resolve_client_ip(req.peer, req.header("x-forwarded-for"), trusted_proxy)
199
+ end
200
+
201
+ def secure?(req) = req.https || req.header("x-forwarded-proto") == "https"
202
+
203
+ def decide(req, body)
204
+ return INERT if disabled? || @snap.nil?
205
+
206
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
207
+ @snap.ensure_fresh
208
+ ip = client_ip(req)
209
+
210
+ # Enforce before anything else, beacon endpoints included — fail open while cold. The
211
+ # custom rules read the user agent and the request headers (§D3).
212
+ v = @snap.verdict(Snapshot::MatchInput.new(ip: ip, path: req.path, ua: req.header("user-agent"), header: ->(n) { req.header(n) }))
213
+ if v.block
214
+ headers = { "content-type" => "text/plain", "x-block-reason" => v.reason || "", "x-block-version" => v.version || "" }
215
+ headers["x-block-rule"] = v.rule if v.rule # a custom rule blocked: name it, so the customer knows which row to edit
216
+ ev = event(req, SecureRandom.uuid, nil, false, ip)
217
+ ev["st"] = 403 # blocked requests always ship: silent expiry makes blocks oscillate
218
+ ev["blk"] = v.reason # the reason rides the event so the analyst counts SDK blocks, not the app's own 403s
219
+ ev["rl"] = v.rule if v.rule
220
+ @queue.push(ev)
221
+ return Answer.new(status: 403, headers: headers, body: "Forbidden")
222
+ end
223
+ # `warn` passes the request and only marks its event (below, on finish); a skip passes
224
+ # with nothing stamped at all — it is the absence of enforcement.
225
+
226
+ # A challenge needs a resolved client IP: the nonce and the _cch cookie are bound to it,
227
+ # so without one a single solve would mint a cookie every unidentified client could
228
+ # present. No ip -> no challenge (fail open), the same stance ip rules take.
229
+ if @challenge_on && ip && !ip.empty?
230
+ # The verify endpoint answers first: a challenged client must be able to reach it.
231
+ return verify(req, body, ip) if req.method == "POST" && req.path == @challenge_path
232
+ if v.challenge && !challenge_passed?(req, ip)
233
+ return serve_challenge_answer(req, ip, Camada.cookie_value(req.header("cookie"), SESSION_COOKIE))
234
+ end
235
+ end
236
+
237
+ if beacon_enabled?
238
+ if req.method == "GET" && req.path == @script_path
239
+ return Answer.new(status: 200, headers: { "content-type" => "application/javascript", "cache-control" => "public, max-age=3600" },
240
+ body: BEACON_JS)
241
+ end
242
+ return relay_beacon(body, ip) if req.method == "POST" && req.path == @fp_path
243
+ end
244
+
245
+ rid = SecureRandom.uuid
246
+ sid = Camada.cookie_value(req.header("cookie"), SESSION_COOKIE)
247
+ new_session = sid.nil? || sid.empty?
248
+ set_cookie = nil
249
+ if new_session
250
+ sid = SecureRandom.uuid
251
+ set_cookie = "#{SESSION_COOKIE}=#{sid}; Path=/; Max-Age=#{SESSION_MAX_AGE}; HttpOnly; SameSite=Lax"
252
+ set_cookie += "; Secure" if secure?(req)
253
+ end
254
+ ctx = { "rid" => rid, "sid" => sid, "ip" => ip, "_req" => req, "_engine" => self }
255
+
256
+ cfg = @snap.config || {}
257
+ excluded = (cfg["exclude"] || []).any? { |x| req.path.start_with?(x.to_s) }
258
+ sample = cfg["sample"]
259
+ sampled = rand < (sample.nil? ? 1.0 : sample.to_f) # sampling, not crypto
260
+ warn_rule = v.warn ? v.rule : nil
261
+
262
+ on_finish = lambda do |status|
263
+ # serve_challenge may have answered from inside the app, and it already shipped the
264
+ # `blk: "challenge"` row — one request, one event.
265
+ next if ctx["challenged"] || excluded || !sampled
266
+
267
+ ev = event(req, rid, sid, new_session, ip)
268
+ ev["st"] = status
269
+ ev["dur"] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).to_i
270
+ ev["rt"] = req.route if req.route
271
+ ev["wrn"] = warn_rule if warn_rule # §D3: the warn rule that let this request through
272
+ @queue.push(ev)
273
+ rescue StandardError => e
274
+ Guarded.log_rate_limited(e)
275
+ end
276
+
277
+ Passed.new(rid: rid, set_cookie: set_cookie, ctx: ctx, on_finish: on_finish)
278
+ end
279
+
280
+ def event(req, rid, sid, new_session, ip)
281
+ info = Events::RequestInfo.new(method: req.method, host: req.host, path: req.path, query: req.query, headers: req.headers,
282
+ ip: ip, http_version: req.http_version)
283
+ Events.build_wire_event(info, tap: TAP, rid: rid, sid: sid, new_session: new_session)
284
+ end
285
+
286
+ # ---- beacon ----
287
+
288
+ # Answers 204, and queues the beacon as a `sig: 1` row with the trusted-proxy-resolved
289
+ # client IP: it rides the next event batch. Junk bodies are dropped, never shipped.
290
+ def relay_beacon(body, ip)
291
+ return Answer.new(status: 413, headers: {}, body: "") if body.nil?
292
+
293
+ answer = Answer.new(status: 204, headers: { "cache-control" => "no-store" }, body: "")
294
+ parsed = Camada.parse_json(body)
295
+ return answer unless parsed.is_a?(Hash)
296
+
297
+ @queue.push(parsed.merge("sig" => 1, "ip" => ip, "tap" => TAP)) # spread first: ip and tap are the server's word
298
+ answer
299
+ end
300
+
301
+ # ---- challenge ----
302
+
303
+ def challenge_passed?(req, ip)
304
+ !@kit.nil? && @kit.token_valid?(ip, now_ms, Camada.cookie_value(req.header("cookie"), Challenge::CHALLENGE_COOKIE))
305
+ end
306
+
307
+ def page(ip, to)
308
+ html = Challenge.challenge_page(nonce: @kit.nonce(ip, now_ms), action: @challenge_path, to: to)
309
+ Answer.new(status: 403, headers: CHALLENGE_HEADERS.merge("content-type" => "text/html; charset=utf-8"), body: html)
310
+ end
311
+
312
+ # 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
313
+ # `blk: "challenge"` event — a served challenge is reported like a block (contract §D2).
314
+ def serve_challenge_answer(req, ip, sid)
315
+ to = Challenge.safe_return_to(req.path + req.query)
316
+ answer =
317
+ if Challenge.wants_html?(req.header("accept"), req.header("sec-fetch-dest"))
318
+ page(ip, to)
319
+ else
320
+ Answer.new(status: 403, headers: CHALLENGE_HEADERS.merge("content-type" => "application/json"),
321
+ body: '{"error":"challenge_required"}')
322
+ end
323
+ begin
324
+ ev = event(req, SecureRandom.uuid, sid, false, ip)
325
+ ev["st"] = 403
326
+ ev["blk"] = "challenge"
327
+ @queue.push(ev)
328
+ rescue StandardError => e # the response is decided; telemetry must never undo that
329
+ Guarded.log_rate_limited(e)
330
+ end
331
+ answer
332
+ end
333
+
334
+ # POST from the challenge page: validate the nonce and the proof of work, set _cch, 302
335
+ # back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`.
336
+ def verify(req, body, ip)
337
+ return Answer.new(status: 413, headers: {}, body: "") if body.nil?
338
+
339
+ form = Challenge.parse_form_body(body)
340
+ to = Challenge.safe_return_to(form["to"])
341
+ now = now_ms
342
+ return page(ip, to) unless @kit.verify?(ip, now, form["nonce"], form["solution"])
343
+
344
+ cookie = Challenge.challenge_cookie(@kit.issue(ip, now), secure?(req))
345
+ ev = event(req, SecureRandom.uuid, Camada.cookie_value(req.header("cookie"), SESSION_COOKIE), false, ip)
346
+ ev["st"] = 200
347
+ ev["ch"] = 1 # challenge passed (contract §A3 ingest field)
348
+ @queue.push(ev)
349
+ Answer.new(status: 302, headers: { "location" => to, "set-cookie" => cookie, "cache-control" => "no-store" }, body: "")
350
+ end
351
+ end
352
+
353
+ # The engine that produced a request context (the helpers resolve script_tag/track through it).
354
+ def self.engine_of(ctx)
355
+ eng = ctx && ctx["_engine"]
356
+ eng.is_a?(Engine) ? eng : nil
357
+ end
358
+
359
+ def self.create_engine(**opts)
360
+ e = Engine.new(**opts)
361
+ Guarded.log_rate_limited("CAMADA_KEY (or CAMADA_TOKEN + CAMADA_SNAPSHOT_TOKEN) not set — camada is inactive") if e.env.nil?
362
+ e
363
+ end
364
+ end
data/lib/camada/env.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "constants"
5
+
6
+ module Camada
7
+ # Environment wiring. The two-line quickstart depends on this doing the right thing:
8
+ # CAMADA_KEY=<ingest_token>.<snap_token> (printed by `reconcile instructions` and seed)
9
+ # CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL (dev: http://localhost:8787[/snapshot])
10
+ # CAMADA_DISABLED=1 kill switch, checked at boot and per request
11
+ # CAMADA_SERVERLESS=1 lazy snapshot mode (no poll thread)
12
+ # CAMADA_TRUSTED_PROXY local override: none | vercel | hops:N | cidrs:a,b
13
+ # CAMADA_CHALLENGE=0 do not enforce challenge verdicts
14
+ Env = Struct.new(
15
+ :ingest_token, :snap_token,
16
+ :secret, # HMAC key for the challenge nonce/cookie — never leaves the process
17
+ :ingest_url, :snapshot_url,
18
+ :serverless,
19
+ :trusted_proxy, # nil = defer to server-delivered config
20
+ keyword_init: true
21
+ )
22
+
23
+ class Env
24
+ # PLACEHOLDER default, the same one @camada/node and camada-python carry — confirm the
25
+ # production ingest domain before any RubyGems publish.
26
+ DEFAULT_INGEST_URL = "https://in.camada.dev"
27
+
28
+ # nil (SDK stays inert, one log line) rather than raising on bad config. `env` is anything
29
+ # answering #[] with strings: ENV, or a Hash.
30
+ def self.resolve(env)
31
+ key = Config.parse_key(env["CAMADA_KEY"])
32
+ ingest_token = key ? key[0] : env["CAMADA_TOKEN"]
33
+ snap_token = key ? key[1] : env["CAMADA_SNAPSHOT_TOKEN"]
34
+ return nil if Camada.present(ingest_token).nil? || Camada.present(snap_token).nil?
35
+
36
+ ingest_url = (Camada.present(env["CAMADA_INGEST_URL"]) || DEFAULT_INGEST_URL).sub(%r{/+\z}, "")
37
+ new(
38
+ ingest_token: ingest_token, snap_token: snap_token,
39
+ secret: Camada.present(env["CAMADA_KEY"]) || "#{ingest_token}.#{snap_token}",
40
+ ingest_url: ingest_url,
41
+ snapshot_url: Camada.present(env["CAMADA_SNAPSHOT_URL"]) || "#{ingest_url}/snapshot",
42
+ serverless: env["CAMADA_SERVERLESS"] == "1",
43
+ trusted_proxy: Config.parse_trusted_proxy_env(env["CAMADA_TRUSTED_PROXY"])
44
+ )
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../redact"
4
+
5
+ module Camada
6
+ # Wire-event builder: reproduces the collector's record() (edge-analyst
7
+ # workers/collector/edge-collector.js) from a normalized request, so events are comparable
8
+ # across taps. HDRS bit order is pinned by the shared fixture (hdrs.json) — never reorder.
9
+ module Events
10
+ HDRS = %w[
11
+ accept accept-language accept-encoding sec-fetch-site sec-fetch-mode sec-fetch-dest
12
+ sec-fetch-user sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform upgrade-insecure-requests dnt
13
+ cache-control pragma referer origin cookie authorization x-requested-with content-type
14
+ via x-forwarded-for priority sec-purpose save-data te if-modified-since if-none-match
15
+ ].freeze
16
+ HDR_BIT = HDRS.each_with_index.to_h { |name, i| [name, 1 << i] }.freeze
17
+
18
+ # A schemeless header (`Authorization: <raw token>`) has no safe prefix: the first "word" IS
19
+ # the credential. Only a real auth-scheme token followed by a space ever ships.
20
+ SCHEME_RE = /\A[A-Za-z0-9!#$%&'*+.^_`|~-]{1,16}\z/
21
+
22
+ RequestInfo = Struct.new(
23
+ :method, :host, :path,
24
+ :query, # includes the leading '?', or empty
25
+ :headers, # [[name, value], ...] in the order the host gives them (the env's order under Rack)
26
+ :ip, # already resolved via trusted-proxy config
27
+ :http_version, # e.g. '1.1'
28
+ keyword_init: true
29
+ )
30
+
31
+ def self.auth_scheme(value)
32
+ return nil if value.nil? || value.empty?
33
+
34
+ sp = value.index(" ")
35
+ return nil if sp.nil? || sp <= 0
36
+
37
+ scheme = value[0, sp]
38
+ SCHEME_RE.match?(scheme) ? scheme : nil
39
+ end
40
+
41
+ def self.now_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
42
+
43
+ # The mutable wire event (string keys, ready for JSON); the caller fills st/dur on
44
+ # response-finish before enqueueing.
45
+ def self.build_wire_event(r, tap:, rid:, sid: nil, new_session: false, ja4: nil)
46
+ mask = hn = hb = 0
47
+ cookie = +""
48
+ names = []
49
+ first = {}
50
+ (r.headers || []).each do |name, value|
51
+ k = name.downcase
52
+ hn += 1
53
+ hb += name.bytesize + value.bytesize # bytes on the wire, as the collector and the WSGI (latin-1) port count them
54
+ names << k
55
+ first[k] = value unless first.key?(k)
56
+ mask |= HDR_BIT.fetch(k, 0)
57
+ cookie << (cookie.empty? ? value : "; #{value}") if k == "cookie"
58
+ end
59
+ query = r.query || ""
60
+ qn = query.length > 1 ? query[1..].split("&").count { |p| !p.empty? } : 0
61
+ ev = {
62
+ "tap" => tap, "rid" => rid, "sid" => sid, "ns" => new_session ? 1 : 0, "ts" => now_ms,
63
+ "ip" => r.ip,
64
+ "proto" => r.http_version ? "HTTP/#{r.http_version}" : nil,
65
+ "m" => r.method, "h" => r.host, "p" => r.path, "q" => Redact.scrub_query(query)[0, 512], "qn" => qn,
66
+ "ct" => first["content-type"], "cl" => first["content-length"],
67
+ "ua" => first["user-agent"], "chua" => first["sec-ch-ua"],
68
+ "chmob" => first["sec-ch-ua-mobile"], "chplat" => first["sec-ch-ua-platform"],
69
+ "acc" => first["accept"], "lang" => first["accept-language"],
70
+ "fs" => first["sec-fetch-site"], "fm" => first["sec-fetch-mode"],
71
+ "fd" => first["sec-fetch-dest"], "fu" => first["sec-fetch-user"], "ref" => first["referer"], "org" => first["origin"],
72
+ "xrw" => first["x-requested-with"], "auth" => auth_scheme(first["authorization"]), # scheme only, never the credential
73
+ "hm" => mask, "hn" => hn, "hb" => hb, "ck" => cookie.empty? ? 0 : cookie.split(";", -1).length,
74
+ "hord" => names.join(",")[0, 2048] # header order as this host reports it (the env's order under Rack)
75
+ }
76
+ ev["ja4"] = ja4 if ja4
77
+ ev["st"] = nil
78
+ ev["dur"] = nil # 'dur': the collector wire already claims 'lat' for latitude
79
+ ev
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../guarded"
5
+ require_relative "../transport"
6
+
7
+ module Camada
8
+ module Events
9
+ # Queue: fire-and-forget batched shipping to POST /e (ported from @camada/core
10
+ # src/events/queue.ts). The collector ships one event per request; an in-process SDK batches,
11
+ # flushes on size or interval, and drains at exit — but the same law holds: NOTHING here may
12
+ # ever raise into the customer's request path, and a dead ingest must cost nothing but dropped
13
+ # telemetry. Defaults (15 s / 500): every flush is one request and one R2 put at the analyst,
14
+ # so the bill scales with instance count x flush cadence — not with traffic.
15
+ class Queue
16
+ attr_accessor :transport
17
+ attr_reader :url, :token, :max_batch, :max_queue, :flush_s, :timeout_s, :sdk, :dropped, :lock, :flush_thread
18
+
19
+ def initialize(url, token, max_batch: 500, max_queue: 2000, flush_s: 15.0, timeout_s: 2.0, transport: nil, sdk: nil)
20
+ # url: ingest base, e.g. https://analyst.example.com; token: ingest token (x-tenant header);
21
+ # max_batch: flush when the queue reaches this many (server caps at 1000); max_queue: drop-oldest beyond this;
22
+ # sdk: '<package>/<version>', sent as x-camada-sdk on every batch (SDK-03).
23
+ @url = url.sub(%r{/+\z}, "")
24
+ @token = token
25
+ @max_batch = max_batch
26
+ @max_queue = max_queue
27
+ @flush_s = flush_s
28
+ @timeout_s = timeout_s
29
+ @sdk = sdk
30
+ @transport = transport || Transport::DEFAULT
31
+ @dropped = 0 # debug counter, not an API promise
32
+ @exit_installed = false
33
+ fresh_state!
34
+ end
35
+
36
+ # A forked worker inherits the queue but not its thread: start fresh on the next push. The
37
+ # parent keeps its pending events (and may have held the lock mid-flush), so the child starts empty.
38
+ def after_fork!
39
+ fresh_state!
40
+ @exit_installed = false
41
+ end
42
+
43
+ def size = @q.length
44
+ def inflight? = @inflight.locked?
45
+
46
+ # Synchronous, never raises. Starts the flush thread lazily on first push; a stopped queue
47
+ # stays stopped (configure replaces the engine rather than reviving one).
48
+ def push(event)
49
+ check_fork!
50
+ n = 0
51
+ @lock.synchronize do
52
+ if @q.length >= @max_queue
53
+ @q.shift
54
+ @dropped += 1
55
+ end
56
+ @q << event
57
+ n = @q.length
58
+ if @flush_thread.nil? && !@stopped
59
+ @flush_thread = Thread.new { run }
60
+ @flush_thread.name = "camada-events"
61
+ @flush_thread.report_on_exception = false
62
+ end
63
+ end
64
+ wake if n >= @max_batch
65
+ rescue StandardError => e # never into the request path
66
+ Guarded.log_rate_limited(e)
67
+ end
68
+
69
+ # Drains the queue, <=1000 events per POST (the server slices there); single-in-flight;
70
+ # never raises. `wait` queues behind a flush already in flight instead of yielding to it —
71
+ # the exit drain needs the full queue gone, not just the batch someone else is posting.
72
+ def flush(wait: false)
73
+ check_fork!
74
+ inflight = @inflight # bound once: after_fork! swaps the attribute
75
+ return unless wait ? inflight.lock : inflight.try_lock
76
+
77
+ begin
78
+ headers = { "x-tenant" => @token, "content-type" => "application/json" }
79
+ headers["x-camada-sdk"] = @sdk if @sdk
80
+ loop do
81
+ batch = @lock.synchronize { @q.shift([1000, @q.length].min) }
82
+ return if batch.empty?
83
+
84
+ begin
85
+ body = encode(batch)
86
+ next if body.nil?
87
+
88
+ res = @transport.call(HttpRequest.new(method: "POST", url: "#{@url}/e", headers: headers, body: body, timeout_s: @timeout_s))
89
+ raise "ingest unreachable" if res.status == 0
90
+ rescue StandardError => e
91
+ @dropped += batch.length
92
+ # Dropping telemetry is by design, doing it silently is not: a mount that can never
93
+ # reach ingest looks identical to a healthy one otherwise.
94
+ Guarded.log_rate_limited(e)
95
+ end
96
+ end
97
+ ensure
98
+ inflight.unlock
99
+ end
100
+ rescue StandardError => e
101
+ Guarded.log_rate_limited(e)
102
+ end
103
+
104
+ def stop
105
+ @wake_m.synchronize do
106
+ @stopped = true
107
+ @woken = true
108
+ @wake_cv.broadcast
109
+ end
110
+ @flush_thread = nil
111
+ end
112
+
113
+ # Opt-in: drain at interpreter exit within a small budget. No signal handlers — an app owns
114
+ # its own shutdown; SIGTERM without a handler skips at_exit, which the README says out loud.
115
+ def install_exit_flush(budget_s = 0.5)
116
+ return if @exit_installed
117
+
118
+ @exit_installed = true
119
+ at_exit { drain(budget_s) }
120
+ end
121
+
122
+ # A full drain (behind any flush in flight) on its own thread, abandoned once the budget is spent.
123
+ def drain(budget_s = 0.5)
124
+ t = Thread.new { flush(wait: true) }
125
+ t.name = "camada-exit-flush"
126
+ t.report_on_exception = false
127
+ t.join(budget_s)
128
+ nil
129
+ end
130
+
131
+ private
132
+
133
+ def fresh_state!
134
+ @pid = Process.pid
135
+ @q = []
136
+ @lock = Mutex.new
137
+ @inflight = Mutex.new
138
+ @wake_m = Mutex.new
139
+ @wake_cv = ConditionVariable.new
140
+ @woken = false
141
+ @stopped = false
142
+ @flush_thread = nil
143
+ end
144
+
145
+ def check_fork!
146
+ after_fork! if Process.pid != @pid
147
+ end
148
+
149
+ # The batch as JSON, or nil when nothing in it can be serialised. The adapters scrub what
150
+ # they ship, but a row that still carries an invalid byte must cost that row alone, never
151
+ # the batch: blocked rows always ship, or blocks oscillate.
152
+ def encode(batch)
153
+ JSON.generate(batch)
154
+ rescue JSON::GeneratorError, EncodingError
155
+ rows = batch.filter_map do |ev|
156
+ JSON.generate(ev)
157
+ rescue JSON::GeneratorError, EncodingError
158
+ nil
159
+ end
160
+ @dropped += batch.length - rows.length
161
+ rows.empty? ? nil : "[#{rows.join(",")}]"
162
+ end
163
+
164
+ def wake
165
+ @wake_m.synchronize do
166
+ @woken = true
167
+ @wake_cv.signal
168
+ end
169
+ end
170
+
171
+ def run
172
+ until @stopped
173
+ @wake_m.synchronize do
174
+ @wake_cv.wait(@wake_m, @flush_s) unless @woken
175
+ @woken = false
176
+ end
177
+ return if @stopped
178
+
179
+ flush
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end