From 6cfbf69e26f27c9d2464a07155e2ac87d0a47dbc Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 28 May 2026 04:44:33 -0700 Subject: [PATCH] v0.15.0 post-correction: @amplitude/unified + session replay + overlay binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-Session-L correction to the v0.15.0 release that the dispatched subagent (Session ξ) shipped. ξ was working from a pre-vendor brief that specified @amplitude/analytics-browser and treated the API key as a secret via `flotilla secret set`. Operator subsequently provisioned the Amplitude project, surfaced the vendor's recommended installation prompt, and confirmed the key value. Three downstream changes: - Package: swap @amplitude/analytics-browser → @amplitude/unified (analytics + session replay in one install; vendor-recommended). - Init call: `amplitude.init(KEY, undefined, { defaultTracking: false })` becomes `amplitude.initAll(KEY, { analytics: { autocapture: true }, sessionReplay: { sampleRate: 1 } })`. Vendor's exact installation- wizard shape; gates remain on the v0.13.0 consent banner. - Binding: Amplitude browser keys are bundle-embedded by design (same nature as VITE_TURNSTILE_SITE_KEY from v0.12.0), so the key is public, not secret. CHANGELOG MUST step rewritten to bind via `flotilla overlay set VITE_AMPLITUDE_API_KEY=` rather than `flotilla secret set`. The roadmap row #13's "new secret: AMPLITUDE_API_KEY" wording predated vendor consultation; the roadmap will be updated when this ships. §19.2 candidate captured in CHANGELOG: split the analytics consent toggle into a separate session-replay category (recording has a larger privacy footprint than event counters), follow-up release. Wrapper structural shape (track/identify/anonymize, queue + drain, consent-flip → setOptOut, lazy import) is unchanged from ξ's work. Event taxonomy and Login.jsx / App.jsx / Admin.jsx / etc. instrument sites are unchanged. Frontend build verified green (VITE_APP_NAME=… npm run build). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 91 +++++--- frontend/.env.example | 29 ++- frontend/package-lock.json | 401 +++++++++++++++++++++++++++++++++- frontend/package.json | 2 +- frontend/src/lib/analytics.js | 86 +++++--- 5 files changed, 524 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 335afe4..3918baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,21 +26,24 @@ that. ## 0.15.0 — 2026-05-28 **Minor — no schema migration; one new build-time env var bound via -`flotilla secret set`.** This release ships Amplitude analytics -instrumentation (roadmap item #13). The frontend gains a small -wrapper around `@amplitude/analytics-browser` that gates SDK +`flotilla overlay set`.** This release ships Amplitude Analytics + +Session Replay instrumentation (roadmap item #13). The frontend +gains a small wrapper around `@amplitude/unified` that gates SDK initialization on the v0.13.0 cookie/privacy consent — the SDK is -never loaded for visitors who have not granted analytics consent, and -a later consent flip to `denied` calls `setOptOut(true)` so events -stop firing immediately. The wrapper exposes a stable taxonomy of +never loaded for visitors who have not granted analytics consent, +no session is recorded, no network request fires; a later consent +flip to `denied` calls `setOptOut(true)` so events and session +replay stop immediately. The wrapper exposes a stable taxonomy of nine events (Page Viewed, RFC Viewed, User Signed In / Signed Out, RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin Permission Decision) wired into the existing routes, the Login flow, the propose / open-PR / discussion / PR-review surfaces, and the admin grant/revoke action. Event bodies carry only ids and enums; no free-text fields (titles, comment bodies, names, emails) are ever -sent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY` at -build time; when unset, the wrapper logs one console warning and +sent. **Session replay** records sessions at `sampleRate: 1` (100%) +— vendor-recommended default; gated by the same v0.13.0 analytics +consent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY` +at build time; when unset, the wrapper logs one console warning and no-ops so dev environments without analytics keep working. No backend events ship in this release — Amplitude SaaS holds the events, nothing lands in our DB, no migration. @@ -51,12 +54,13 @@ nothing lands in our DB, no migration. surface: `track(name, props)`, `identify({ user_id })`, `anonymize()`, the `EVENTS` taxonomy constant, and a `__resetForTests` helper. Internally lazy-imports - `@amplitude/analytics-browser` and calls `amplitude.init(API_KEY, - undefined, { defaultTracking: false })` only after consent is - granted; queues pre-init calls and drains them on init resolve; - flips `setOptOut(true)` on a granted→denied consent change. The - wrapper subscribes to `onConsentChange()` so a freshly-banner-clicked - "analytics on" flips the SDK live without a page reload. + `@amplitude/unified` and calls `amplitude.initAll(API_KEY, + { analytics: { autocapture: true }, sessionReplay: { sampleRate: 1 } })` + only after consent is granted; queues pre-init calls and drains + them on init resolve; flips `setOptOut(true)` on a granted→denied + consent change (stops both analytics events and session replay). + The wrapper subscribes to `onConsentChange()` so a freshly-banner- + clicked "analytics on" flips the SDK live without a page reload. - **Event taxonomy** wired into the app: - `Page Viewed` — fires from `App.jsx` on every route change with `path` (`location.pathname`); the location hook owns the firing @@ -86,8 +90,9 @@ nothing lands in our DB, no migration. called with `String(viewer.id)`. The sign-out gesture calls `anonymize()` before the nav. No email, display name, or other PII is passed through the SDK. -- **`@amplitude/analytics-browser`** dependency added to - `frontend/package.json`. Lockfile updated. +- **`@amplitude/unified`** dependency added to + `frontend/package.json` (analytics + session replay in one + install). Lockfile updated. - **`VITE_AMPLITUDE_API_KEY`** documented in `frontend/.env.example` with the secret-vs-overlay binding caveat (see below). @@ -124,40 +129,60 @@ nothing lands in our DB, no migration. this release and remains available for the next minor that needs a schema bump. -### Caveat — secret-vs-overlay binding for `AMPLITUDE_API_KEY` +### Caveat — overlay binding for `VITE_AMPLITUDE_API_KEY` Amplitude browser API keys are embedded in the frontend bundle at build time and visible to anyone with browser dev tools. They are -conventionally treated as semi-sensitive (not truly secret) and would -in principle fit `flotilla overlay set` rather than `flotilla secret -set`. The roadmap calls for `secret set` to keep -all-keys-in-Secret-Manager regularity for the deployment — that's the -choice this release follows. The matching CloudFlare Turnstile pair -(public site key via overlay, secret key via Secret Manager) is the -contrast; Amplitude only has one key so the "is it public?" question -has no separating answer at provisioning time, and the operator runs -the secret-set gesture rather than the overlay-set one. +public by design — same nature as the v0.12.0 +`VITE_TURNSTILE_SITE_KEY` (also public, also bundle-embedded, +explicitly contrasted with `CLOUDFLARE_TURNSTILE_SECRET` which is +the real secret-half of that pair). The Amplitude installation +guidance from the vendor shows the key inline as a literal string +argument to `initAll(…)`, confirming the public framing. This +release accordingly binds the value via `flotilla overlay set`, +not `flotilla secret set` — the env-var name is `VITE_AMPLITUDE_API_KEY` +(Vite-prefix convention, so the build picks it up directly without +an alias step). + +(Roadmap row #13 originally said "new secret: AMPLITUDE_API_KEY"; +that wording predated vendor consultation. Mid-Session-L the +operator provisioned the Amplitude project, surfaced the vendor's +recommended init prompt, and the binding settled as overlay. The +roadmap row will be updated to match when #13 ships.) + +### Caveat — session replay scope and consent + +This release enables Amplitude Session Replay at `sampleRate: 1` +(100% of sessions recorded for full-DOM playback). The vendor's +installation wizard recommends this default for new deployments — +maximum learning during the early phase. The v0.13.0 single +"analytics" consent toggle gates session replay together with +events, so no recording happens without explicit opt-in. A future +release **MAY** split this into a separate consent category for +session replay specifically (recording has a meaningfully larger +privacy footprint than event counters); §19.2 candidate. ### Upgrade steps (from 0.14.0) - You **MUST** install the new frontend dependency before building: - `cd frontend && npm install` picks up `@amplitude/analytics-browser` - from the updated `frontend/package.json` and the refreshed + `cd frontend && npm install` picks up `@amplitude/unified` from + the updated `frontend/package.json` and the refreshed `package-lock.json`. The lockfile change is committed. - You **MUST** rebuild the frontend after upgrading so the analytics wrapper and its consent gate ship to viewers. `frontend/package.json#version` and `VERSION` both move to `0.15.0`. No schema migration; the backend is unchanged for this release. -- **MUST**: before deploying, the operator runs `pbpaste | /Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla secret set ohm-rfc-app AMPLITUDE_API_KEY` (with the Amplitude project's API key in the clipboard) to bind the new `AMPLITUDE_API_KEY` secret. The deploy MUST NOT proceed before this binding exists. If the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events are sent. +- **MUST**: before deploying, the operator runs `/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla overlay set ohm-rfc-app VITE_AMPLITUDE_API_KEY=` to bind the Amplitude project's public API key. (Receiving the value in the conversation is fine — it's bundle-embedded by design, same as `VITE_TURNSTILE_SITE_KEY`.) The deploy **SHOULD NOT** proceed before this binding exists; if the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events or session replays are sent. - You **MAY** leave `VITE_AMPLITUDE_API_KEY` unset in dev environments — the wrapper detects the empty value and no-ops with a single console warning. The app, the consent banner, and every other surface keep working unchanged. - You **SHOULD** verify after deploy that the Amplitude dashboard - receives events when a consenting browser exercises one of the - taxonomy events (the easiest probe: open the deployed site in an - Incognito window, accept analytics on the consent banner, navigate - to an RFC, and watch the project's live event stream). + receives events and a session replay when a consenting browser + exercises one of the taxonomy events (the easiest probe: open the + deployed site in an Incognito window, accept analytics on the + consent banner, navigate to an RFC, and watch the project's live + event stream + replay panel). ## 0.14.0 — 2026-05-28 diff --git a/frontend/.env.example b/frontend/.env.example index 82627a2..9176ea9 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -63,19 +63,24 @@ VITE_COOKIES_POLICY_URL= # VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA... VITE_TURNSTILE_SITE_KEY= -# v0.15.0 / roadmap item #13: Amplitude project API key. Embedded in -# the frontend bundle at build time and used by the analytics wrapper -# (`frontend/src/lib/analytics.js`) when the user has granted analytics -# consent (v0.13.0 cookie banner). Provision an Amplitude project at -# app.amplitude.com → Projects → New, copy the API key. +# v0.15.0 / roadmap item #13: Amplitude project API key (public). +# Embedded in the frontend bundle at build time and used by the +# analytics wrapper (`frontend/src/lib/analytics.js`) — which loads +# `@amplitude/unified` (Analytics + Session Replay) when the user +# has granted analytics consent (v0.13.0 cookie banner). Provision +# an Amplitude project at app.amplitude.com → Projects → New, copy +# the API key. # -# Caveat — secret-vs-overlay binding choice: Amplitude browser API -# keys are visible to anyone with browser dev tools (they ride in the -# shipped bundle). They are conventionally treated as semi-sensitive, -# not truly secret. The roadmap binds the value through flotilla's -# `secret set` verb anyway, to keep all-keys-in-Secret-Manager -# regularity for the OHM deployment. Leave unset in dev; the wrapper -# logs one console warning and no-ops (the app continues to work). +# Public by design: Amplitude browser keys are bundle-embedded +# (visible in dev tools), same nature as VITE_TURNSTILE_SITE_KEY +# (also public; the truly-secret half of that Turnstile pair is +# CLOUDFLARE_TURNSTILE_SECRET on the backend). For deployments +# behind flotilla, bind via `flotilla overlay set +# VITE_AMPLITUDE_API_KEY=` — NOT `flotilla secret set`. The +# vendor's installation wizard shows the key inline as a literal +# string in the init call, confirming the public framing. Leave +# unset in dev; the wrapper logs one console warning and no-ops +# (the app continues to work). # # Examples: # VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 705172a..ad2b45f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "rfc-app-frontend", "version": "0.15.0", "dependencies": { - "@amplitude/analytics-browser": "^2.42.4", + "@amplitude/unified": "^1.1.9", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", @@ -48,6 +48,18 @@ "tslib": "^2.4.1" } }, + "node_modules/@amplitude/analytics-client-common": { + "version": "2.4.48", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.4.48.tgz", + "integrity": "sha512-jdRvu8ux3aIf74FvTDZuSFR1mutzdrIg1ebXYqpKizs9upXz1AJnHClkldSw9i4yu924AJ2wudxq6dccHWlNiA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.4.8", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/analytics-types": "2.11.1", + "tslib": "^2.4.1" + } + }, "node_modules/@amplitude/analytics-connector": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz", @@ -67,6 +79,43 @@ "zen-observable": "0.10.0" } }, + "node_modules/@amplitude/analytics-types": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.11.1.tgz", + "integrity": "sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==", + "license": "MIT" + }, + "node_modules/@amplitude/engagement-browser": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@amplitude/engagement-browser/-/engagement-browser-1.0.9.tgz", + "integrity": "sha512-zvPr0L5aLlOS3nG8scIkEEDMVK2y3MaMbgjYhMfYruhMpfsC/U0apov22nEc1RRrTwve2awEXruPRKf1TysqrQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-types": "^2.0.0" + } + }, + "node_modules/@amplitude/experiment-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.13.1.tgz", + "integrity": "sha512-ZHvR0dxTltasp8MiMcQ6qKsY20mWnODoy3oebGad6qaRR1ywpUi8IuLf5AwLTM35ZwgzEUTn9TEIWKLHpDwHMw==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@amplitude/experiment-js-client": { + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-js-client/-/experiment-js-client-1.21.1.tgz", + "integrity": "sha512-chE/4qQG/5Cgl93Wqj1NEdgOL5LkqySLlfk1EN0f+7bJa52HpkGFALA2FeCNYf31Z5CglEeKX6dUMgL7y33SIw==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@amplitude/experiment-core": "^0.13.1", + "@amplitude/ua-parser-js": "^0.7.31", + "base64-js": "1.5.1", + "unfetch": "4.1.0" + } + }, "node_modules/@amplitude/plugin-autocapture-browser": { "version": "1.27.2", "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.2.tgz", @@ -97,6 +146,16 @@ "tslib": "^2.4.1" } }, + "node_modules/@amplitude/plugin-experiment-browser": { + "version": "1.0.0-beta.28", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-experiment-browser/-/plugin-experiment-browser-1.0.0-beta.28.tgz", + "integrity": "sha512-NQz267zLi7vl2G2lx10yUrEoGOCe5K9iqcPSIjbTavGu/XGvsmqLDqBHhg+EkdEMAPwypoXnmtPEs3RMhX+1MA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "@amplitude/experiment-js-client": "^1.15.5" + } + }, "node_modules/@amplitude/plugin-network-capture-browser": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.1.tgz", @@ -127,6 +186,22 @@ "tslib": "^2.4.1" } }, + "node_modules/@amplitude/plugin-session-replay-browser": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-session-replay-browser/-/plugin-session-replay-browser-1.31.0.tgz", + "integrity": "sha512-b7kyYVEdW3EMR6cPXCfld+h8nQsuAR5o6vum8Glu+ofhFDfG4wj/mTJ0ITEaNbsJCfXniKQ3kFgTe6hTtxSFGQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-client-common": "2.4.48", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/analytics-types": "2.11.1", + "@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40", + "@amplitude/rrweb-record": "2.0.0-alpha.40", + "@amplitude/session-replay-browser": "1.44.0", + "idb-keyval": "^6.2.1", + "tslib": "^2.4.1" + } + }, "node_modules/@amplitude/plugin-web-vitals-browser": { "version": "1.1.33", "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.33.tgz", @@ -138,6 +213,178 @@ "web-vitals": "5.1.0" } }, + "node_modules/@amplitude/rrdom": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrdom/-/rrdom-2.1.0.tgz", + "integrity": "sha512-2dAtxXL02usBV2CSOnScLd3WoVqWaeiGpxN8LuXJ0r/NpLJkW1k876v2tRKAz5NrxPwSdjihsMmwCIXHpJhHfA==", + "license": "MIT", + "dependencies": { + "@amplitude/rrweb-snapshot": "^2.1.0" + } + }, + "node_modules/@amplitude/rrweb": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb/-/rrweb-2.1.1.tgz", + "integrity": "sha512-6uA+5VE/VHumaXPXTTLGRogd/K9MDwd01jGteppeLzsX0PvqlDyY5aIi35yh9+q1iS6ciPBn/2NRg0lg4cFIlw==", + "license": "MIT", + "dependencies": { + "@amplitude/rrdom": "^2.1.0", + "@amplitude/rrweb-snapshot": "^2.1.0", + "@amplitude/rrweb-types": "^2.1.0", + "@amplitude/rrweb-utils": "^2.1.0", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0" + } + }, + "node_modules/@amplitude/rrweb-packer": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-packer/-/rrweb-packer-2.0.0-alpha.40.tgz", + "integrity": "sha512-Btb6b9pS1IvDMbvyYxpUdTk9NRJugSoJjRCl7R6jP/iSlPWXoveJIwHaNFAS9ZmWUEK7HhyBJ8bKGFN3giUsDg==", + "license": "MIT", + "dependencies": { + "@amplitude/rrweb-types": "^2.0.0-alpha.40", + "fflate": "^0.4.4" + } + }, + "node_modules/@amplitude/rrweb-plugin-console-record": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.40.tgz", + "integrity": "sha512-vtY7T/kGFl62nC1u7ZUXQvU7ulB70cZGVHPRN/SO9fzVfsY7y6rCmBfoc2jS5KmISdlgkVzMjY2r/EE2Gk9AQA==", + "license": "MIT", + "peerDependencies": { + "@amplitude/rrweb": "^2.0.0-alpha.40" + } + }, + "node_modules/@amplitude/rrweb-record": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-record/-/rrweb-record-2.0.0-alpha.40.tgz", + "integrity": "sha512-5cJhQwzhymJWX5/XOtpWK0h2NLq9+t2YiO6ub0cdZ9F5AZizaRbsVH88int07DfX0YiXTKWbISezVuduCLqgSQ==", + "license": "MIT", + "dependencies": { + "@amplitude/rrweb": "^2.0.0-alpha.40", + "@amplitude/rrweb-types": "^2.0.0-alpha.40" + } + }, + "node_modules/@amplitude/rrweb-snapshot": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-snapshot/-/rrweb-snapshot-2.1.0.tgz", + "integrity": "sha512-xYQvOW73ig+5M7caqilA8j0S6MHWUULLeJNK+2VVvUqv8mr4FMT2DUAQiVBGCImNlb9Gu2rLUfCScMnVxn+EDg==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, + "node_modules/@amplitude/rrweb-types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.1.0.tgz", + "integrity": "sha512-S73tBI/04A6HCHgnrUNeeVOvnDTEoQnNrmZGyrZncJwRlTIX+6BQSYtBFofMag8GnAy9gA+NtC0TL0CnluOWBw==", + "license": "MIT" + }, + "node_modules/@amplitude/rrweb-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.1.0.tgz", + "integrity": "sha512-dTCDnSiMMHZ10utYHJ8dSd/xkjFgdF67y74PkOzAPcCKW1rLxyJYcFOA3uPL2b7cIVVmoel/5NTp5eflaUaJfQ==", + "license": "MIT" + }, + "node_modules/@amplitude/session-replay-browser": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@amplitude/session-replay-browser/-/session-replay-browser-1.44.0.tgz", + "integrity": "sha512-8Ruep2TTDMcfVMKurSpBbVclBK/v8Lb3aSHFsYd/xOQ1E3CaKoAu39pplli28NWoUcW7unyVE7khkOa2zzn0Lw==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-client-common": "2.4.48", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/analytics-types": "2.11.1", + "@amplitude/experiment-core": "0.7.2", + "@amplitude/rrweb-packer": "2.0.0-alpha.40", + "@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40", + "@amplitude/rrweb-record": "2.0.0-alpha.40", + "@amplitude/rrweb-types": "2.0.0-alpha.40", + "@amplitude/rrweb-utils": "2.0.0-alpha.40", + "@amplitude/targeting": "0.2.0", + "@rollup/plugin-replace": "^6.0.1", + "idb": "8.0.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/experiment-core": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz", + "integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-types": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.0.0-alpha.40.tgz", + "integrity": "sha512-rP7CBDkzXupxOA7ukvC+zDYLuCtsz54TuJKC4+5O72Jsz4YdokLznKZRG34P6zXozfhGU0261qckk87lLY6mKQ==", + "license": "MIT" + }, + "node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-utils": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.0.0-alpha.40.tgz", + "integrity": "sha512-i1CCt6MCjlqoeNc+1Hse5bz+ZbASaWaIJ0WdJZvnQjUCHH29Xy/QFouyOuor73RZ+UWX4s2tYSrUIdmBepXk3w==", + "license": "MIT" + }, + "node_modules/@amplitude/targeting": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@amplitude/targeting/-/targeting-0.2.0.tgz", + "integrity": "sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-core": ">=1 <3", + "@amplitude/analytics-types": ">=1 <3", + "@amplitude/experiment-core": "0.7.2", + "idb": "^8.0.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/targeting/node_modules/@amplitude/experiment-core": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz", + "integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@amplitude/ua-parser-js": { + "version": "0.7.33", + "resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz", + "integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@amplitude/unified": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@amplitude/unified/-/unified-1.1.9.tgz", + "integrity": "sha512-YPgQbp/vDQ92GshHs2hfUxoeRnR3rRBWCoQ6wXgFjXQ1uiJf2tP0CBZWdrCStSDuhcpo2rsCz/Ek2LGq5J6SIQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-browser": "2.42.4", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/engagement-browser": "^1.0.3", + "@amplitude/plugin-experiment-browser": "1.0.0-beta.28", + "@amplitude/plugin-session-replay-browser": "1.31.0" + } + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -372,6 +619,12 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", @@ -765,6 +1018,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@tiptap/core": { "version": "3.23.6", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz", @@ -1217,6 +1513,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1470,6 +1772,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -1549,6 +1857,41 @@ } } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -2135,6 +2478,12 @@ "benchmarks" ] }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -2162,6 +2511,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2195,6 +2550,18 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==", + "license": "ISC" + }, + "node_modules/idb-keyval": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz", + "integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==", + "license": "Apache-2.0" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -2214,6 +2581,12 @@ "node": ">=12" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -2535,6 +2908,15 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", @@ -2588,11 +2970,16 @@ "node": ">= 20" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -2629,14 +3016,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2665,7 +3050,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2970,7 +3354,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3029,6 +3412,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/unfetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz", + "integrity": "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg==", + "license": "MIT" + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 49bdf33..f9b6b2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@amplitude/analytics-browser": "^2.42.4", + "@amplitude/unified": "^1.1.9", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js index 586f5b8..84e0f47 100644 --- a/frontend/src/lib/analytics.js +++ b/frontend/src/lib/analytics.js @@ -1,9 +1,10 @@ // analytics.js — v0.15.0 / roadmap item #13. // -// Wrapper around `@amplitude/analytics-browser` that gates SDK -// initialization on the user's cookie/privacy consent (v0.13.0, -// `frontend/src/lib/consent.js`, SPEC §14.5). The wrapper presents -// a stable surface to the rest of the app: +// Wrapper around `@amplitude/unified` (Amplitude Analytics + +// Session Replay) that gates SDK initialization on the user's +// cookie/privacy consent (v0.13.0, `frontend/src/lib/consent.js`, +// SPEC §14.5). The wrapper presents a stable surface to the rest +// of the app: // // import { track, identify, anonymize } from './lib/analytics' // @@ -15,17 +16,20 @@ // 1. Calls `bootstrap()` once, which reads `getConsent()` and // subscribes to `onConsentChange()`. If consent.analytics is // true, it lazily imports the Amplitude SDK and calls -// `amplitude.init(API_KEY, { defaultTracking: false })`. +// `amplitude.initAll(API_KEY, { analytics: { autocapture: true }, +// sessionReplay: { sampleRate: 1 } })`. // If consent.analytics is false (or undecided), the SDK is -// not loaded — no network request, no cookies. A later -// consent change to `true` triggers init at that moment. +// not loaded — no network request, no cookies, no session +// replay recording. A later consent change to `true` triggers +// init at that moment. // 2. The wrapper queues `track()` and `identify()` calls made // before init finishes (lazy import + consent grant), and // drains the queue when init completes. // 3. If the user later flips consent from granted → denied, the // wrapper calls `amplitude.setOptOut(true)` so subsequent -// events are dropped client-side (the SDK is still loaded — -// we cannot unload a script — but it stops firing). +// events are dropped client-side and session replay stops +// recording (the SDK is still loaded — we cannot unload a +// script — but it stops firing). // // Consent precedence ladder: // @@ -35,6 +39,14 @@ // consent.recorded_at === null → treat as denied (banner is up; // the user has not yet chosen) // +// Session replay scope: this release ships session replay at +// `sampleRate: 1` (100% of sessions are recorded for full-DOM +// playback). That is the vendor-recommended default for new +// Amplitude deployments. The v0.13.0 consent banner's single +// "analytics" toggle gates both events and session replay together — +// a separate consent category for session-replay specifically is a +// §19.2 follow-up. +// // API key resolution: // // The build-time env var `VITE_AMPLITUDE_API_KEY` carries the @@ -43,15 +55,11 @@ // function becomes a deterministic no-op so dev environments // (and deployments that intentionally don't ship analytics) // keep working. The deploy gesture wires the key via flotilla's -// `secret set` verb (see CHANGELOG for the operator gesture). -// -// Note on bundle visibility: Amplitude browser API keys are embedded -// in the frontend bundle and visible via dev tools — they are -// conventionally treated as semi-sensitive, not truly secret. The -// roadmap binds the value through `flotilla secret set` rather than -// `flotilla overlay set` to keep all-keys-in-Secret-Manager -// regularity for the deployment. See CHANGELOG 0.15.0 for the -// caveat write-up. +// `overlay set` verb (see CHANGELOG for the operator gesture): +// Amplitude browser keys are bundle-embedded by design (visible +// to anyone with dev tools, same nature as the v0.12.0 +// `VITE_TURNSTILE_SITE_KEY`), so the binding is overlay, not +// secret. // // PII discipline: // @@ -100,9 +108,9 @@ function warnNoKey() { // eslint-disable-next-line no-console console.warn( '[analytics] VITE_AMPLITUDE_API_KEY is unset; analytics events ' + - 'will not be sent. This is expected in dev; in production it ' + - 'means the operator has not yet run `flotilla secret set ' + - 'ohm-rfc-app AMPLITUDE_API_KEY`.', + 'and session replay will not be sent. This is expected in dev; ' + + 'in production it means the operator has not yet run ' + + '`flotilla overlay set VITE_AMPLITUDE_API_KEY=`.', ) } @@ -148,19 +156,31 @@ async function initSdk() { } _initPromise = (async () => { try { - const mod = await import('@amplitude/analytics-browser') - // The SDK exports `init`, `track`, `setUserId`, `reset`, - // `setOptOut` as named functions. We hold the module so the - // queue drainer can call them by name. + const mod = await import('@amplitude/unified') + // The unified package exposes `initAll`, `track`, + // `setUserId`, `reset`, `setOptOut` as named functions. + // We hold the module so the queue drainer can call them + // by name. _amplitude = mod - // defaultTracking: false — we choose what to send, and we - // already gate on consent here. The SDK's own "default - // tracking" would otherwise capture page-views, sessions, - // and form interactions automatically; we want explicit - // `track('Page Viewed', …)` calls from the app instead. - await mod.init(API_KEY, undefined, { - defaultTracking: false, - }).promise + // initAll wires up both Analytics and Session Replay in one + // call. Vendor-recommended init shape from the Amplitude + // installation wizard: + // - analytics.autocapture: true — auto-instruments page + // views, session start/end, clicks, and form interactions. + // Our explicit `track('Page Viewed', …)` etc. layer on top + // for app-specific names that survive renames. + // - sessionReplay.sampleRate: 1 — record 100% of sessions + // for full-DOM playback. Gated by the v0.13.0 consent + // banner just like the rest of the SDK; never starts + // recording without explicit analytics opt-in. + const ret = mod.initAll(API_KEY, { + analytics: { autocapture: true }, + sessionReplay: { sampleRate: 1 }, + }) + // initAll returns an AmplitudeReturn with a `.promise` accessor + // (consistent with the legacy `init`). Some unified builds + // resolve synchronously; await defensively. + if (ret && ret.promise) await ret.promise _initialized = true drainQueue() } catch (err) {