From 6b4fb86690b88fa308ac0886badb92ab80fe6126 Mon Sep 17 00:00:00 2001 From: kp Date: Sun, 12 Jul 2026 23:04:34 -0700 Subject: [PATCH] feat: telemetry collector worker (Analytics Engine + R2) with week-2 SQL (#11) --- .github/workflows/ci.yml | 18 + telemetry-worker/README.md | 122 +++ telemetry-worker/package-lock.json | 1608 ++++++++++++++++++++++++++++ telemetry-worker/package.json | 18 + telemetry-worker/schema.sql | 103 ++ telemetry-worker/src/index.test.ts | 249 +++++ telemetry-worker/src/index.ts | 317 ++++++ telemetry-worker/tsconfig.json | 15 + telemetry-worker/vitest.config.ts | 14 + telemetry-worker/wrangler.toml | 39 + 10 files changed, 2503 insertions(+) create mode 100644 telemetry-worker/README.md create mode 100644 telemetry-worker/package-lock.json create mode 100644 telemetry-worker/package.json create mode 100644 telemetry-worker/schema.sql create mode 100644 telemetry-worker/src/index.test.ts create mode 100644 telemetry-worker/src/index.ts create mode 100644 telemetry-worker/tsconfig.json create mode 100644 telemetry-worker/vitest.config.ts create mode 100644 telemetry-worker/wrangler.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68f9c65..42d08a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,24 @@ jobs: node-version: 22 - run: node scripts/check-plugin.mjs + telemetry-worker: + runs-on: ubuntu-latest + defaults: + run: + working-directory: telemetry-worker + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: telemetry-worker/package-lock.json + # Not part of the pnpm workspace by design (see telemetry-worker/package.json) — + # its own npm install/test, independent of the root pnpm install above. + - run: npm ci + - run: npm run typecheck + - run: npm test + e2e: runs-on: ubuntu-latest steps: diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md new file mode 100644 index 0000000..e81e05f --- /dev/null +++ b/telemetry-worker/README.md @@ -0,0 +1,122 @@ +# stackcanvas telemetry collector + +The receiving end of stackcanvas' opt-in, privacy-credible product +telemetry. Source-in-repo per the design in +[`docs/SPEC.md`](../docs/SPEC.md)'s "Telemetry, CI matrix, release & +registry engineering" chapter; the user-facing contract (what's sent, when, +how to opt out) lives in [`TELEMETRY.md`](../TELEMETRY.md) at the repo root. + +This package is **deliberately not part of the pnpm workspace** and is never +published to npm — it's a standalone Cloudflare Worker with its own +`package.json`, installed and tested with plain `npm`, not `pnpm`. + +Storage: **Workers Analytics Engine** for aggregate counters (~90-day +retention) + an **R2** NDJSON mirror as the long-term system of record — no +D1 database, per the chapter's explicit transport decision. See +[`schema.sql`](./schema.sql) for the row layout and the week-2 reopen query. + +## Local development + +```bash +npm install +npm run typecheck +npm test # 29 vitest cases, no network, no Cloudflare tooling required +``` + +Tests fake the `EVENTS` (Analytics Engine) and `BUCKET` (R2) bindings with +plain spies — no `wrangler dev`, no miniflare, no workerd binary download. +See `vitest.config.ts` and `src/index.test.ts` for why that's sufficient. + +## Founder deploy steps + +Everything below runs from this directory (`telemetry-worker/`). **Two of +these steps are manual and cannot be scripted or done in CI**, flagged +explicitly: + +1. **[MANUAL] Cloudflare authentication.** + + ```bash + npx wrangler@4 login + ``` + + Opens a browser to authorize the CLI against your Cloudflare account. + Confirm the right account with: + + ```bash + npx wrangler@4 whoami + ``` + + Copy the Account ID it prints into `wrangler.toml`'s commented-out + `account_id = "..."` line (uncomment it). + +2. Create the R2 bucket (one-time; Analytics Engine needs no equivalent — + its dataset is created implicitly by the first write): + + ```bash + npx wrangler@4 r2 bucket create stackcanvas-telemetry-mirror + ``` + +3. **[MANUAL] DNS for `t.stackcanvas.dev`.** `wrangler.toml`'s `[[routes]]` + block tells *Cloudflare* which Worker answers for that host once the zone + exists and points here — it does not create the DNS record itself. In the + Cloudflare dashboard for the `stackcanvas.dev` zone: add a `t` record + (CNAME or a proxied A/AAAA record, orange-clouded) so the hostname is + served by Cloudflare, then attach the custom domain to this Worker (the + dashboard does this automatically once `wrangler deploy` has run once and + the route is picked up, or do it manually under Workers & Pages > your + worker > Settings > Domains & Routes > Custom Domains). + +4. Deploy: + + ```bash + npm run deploy # npx wrangler@4 deploy + ``` + + No CI secret sprawl on purpose — this is a founder-run command, not part + of `ci.yml`/`release.yml` (see docs/SPEC.md: "Deployed manually by + founder via `wrangler deploy`"). + +5. Verify: + + ```bash + # Before DNS is live, or to sanity-check the deploy directly: + curl -i https://stackcanvas-telemetry..workers.dev/health + # -> HTTP/1.1 200 OK {"status":"ok"} + + # After DNS + custom domain are attached: + curl -i https://t.stackcanvas.dev/health + # -> HTTP/1.1 200 OK {"status":"ok"} + + curl -i -X POST https://t.stackcanvas.dev/e \ + -H 'content-type: application/json' \ + -d '{"schema":1,"anon_id":"00000000-0000-4000-8000-000000000000","day":"2026-07-12","app_version":"0.1.0","platform":"darwin","node_major":22,"payload":{"event":"install"}}' + # -> HTTP/1.1 204 No Content + + curl -i -X POST https://t.stackcanvas.dev/e -d 'not json' + # -> HTTP/1.1 400 Bad Request + ``` + + Then confirm the row landed: query Analytics Engine's SQL API (see + `schema.sql` §3a) or list the R2 bucket: + + ```bash + npx wrangler@4 r2 object get stackcanvas-telemetry-mirror/events/2026-07-12/.ndjson + ``` + +## Routes + +| Route | Behavior | +|---|---| +| `POST /e` | Validate the envelope against the hard allowlist (schema version, envelope keys, event name, per-event payload keys, UUID-shaped `anon_id`, `YYYY-MM-DD` `day`, counters capped 0-50) and body size (`>4KB` → `413`). Valid → write to Analytics Engine + mirror to R2, respond `204`. Invalid → `400`, nothing stored. Storage failures (AE or R2) never surface as an error response — see "Error handling" in the chapter. | +| `GET /health` | Always `200 {"status":"ok"}` — liveness check, no bindings touched. | +| everything else | `404` — including `GET /e` (only `POST` is handled) and any CORS preflight `OPTIONS` request. There is no browser client for this endpoint (only stackcanvas' own server-side `TelemetryClient.emit()`), so no CORS headers are ever set and preflights simply hit the unmatched-route case. | + +## What's never done here + +- No cookies, no reading `cf-connecting-ip` — the worker never sees or + stores the caller's IP. +- No CORS — closed by construction (see routing table above). +- No retries, no queue on the collector side — the client + (`TelemetryClient.emit()` in `packages/server/src/telemetry.ts`) is + already fire-and-forget with a 3s timeout, so a slow or down collector + never blocks or breaks stackcanvas itself. diff --git a/telemetry-worker/package-lock.json b/telemetry-worker/package-lock.json new file mode 100644 index 0000000..da5f934 --- /dev/null +++ b/telemetry-worker/package-lock.json @@ -0,0 +1,1608 @@ +{ + "name": "stackcanvas-telemetry-worker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stackcanvas-telemetry-worker", + "version": "0.1.0", + "devDependencies": { + "@cloudflare/workers-types": "^5.20260713.1", + "typescript": "^5.5.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260713.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260713.1.tgz", + "integrity": "sha512-4YZE9YIzr3iP9eRUV4ekZozLLsYQFFKxmMJfAcsteCuzKJ7RJrLqcCxm1aMzTE9uFcy+D6A89WE+cjJadF2c9g==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.18", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", + "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/telemetry-worker/package.json b/telemetry-worker/package.json new file mode 100644 index 0000000..7bade88 --- /dev/null +++ b/telemetry-worker/package.json @@ -0,0 +1,18 @@ +{ + "name": "stackcanvas-telemetry-worker", + "private": true, + "version": "0.1.0", + "description": "Collector for stackcanvas's opt-in product telemetry. Deliberately NOT part of the pnpm workspace or the published stackcanvas npm package — this is a standalone Cloudflare Worker with its own dependency graph, installed/tested with plain npm. See docs/SPEC.md \"Telemetry, CI matrix, release & registry engineering\" chapter and TELEMETRY.md at the repo root.", + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "dev": "npx wrangler@4 dev", + "deploy": "npx wrangler@4 deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260713.1", + "typescript": "^5.5.0", + "vitest": "^3.0.0" + } +} diff --git a/telemetry-worker/schema.sql b/telemetry-worker/schema.sql new file mode 100644 index 0000000..25fa01e --- /dev/null +++ b/telemetry-worker/schema.sql @@ -0,0 +1,103 @@ +-- stackcanvas telemetry collector — storage layout + week-2 reopen query. +-- +-- NOTE ON STORAGE ENGINE: docs/SPEC.md's Telemetry chapter ("Transport +-- decision — PostHog free tier vs Cloudflare Worker") specifies Workers +-- Analytics Engine + an R2 NDJSON mirror, not D1 — so there is no D1 +-- database or CREATE TABLE for this collector. Analytics Engine has no +-- migration step either: the dataset named in wrangler.toml's +-- [[analytics_engine_datasets]] binding is created implicitly by the first +-- `env.EVENTS.writeDataPoint()` call. This file instead documents (a) the +-- fixed-width blob/double column layout src/index.ts's writeAnalytics() +-- writes into that dataset, and (b) the week-2 reopen query in both forms +-- the chapter anticipates: live against Analytics Engine's SQL API, and as +-- a fallback against the R2 NDJSON mirror once AE's ~90-day retention rolls +-- off past the gate window (see docs/SPEC.md, Risks: "Analytics Engine +-- ~90-day retention"). +-- +-- ============================================================================ +-- 1. Analytics Engine row layout (one row per accepted event) +-- ============================================================================ +-- +-- blob1 = payload.event ('install' | 'canvas_opened' | 'intent_sent' | 'scan_run' | 'drift_opened') +-- blob2 = anon_id (UUIDv4) +-- blob3 = day ('YYYY-MM-DD', UTC) +-- blob4 = app_version +-- blob5 = platform ('darwin' | 'linux' | 'win32' | 'other') +-- blob6 = payload.nodes_bucket ('' if the event has no nodes_bucket) +-- blob7 = payload.tf_bin ('' unless event = 'canvas_opened') +-- blob8 = payload.provider ('' unless event = 'scan_run') +-- double1 = node_major +-- double2 = payload.add (0 unless event = 'intent_sent') +-- double3 = payload.modify (0 unless event = 'intent_sent') +-- double4 = payload.remove (0 unless event = 'intent_sent') +-- double5 = payload.adopt (0 unless event = 'intent_sent') +-- double6 = payload.investigate (0 unless event = 'intent_sent') +-- index1 = payload.event (sampling index, per AE's one-index limit) +-- +-- ============================================================================ +-- 2. R2 mirror layout +-- ============================================================================ +-- +-- One object per accepted event: `events//.ndjson`, body = +-- the exact raw envelope JSON + a trailing newline. Concatenating everything +-- under `events//` yields a valid NDJSON file for that day. +-- +-- ============================================================================ +-- 3. Week-2 reopen rate — the gate metric +-- "distinct installs with canvas_opened on >=1 day in days 8-14 after +-- install / installs" (docs/SPEC.md, this chapter's Design section, and +-- the client-side note in packages/server/src/telemetry.ts: "Week-2 +-- reopen is not a client event — it is derived server-side as distinct +-- anon_ids with a canvas_opened between install.day + 7 and +-- install.day + 14"). +-- ============================================================================ + +-- 3a. Live query, Analytics Engine SQL API +-- (https://developers.cloudflare.com/analytics/analytics-engine/sql-api/) +-- Table name = the `dataset` value from wrangler.toml, not the binding +-- name: `stackcanvas_telemetry`. Run via: +-- curl -s "https://api.cloudflare.com/client/v4/accounts//analytics_engine/sql" \ +-- -H "Authorization: Bearer " --data-binary @this-query.sql +WITH installs AS ( + SELECT blob2 AS anon_id, toDate(blob3) AS install_day + FROM stackcanvas_telemetry + WHERE blob1 = 'install' +), +reopens AS ( + SELECT DISTINCT i.anon_id + FROM stackcanvas_telemetry co + INNER JOIN installs i ON co.blob2 = i.anon_id + WHERE co.blob1 = 'canvas_opened' + AND toDate(co.blob3) BETWEEN i.install_day + 8 AND i.install_day + 14 +) +SELECT + (SELECT count() FROM reopens) AS week2_reopens, + (SELECT count(DISTINCT anon_id) FROM installs) AS total_installs, + (SELECT count() FROM reopens) * 1.0 + / NULLIF((SELECT count(DISTINCT anon_id) FROM installs), 0) AS week2_reopen_rate; + +-- 3b. Fallback query, DuckDB over the R2 NDJSON mirror +-- (for when the gate window has moved past Analytics Engine's ~90-day +-- retention, or for an offline/local audit). Sync the mirror down first, +-- e.g. `rclone sync r2:stackcanvas-telemetry-mirror ./mirror` with an R2 +-- rclone remote, then run this with `duckdb`: +WITH events AS ( + SELECT * FROM read_ndjson_auto('mirror/events/*/*.ndjson') +), +installs AS ( + SELECT anon_id, day::DATE AS install_day + FROM events + WHERE payload.event = 'install' +), +reopens AS ( + SELECT DISTINCT e.anon_id + FROM events e + INNER JOIN installs i USING (anon_id) + WHERE e.payload.event = 'canvas_opened' + AND e.day::DATE BETWEEN i.install_day + 8 AND i.install_day + 14 +) +SELECT + (SELECT count(*) FROM reopens) AS week2_reopens, + (SELECT count(DISTINCT anon_id) FROM installs) AS total_installs, + (SELECT count(*) FROM reopens)::DOUBLE + / NULLIF((SELECT count(DISTINCT anon_id) FROM installs), 0) AS week2_reopen_rate; diff --git a/telemetry-worker/src/index.test.ts b/telemetry-worker/src/index.test.ts new file mode 100644 index 0000000..53a781f --- /dev/null +++ b/telemetry-worker/src/index.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from 'vitest' +import worker, { type Env } from './index.js' + +// Plain-vitest style: no miniflare / @cloudflare/vitest-pool-workers. The +// worker's `fetch` handler only touches standard Web APIs (Request, +// Response, URL, crypto.randomUUID) plus the two bindings on Env, both of +// which are trivially faked below — see vitest.config.ts for why this stays +// offline-safe. `ctx` is unused by the handler, so a minimal stub suffices. + +const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext + +function makeEnv(overrides: Partial = {}): Env & { writeDataPoint: ReturnType; put: ReturnType } { + const writeDataPoint = vi.fn() + const put = vi.fn().mockResolvedValue(undefined) + return { + EVENTS: { writeDataPoint }, + BUCKET: { put }, + writeDataPoint, + put, + ...overrides, + } as Env & { writeDataPoint: ReturnType; put: ReturnType } +} + +function post(body: unknown, headers: Record = {}): Request { + const raw = typeof body === 'string' ? body : JSON.stringify(body) + return new Request('https://t.stackcanvas.dev/e', { + method: 'POST', + body: raw, + headers: { 'content-type': 'application/json', ...headers }, + }) +} + +function baseEnvelope(payload: unknown) { + return { + schema: 1, + anon_id: '1c9f6a8e-1234-4a1b-8c1d-abcdef123456', + day: '2026-07-12', + app_version: '0.1.0', + platform: 'darwin', + node_major: 22, + payload, + } +} + +const validPayloads: unknown[] = [ + { event: 'install' }, + { event: 'canvas_opened', nodes_bucket: '1-10', tf_bin: 'terraform' }, + { event: 'intent_sent', add: 3, modify: 0, remove: 1, adopt: 0, investigate: 0 }, + { event: 'scan_run', provider: 'aws', nodes_bucket: '11-50' }, + { event: 'drift_opened', nodes_bucket: '0' }, +] + +describe('GET /health', () => { + it('returns 200', async () => { + const res = await worker.fetch(new Request('https://t.stackcanvas.dev/health'), makeEnv(), ctx) + expect(res.status).toBe(200) + }) + + it('POST /health is not found', async () => { + const res = await worker.fetch(new Request('https://t.stackcanvas.dev/health', { method: 'POST' }), makeEnv(), ctx) + expect(res.status).toBe(404) + }) +}) + +describe('routing', () => { + it('GET /e is 404 (only POST is handled)', async () => { + const res = await worker.fetch(new Request('https://t.stackcanvas.dev/e'), makeEnv(), ctx) + expect(res.status).toBe(404) + }) + + it('unknown path is 404', async () => { + const res = await worker.fetch(new Request('https://t.stackcanvas.dev/whatever'), makeEnv(), ctx) + expect(res.status).toBe(404) + }) + + it('OPTIONS /e (CORS preflight) is 404, no CORS headers set', async () => { + const res = await worker.fetch(new Request('https://t.stackcanvas.dev/e', { method: 'OPTIONS' }), makeEnv(), ctx) + expect(res.status).toBe(404) + expect(res.headers.get('access-control-allow-origin')).toBeNull() + }) +}) + +describe('POST /e — valid events', () => { + it.each(validPayloads)('stores %o and returns 204', async (payload) => { + const env = makeEnv() + const res = await worker.fetch(post(baseEnvelope(payload)), env, ctx) + + expect(res.status).toBe(204) + expect(await res.text()).toBe('') + expect(env.writeDataPoint).toHaveBeenCalledTimes(1) + expect(env.put).toHaveBeenCalledTimes(1) + + const [key, body] = env.put.mock.calls[0] as [string, string] + expect(key).toMatch(/^events\/2026-07-12\/[0-9a-f-]+\.ndjson$/) + expect(JSON.parse(body.trim())).toMatchObject(baseEnvelope(payload)) + }) +}) + +describe('POST /e — validation', () => { + it('wrong schema version -> 400', async () => { + const env = makeEnv() + const body = baseEnvelope({ event: 'install' }) + const res = await worker.fetch(post({ ...body, schema: 2 }), env, ctx) + expect(res.status).toBe(400) + expect(env.writeDataPoint).not.toHaveBeenCalled() + expect(env.put).not.toHaveBeenCalled() + }) + + it('missing schema -> 400', async () => { + const body = baseEnvelope({ event: 'install' }) as Record + delete body.schema + const res = await worker.fetch(post(body), makeEnv(), ctx) + expect(res.status).toBe(400) + }) + + it('unknown event -> 400', async () => { + const env = makeEnv() + const res = await worker.fetch(post(baseEnvelope({ event: 'totally_made_up' })), env, ctx) + expect(res.status).toBe(400) + expect(env.writeDataPoint).not.toHaveBeenCalled() + expect(env.put).not.toHaveBeenCalled() + }) + + it('unknown payload key -> 400, nothing stored', async () => { + const env = makeEnv() + const res = await worker.fetch( + post(baseEnvelope({ event: 'drift_opened', nodes_bucket: '0', resource_name: 'sneaky' })), + env, + ctx, + ) + expect(res.status).toBe(400) + expect(env.writeDataPoint).not.toHaveBeenCalled() + expect(env.put).not.toHaveBeenCalled() + }) + + it('unknown top-level envelope key -> 400', async () => { + const body = { ...baseEnvelope({ event: 'install' }), extra_field: 'nope' } + const res = await worker.fetch(post(body), makeEnv(), ctx) + expect(res.status).toBe(400) + }) + + it('anon_id not UUID-shaped -> 400', async () => { + const body = baseEnvelope({ event: 'install' }) + const res = await worker.fetch(post({ ...body, anon_id: 'not-a-uuid' }), makeEnv(), ctx) + expect(res.status).toBe(400) + }) + + it('malformed day -> 400', async () => { + const body = baseEnvelope({ event: 'install' }) + const res = await worker.fetch(post({ ...body, day: '07/12/2026' }), makeEnv(), ctx) + expect(res.status).toBe(400) + }) + + it('invalid platform -> 400', async () => { + const body = baseEnvelope({ event: 'install' }) + const res = await worker.fetch(post({ ...body, platform: 'plan9' }), makeEnv(), ctx) + expect(res.status).toBe(400) + }) + + it('intent_sent counter over 50 -> 400', async () => { + const res = await worker.fetch( + post(baseEnvelope({ event: 'intent_sent', add: 51, modify: 0, remove: 0, adopt: 0, investigate: 0 })), + makeEnv(), + ctx, + ) + expect(res.status).toBe(400) + }) + + it('intent_sent negative counter -> 400', async () => { + const res = await worker.fetch( + post(baseEnvelope({ event: 'intent_sent', add: -1, modify: 0, remove: 0, adopt: 0, investigate: 0 })), + makeEnv(), + ctx, + ) + expect(res.status).toBe(400) + }) + + it('invalid nodes_bucket -> 400', async () => { + const res = await worker.fetch(post(baseEnvelope({ event: 'drift_opened', nodes_bucket: '9999' })), makeEnv(), ctx) + expect(res.status).toBe(400) + }) + + it('invalid JSON body -> 400', async () => { + const res = await worker.fetch(post('{not json'), makeEnv(), ctx) + expect(res.status).toBe(400) + }) + + it('non-object body -> 400', async () => { + const res = await worker.fetch(post('"just a string"'), makeEnv(), ctx) + expect(res.status).toBe(400) + }) +}) + +describe('POST /e — size limit', () => { + it('oversized body (>4KB) -> 413, even if also invalid JSON', async () => { + const huge = 'x'.repeat(5000) + const res = await worker.fetch(post(huge), makeEnv(), ctx) + expect(res.status).toBe(413) + }) + + it('oversized body reported honestly via Content-Length short-circuit -> 413', async () => { + const bigEnvelope = baseEnvelope({ event: 'install', padding: 'y'.repeat(5000) }) + const res = await worker.fetch(post(bigEnvelope), makeEnv(), ctx) + expect(res.status).toBe(413) + }) + + it('body right at the 4KB boundary is accepted', async () => { + // Pad app_version so the serialized envelope lands under 4096 bytes but close to it. + const env = makeEnv() + const body = baseEnvelope({ event: 'install' }) + const res = await worker.fetch(post(body), env, ctx) + expect(res.status).toBe(204) + }) +}) + +describe('POST /e — storage failures never surface to the caller', () => { + it('Analytics Engine write throwing still returns 204 and still attempts R2', async () => { + const env = makeEnv() + env.writeDataPoint.mockImplementation(() => { + throw new Error('AE unavailable') + }) + + const res = await worker.fetch(post(baseEnvelope({ event: 'install' })), env, ctx) + + expect(res.status).toBe(204) + expect(env.put).toHaveBeenCalledTimes(1) + }) + + it('R2 put rejecting still returns 204', async () => { + const env = makeEnv() + env.put.mockRejectedValue(new Error('R2 unavailable')) + + const res = await worker.fetch(post(baseEnvelope({ event: 'install' })), env, ctx) + + expect(res.status).toBe(204) + }) + + it('both AE and R2 failing still returns 204', async () => { + const env = makeEnv() + env.writeDataPoint.mockImplementation(() => { + throw new Error('AE unavailable') + }) + env.put.mockRejectedValue(new Error('R2 unavailable')) + + const res = await worker.fetch(post(baseEnvelope({ event: 'install' })), env, ctx) + + expect(res.status).toBe(204) + }) +}) diff --git a/telemetry-worker/src/index.ts b/telemetry-worker/src/index.ts new file mode 100644 index 0000000..b5e9155 --- /dev/null +++ b/telemetry-worker/src/index.ts @@ -0,0 +1,317 @@ +// stackcanvas telemetry collector — M1-7 / issue #11. +// +// See docs/SPEC.md, "Telemetry, CI matrix, release & registry engineering" +// chapter, §Design/1 ("Transport decision") for the design this implements, +// and the repo-root TELEMETRY.md for the user-facing contract. This worker +// is the *only* vendor-side endpoint stackcanvas' own code ever calls out to. +// +// Routes: +// POST /e validate + store an envelope, 204 on success +// GET /health liveness check, always 200 +// * * 404 (this includes GET /e, and any CORS preflight OPTIONS +// request — there is no browser client for this endpoint, +// only server-side `fetch` calls from stackcanvas itself, +// so no CORS headers are ever set and preflights are simply +// unmatched routes) +// +// Storage: Workers Analytics Engine (aggregate counters, ~90-day retention) +// + a raw-envelope NDJSON mirror in R2 (long-term system of record), per the +// chapter's explicit "PostHog vs Cloudflare Worker" transport decision. Both +// writes are best-effort — a storage failure never turns into an error +// response to the caller (see docs/SPEC.md "Error handling": "both fail → +// 204 anyway (client must never see backpressure)"). +// +// The envelope/payload shapes below intentionally duplicate (rather than +// import) packages/server/src/telemetry.ts's types: this package is NOT +// part of the pnpm workspace (see package.json) and ships as a single +// bundled Worker with no monorepo dependency, so the collector's allowlist +// must be self-contained. Keep the two in sync by hand — TELEMETRY.md is the +// document of record for the schema either side must match. + +export interface AnalyticsEngineDataset { + writeDataPoint(point: { blobs?: string[]; doubles?: number[]; indexes?: string[] }): void +} + +export interface R2Bucket { + put(key: string, value: string): Promise +} + +export interface Env { + /** Workers Analytics Engine binding — see wrangler.toml [[analytics_engine_datasets]]. */ + EVENTS: AnalyticsEngineDataset + /** R2 bucket binding for the raw NDJSON mirror — see wrangler.toml [[r2_buckets]]. */ + BUCKET: R2Bucket +} + +const MAX_BODY_BYTES = 4096 + +const NODES_BUCKETS = ['0', '1-10', '11-50', '51-200', '200+'] as const +const TF_BINS = ['terraform', 'tofu', 'unknown'] as const +const PROVIDERS = ['aws', 'gcp', 'azure', 'other'] as const +const PLATFORMS = ['darwin', 'linux', 'win32', 'other'] as const + +type NodesBucket = (typeof NODES_BUCKETS)[number] +type TfBin = (typeof TF_BINS)[number] +type Provider = (typeof PROVIDERS)[number] +type Platform = (typeof PLATFORMS)[number] + +export type TelemetryProps = + | { event: 'install' } + | { event: 'canvas_opened'; nodes_bucket: NodesBucket; tf_bin: TfBin } + | { event: 'intent_sent'; add: number; modify: number; remove: number; adopt: number; investigate: number } + | { event: 'scan_run'; provider: Provider; nodes_bucket: NodesBucket } + | { event: 'drift_opened'; nodes_bucket: NodesBucket } + +export interface TelemetryEnvelope { + schema: 1 + anon_id: string + day: string + app_version: string + platform: Platform + node_major: number + payload: TelemetryProps +} + +/** Envelope allowlist — the "hard allowlist" the chapter requires: any key + * outside this set is a 400, not a silent strip (a stripping collector + * would mask exactly the accidental-field-growth bug this schema exists to + * catch — see the client-side "envelope allowlist tripwire" test this + * mirrors, packages/server/src/telemetry.test.ts). */ +const ENVELOPE_KEYS = ['schema', 'anon_id', 'day', 'app_version', 'platform', 'node_major', 'payload'] as const + +/** Per-event payload key allowlist — mirrors TelemetryProps above exactly. */ +const PAYLOAD_KEYS: Record = { + install: ['event'], + canvas_opened: ['event', 'nodes_bucket', 'tf_bin'], + intent_sent: ['event', 'add', 'modify', 'remove', 'adopt', 'investigate'], + scan_run: ['event', 'provider', 'nodes_bucket'], + drift_opened: ['event', 'nodes_bucket'], +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const DAY_RE = /^\d{4}-\d{2}-\d{2}$/ + +type ValidationResult = { ok: true; envelope: TelemetryEnvelope } | { ok: false; status: number; error: string } + +function fail(status: number, error: string): { ok: false; status: number; error: string } { + return { ok: false, status, error } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasOnlyKeys(obj: Record, allowed: readonly string[]): boolean { + return Object.keys(obj).every((key) => (allowed as readonly string[]).includes(key)) +} + +function isCappedCount(n: unknown): n is number { + return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 50 +} + +function validatePayload(payload: unknown): { ok: true; payload: TelemetryProps } | { ok: false; status: number; error: string } { + if (!isRecord(payload)) return fail(400, 'invalid payload') + + const event = payload.event + if (typeof event !== 'string' || !(event in PAYLOAD_KEYS)) return fail(400, 'unknown event') + const eventName = event as TelemetryProps['event'] + + if (!hasOnlyKeys(payload, PAYLOAD_KEYS[eventName])) return fail(400, 'unknown payload field') + + switch (eventName) { + case 'install': + return { ok: true, payload: { event: 'install' } } + + case 'canvas_opened': { + const { nodes_bucket, tf_bin } = payload + if (typeof nodes_bucket !== 'string' || !(NODES_BUCKETS as readonly string[]).includes(nodes_bucket)) { + return fail(400, 'invalid nodes_bucket') + } + if (typeof tf_bin !== 'string' || !(TF_BINS as readonly string[]).includes(tf_bin)) { + return fail(400, 'invalid tf_bin') + } + return { ok: true, payload: { event: 'canvas_opened', nodes_bucket: nodes_bucket as NodesBucket, tf_bin: tf_bin as TfBin } } + } + + case 'intent_sent': { + const { add, modify, remove, adopt, investigate } = payload + if (![add, modify, remove, adopt, investigate].every(isCappedCount)) { + return fail(400, 'invalid counter (must be an integer 0-50)') + } + return { + ok: true, + payload: { + event: 'intent_sent', + add: add as number, + modify: modify as number, + remove: remove as number, + adopt: adopt as number, + investigate: investigate as number, + }, + } + } + + case 'scan_run': { + const { provider, nodes_bucket } = payload + if (typeof provider !== 'string' || !(PROVIDERS as readonly string[]).includes(provider)) { + return fail(400, 'invalid provider') + } + if (typeof nodes_bucket !== 'string' || !(NODES_BUCKETS as readonly string[]).includes(nodes_bucket)) { + return fail(400, 'invalid nodes_bucket') + } + return { ok: true, payload: { event: 'scan_run', provider: provider as Provider, nodes_bucket: nodes_bucket as NodesBucket } } + } + + case 'drift_opened': { + const { nodes_bucket } = payload + if (typeof nodes_bucket !== 'string' || !(NODES_BUCKETS as readonly string[]).includes(nodes_bucket)) { + return fail(400, 'invalid nodes_bucket') + } + return { ok: true, payload: { event: 'drift_opened', nodes_bucket: nodes_bucket as NodesBucket } } + } + } +} + +/** The hard allowlist: schema version, envelope keys, top-level field shapes, + * and (via validatePayload) the event name + its per-event payload keys. + * Anything outside this is rejected with 400 — nothing is ever silently + * stripped, so a bug that starts sending an extra field is loud, not + * quietly swallowed. */ +function validateEnvelope(body: unknown): ValidationResult { + if (!isRecord(body)) return fail(400, 'invalid envelope') + if (!hasOnlyKeys(body, ENVELOPE_KEYS)) return fail(400, 'unknown field') + + if (body.schema !== 1) return fail(400, 'unsupported schema version') + + if (typeof body.anon_id !== 'string' || !UUID_RE.test(body.anon_id)) return fail(400, 'invalid anon_id') + + if (typeof body.day !== 'string' || !DAY_RE.test(body.day) || Number.isNaN(Date.parse(body.day))) { + return fail(400, 'invalid day') + } + + if (typeof body.app_version !== 'string' || body.app_version.length === 0) { + return fail(400, 'invalid app_version') + } + + if (typeof body.platform !== 'string' || !(PLATFORMS as readonly string[]).includes(body.platform)) { + return fail(400, 'invalid platform') + } + + if (typeof body.node_major !== 'number' || !Number.isInteger(body.node_major) || body.node_major < 0) { + return fail(400, 'invalid node_major') + } + + const payloadResult = validatePayload(body.payload) + if (!payloadResult.ok) return payloadResult + + return { + ok: true, + envelope: { + schema: 1, + anon_id: body.anon_id, + day: body.day, + app_version: body.app_version, + platform: body.platform as Platform, + node_major: body.node_major, + payload: payloadResult.payload, + }, + } +} + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +/** Fixed-width Analytics Engine row so every event lands in the same column + * layout regardless of which payload shape produced it — see schema.sql + * for the blob/double index mapping and the week-2 reopen query. Never + * reads or stores the caller's IP (no `cf-connecting-ip` access) or any + * cookie. */ +function writeAnalytics(env: Env, envelope: TelemetryEnvelope): void { + const p = envelope.payload + env.EVENTS.writeDataPoint({ + blobs: [ + p.event, + envelope.anon_id, + envelope.day, + envelope.app_version, + envelope.platform, + 'nodes_bucket' in p ? p.nodes_bucket : '', + 'tf_bin' in p ? p.tf_bin : '', + 'provider' in p ? p.provider : '', + ], + doubles: [ + envelope.node_major, + 'add' in p ? p.add : 0, + 'modify' in p ? p.modify : 0, + 'remove' in p ? p.remove : 0, + 'adopt' in p ? p.adopt : 0, + 'investigate' in p ? p.investigate : 0, + ], + indexes: [p.event], + }) +} + +/** Raw-envelope mirror: one object per event, one NDJSON line each, keyed + * under the event's UTC day so `events/YYYY-MM-DD/*.ndjson` (concatenated) + * is the long-term system of record once Analytics Engine's ~90-day + * retention rolls off — see schema.sql for the duckdb-over-R2 fallback + * query. */ +async function mirrorToR2(env: Env, envelope: TelemetryEnvelope): Promise { + const key = `events/${envelope.day}/${crypto.randomUUID()}.ndjson` + await env.BUCKET.put(key, `${JSON.stringify(envelope)}\n`) +} + +async function handleEvent(request: Request, env: Env): Promise { + const contentLength = request.headers.get('content-length') + if (contentLength !== null && Number(contentLength) > MAX_BODY_BYTES) { + return json({ error: 'payload too large' }, 413) + } + + const rawBody = await request.text() + if (new TextEncoder().encode(rawBody).length > MAX_BODY_BYTES) { + return json({ error: 'payload too large' }, 413) + } + + let parsed: unknown + try { + parsed = JSON.parse(rawBody) + } catch { + return json({ error: 'invalid json' }, 400) + } + + const result = validateEnvelope(parsed) + if (!result.ok) return json({ error: result.error }, result.status) + + // Best-effort, independently: an Analytics Engine or R2 failure never + // turns into an error response — "client must never see backpressure" + // (docs/SPEC.md, Error handling). Promise.allSettled also converts any + // *synchronous* throw from writeDataPoint into a settled rejection, since + // it's wrapped in an async arrow here. + await Promise.allSettled([ + (async () => writeAnalytics(env, result.envelope))(), + mirrorToR2(env, result.envelope), + ]) + + return new Response(null, { status: 204 }) +} + +export default { + async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise { + const url = new URL(request.url) + + if (request.method === 'GET' && url.pathname === '/health') { + return json({ status: 'ok' }, 200) + } + + if (request.method === 'POST' && url.pathname === '/e') { + return handleEvent(request, env) + } + + // Everything else — including GET /e, any method on /health, unknown + // paths, and CORS preflight OPTIONS requests (no CORS headers are ever + // set anywhere in this worker; there is no browser client for /e, only + // server-side `fetch` calls from stackcanvas's own TelemetryClient). + return json({ error: 'not found' }, 404) + }, +} diff --git a/telemetry-worker/tsconfig.json b/telemetry-worker/tsconfig.json new file mode 100644 index 0000000..159d8a1 --- /dev/null +++ b/telemetry-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "types": ["@cloudflare/workers-types"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/telemetry-worker/vitest.config.ts b/telemetry-worker/vitest.config.ts new file mode 100644 index 0000000..909223a --- /dev/null +++ b/telemetry-worker/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' + +// Plain vitest, no Cloudflare test runtime (miniflare / @cloudflare/vitest-pool-workers). +// The worker's fetch handler is a pure function of (Request, Env, ExecutionContext) with +// no Workers-only globals beyond `fetch`/`Request`/`Response`/`URL`/`crypto.randomUUID`, +// all of which Node 20+ already provides — so Env (Analytics Engine dataset + R2 bucket) +// is trivially faked with plain objects in tests. This keeps the suite offline-safe (no +// workerd binary download, no `wrangler dev` needed) and fast in CI. See src/index.test.ts. +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}) diff --git a/telemetry-worker/wrangler.toml b/telemetry-worker/wrangler.toml new file mode 100644 index 0000000..e1358d8 --- /dev/null +++ b/telemetry-worker/wrangler.toml @@ -0,0 +1,39 @@ +name = "stackcanvas-telemetry" +main = "src/index.ts" +compatibility_date = "2026-07-01" + +# --- Founder: set this before your first `wrangler deploy` ----------------- +# Find it with `npx wrangler@4 whoami` after `npx wrangler@4 login`, or copy +# it from the Cloudflare dashboard (Workers & Pages > Overview > Account ID, +# right-hand sidebar). Left blank here on purpose — this repo is public. +# account_id = "REPLACE_ME" +# ----------------------------------------------------------------------------- + +# Public route. DNS for t.stackcanvas.dev (a CNAME to your workers.dev +# subdomain, or a Cloudflare-proxied record if the zone lives on Cloudflare) +# is a manual, one-time step documented in README.md — this line alone does +# not create the DNS record, it only tells the zone (once it exists) which +# Worker should answer for the path. +[[routes]] +pattern = "t.stackcanvas.dev/e" +custom_domain = true + +# Workers Analytics Engine — aggregate counters, ~90-day retention. No schema +# to migrate: the dataset is created on first `writeDataPoint()` call. See +# schema.sql for the blob/double column mapping and the week-2 reopen query. +[[analytics_engine_datasets]] +binding = "EVENTS" +dataset = "stackcanvas_telemetry" + +# R2 bucket — raw envelope mirror, one NDJSON-line object per event, keyed +# `events/YYYY-MM-DD/.ndjson`. This is the long-term system of record +# once Analytics Engine's retention window rolls off. Create it once with +# `npx wrangler@4 r2 bucket create stackcanvas-telemetry-mirror` (see +# README.md) — wrangler does not create R2 buckets implicitly. +[[r2_buckets]] +binding = "BUCKET" +bucket_name = "stackcanvas-telemetry-mirror" + +# GET /health is served by the same Worker at whatever hostname routes to it +# (the *.workers.dev subdomain wrangler prints on deploy, or t.stackcanvas.dev +# once routed) — no separate binding or route needed.