| 1 | /** |
| 2 | * Codewhale first-party telemetry ingest. |
| 3 | * |
| 4 | * ============================ THE RED LINE ============================ |
| 5 | * |
| 6 | * THIS WORKER NEVER READS, LOGS, STORES, OR FORWARDS THE CLIENT IP. |
| 7 | * |
| 8 | * `docs/TELEMETRY.md` publishes "Batches are IP-stripped at ingest. No IP is |
| 9 | * stored, logged, or joined to `install_id`." This file is the whole of what |
| 10 | * makes that sentence true. There is no other component. If you add an IP read |
| 11 | * here, the published document becomes a false statement about a shipped |
| 12 | * product, and every user who opted in did so on the strength of it. |
| 13 | * |
| 14 | * Concretely, and permanently: |
| 15 | * |
| 16 | * - Do not read the connecting-address header, the proxy-chain header, or any |
| 17 | * other header that carries a network address. The names are deliberately |
| 18 | * spelled nowhere in this directory: `test/no-ip.test.ts` greps the source |
| 19 | * for them and fails the build, so an edit that adds one cannot land quietly |
| 20 | * and cannot be justified as "just for debugging". |
| 21 | * - Do not read the `cf` property of the request. Country, colo, city, region, |
| 22 | * ASN, and coordinates all live there; none of them are in the schema, and |
| 23 | * the same test greps for that access too. |
| 24 | * - Read exactly two headers, ever: `content-type` and `content-length`. The |
| 25 | * same test extracts every header name this source asks for and fails if the |
| 26 | * set grows. |
| 27 | * - Do not log the request. Structured logs in Workers are queryable, and a |
| 28 | * log line is storage. Nothing in this file logs a payload or a header. |
| 29 | * - Analytics Engine rows are built in `datapoint.ts` from the *validated |
| 30 | * batch body only*. The request object is not in scope there. |
| 31 | * |
| 32 | * Debugging without an IP is a solved problem: the schema carries `os`, `arch`, |
| 33 | * `libc`, `surface`, `app_version`, and `git_sha`, which is what a crash triage |
| 34 | * actually needs. If you find yourself wanting the IP, you want a different |
| 35 | * feature, and it needs the owner's sign-off and a doc change first. |
| 36 | * |
| 37 | * ====================================================================== |
| 38 | * |
| 39 | * Shape of the service: write-only. One POST route, no GET that returns data, |
| 40 | * no response body on any path, ever. The client |
| 41 | * (`crates/telemetry/src/client.rs`) reads only the status class and drops the |
| 42 | * batch on anything that is not 2xx — no retry, no backoff, no re-queue — so a |
| 43 | * rejection here is invisible to the user by construction, and a 5xx can never |
| 44 | * become a client-visible error. That is what lets this endpoint fail closed: |
| 45 | * when in doubt, refuse the batch. |
| 46 | */ |
| 47 | |
| 48 | import { writeBatch, type DataPointSink } from "./datapoint"; |
| 49 | import { INGEST_PATH } from "./route"; |
| 50 | import { MAX_BODY_BYTES, validateBatch } from "./schema"; |
| 51 | |
| 52 | /** |
| 53 | * Rate-limit binding shape (`ratelimits` in `wrangler.jsonc`). |
| 54 | * |
| 55 | * Note that this module exports exactly one value — the default handler. The |
| 56 | * Workers runtime maps every *named* export of the entrypoint to an entrypoint |
| 57 | * of its own and refuses to start when one is not callable. Interfaces are |
| 58 | * erased at build time, so these cost nothing; a constant would not. |
| 59 | */ |
| 60 | export interface RateLimiter { |
| 61 | limit(options: { key: string }): Promise<{ success: boolean }>; |
| 62 | } |
| 63 | |
| 64 | export interface Env { |
| 65 | /** `analytics_engine_datasets` binding. */ |
| 66 | TELEMETRY: DataPointSink; |
| 67 | /** |
| 68 | * Optional per-install rate limiter. |
| 69 | * |
| 70 | * Keyed on `install_id` — the identifier the batch already carries — and |
| 71 | * never on a network address. That is a weaker limiter than an IP-keyed one |
| 72 | * (a `install_id.json` can be rewritten between POSTs) and it is the right |
| 73 | * trade: Cloudflare's edge already absorbs volumetric abuse, and the failure |
| 74 | * mode of an IP-keyed limiter is that this Worker starts handling IPs. |
| 75 | */ |
| 76 | RATE_LIMITER?: RateLimiter; |
| 77 | } |
| 78 | |
| 79 | /** Every response is a bare status. No body, no echo of the payload, ever. */ |
| 80 | function status(code: number, headers?: HeadersInit): Response { |
| 81 | return new Response(null, { status: code, headers }); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Read at most `limit` bytes, aborting the stream the moment it goes over. |
| 86 | * |
| 87 | * `content-length` is checked first as a cheap reject, but it is client-supplied |
| 88 | * and may be absent or wrong, so the real bound is enforced while reading. |
| 89 | * Returns `null` when the body is missing or too large. |
| 90 | */ |
| 91 | async function readBounded( |
| 92 | body: ReadableStream<Uint8Array> | null, |
| 93 | limit: number, |
| 94 | ): Promise<Uint8Array | null> { |
| 95 | if (body === null) return null; |
| 96 | const reader = body.getReader(); |
| 97 | const chunks: Uint8Array[] = []; |
| 98 | let total = 0; |
| 99 | try { |
| 100 | for (;;) { |
| 101 | const { done, value } = await reader.read(); |
| 102 | if (done) break; |
| 103 | if (value === undefined) continue; |
| 104 | total += value.byteLength; |
| 105 | if (total > limit) { |
| 106 | await reader.cancel(); |
| 107 | return null; |
| 108 | } |
| 109 | chunks.push(value); |
| 110 | } |
| 111 | } finally { |
| 112 | reader.releaseLock(); |
| 113 | } |
| 114 | const joined = new Uint8Array(total); |
| 115 | let offset = 0; |
| 116 | for (const chunk of chunks) { |
| 117 | joined.set(chunk, offset); |
| 118 | offset += chunk.byteLength; |
| 119 | } |
| 120 | return joined; |
| 121 | } |
| 122 | |
| 123 | async function ingest(request: Request, env: Env): Promise<Response> { |
| 124 | // Method before path, so a probe of any path with any verb other than POST |
| 125 | // gets the same answer and learns nothing about what exists here. |
| 126 | if (request.method !== "POST") { |
| 127 | return status(405, { allow: "POST" }); |
| 128 | } |
| 129 | if (new URL(request.url).pathname !== INGEST_PATH) { |
| 130 | return status(404); |
| 131 | } |
| 132 | |
| 133 | // Header read #1 of 2. `client.rs` sends exactly `application/json`. |
| 134 | const contentType = request.headers.get("content-type") ?? ""; |
| 135 | if (!contentType.toLowerCase().startsWith("application/json")) { |
| 136 | return status(415); |
| 137 | } |
| 138 | |
| 139 | // Header read #2 of 2, and the last. See the red line above. |
| 140 | const declared = request.headers.get("content-length"); |
| 141 | if (declared !== null) { |
| 142 | const length = Number(declared); |
| 143 | if (!Number.isFinite(length) || length > MAX_BODY_BYTES) { |
| 144 | return status(413); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | const raw = await readBounded(request.body, MAX_BODY_BYTES); |
| 149 | if (raw === null) return status(413); |
| 150 | |
| 151 | let text: string; |
| 152 | try { |
| 153 | text = new TextDecoder("utf-8", { fatal: true }).decode(raw); |
| 154 | } catch { |
| 155 | return status(400); |
| 156 | } |
| 157 | |
| 158 | let parsed: unknown; |
| 159 | try { |
| 160 | parsed = JSON.parse(text); |
| 161 | } catch { |
| 162 | return status(400); |
| 163 | } |
| 164 | |
| 165 | // The closed-field-set check. An unexpected key anywhere rejects the whole |
| 166 | // batch: a future client bug that starts attaching a path or a prompt must be |
| 167 | // refused by the server rather than quietly stored. The reason string stays |
| 168 | // here — the response carries no body, because a parse error echoed back is a |
| 169 | // way to learn what this endpoint keeps. |
| 170 | const result = validateBatch(parsed); |
| 171 | if (!result.ok) return status(400); |
| 172 | |
| 173 | // Rate limiting is keyed on the install id the batch already carries. It runs |
| 174 | // after validation because that is the only way to have a non-network key. |
| 175 | if (env.RATE_LIMITER !== undefined) { |
| 176 | const { success } = await env.RATE_LIMITER.limit({ |
| 177 | key: result.batch.install_id, |
| 178 | }); |
| 179 | if (!success) return status(429); |
| 180 | } |
| 181 | |
| 182 | // `writeDataPoint` is non-blocking and returns void; it is never awaited. |
| 183 | writeBatch(env.TELEMETRY, result.batch); |
| 184 | |
| 185 | return status(204); |
| 186 | } |
| 187 | |
| 188 | export default { |
| 189 | async fetch(request: Request, env: Env): Promise<Response> { |
| 190 | try { |
| 191 | return await ingest(request, env); |
| 192 | } catch { |
| 193 | // Fail closed and quiet. Nothing is written, nothing is logged, and the |
| 194 | // response has no body. The client treats any non-2xx as "dropped" and |
| 195 | // surfaces nothing to the user, so a 5xx here costs one batch and never |
| 196 | // becomes a user-visible error. |
| 197 | return status(500); |
| 198 | } |
| 199 | }, |
| 200 | }; |
| 201 |