返回 CodeWhale
index.ts
根目录 / telemetry-ingest / src / index.ts
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
201 lines TYPESCRIPT