From 61269c7ae1de988d7ae68f33322cb1f68830008a Mon Sep 17 00:00:00 2001 From: kp Date: Mon, 13 Jul 2026 11:49:39 -0700 Subject: [PATCH 1/2] feat: telemetry collector on AWS Lambda, replacing the Cloudflare worker (#35) --- .github/workflows/ci.yml | 10 +- .github/workflows/release.yml | 6 +- RELEASING.md | 4 +- TELEMETRY.md | 25 +- telemetry-collector/README.md | 234 ++++ telemetry-collector/build.mjs | 42 + .../package-lock.json | 1129 +++++++++++++++-- telemetry-collector/package.json | 21 + telemetry-collector/schema.md | 109 ++ telemetry-collector/src/handler.test.ts | 184 +++ telemetry-collector/src/handler.ts | 113 ++ telemetry-collector/src/validate.test.ts | 148 +++ .../src/validate.ts | 183 +-- .../tsconfig.json | 2 +- telemetry-collector/vitest.config.ts | 13 + telemetry-worker/README.md | 122 -- telemetry-worker/package.json | 18 - telemetry-worker/schema.sql | 103 -- telemetry-worker/src/index.test.ts | 249 ---- telemetry-worker/vitest.config.ts | 14 - telemetry-worker/wrangler.toml | 39 - 21 files changed, 1942 insertions(+), 826 deletions(-) create mode 100644 telemetry-collector/README.md create mode 100644 telemetry-collector/build.mjs rename {telemetry-worker => telemetry-collector}/package-lock.json (62%) create mode 100644 telemetry-collector/package.json create mode 100644 telemetry-collector/schema.md create mode 100644 telemetry-collector/src/handler.test.ts create mode 100644 telemetry-collector/src/handler.ts create mode 100644 telemetry-collector/src/validate.test.ts rename telemetry-worker/src/index.ts => telemetry-collector/src/validate.ts (50%) rename {telemetry-worker => telemetry-collector}/tsconfig.json (87%) create mode 100644 telemetry-collector/vitest.config.ts delete mode 100644 telemetry-worker/README.md delete mode 100644 telemetry-worker/package.json delete mode 100644 telemetry-worker/schema.sql delete mode 100644 telemetry-worker/src/index.test.ts delete mode 100644 telemetry-worker/vitest.config.ts delete mode 100644 telemetry-worker/wrangler.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62a65e3..647f805 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: push: branches: [main] # Lets release.yml's `verify` job run this exact workflow (unit matrix, - # check-plugin, telemetry-worker, e2e) with zero duplicated steps — one + # check-plugin, telemetry-collector, e2e) with zero duplicated steps — one # place to keep "full CI" in sync, per docs/SPEC.md's release chapter. workflow_call: @@ -68,19 +68,19 @@ jobs: node-version: 22 - run: node scripts/check-plugin.mjs - telemetry-worker: + telemetry-collector: runs-on: ubuntu-latest defaults: run: - working-directory: telemetry-worker + working-directory: telemetry-collector 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) — + cache-dependency-path: telemetry-collector/package-lock.json + # Not part of the pnpm workspace by design (see telemetry-collector/package.json) — # its own npm install/test, independent of the root pnpm install above. - run: npm ci - run: npm run typecheck diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb5e0ce..73c2c5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,9 +40,9 @@ jobs: echo "dispatched version matches manifests: $pkg_version" # The exact same workflow every PR runs (unit matrix, check-plugin, - # telemetry-worker, e2e) — see ci.yml's `workflow_call` trigger. Reusing it - # rather than copying steps here is what keeps "full CI" from drifting out - # of sync between the two workflows. + # telemetry-collector, e2e) — see ci.yml's `workflow_call` trigger. Reusing + # it rather than copying steps here is what keeps "full CI" from drifting + # out of sync between the two workflows. verify: needs: check-version uses: ./.github/workflows/ci.yml diff --git a/RELEASING.md b/RELEASING.md index db2e5d4..6f1e9dd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -108,7 +108,7 @@ spending CI minutes on the rest. 1. **`verify`** — runs the exact same workflow every PR runs (`ci.yml`, called as a reusable workflow: unit tests on Node 20/22, `check-plugin`, - the telemetry-worker suite, Playwright e2e). No separate copy of these + the telemetry-collector suite, Playwright e2e). No separate copy of these steps to keep in sync. 2. **`build`** — `pnpm build:pkg`, then `npm pack` inside `packages/mcp`, uploads the resulting tarball as a workflow artifact named @@ -261,7 +261,7 @@ real publish: earliest interest signal is captured in public and durably (not scattered across HN/Reddit comment threads). - [ ] Telemetry is live end-to-end (`t.stackcanvas.dev` responding — see - `telemetry-worker/README.md`) *before* launch traffic hits, so the + `telemetry-collector/README.md`) *before* launch traffic hits, so the 2026-08-15 gate's numbers start accruing from day one, not from whenever someone notices the collector was never deployed. diff --git a/TELEMETRY.md b/TELEMETRY.md index a122647..6d61b5f 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -17,8 +17,8 @@ build the moment the code sends a field that isn't documented here. scans run, and drift-lens opens. No resource names, no infrastructure data, no file paths, no IPs. - **One vendor endpoint, source included.** `https://t.stackcanvas.dev/e`, a - small Cloudflare Worker whose source lives in this repo. It is the *only* - network endpoint stackcanvas' own code ever calls out to. + small AWS Lambda (behind API Gateway) whose source lives in this repo. It + is the *only* network endpoint stackcanvas' own code ever calls out to. - **Three ways to turn it off:** click "No thanks", set `DO_NOT_TRACK=1`, or set `STACKCANVAS_TELEMETRY=0`. @@ -178,17 +178,18 @@ never any resource identity. ## The collector **Endpoint:** `https://t.stackcanvas.dev/e` (`POST` only). A small -self-hosted Cloudflare Worker, source in this repo at -`telemetry-worker/worker.ts` (ships with the collector-deployment increment; -until then, consent-gated code paths in this repo are wired to POST there, -but no code path can ever reach it without an explicit "Allow" click). It +self-hosted AWS Lambda behind API Gateway, source in this repo at +[`telemetry-collector/src/handler.ts`](telemetry-collector/src/handler.ts) +(no code path can ever reach it without an explicit "Allow" click). It validates every envelope against the same hard allowlist described above (rejects unknown keys, unknown `event`, a non-UUID `anon_id`, counts over -50, or a malformed `day`), writes one row to Workers Analytics Engine and -mirrors the raw envelope as one line to an R2 bucket, and responds `204` on -success / `400` otherwise. It stores no cookies, reads no client IP, and -has CORS closed (only this repo's own server-side code calls it — never the -browser directly, see "When events fire" above). +50, or a malformed `day`), ships one record to Kinesis Data Firehose — +which batches and delivers it to S3 as the system of record, see +[`telemetry-collector/schema.md`](telemetry-collector/schema.md) — and +responds `200 {ok: true}` on success / `400`/`413` otherwise. It stores no +cookies, reads no client IP, and has CORS closed (only this repo's own +server-side code calls it — never the browser directly, see "When events +fire" above). **PostHog / third-party analytics were evaluated and rejected**: a third-party processor breaks the "no cloud backend of ours, fully @@ -228,7 +229,7 @@ Because the collector's only key is the random `anon_id` in your own associated with it: open an issue at [github.com/pshenok/stackcanvas](https://github.com/pshenok/stackcanvas) with the `anon_id` value (never anything else from the config file) and -we'll delete the matching rows from Analytics Engine and the R2 mirror. +we'll delete the matching records from the S3 mirror. ## How to verify this yourself diff --git a/telemetry-collector/README.md b/telemetry-collector/README.md new file mode 100644 index 0000000..34d2352 --- /dev/null +++ b/telemetry-collector/README.md @@ -0,0 +1,234 @@ +# stackcanvas telemetry collector + +The receiving end of stackcanvas' opt-in, privacy-credible product +telemetry. Source-in-repo, same as its predecessor; the user-facing +contract (what's sent, when, how to opt out) lives in +[`TELEMETRY.md`](../TELEMETRY.md) at the repo root. + +**This replaces `telemetry-worker/`** (a Cloudflare Worker) — issue #35 +ported the collector to AWS Lambda. The endpoint URL is unchanged: +**`https://t.stackcanvas.dev/e`**. Nothing in `packages/server/src/telemetry.ts` +(the client) or `TELEMETRY.md`'s validation contract changed; only where the +collector runs and how events are stored changed. See `src/handler.ts`'s +header comment for the one deliberate behavior change (storage failures now +surface as `500` instead of being swallowed into a `204`) and why it's safe. + +This package is **deliberately not part of the pnpm workspace** and is never +published to npm — it's a standalone AWS Lambda with its own +`package.json`, installed and tested with plain `npm`, not `pnpm`. + +Storage: **Kinesis Data Firehose -> S3**, one gzip NDJSON batch object per +delivery — see [`schema.md`](./schema.md) for the record shape, the S3 +layout, and the week-2 reopen query. + +## Local development + +```bash +npm install +npm run typecheck +npm test # vitest, no network, no AWS credentials required +``` + +Tests fake the Firehose client (`FirehoseLike`, see `src/handler.ts`) with a +plain `vi.fn()` spy — no `sam local invoke`, no LocalStack, no real +`@aws-sdk/client-firehose` call ever happens in the test suite. Validation +logic itself (`src/validate.ts`) has zero AWS imports and is tested +independently in `src/validate.test.ts`. + +## Founder deploy steps + +Everything below is **CLI-provisioned** (no Terraform/CDK stack for this +piece — five moving parts, provisioned once, matching the original worker's +"no CI secret sprawl, founder-run command" philosophy). Two steps are +manual and cannot be scripted, flagged explicitly. Replace +`` / `` / `` with your own throughout. + +1. **S3 bucket** (long-term system of record): + + ```bash + aws s3api create-bucket --bucket stackcanvas-telemetry \ + --region --create-bucket-configuration LocationConstraint= + ``` + +2. **IAM role for Firehose** (trust: `firehose.amazonaws.com`; permission: + `s3:PutObject`/`s3:PutObjectAcl`/`s3:GetBucketLocation`/`s3:ListBucket` + scoped to `arn:aws:s3:::stackcanvas-telemetry*`) — create via the console + or `aws iam create-role` + `aws iam put-role-policy` with your usual IAM + bootstrap process; the exact policy JSON is the standard + [Firehose S3 destination policy](https://docs.aws.amazon.com/firehose/latest/dev/controlling-access.html#using-iam-s3). + +3. **Firehose delivery stream**, targeting the bucket from step 1: + + ```bash + aws firehose create-delivery-stream \ + --delivery-stream-name stackcanvas-telemetry-stream \ + --delivery-stream-type DirectPut \ + --extended-s3-destination-configuration \ + 'RoleARN=arn:aws:iam:::role/firehose-stackcanvas-telemetry,BucketARN=arn:aws:s3:::stackcanvas-telemetry,Prefix=events/!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/,CompressionFormat=GZIP,BufferingHints={SizeInMBs=5,IntervalInSeconds=300}' + ``` + + **Not provisioned**: dynamic partitioning by the record's own `day` + field (see `schema.md` §3's caveat on delivery-time vs event-time + partitioning) — the default timestamp-based prefix above is good enough + at this volume and avoids the extra per-record JQ processing cost. + +4. **IAM role for the Lambda** (trust: `lambda.amazonaws.com`; permissions: + `firehose:PutRecord` scoped to the stream's ARN, plus the AWS-managed + `AWSLambdaBasicExecutionRole` for CloudWatch Logs): + + ```bash + aws iam create-role --role-name stackcanvas-telemetry-lambda \ + --assume-role-policy-document file://trust-lambda.json + aws iam attach-role-policy --role-name stackcanvas-telemetry-lambda \ + --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + aws iam put-role-policy --role-name stackcanvas-telemetry-lambda \ + --policy-name put-firehose-record --policy-document file://firehose-put-policy.json + ``` + +5. **Build and create the Lambda function**: + + ```bash + npm run build # -> dist/lambda.zip (see build.mjs) + + aws lambda create-function \ + --function-name stackcanvas-telemetry \ + --runtime nodejs22.x \ + --handler handler.handler \ + --role arn:aws:iam:::role/stackcanvas-telemetry-lambda \ + --zip-file fileb://dist/lambda.zip \ + --environment 'Variables={FIREHOSE_STREAM=stackcanvas-telemetry-stream}' \ + --timeout 5 --memory-size 128 + ``` + +6. **API Gateway HTTP API** (payload format version 2, the shape + `src/handler.ts` expects), Lambda proxy integration, both routes, and an + auto-deploying `$default` stage: + + ```bash + API_ID=$(aws apigatewayv2 create-api \ + --name stackcanvas-telemetry --protocol-type HTTP \ + --target arn:aws:lambda:::function:stackcanvas-telemetry \ + --query ApiId --output text) + + # `--target` above already wires a $default route + $default stage to the + # Lambda for a quick start; the explicit routes below make POST /e and + # GET /health the only two that exist, matching the worker's routing + # table (everything else falls through to the implicit $default route + # only if one still exists — delete it if `create-api --target` created + # one, so unmatched routes 404 from API Gateway itself rather than ever + # reaching the Lambda): + aws apigatewayv2 create-route --api-id $API_ID \ + --route-key 'POST /e' --target integrations/$(aws apigatewayv2 get-integrations --api-id $API_ID --query 'Items[0].IntegrationId' --output text) + aws apigatewayv2 create-route --api-id $API_ID \ + --route-key 'GET /health' --target integrations/$(aws apigatewayv2 get-integrations --api-id $API_ID --query 'Items[0].IntegrationId' --output text) + + aws lambda add-permission \ + --function-name stackcanvas-telemetry --statement-id apigw-invoke \ + --action lambda:InvokeFunction --principal apigateway.amazonaws.com \ + --source-arn "arn:aws:execute-api:::${API_ID}/*/*" + ``` + +7. **[MANUAL] ACM certificate for `t.stackcanvas.dev`.** Request a + DNS-validated public certificate in the **same region as the API** + (regional custom domains, not edge-optimized): + + ```bash + aws acm request-certificate --domain-name t.stackcanvas.dev \ + --validation-method DNS --region + ``` + + Add the printed CNAME validation record to the `stackcanvas.dev` zone + (Route53 or wherever it's hosted) and wait for `aws acm + describe-certificate` to show `Status: ISSUED` — this step cannot be + scripted end-to-end because DNS validation requires the zone owner to + publish a record and DNS propagation isn't instantaneous. + +8. **API Gateway custom domain + mapping**, once the cert is issued: + + ```bash + aws apigatewayv2 create-domain-name --domain-name t.stackcanvas.dev \ + --domain-name-configurations CertificateArn=arn:aws:acm:::certificate/ + + aws apigatewayv2 create-api-mapping --domain-name t.stackcanvas.dev \ + --api-id $API_ID --stage '$default' + ``` + + Note the `ApiGatewayDomainName` (a `*.execute-api..amazonaws.com` + -style regional target) the first command prints — that's what DNS needs + to point at, next. + +9. **[MANUAL] DNS for `t.stackcanvas.dev`.** In the zone (Route53 or + wherever `stackcanvas.dev` lives), point `t` at the regional domain name + from step 8: + + ```bash + # If the zone is in Route53, an ALIAS A record is preferred (no extra + # DNS lookup, works at the zone apex too — not relevant here but the + # right habit): + aws route53 change-resource-record-sets --hosted-zone-id \ + --change-batch file://alias-record.json + ``` + + where `alias-record.json` is an `UPSERT` of an `A` record with an + `AliasTarget` pointing at the API Gateway regional domain name's target + domain + hosted zone ID (`aws apigatewayv2 get-domain-name` prints both + under `DomainNameConfigurations[0].{ApiGatewayDomainName,HostedZoneId}`). + If the zone isn't in Route53, a `CNAME t -> ` at + whatever DNS provider owns the zone works the same way. This is manual + for the same reason the old worker's DNS step was: it requires access to + the zone, which isn't something this repo's CI should ever hold + credentials for. + +## Update path (code changes only — infra above is one-time) + +```bash +npm run build && aws lambda update-function-code \ + --function-name stackcanvas-telemetry --zip-file fileb://dist/lambda.zip +``` + +`npm run build` (see `build.mjs`) bundles `src/handler.ts` — and everything +it imports, including `@aws-sdk/client-firehose` — into a single +`dist/handler.mjs` with esbuild (target `node22`, ESM), then zips it to +`dist/lambda.zip` with `handler.mjs` at the zip root so the Lambda's +`handler.handler` setting keeps resolving. + +## Verify + +```bash +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 200 OK {"ok":true} + +curl -i -X POST https://t.stackcanvas.dev/e -d 'not json' +# -> HTTP/1.1 400 Bad Request +``` + +Then confirm the record landed once Firehose's buffer window flushes (see +`schema.md` for the object layout and the week-2 reopen query): + +```bash +aws s3 ls s3://stackcanvas-telemetry/events/2026/07/12/ +``` + +## Routes + +| Route | Behavior | +|---|---| +| `POST /e` | Validate the envelope against the hard allowlist (`src/validate.ts`: 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 → one `firehose:PutRecord`, respond `200 {ok:true}`. Invalid → `400`/`413`, nothing stored. A Firehose failure → `500` (see `src/handler.ts`'s header comment for why this is safe to surface, unlike the old worker's always-succeed design). | +| `GET /health` | Always `200 {"status":"ok"}` — liveness check, touches no AWS service. | +| 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 the caller's source IP into anything stored — the + handler never puts `event.requestContext.http.sourceIp` into the record. +- No CORS — closed by construction (see routing table above). +- No retries, no queue on the collector side beyond what Firehose itself + buffers before flushing to S3 — 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-collector/build.mjs b/telemetry-collector/build.mjs new file mode 100644 index 0000000..63d5b71 --- /dev/null +++ b/telemetry-collector/build.mjs @@ -0,0 +1,42 @@ +// Build script for the telemetry-collector Lambda — issue #35. +// +// Bundles src/handler.ts (and everything it imports, including +// @aws-sdk/client-firehose — see package.json's comment on why that's a +// devDependency here) into a single self-contained dist/handler.mjs with +// esbuild, then zips it into dist/lambda.zip at the zip root so +// `aws lambda update-function-code --zip-file fileb://dist/lambda.zip` +// works with handler = "handler.handler" (see README.md). +// +// Run with: npm run build + +import { build } from 'esbuild' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, rmSync } from 'node:fs' + +const outfile = 'dist/handler.mjs' +const zipfile = 'dist/lambda.zip' + +rmSync('dist', { recursive: true, force: true }) +mkdirSync('dist', { recursive: true }) + +await build({ + entryPoints: ['src/handler.ts'], + bundle: true, + platform: 'node', + target: 'node22', + format: 'esm', + outfile, + sourcemap: false, + logLevel: 'info', +}) + +if (!existsSync(outfile)) { + throw new Error(`esbuild did not produce ${outfile}`) +} + +// -j / --junk-paths: store handler.mjs at the zip root (no src/ or dist/ +// prefix inside the archive), which is what Lambda's +// "handler.handler" (file.exportedFunction) setting expects. +execFileSync('zip', ['-j', zipfile, outfile], { stdio: 'inherit' }) + +console.log(`Built ${outfile} -> ${zipfile}`) diff --git a/telemetry-worker/package-lock.json b/telemetry-collector/package-lock.json similarity index 62% rename from telemetry-worker/package-lock.json rename to telemetry-collector/package-lock.json index da5f934..e656731 100644 --- a/telemetry-worker/package-lock.json +++ b/telemetry-collector/package-lock.json @@ -1,29 +1,313 @@ { - "name": "stackcanvas-telemetry-worker", + "name": "telemetry-collector", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "stackcanvas-telemetry-worker", + "name": "telemetry-collector", "version": "0.1.0", "devDependencies": { - "@cloudflare/workers-types": "^5.20260713.1", + "@aws-sdk/client-firehose": "^3.700.0", + "@types/aws-lambda": "^8.10.145", + "@types/node": "^22.0.0", + "esbuild": "^0.25.0", "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==", + "node_modules/@aws-sdk/client-firehose": { + "version": "3.1085.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-firehose/-/client-firehose-3.1085.0.tgz", + "integrity": "sha512-hZvjOez7dFO52pfAs/f9AMvaOkYBPeAbl/NPd+Z9g/ydXCeF48/J5vgz4jUaWm1HUjaKwQFgcv/76qpp4zyMkA==", "dev": true, - "license": "MIT OR Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.975.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", + "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.0", + "@aws-sdk/xml-builder": "^3.972.34", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.2", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", + "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", + "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", + "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-login": "^3.972.63", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", + "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", + "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-ini": "^3.973.1", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", + "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", + "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/token-providers": "3.1083.0", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", + "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", + "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", + "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.0", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1083.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", + "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", + "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", + "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -38,9 +322,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -55,9 +339,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -72,9 +356,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -89,9 +373,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -106,9 +390,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -123,9 +407,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -140,9 +424,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -157,9 +441,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -174,9 +458,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -191,9 +475,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -208,9 +492,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -225,9 +509,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -242,9 +526,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -259,9 +543,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -276,9 +560,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -293,9 +577,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -310,9 +594,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -327,9 +611,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -344,9 +628,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -361,9 +645,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -378,9 +662,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -395,9 +679,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -412,9 +696,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -429,9 +713,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -446,9 +730,9 @@ } }, "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -819,6 +1103,100 @@ "win32" ] }, + "node_modules/@smithy/core": { + "version": "3.29.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.3.tgz", + "integrity": "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.8.tgz", + "integrity": "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.5.tgz", + "integrity": "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.5.tgz", + "integrity": "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.4.tgz", + "integrity": "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.3", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.162", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.162.tgz", + "integrity": "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -844,6 +1222,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -969,6 +1357,13 @@ "node": ">=12" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1042,9 +1437,9 @@ "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==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1055,32 +1450,32 @@ "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" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/estree-walker": { @@ -1224,9 +1619,9 @@ } }, "node_modules/postcss": { - "version": "8.5.18", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", - "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -1402,6 +1797,13 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1416,6 +1818,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1514,6 +1923,490 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vitest": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", diff --git a/telemetry-collector/package.json b/telemetry-collector/package.json new file mode 100644 index 0000000..6fad051 --- /dev/null +++ b/telemetry-collector/package.json @@ -0,0 +1,21 @@ +{ + "name": "telemetry-collector", + "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 AWS Lambda with its own dependency graph, installed/tested with plain npm. See TELEMETRY.md at the repo root and README.md / schema.md in this directory. Replaces telemetry-worker/ (issue #35).", + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "build": "node build.mjs" + }, + "dependencies": {}, + "devDependencies": { + "@aws-sdk/client-firehose": "^3.700.0", + "@types/aws-lambda": "^8.10.145", + "@types/node": "^22.0.0", + "esbuild": "^0.25.0", + "typescript": "^5.5.0", + "vitest": "^3.0.0" + } +} diff --git a/telemetry-collector/schema.md b/telemetry-collector/schema.md new file mode 100644 index 0000000..d4cfdc9 --- /dev/null +++ b/telemetry-collector/schema.md @@ -0,0 +1,109 @@ +# stackcanvas telemetry collector — storage layout + week-2 reopen query + +Ported from `telemetry-worker/schema.sql` (Cloudflare Worker: Workers +Analytics Engine + R2) to the AWS Lambda collector's storage: **Kinesis +Data Firehose -> S3**, no separate aggregate-query engine — one NDJSON +system of record, queried with DuckDB. + +## 1. Why Firehose -> S3, not a DB + +Same rationale as the worker's original "PostHog vs Cloudflare Worker" +transport decision (`docs/SPEC.md`, Telemetry chapter): no third-party +processor, no always-on database to operate for five low-cardinality +counters. `src/handler.ts` validates each envelope, then does one +`firehose:PutRecord` per event; Firehose buffers and batches delivery to S3 +on its own schedule (size/time-based, configured on the delivery stream — +see README.md's provisioning steps). There is no D1/Analytics-Engine +equivalent in this port: S3 + DuckDB is the entire system of record. + +## 2. Record shape (one JSON object per line, NDJSON) + +Each line `handler.ts`'s `toNdjsonLine()` sends to Firehose is the +validated envelope (see `src/validate.ts`'s `ValidatedEvent`) plus one +field the collector stamps on receipt: + +```jsonc +{ + "schema": 1, + "anon_id": "1c9f6a8e-…", // UUIDv4 + "day": "2026-07-12", // 'YYYY-MM-DD', UTC, client-supplied + "app_version": "0.1.0", + "platform": "darwin", // 'darwin' | 'linux' | 'win32' | 'other' + "node_major": 22, + "payload": { "event": "install" }, // one of the five TelemetryProps shapes + "received_at": "2026-07-12T18:03:41.552Z" // server-stamped, ISO 8601, collector-added +} +``` + +`received_at` is the one field this collector adds that the old worker's R2 +mirror didn't have (the worker relied on the R2 object key's `events//` +prefix plus Analytics Engine for freshness; Firehose-delivered S3 objects +have no per-record key, so a receipt timestamp is the only way to recover +"when did the collector actually see this" from the file alone). + +## 3. S3 layout + +``` +s3://stackcanvas-telemetry/events/YYYY/MM/DD/*.gz +``` + +Each object is a gzip-compressed batch of concatenated NDJSON lines (one or +more records — however Firehose happened to batch them), written under the +delivery stream's configured S3 prefix. **Note the partitioning caveat**: +Firehose's built-in S3 prefix (`events/!{timestamp:yyyy}/!{timestamp:MM}/!{timestamp:dd}/`) +partitions by the *delivery* timestamp (when Firehose flushed the buffer to +S3), not by the event's own `day` field — unlike the old R2 mirror, which +was keyed by `envelope.day` directly on `put()`. In practice these agree to +within Firehose's buffer window (60s–900s, whatever the delivery stream is +configured with), but a query that needs exact `day`-based partitioning +should filter on the `day` field inside the record, not rely on the S3 key. +(Dynamic partitioning by the record's own `day` field is possible with +Firehose's JQ-based dynamic partitioning feature but isn't provisioned here +— see README.md's "Not provisioned" note — since it adds cost/complexity +this collector's volume doesn't justify yet.) + +## 4. Week-2 reopen rate — the gate metric + +"Distinct installs with `canvas_opened` on >=1 day in days 8-14 after +install / installs" — same definition as the original worker's schema.sql +§3, ported to DuckDB reading over the S3 mirror (no Analytics Engine SQL +API equivalent exists on this stack, so there's only one query form here, +not two). + +```bash +# Sync the mirror down first (read-only, no need for full bucket access — +# scope the IAM policy to this prefix): +aws s3 sync s3://stackcanvas-telemetry/events ./mirror/events + +# Then run with `duckdb` (DuckDB's read_ndjson_auto transparently +# decompresses .gz by extension): +duckdb -c "$(cat <<'SQL' +WITH events AS ( + SELECT * FROM read_ndjson_auto('mirror/events/**/*.gz') +), +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; +SQL +)" +``` + +Or, without syncing down first, DuckDB's `httpfs` extension can query S3 +directly (`INSTALL httpfs; LOAD httpfs;` then read `s3://stackcanvas-telemetry/events/**/*.gz` +with credentials in the environment) — useful for an ad hoc check, but +`aws s3 sync` + local DuckDB is the reproducible form and what the gate +review should use. diff --git a/telemetry-collector/src/handler.test.ts b/telemetry-collector/src/handler.test.ts new file mode 100644 index 0000000..8094780 --- /dev/null +++ b/telemetry-collector/src/handler.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest' +import type { APIGatewayProxyEventV2 } from 'aws-lambda' +import { createHandler, type FirehoseLike } from './handler.js' + +// Ported from telemetry-worker/src/index.test.ts's routing, storage, and +// end-to-end validation cases — the parts that need an AWS-shaped request +// and a storage client, as opposed to the pure validation cases now in +// src/validate.test.ts. `FirehoseLike` is faked with a plain vi.fn(), no +// real @aws-sdk/client-firehose involvement — see handler.ts's +// `createHandler(firehose)` injection seam. + +function makeFirehose(overrides: Partial = {}): FirehoseLike & { putRecord: ReturnType } { + const putRecord = vi.fn().mockResolvedValue(undefined) + return { putRecord, ...overrides } as FirehoseLike & { putRecord: ReturnType } +} + +function makeEvent(opts: { + method: string + path: string + body?: unknown + isBase64Encoded?: boolean +}): APIGatewayProxyEventV2 { + const rawBody = opts.body === undefined ? undefined : typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body) + return { + version: '2.0', + routeKey: '$default', + rawPath: opts.path, + rawQueryString: '', + headers: { 'content-type': 'application/json' }, + requestContext: { + accountId: '123456789012', + apiId: 'testapi', + domainName: 't.stackcanvas.dev', + domainPrefix: 't', + http: { + method: opts.method, + path: opts.path, + protocol: 'HTTP/1.1', + sourceIp: '127.0.0.1', + userAgent: 'vitest', + }, + requestId: 'test-request-id', + routeKey: '$default', + stage: '$default', + time: '12/Jul/2026:00:00:00 +0000', + timeEpoch: 1_784_073_600_000, + }, + body: rawBody, + isBase64Encoded: opts.isBase64Encoded ?? false, + } as unknown as APIGatewayProxyEventV2 +} + +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 handler = createHandler(makeFirehose()) + const res = await handler(makeEvent({ method: 'GET', path: '/health' })) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body as string)).toEqual({ status: 'ok' }) + }) + + it('POST /health is not found', async () => { + const handler = createHandler(makeFirehose()) + const res = await handler(makeEvent({ method: 'POST', path: '/health' })) + expect(res.statusCode).toBe(404) + }) +}) + +describe('routing', () => { + it('GET /e is 404 (only POST is handled)', async () => { + const handler = createHandler(makeFirehose()) + const res = await handler(makeEvent({ method: 'GET', path: '/e' })) + expect(res.statusCode).toBe(404) + }) + + it('unknown path is 404', async () => { + const handler = createHandler(makeFirehose()) + const res = await handler(makeEvent({ method: 'GET', path: '/whatever' })) + expect(res.statusCode).toBe(404) + }) + + it('OPTIONS /e (CORS preflight) is 404, no CORS headers set', async () => { + const handler = createHandler(makeFirehose()) + const res = await handler(makeEvent({ method: 'OPTIONS', path: '/e' })) + expect(res.statusCode).toBe(404) + expect(res.headers?.['access-control-allow-origin']).toBeUndefined() + }) +}) + +describe('POST /e — valid events', () => { + it.each(validPayloads)('stores %o and returns 200 {ok:true}', async (payload) => { + process.env.FIREHOSE_STREAM = 'stackcanvas-telemetry-stream' + const firehose = makeFirehose() + const handler = createHandler(firehose) + const envelope = baseEnvelope(payload) + + const res = await handler(makeEvent({ method: 'POST', path: '/e', body: envelope })) + + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body as string)).toEqual({ ok: true }) + expect(firehose.putRecord).toHaveBeenCalledTimes(1) + + const [streamName, line] = firehose.putRecord.mock.calls[0] as [string, string] + expect(streamName).toBe('stackcanvas-telemetry-stream') + expect(line.endsWith('\n')).toBe(true) + const record = JSON.parse(line.trim()) + expect(record).toMatchObject(envelope) + expect(typeof record.received_at).toBe('string') + expect(Number.isNaN(Date.parse(record.received_at))).toBe(false) + }) +}) + +describe('POST /e — validation failures never reach Firehose', () => { + it('invalid envelope -> 400, nothing stored', async () => { + const firehose = makeFirehose() + const handler = createHandler(firehose) + const res = await handler( + makeEvent({ method: 'POST', path: '/e', body: baseEnvelope({ event: 'totally_made_up' }) }), + ) + expect(res.statusCode).toBe(400) + expect(firehose.putRecord).not.toHaveBeenCalled() + }) + + it('oversized body -> 413, nothing stored', async () => { + const firehose = makeFirehose() + const handler = createHandler(firehose) + const res = await handler(makeEvent({ method: 'POST', path: '/e', body: 'x'.repeat(5000) })) + expect(res.statusCode).toBe(413) + expect(firehose.putRecord).not.toHaveBeenCalled() + }) + + it('invalid JSON body -> 400, nothing stored', async () => { + const firehose = makeFirehose() + const handler = createHandler(firehose) + const res = await handler(makeEvent({ method: 'POST', path: '/e', body: '{not json' })) + expect(res.statusCode).toBe(400) + expect(firehose.putRecord).not.toHaveBeenCalled() + }) +}) + +describe('POST /e — Firehose failure', () => { + it('putRecord rejecting -> 500 (unlike the old worker, storage failures are not swallowed here — see handler.ts header comment; the client never inspects the response either way)', async () => { + process.env.FIREHOSE_STREAM = 'stackcanvas-telemetry-stream' + const firehose = makeFirehose() + firehose.putRecord.mockRejectedValue(new Error('Firehose unavailable')) + const handler = createHandler(firehose) + + const res = await handler(makeEvent({ method: 'POST', path: '/e', body: baseEnvelope({ event: 'install' }) })) + + expect(res.statusCode).toBe(500) + }) + + it('missing FIREHOSE_STREAM env -> 500, nothing stored', async () => { + const prev = process.env.FIREHOSE_STREAM + delete process.env.FIREHOSE_STREAM + const firehose = makeFirehose() + const handler = createHandler(firehose) + + const res = await handler(makeEvent({ method: 'POST', path: '/e', body: baseEnvelope({ event: 'install' }) })) + + expect(res.statusCode).toBe(500) + expect(firehose.putRecord).not.toHaveBeenCalled() + if (prev !== undefined) process.env.FIREHOSE_STREAM = prev + }) +}) diff --git a/telemetry-collector/src/handler.ts b/telemetry-collector/src/handler.ts new file mode 100644 index 0000000..551639b --- /dev/null +++ b/telemetry-collector/src/handler.ts @@ -0,0 +1,113 @@ +// stackcanvas telemetry collector — AWS Lambda handler — issue #35. +// +// Replaces telemetry-worker/ (Cloudflare Worker). See TELEMETRY.md at the +// repo root for the user-facing contract and README.md in this directory +// for the deploy path. The endpoint URL is unchanged: +// https://t.stackcanvas.dev/e (now API Gateway HTTP API -> this Lambda, +// instead of a Cloudflare route). +// +// Routes: +// POST /e validate + ship to Firehose, 200 {ok:true} on success +// GET /health liveness check, always 200, touches no AWS service +// * * 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: events are handed to Kinesis Data Firehose as one NDJSON line +// each (`putRecord`); Firehose buffers and batches delivery to S3 as the +// long-term system of record — see schema.md for the object layout and the +// week-2 reopen query. Unlike the old worker (which treated storage +// failures as best-effort and always returned 204/204-equivalent), a +// Firehose failure here surfaces as a 500: the client +// (`packages/server/src/telemetry.ts`'s `TelemetryClient.emit()`) never +// inspects the response status or body — it's a fire-and-forget +// `fetch(...).catch(() => {})` — so this is safe and gives the collector's +// own logs/metrics an honest signal instead of silently swallowing errors +// twice over. +// +// The validation logic itself (envelope shape, event + payload allowlists, +// size limit, schema version) lives in src/validate.ts, which has zero AWS +// imports and is independently unit-tested — this file is only the +// AWS-specific plumbing (API Gateway payload v2 routing + Firehose). + +import { FirehoseClient, PutRecordCommand } from '@aws-sdk/client-firehose' +import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda' +import { validateEvent, type ValidatedEvent } from './validate.js' + +/** Minimal seam around Firehose so tests can inject a spy with zero AWS SDK + * involvement (no credentials, no network) — see src/handler.test.ts. */ +export interface FirehoseLike { + putRecord(streamName: string, data: string): Promise +} + +class RealFirehose implements FirehoseLike { + private client = new FirehoseClient({}) + + async putRecord(streamName: string, data: string): Promise { + await this.client.send( + new PutRecordCommand({ + DeliveryStreamName: streamName, + Record: { Data: Buffer.from(data, 'utf8') }, + }), + ) + } +} + +function json(body: unknown, statusCode: number): APIGatewayProxyStructuredResultV2 { + return { + statusCode, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + } +} + +function decodeBody(event: APIGatewayProxyEventV2): string { + if (event.body == null) return '' + return event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body +} + +/** One NDJSON line: the validated event plus a server-stamped receipt + * timestamp — see schema.md, "Record shape". */ +function toNdjsonLine(event: ValidatedEvent): string { + return `${JSON.stringify({ ...event, received_at: new Date().toISOString() })}\n` +} + +/** Builds the Lambda handler, closing over an injectable Firehose client so + * tests never touch the real AWS SDK. `createHandler()` with no argument + * (the default export below) is what API Gateway actually invokes. */ +export function createHandler(firehose: FirehoseLike = new RealFirehose()) { + return async function handler(event: APIGatewayProxyEventV2): Promise { + const method = event.requestContext.http.method + const path = event.rawPath + + if (method === 'GET' && path === '/health') { + return json({ status: 'ok' }, 200) + } + + if (method === 'POST' && path === '/e') { + const result = validateEvent(decodeBody(event)) + if (!result.ok) return json({ error: result.error }, result.status) + + const streamName = process.env.FIREHOSE_STREAM + if (!streamName) return json({ error: 'server misconfigured' }, 500) + + try { + await firehose.putRecord(streamName, toNdjsonLine(result.event)) + } catch { + return json({ error: 'storage failed' }, 500) + } + + return json({ ok: true }, 200) + } + + // 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 handler; there is no browser client for /e, only + // server-side `fetch` calls from stackcanvas's own TelemetryClient). + return json({ error: 'not found' }, 404) + } +} + +export const handler = createHandler() diff --git a/telemetry-collector/src/validate.test.ts b/telemetry-collector/src/validate.test.ts new file mode 100644 index 0000000..bf34dc7 --- /dev/null +++ b/telemetry-collector/src/validate.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { validateEvent } from './validate.js' + +// Ported from telemetry-worker/src/index.test.ts's validation cases (the +// worker's `describe('POST /e — validation')` and +// `describe('POST /e — size limit')` blocks, plus the valid-payload cases), +// adapted to call the pure `validateEvent(body: string)` entry point +// directly instead of going through a fetch handler. The AWS-specific +// routing/storage cases live in src/handler.test.ts. + +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, + } +} + +function body(obj: unknown): string { + return typeof obj === 'string' ? obj : JSON.stringify(obj) +} + +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('validateEvent — valid envelopes', () => { + it.each(validPayloads)('accepts %o', (payload) => { + const envelope = baseEnvelope(payload) + const result = validateEvent(body(envelope)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.event).toMatchObject(envelope) + } + }) + + it('body right at the 4KB boundary is accepted', () => { + const result = validateEvent(body(baseEnvelope({ event: 'install' }))) + expect(result.ok).toBe(true) + }) +}) + +describe('validateEvent — rejection paths', () => { + it('wrong schema version -> 400', () => { + const envelope = { ...baseEnvelope({ event: 'install' }), schema: 2 } + const result = validateEvent(body(envelope)) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('missing schema -> 400', () => { + const envelope = baseEnvelope({ event: 'install' }) as Record + delete envelope.schema + const result = validateEvent(body(envelope)) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('unknown event -> 400', () => { + const result = validateEvent(body(baseEnvelope({ event: 'totally_made_up' }))) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('unknown payload key -> 400', () => { + const result = validateEvent( + body(baseEnvelope({ event: 'drift_opened', nodes_bucket: '0', resource_name: 'sneaky' })), + ) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('unknown top-level envelope key -> 400', () => { + const envelope = { ...baseEnvelope({ event: 'install' }), extra_field: 'nope' } + const result = validateEvent(body(envelope)) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('anon_id not UUID-shaped -> 400', () => { + const envelope = { ...baseEnvelope({ event: 'install' }), anon_id: 'not-a-uuid' } + const result = validateEvent(body(envelope)) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('malformed day -> 400', () => { + const envelope = { ...baseEnvelope({ event: 'install' }), day: '07/12/2026' } + const result = validateEvent(body(envelope)) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('invalid platform -> 400', () => { + const envelope = { ...baseEnvelope({ event: 'install' }), platform: 'plan9' } + const result = validateEvent(body(envelope)) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('intent_sent counter over 50 -> 400', () => { + const result = validateEvent( + body(baseEnvelope({ event: 'intent_sent', add: 51, modify: 0, remove: 0, adopt: 0, investigate: 0 })), + ) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('intent_sent negative counter -> 400', () => { + const result = validateEvent( + body(baseEnvelope({ event: 'intent_sent', add: -1, modify: 0, remove: 0, adopt: 0, investigate: 0 })), + ) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('invalid nodes_bucket -> 400', () => { + const result = validateEvent(body(baseEnvelope({ event: 'drift_opened', nodes_bucket: '9999' }))) + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('invalid JSON body -> 400', () => { + const result = validateEvent('{not json') + expect(result).toMatchObject({ ok: false, status: 400 }) + }) + + it('non-object body -> 400', () => { + const result = validateEvent('"just a string"') + expect(result).toMatchObject({ ok: false, status: 400 }) + }) +}) + +describe('validateEvent — size limit', () => { + it('oversized body (>4KB) -> 413, even if also invalid JSON', () => { + const huge = 'x'.repeat(5000) + const result = validateEvent(huge) + expect(result).toMatchObject({ ok: false, status: 413 }) + }) + + it('oversized but otherwise well-formed envelope -> 413', () => { + // Ported from the worker's "reported honestly via Content-Length + // short-circuit" case. There is no separate header short-circuit here + // (API Gateway hands Lambda the fully-buffered body already), but the + // same oversized, validly-shaped envelope must still be rejected on + // size before any field-level validation runs. + const bigEnvelope = baseEnvelope({ event: 'install', padding: 'y'.repeat(5000) }) + const result = validateEvent(body(bigEnvelope)) + expect(result).toMatchObject({ ok: false, status: 413 }) + }) +}) diff --git a/telemetry-worker/src/index.ts b/telemetry-collector/src/validate.ts similarity index 50% rename from telemetry-worker/src/index.ts rename to telemetry-collector/src/validate.ts index b5e9155..6ab02d0 100644 --- a/telemetry-worker/src/index.ts +++ b/telemetry-collector/src/validate.ts @@ -1,48 +1,20 @@ -// stackcanvas telemetry collector — M1-7 / issue #11. +// stackcanvas telemetry collector — event validation — issue #35. // -// 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)"). +// Pure validation module, ported from telemetry-worker/src/index.ts's +// `validateEnvelope`/`validatePayload` as part of the Cloudflare Worker -> +// AWS Lambda migration. Zero AWS imports on purpose: this module only +// touches plain JS/TS (JSON.parse, RegExp, TextEncoder) so it is fully +// unit-testable with no Lambda runtime, no API Gateway event shape, no +// network. See src/handler.ts for the Lambda-specific wiring (routing, +// Firehose) that calls into this. // // 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 +// bundled Lambda 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 @@ -62,7 +34,11 @@ export type TelemetryProps = | { event: 'scan_run'; provider: Provider; nodes_bucket: NodesBucket } | { event: 'drift_opened'; nodes_bucket: NodesBucket } -export interface TelemetryEnvelope { +/** A validated envelope, ready to be stamped with `received_at` and shipped + * to Firehose by src/handler.ts. Same shape as the old worker's + * `TelemetryEnvelope` — renamed to `ValidatedEvent` to match this module's + * `validateEvent()` entry point. */ +export interface ValidatedEvent { schema: 1 anon_id: string day: string @@ -72,11 +48,11 @@ export interface TelemetryEnvelope { 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). */ +/** Envelope allowlist — the "hard allowlist" the original design 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. */ @@ -91,7 +67,7 @@ const PAYLOAD_KEYS: Record = { 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 } +export type ValidateResult = { ok: true; event: ValidatedEvent } | { ok: false; status: number; error: string } function fail(status: number, error: string): { ok: false; status: number; error: string } { return { ok: false, status, error } @@ -172,12 +148,14 @@ function validatePayload(payload: unknown): { ok: true; payload: TelemetryProps } } -/** 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 { +/** The hard allowlist: 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. Schema-version and size checks happen one level up, in + * `validateEvent()`, since they apply before this function ever sees an + * object. */ +function validateEnvelope(body: unknown): ValidateResult { if (!isRecord(body)) return fail(400, 'invalid envelope') if (!hasOnlyKeys(body, ENVELOPE_KEYS)) return fail(400, 'unknown field') @@ -206,7 +184,7 @@ function validateEnvelope(body: unknown): ValidationResult { return { ok: true, - envelope: { + event: { schema: 1, anon_id: body.anon_id, day: body.day, @@ -218,100 +196,25 @@ function validateEnvelope(body: unknown): ValidationResult { } } -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) +/** Entry point: validates a raw request body string end to end — size, + * JSON well-formedness, then the envelope/payload allowlist above. + * + * Size is checked on the decoded body string itself (there is no + * Content-Length-header short-circuit here, unlike the old Cloudflare + * Worker: API Gateway has already buffered the whole body into + * `event.body` before Lambda ever runs, so there is no earlier point at + * which this module could reject a request without reading the body). */ +export function validateEvent(body: string): ValidateResult { + if (new TextEncoder().encode(body).length > MAX_BODY_BYTES) { + return fail(413, 'payload too large') } let parsed: unknown try { - parsed = JSON.parse(rawBody) + parsed = JSON.parse(body) } catch { - return json({ error: 'invalid json' }, 400) + return fail(400, 'invalid json') } - 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) - }, + return validateEnvelope(parsed) } diff --git a/telemetry-worker/tsconfig.json b/telemetry-collector/tsconfig.json similarity index 87% rename from telemetry-worker/tsconfig.json rename to telemetry-collector/tsconfig.json index 159d8a1..ade2da6 100644 --- a/telemetry-worker/tsconfig.json +++ b/telemetry-collector/tsconfig.json @@ -8,7 +8,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noUncheckedIndexedAccess": true, - "types": ["@cloudflare/workers-types"], + "types": ["node", "aws-lambda"], "noEmit": true }, "include": ["src"] diff --git a/telemetry-collector/vitest.config.ts b/telemetry-collector/vitest.config.ts new file mode 100644 index 0000000..f8e87ad --- /dev/null +++ b/telemetry-collector/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +// Plain vitest, no AWS SAM/Lambda-local runtime. src/validate.ts is pure +// TS/JS with zero AWS imports; src/handler.ts takes its Firehose client as +// an injected `FirehoseLike` (see createHandler()), so tests fake it with a +// plain vi.fn() spy instead of the real @aws-sdk/client-firehose — no +// credentials, no network, no `sam local invoke`. See src/handler.test.ts. +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}) diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md deleted file mode 100644 index e81e05f..0000000 --- a/telemetry-worker/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# 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.json b/telemetry-worker/package.json deleted file mode 100644 index 7bade88..0000000 --- a/telemetry-worker/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 25fa01e..0000000 --- a/telemetry-worker/schema.sql +++ /dev/null @@ -1,103 +0,0 @@ --- 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 deleted file mode 100644 index 53a781f..0000000 --- a/telemetry-worker/src/index.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -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/vitest.config.ts b/telemetry-worker/vitest.config.ts deleted file mode 100644 index 909223a..0000000 --- a/telemetry-worker/vitest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index e1358d8..0000000 --- a/telemetry-worker/wrangler.toml +++ /dev/null @@ -1,39 +0,0 @@ -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. From e45b8359e480ff82a8a49622a3d8b8863035f773 Mon Sep 17 00:00:00 2001 From: kp Date: Mon, 13 Jul 2026 12:07:38 -0700 Subject: [PATCH 2/2] fix(collector): createRequire banner for CJS AWS SDK internals in the ESM bundle (#35) --- telemetry-collector/build.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/telemetry-collector/build.mjs b/telemetry-collector/build.mjs index 63d5b71..6a727a8 100644 --- a/telemetry-collector/build.mjs +++ b/telemetry-collector/build.mjs @@ -28,6 +28,12 @@ await build({ outfile, sourcemap: false, logLevel: 'info', + // The AWS SDK ships CJS internals that call require('node:https') at module + // scope; in an ESM bundle there is no `require`, so Lambda dies on init + // ("Dynamic require of node:https is not supported"). Standard shim: + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", + }, }) if (!existsSync(outfile)) {