diff --git a/AGENTS.md b/AGENTS.md index fd6ef4fe..7cb5f7d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,6 +219,20 @@ not a privacy platform (D41): the node is readable by design (discovery / search / auditability), there is no default e2e, and the client is a PWA, not a native app. +**Telemetry (D56):** web10 tracks hard — GA4 + Hotjar on every user-facing +surface (marketing site, social app, authenticator) — because it competes +with Meta and TikTok on user experience, and their UX is the output of a +decade of aggressive telemetry. The recording is content-blind by +construction (Hotjar `maskAllText` + `blockAllImages` — text blurred, +images blocked), and GA4 events are content-free by convention. **Content +is never tracked** — posts, DMs, media are not in the recordings, not in +the events, not sold, not fed to any ad machine (GA4 advertising features +stay off; the only sponsors a fan sees are the creator's, D50/D55). The +trade is terms-level, not a consent popup: "it is the wrong platform for +you if you arent ok with that." Full model: +`knowledge/knowledge-base/web10-v3/telemetry.md`. Do not "tighten" or +"privacy-wash" tracking without re-reading D56. + ## The stack - `api/` — FastAPI. The node. All data + auth + billing + media. Entry: diff --git a/README.md b/README.md index 11357a6f..e691fe6c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ paid for value delivered, not for permission granted. | --- | --- | | **You own your data** | One collection per user — the record of your own life, held by you. Export it, move it, erase it. Delete means delete. | | **No shadow ban** | Every post reaches every follower, by construction (fan-out on write). The feed is chronological, because a feed should report — not editorialize. | +| **Built like the best, owned like yours** | web10 tracks hard — GA4 + Hotjar, content masked — to compete with Meta and TikTok on user experience. Your content is never tracked, sold, or fed to an ad machine; the trade is stated in the terms, not hidden (D56). | | **Apps are just frontends** | An app earns access through a scoped, expiring, revocable token. It never owns what it touches. | | **Federated identity** | Identity is `(username, provider)`, like email. No central registry to petition, no account that can be taken from you. | | **Private, not permanent** | Unlike a blockchain, your data can be private, temporary, and deletable. You set who sees it via terms — and delete means delete. | diff --git a/api/app/endpoints/system.py b/api/app/endpoints/system.py index e8649476..2d9a7cc6 100644 --- a/api/app/endpoints/system.py +++ b/api/app/endpoints/system.py @@ -125,6 +125,28 @@ def patch_config(token: Token, update: ConfigUpdate): return {"status": "updated", "changed": list(changes.keys())} +@router.get("/telemetry", tags=["system"]) +def telemetry_config(): + """The node's telemetry IDs, for the frontends to install at runtime (D56). + + Public — no token. GA4 measurement IDs and Hotjar site IDs are public + identifiers (they are embedded in every page's HTML for the scripts to + load); there is no secret here. CORS is wildcard on this node (the + security boundary is the token, not the origin), so every surface — + marketing site, social app, authenticator — can read this pre-login from + any origin. + + The Node Config UI (admin) is where these are set; this endpoint is how + the values reach the client at runtime, so an operator can change the + IDs live without a rebuild. Empty string = that instrument is off. + """ + cfg = config_svc.effective_config() + return { + "ga4_measurement_id": cfg.get("ga4_measurement_id") or "", + "hotjar_site_id": cfg.get("hotjar_site_id") or "", + } + + # --- Health --- diff --git a/api/app/models/config.py b/api/app/models/config.py index b76420eb..30fbe37a 100644 --- a/api/app/models/config.py +++ b/api/app/models/config.py @@ -52,6 +52,11 @@ class NodeConfig(BaseModel): # Dev pay dev_pay_pct: int = 98 + # Telemetry (D56) — public analytics IDs, admin-set in the Node Config + # UI. Served to every surface at runtime via GET /telemetry. Empty = off. + ga4_measurement_id: str = "" + hotjar_site_id: str = "" + # Branding brand_text: str = "web10" logo_dark: str = "" @@ -161,6 +166,8 @@ class ConfigUpdate(BaseModel): stripe_live_credit_sub_id: str | None = None stripe_live_space_sub_id: str | None = None dev_pay_pct: int | None = None + ga4_measurement_id: str | None = None + hotjar_site_id: str | None = None brand_text: str | None = None logo_dark: str | None = None logo_light: str | None = None diff --git a/api/app/services/config.py b/api/app/services/config.py index 25cd450d..a6937b2e 100644 --- a/api/app/services/config.py +++ b/api/app/services/config.py @@ -85,6 +85,11 @@ def effective_config() -> dict: "stripe_test_key": s.STRIPE_TEST_KEY, "stripe_live_key": s.STRIPE_LIVE_KEY, "dev_pay_pct": _as_int(s.DEV_PAY_PCT), + # Telemetry (D56) — public analytics IDs, admin-set in the Node + # Config UI. No settings.py default (empty = tracking off); the + # saved node_config is the only source. Served via GET /telemetry. + "ga4_measurement_id": "", + "hotjar_site_id": "", "brand_text": "web10", "logo_dark": "", "logo_light": "", diff --git a/api/tests/test_node_config.py b/api/tests/test_node_config.py index 3e46cfba..b725a121 100644 --- a/api/tests/test_node_config.py +++ b/api/tests/test_node_config.py @@ -270,3 +270,84 @@ def test_non_admin_cannot_read_config(self, token): resp = tc.post("/config", json={"token": token}) assert resp.status_code == 403 assert "s3_secret_key" not in (resp.json() or {}) + + +class TestTelemetryEndpoint: + """GET /telemetry — the node's telemetry IDs (D56), served to every + surface at runtime so the admin can change them live without a rebuild. + Public: GA4/Hotjar IDs are public identifiers, no token, no secret.""" + + def test_public_no_token(self, client): + with patch("app.v3.services.clickhouse.client") as mock_ch: + mock_ch.query.return_value = _config_result(None) + resp = client.get("/telemetry") + assert resp.status_code == 200 + assert resp.json() == {"ga4_measurement_id": "", "hotjar_site_id": ""} + + def test_returns_saved_ids(self, client): + saved = { + "admins": ["jacoby149"], + "ga4_measurement_id": "G-ABC123", + "hotjar_site_id": "123456", + } + with patch("app.v3.services.clickhouse.client") as mock_ch: + mock_ch.query.return_value = _config_result(saved) + resp = client.get("/telemetry") + assert resp.status_code == 200 + assert resp.json() == {"ga4_measurement_id": "G-ABC123", "hotjar_site_id": "123456"} + + def test_partial_save_only_returns_that_id(self, client): + with patch("app.v3.services.clickhouse.client") as mock_ch: + mock_ch.query.return_value = _config_result({"ga4_measurement_id": "G-ONLY"}) + resp = client.get("/telemetry") + assert resp.json() == {"ga4_measurement_id": "G-ONLY", "hotjar_site_id": ""} + + +class TestTelemetryConfigFields: + """The two telemetry fields flow through effective_config (the Node + Config UI read) and /config/update (the admin write).""" + + def test_effective_config_defaults_empty(self, client): + from app.services import config as config_svc + + with patch("app.v3.services.clickhouse.client") as mock_ch: + mock_ch.query.return_value = _config_result(None) + cfg = config_svc.effective_config() + assert cfg["ga4_measurement_id"] == "" + assert cfg["hotjar_site_id"] == "" + + def test_saved_ids_surface_in_effective_config(self, client): + from app.services import config as config_svc + + saved = {"ga4_measurement_id": "G-XYZ", "hotjar_site_id": "999"} + with patch("app.v3.services.clickhouse.client") as mock_ch: + mock_ch.query.return_value = _config_result(saved) + cfg = config_svc.effective_config() + assert cfg["ga4_measurement_id"] == "G-XYZ" + assert cfg["hotjar_site_id"] == "999" + + def test_config_update_accepts_telemetry_fields(self, client, token): + """An admin can set the IDs via /config/update; they persist and + surface on the next /telemetry read. The endpoint takes two body + models — token (nested) + update (the field changes).""" + with patch("app.v3.services.clickhouse.client") as mock_ch: + # /config/update reads current, merges, saves (one query read). + mock_ch.query.return_value = _config_result({"admins": ["testuser"]}) + resp = client.post( + "/config/update", + json={ + "token": {"token": token}, + "update": {"ga4_measurement_id": "G-NEW", "hotjar_site_id": "42"}, + }, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "updated" + assert set(resp.json()["changed"]) == {"ga4_measurement_id", "hotjar_site_id"} + # the merged body was persisted with the new IDs — find the + # node_config insert and check its body column (index 1 of the row). + node_inserts = [c for c in mock_ch.insert.call_args_list if c[0] and c[0][0] == "node_config"] + assert node_inserts, "expected a node_config insert" + row = node_inserts[-1][0][1][0] + inserted = json.loads(row[1]) + assert inserted["ga4_measurement_id"] == "G-NEW" + assert inserted["hotjar_site_id"] == "42" diff --git a/knowledge/changelogs/CHANGELOG.md b/knowledge/changelogs/CHANGELOG.md index 7072cb5c..161730cd 100644 --- a/knowledge/changelogs/CHANGELOG.md +++ b/knowledge/changelogs/CHANGELOG.md @@ -1,3 +1,11 @@ +3.27.3 || 28.08.2026 +feature(api) + feature(ui) + feature(web10-social) + feature(marketing-ui) + fix(ui) + docs(kb): the telemetry IDs are runtime-configurable from the Node Config UI — no rebuild to change them. Operator: "you need to make it addable from the auth ui right? to change those ids!" **The problem:** 3.27.1 baked the GA4/Hotjar IDs at build time (Vite env → Dockerfile ARG), so changing them required a redeploy. **The fix:** the IDs now live in `node_config` (ClickHouse, same table as every other node setting) and are resolved at runtime. (1) **API:** `NodeConfig` + `ConfigUpdate` gain `ga4_measurement_id` + `hotjar_site_id`; `effective_config()` defaults them to `""`; new **public** `GET /telemetry` endpoint (no token — GA4/Hotjar IDs are public identifiers embedded in every page's HTML, not secrets; CORS is wildcard on this node so every surface reads it pre-login from any origin) returning the two IDs from the effective config. (2) **Auth UI:** a Telemetry card in the Node Config panel (GA4 Measurement ID + Hotjar Site ID inputs, blank = off, "applies live on next page load"). (3) **All three surfaces:** `src/lib/analytics.ts` gains `resolveTelemetryIds()` (fetches `GET /telemetry`; the node is authoritative when reachable, the build-time env is the fallback for pure frontend dev where the node is unreachable) + `loadGa4(id)` / `loadHotjar(id)` (idempotent) + `installTelemetry()` (fire-and-forget orchestrator). Each `main.tsx` calls `installTelemetry()`; the authenticator (single screen, no router) fires its one pageview after GA4 is ready. **Bug fix (exposed by the feature):** the Node Config save was broken — `saveConfig` + `saveAdmins` sent a flat body `{token, ...fields}` but `POST /config/update` takes two body models (`token: Token` + `update: ConfigUpdate`) and therefore expects `{token:{token}, update:{...}}`, so every save (main Save + Admins add/remove) 422'd. A `configUpdate(fields)` helper now builds the correct nested shape; pinned by new UI tests. **Tests:** 5 new API tests (public /telemetry empty/saved/partial; effective_config defaults + saved overlay; /config/update accepts the fields and persists them) + new/extended unit suites per app (loadGa4/loadHotjar idempotency + masking, resolveTelemetryIds node-vs-env precedence + fallback, installTelemetry) + new `configTelemetry.test.tsx` (telemetry fields render; Save + Admins send the nested shape; diff-only save). 801 API + 205 social + 235 marketing-ui + 122 ui tests green, tsc clean, builds clean. KB: `telemetry.md` updated (runtime resolution, the /telemetry endpoint, the save fix) + D56 addendum. + +3.27.2 || 28.08.2026 +docs(strategy) + docs(kb) + docs(readme): the positioning realignment — the docs stop reading "anti-analytics" and say the D56 game out loud. Operator: "can you updated the decisions the knowledge base, everything? because it as very anti analytics, but now that we arent doing the encryption privacy angle, we are trying to be influencer friendly, give them a better deal than these garbo social platforms, it changes the game!" **The reframe:** web10 is a data-policy platform that tracks hard — the incumbents' UX is the output of a decade of aggressive telemetry, and web10 now runs the same engine (GA4 + masked Hotjar, D56) with a data policy they can't offer (content never tracked, never sold, never fed to the ad machine; the only ads are the creator's, D50/D55). **The edits:** (1) `thesis.md` gains the "and it tracks hard (D56)" section — the data-policy frame is not a no-telemetry frame; the line (content is never tracked; advertising features off; the trade stated in the terms, not a consent popup); (2) `manifesto.md`'s "nobody is mining you" block is narrowed to what it was always about — the creator's content and the fan's words/pictures are never scanned, sold, or fed to an ad machine — and gains the candid parenthetical (we watch how people use the place, that's how it stays this good; your words and pictures are never part of it); (3) `AGENTS.md` gains the Telemetry (D56) paragraph in "What web10 is" — the operating rule for agents: do not "tighten" or "privacy-wash" tracking without re-reading D56; (4) `README.md`'s premise table gains the "Built like the best, owned like yours" row; (5) `design.md`'s self-hosted-fonts rule drops the stale "privacy-first product" justification (fonts are a core rendering dependency — reliability + control; telemetry scripts are a different category: intentional, disclosed, env-gated) and the FontAwesome retirement drops the "privacy leak" framing (render-blocking + uncontrolled third-party dependency). No code change. + +3.27.1 || 28.08.2026 +feature(marketing-ui) + feature(web10-social) + feature(ui) + docs(kb) + docs(strategy): D56 — full-platform telemetry: GA4 + Hotjar on every surface, content masked. Operator: "ultimate privacy isnt what web10 is about, we arent encrypting like whatsapp, we are doing analytics for influencers tracking, we should just put on max tracking like hotjar the shit out of the whole platform, we can blur photos and text, but so we can improve the platform, nothing wrong about hotjaring the shit out of this, it is the wrong platform for you if you arent ok with that" — then: "hotjar and google analytics" — then: "we should talk about the telemetry in the kb in a good way, we do alot of telemetry to compete with meta tik tok, to have the most competitive user experience." **The decision (D56):** every user-facing surface is tracked — GA4 + Hotjar on marketing-ui, web10-social, AND the authenticator (`ui/`). Supersedes the old "platform surfaces stay recording-free" rule (the comment that lived in the analytics modules). The recording is **content-blind by construction**: Hotjar initialises with `maskAllText: true` + `blockAllImages: true` (all text blurred, all images blocked — the operator sees cursor + layout + timing, never words or pictures); GA4 events stay content-free by convention (paths, actions, counts — never post text, media URLs, or PII). **Max tracking, one exception:** no privacy flags on GA4 (no IP anonymization — the operator asked for max, the trade is stated in the terms); the single kept flag is `advertising_id: 'OFF'` — we do not feed Google's ad network (the only sponsors a fan sees are the creator's, D50/D55). The trade is terms-level, not a consent popup. **The build:** (1) web10-social gains masked Hotjar (`installHotjar` in `src/lib/analytics.ts`, canonical `static.hotjar.com/c/hotjar-.js?sv=6` snippet + `hj('init', {hjid, maskAllText, blockAllImages})`); its GA4 moves to the max-tracking config (drops `anonymize_ip`, keeps `advertising_id: 'OFF'`); `hotjarIdentify(username)` fires on login. (2) marketing-ui gains GA4 (`installGa4`, same pattern); its Hotjar moves from the old `script.hotjar.com` + `hj('initialize', id, version)` to the canonical masked init (the old `VITE_HOTJAR_VERSION` knob is retired). (3) The authenticator gains both — new `ui/src/lib/analytics.ts` (GA4 + masked Hotjar + `trackPageview`), installed in `main.tsx` with one pageview per load (it's query-parameter-driven, no router). All three are env-gated no-ops in dev (`VITE_GA4_MEASUREMENT_ID` / `VITE_HOTJAR_SITE_ID` unset → nothing loads). **Deploy wiring:** Dockerfile ARG/ENV on all three frontends; compose passes `GA4_MEASUREMENT_ID` / `HOTJAR_SITE_ID` per environment (empty = tracking off); env examples updated (`ubuntu-deployment/.env.example`, `ui/.env_example`). **KB:** new `knowledge-base/web10-v3/telemetry.md` (the Why layer — compete with Meta/TikTok on UX; the use case; the technical how; the line it does not cross: content never tracked, not sold, trade stated; logistics) + README links. **Tests:** new/extended unit suites per app — web10-social `analytics.test.ts` (Hotjar no-op / load / masking config pinned / idempotency / identify), marketing-ui `analytics.test.ts` (GA4 + masked Hotjar + the in-house beacon), ui `analytics.test.ts` (GA4 + masked Hotjar + pageview + identify). Screenshots not applicable (no visual change — tracking is invisible by design). 3.26.3 || 28.08.2026 test(e2e) + fix(social): the profiles surface e2e spec (social-e2e lane) + `profile.ts` addressed through the shared node-scoped `followersGroupId`. **Context:** 3.25.1 already fixed the shared `followersGroupId` helper (`src/data/groups.ts`) to be node-scoped (`{provider}/groups/users/{username}/followers`) — but `src/data/profile.ts` still carried its own local copy hardcoded to `web10.app/groups/{username}/followers`, so the profile read/write still addressed a group that never exists on a non-production node (every profile read 403'd, edits never persisted). **The fix:** `profile.ts` drops its local `followersGroupId` and imports the shared helper from `./groups`; `readProfile`/`saveProfile`/`readUserProfile` now use it. **The spec (`e2e/tests/social-profile.spec.ts`):** the API floor pins the app's exact read pattern (what `profile.ts` + `posts.ts` + `follows.ts` send) — the profile doc read, the posts read, the follower count — plus the I3 anti-test (a stranger's non-public data is not readable: a non-member's profile/posts read 403s, the members/list 401s) and the positive case (a follower CAN read the profile + posts). The browser gauntlet drives the real app pre-authed via the token cookie: own `/profile` (edit the bio → persists across a reload) → another user's public `/u/:username` (posts + follow UI) → the `/u/:username/p/:postId` deep link lands on the post. The followers group is created through the public API (`name: "followers"`) so the derived ID matches what the app computes. **Tests:** 197 social-app unit tests green, `tsc -b` + `vite build` clean, the 3 API-floor e2e tests green. **Note:** a fully green local browser-gauntlet run is blocked by the shared multi-workspace e2e stack (concurrent workspaces collide on the proxy/minio ports + the ClickHouse, tearing the stack down mid-run); the spec adds a signup/login retry + a short `settle()` after setup to ride out the ClickHouse merge race (no-ops on a healthy CI stack). The dedicated CI e2e stack does not have that conflict. diff --git a/knowledge/knowledge-base/web10-v3/README.md b/knowledge/knowledge-base/web10-v3/README.md index 26364894..debea48d 100644 --- a/knowledge/knowledge-base/web10-v3/README.md +++ b/knowledge/knowledge-base/web10-v3/README.md @@ -43,7 +43,8 @@ web10-v3/ │ ├── group-policy-example.json.md ← concrete role/permission examples │ ├── ads.md ← the creator-owned ads: a post tagged `ad` (the locked object, feed read, dissemination) │ └── ads-catalog.md ← the Ad Catalog (Studio) + the composer integration (attach by ref, round-robin) -├── media/ ← the media pipeline: HLS transcoding, streaming, auth + ├── telemetry.md ← why web10 tracks hard (GA4 + masked Hotjar, platform-wide) and the line it doesn't cross + ├── media/ ← the media pipeline: HLS transcoding, streaming, auth │ ├── transcoding-foundation.md ← the model: source doc, transcoding_settings, variants │ ├── transcoding.md ← ffmpeg pipeline, HLS segments, storage, player │ ├── streaming.md ← the layers: range requests (day 1) + HLS transcoding @@ -68,6 +69,7 @@ web10-v3/ - **Groups** — `groups/overview.md` (primitive), `groups/identity.md` (profiles) - **Social** — `social/overview.md` (implementation), `social/cross-app-sharing.md` (patterns), `social/ads.md` (the creator-owned ads — a post tagged `ad`), `social/ads-catalog.md` (the catalog + composer) - **Media** — `media/transcoding-foundation.md` (the model), `media/transcoding.md` (the pipeline), `media/streaming.md` (the layers), `media/minio-auth-bifurcated.md` (the auth split) +- **Telemetry** — `telemetry.md` (why web10 tracks hard: GA4 + masked Hotjar, the content line, the terms trade — D56) ## What's Not Here (v4) diff --git a/knowledge/knowledge-base/web10-v3/telemetry.md b/knowledge/knowledge-base/web10-v3/telemetry.md new file mode 100644 index 00000000..2fb19d10 --- /dev/null +++ b/knowledge/knowledge-base/web10-v3/telemetry.md @@ -0,0 +1,111 @@ +# telemetry.md — why web10 watches how you use it (and what it never sees) + +The Why layer: the reason web10 runs aggressive, platform-wide usage +telemetry (GA4 + Hotjar, content masked) — and the line it does not +cross. Read this before adding, removing, or "tightening" any tracking. +The decision record is D56 (`knowledge/strategy/decisions.md`). + +## the abstract use case + +A fan leaves TikTok because the algorithm buried their creator. They +land on a web10 node expecting the person they followed — and a product +that feels as alive as the one they left. "Alive" is not an accident. +TikTok's feed feels alive because a thousand engineers spent a decade +watching how people use it: where they tap, where they stall, where +they rage-quit at eleven on a Tuesday. The feeling is the *output* of +the telemetry. + +web10 competes with Meta and TikTok for the same attention. A node that +cannot see its own usage is a blog from 2003 — the operator flies blind, +the UX rots quietly, and the fan who came for 100% delivery leaves +because the product feels *dead* next to the app they grew up on. So +web10 tracks hard. Deliberately. Platform-wide. It is how you build the +most competitive user experience in the business: you watch, you learn, +you fix, you repeat, faster than the incumbent can turn around. + +## the specific use case + +The operator of a 500k-follower node wants to know: which screen do new +fans bounce from? Where does the export flow stall? Why do people open +messages and leave without sending? The database cannot answer these — +the database says *what* happened (a post was made), not *how it felt* +(three taps, a four-second stall, a scroll back, a close). That is what +session recordings and heatmaps are for. + +At the same time, the fan's content — their posts, their DMs, their +photos — is the one thing this platform exists to keep out of the ad +machine. So the telemetry watches the *hands*, never the *words*: every +recording masks all text and blocks all images. The operator sees a +cursor moving over a blurred page, not the page itself. It is the +difference between watching someone drive a car and reading their +diary. + +## the technical how + +Two tools, on every user-facing surface (marketing-ui, web10-social, +the authenticator `ui/`): + +- **GA4 (gtag.js)** — pageviews + structural events (login, logout, + post_created, follow, unfollow). Events are content-free by + convention: paths, actions, counts, visibility — never post text, + media URLs, or PII. Loaded with the resolved measurement ID; a no-op + when the ID is empty. +- **Hotjar** — session recordings + heatmaps. Initialised with + `maskAllText: true` + `blockAllImages: true`: all text blurred, all + images blocked. A recording is layout + cursor + timing, nothing + else. Loaded with the resolved site ID; a no-op when the ID is empty. + +**The IDs are resolved at runtime, not baked in.** Each surface calls +`GET /telemetry` on the node at page load; the node returns the two IDs +from `node_config` (ClickHouse) — the same table every other node +setting lives in, edited in the Node Config UI (authenticator). The +node is **authoritative when reachable**: an operator changes the IDs +live and it applies on the next page load, no rebuild. The build-time +env (`VITE_GA4_MEASUREMENT_ID` / `VITE_HOTJAR_SITE_ID`) is the +**fallback** for pure frontend dev where the node is unreachable. Empty +ID = that instrument off. The per-app module lives in each app's +`src/lib/analytics.ts` (`resolveTelemetryIds` → `loadGa4` / `loadHotjar`, +idempotent). marketing-ui additionally keeps its own in-house +pageview/funnel/error beacon to the marketing-api on top — the three +are complementary, not redundant (the beacon is first-party, the other +two are the industry-standard instruments). + +**The one GA4 flag we keep:** `advertising_id: 'OFF'`. We track hard, +but we do not feed the ad machine — the only sponsors a fan ever sees +are the ones the creator chose (D50/D55). Google's ad network is not +one of them. Everything else runs at full strength; there is no IP +anonymization, because the trade is stated, not hidden (below). + +## the line it does not cross + +- **Content is never tracked.** Post bodies, DMs, media, profile data: + not in GA4 events (the content-free convention), not in Hotjar + recordings (text masked, images blocked). If a change would put + content into telemetry, it is a thesis violation, not a config + tweak. +- **Not sold, not scanned for ads.** The manifesto's "nobody is mining + you" stands: telemetry is first-party product analytics (web10's own + GA4/Hotjar properties) used to build the product — not a data feed + to advertisers. +- **The trade is stated, not hidden.** This is a data-policy platform + (D41): the terms say we watch how you use the place so we can keep + it the best version of itself. If that is not for you, this is the + wrong platform for you — and that sentence belongs in the terms, + verbatim or close to it. + +## logistics + +- **Built (3.26.0):** GA4 + masked Hotjar on all three surfaces. +- **Built (3.27.0):** runtime ID resolution — `GET /telemetry` endpoint + (public, no token — the IDs are public identifiers), the two fields + in `node_config` + the Node Config UI (Telemetry card), and each + surface resolves the IDs at page load (node authoritative, env + fallback). Also fixed the Node Config save (it sent a flat body but + the API takes `{token:{token}, update:{...}}` — every save 422'd). +- **Enable:** set the GA4 Measurement ID + Hotjar Site ID in the Node + Config UI (authenticator, admin-only). Applies live on the next page + load. Blank = off. The build-time env (`GA4_MEASUREMENT_ID` / + `HOTJAR_SITE_ID` in the deployment env) is only the dev fallback. +- **Deferred:** per-creator audience analytics in the Studio + (the influencer-facing numbers — that is the `ads`/metrics lanes' + job, not this one). diff --git a/knowledge/strategy/decisions.md b/knowledge/strategy/decisions.md index e77f28e3..cd562b39 100644 --- a/knowledge/strategy/decisions.md +++ b/knowledge/strategy/decisions.md @@ -9,6 +9,73 @@ Status legend: [decided] intent set · [in-progress] · [open] still debating. --- +### D56 — Full-platform telemetry: GA4 + Hotjar on every surface, content masked [decided] +Operator, 28.08.2026 — "ultimate privacy isnt what web10 is about, we arent +encrypting like whatsapp, we are doing analytics for influencers tracking, +we should just put on max tracking like hotjar the shit out of the whole +platform, we can blur photos and text, but so we can improve the platform, +nothing wrong about hotjaring the shit out of this, it is the wrong platform +for you if you arent ok with that" — then: "hotjar and google analytics" — +then: "we should talk about the telemetry in the kb in a good way, we do +alot of telemetry to compete with meta tik tok, to have the most competitive +user experience." + +**Decided** — (1) **Every user-facing surface is tracked**: GA4 + Hotjar +on marketing-ui, web10-social, AND the authenticator (`ui/`). This +supersedes the old "platform surfaces stay recording-free" rule (the +comment that lived in the analytics modules) — the authenticator and +the social app are now recorded, masked, like everything else. (2) +**The recording is content-blind by construction**: Hotjar initialises +with `maskAllText: true` + `blockAllImages: true` — all text blurred, +all images blocked. The operator sees cursor + layout + timing, never +words or pictures. GA4 events stay content-free by convention (paths, +actions, counts — never post text, media URLs, or PII). (3) **Max +tracking, one exception**: no privacy flags on GA4 (no IP +anonymization — the operator asked for max, and the trade is stated in +the terms). The single kept flag is `advertising_id: 'OFF'`: we do not +feed Google's ad network. The manifesto's "nobody is mining you / not +fed to an ad machine" is a promise about the *ad machine*, and the only +sponsors a fan ever sees are the creator's (D50/D55). (4) **The trade +is a terms-level statement, not a consent popup**: "it is the wrong +platform for you if you arent ok with that." D41's data-policy model: +the terms say what we do with your usage; the node is readable by +design; this is usage telemetry, disclosed. (5) **Why**: web10 +competes with Meta and TikTok for the same attention. Their UX is the +output of a decade of aggressive telemetry; a node that cannot see its +own usage is a blog from 2003. The telemetry exists to build the most +competitive user experience — for the fan, and for the influencer whose +audience it holds. + +**Why:** the thesis already killed "privacy platform" (D41). What this +adds is the positive claim: tracking is a *feature* — the engine of UX +competitiveness. The masking is what keeps it compatible with the +manifesto: we watch the hands, never the words. Content (posts, DMs, +media) stays out of telemetry by construction (masking + the +content-free event convention), so "your data isn't scanned, sold, or +fed to an ad machine" remains true of the data that matters — the +creator's content and the fan's. + +**Rejected:** recording-free platform surfaces (the old rule — the +authenticator and social app are now tracked, masked); e2e / +"we can't read it" framing (D41 — that is not the product); consent +popups / opt-in tracking (the trade is terms-level: wrong platform if +you're not ok with that); GA4 advertising features (feeds the ad +machine — the one line we don't cross); selling telemetry to third + parties (first-party properties only). + +**Addendum (3.27.0) — the IDs are runtime-configurable, not baked in.** +The GA4 measurement ID + Hotjar site ID live in `node_config` +(ClickHouse), set in the Node Config UI (authenticator, admin-only). +Each surface resolves them at page load via a public `GET /telemetry` +endpoint (no token — the IDs are public identifiers, not secrets); the +node is authoritative when reachable, the build-time env is the dev +fallback. An operator changes the IDs live; it applies on the next +page load, no rebuild. Blank = off. + +Full model: `knowledge-base/web10-v3/telemetry.md`. + +--- + ### D55 — An ad is a `posts` document tagged `ad`, not a service; the object is locked; `html_template` is v4 [decided] Operator, 27.08.2026 — after the D54 catalog/composer plan: "ads shouldnt be some kind of a service, they should be locked in what they are.... stuck in diff --git a/knowledge/strategy/design.md b/knowledge/strategy/design.md index 1e0d14c2..e3508254 100644 --- a/knowledge/strategy/design.md +++ b/knowledge/strategy/design.md @@ -222,10 +222,14 @@ numbers ONLY — everything else in the Studio stays neutral + brand. ## 5. Typography Three families, self-hosted via `@fontsource-variable/*` packages. -**Never load fonts from Google's CDN** — a privacy-first product does -not leak its users' IPs to a tracking company for a font. Today all -three apps silently fall back to system-ui (social declares Inter but -never loads it); actually loading the fonts is part of the level-up. +**Never load fonts from Google's CDN** — the app's own face is a core +rendering dependency, and it does not depend on a third party for it +(reliability + control, no external request on the critical path). +Telemetry scripts (GA4/Hotjar, D56) are a different category: an +intentional, disclosed, env-gated product decision — not a rendering +dependency. Today all three apps silently fall back to system-ui +(social declares Inter but never loads it); actually loading the fonts +is part of the level-up. | Family | Package | Role | |---|---|---| @@ -314,9 +318,9 @@ Motion confirms causality; it never performs. per component. No CSS-in-JS (rejected in D22). - **Icons: Lucide only.** `lucide-react`, 16/20/24px, `stroke-width` 1.5–2, colored via `currentColor`. **FontAwesome is retired** — - social loads a FA kit script from a third-party CDN (privacy leak + - render-blocking) and marketing-ui uses `fa` classes without loading - FA at all (invisible icons). Both go. + social loads a FA kit script from a third-party CDN (render-blocking + + an uncontrolled third-party dependency) and marketing-ui uses `fa` + classes without loading FA at all (invisible icons). Both go. - **Focus**: every interactive element shows `focus-visible` as a 2px `--color-ring` (brand) ring with 2px offset. Keyboard users see exactly where they are on every screen. diff --git a/knowledge/strategy/manifesto.md b/knowledge/strategy/manifesto.md index 3ad3d2ce..f49beb11 100644 --- a/knowledge/strategy/manifesto.md +++ b/knowledge/strategy/manifesto.md @@ -26,10 +26,12 @@ time.** There is no algorithm between you and them. Nothing is promoted into your feed, nothing is buried out of it. Newest first. That's it. -**Here, nobody is mining you.** Your data isn't scanned, sold, or -fed to an ad machine. The only sponsors you'll ever see are ones -[CREATOR] chose and vouches for — and that's how they keep this -place running without selling you. +**Here, nobody is mining you.** What you post and what you message +is never scanned, sold, or fed to an ad machine. The only sponsors +you'll ever see are ones [CREATOR] chose and vouches for — and that's +how they keep this place running without selling you. (We do watch +how people use the place, the way any serious product does — that's +how it stays this good. Your words and pictures are never part of it.) **Here, delete means delete.** Your stuff is yours. Take it with you, wipe it, export it to your own drive. This isn't a permanent diff --git a/knowledge/strategy/parallel-execution.md b/knowledge/strategy/parallel-execution.md index 7a03fb8c..b3830618 100644 --- a/knowledge/strategy/parallel-execution.md +++ b/knowledge/strategy/parallel-execution.md @@ -342,3 +342,22 @@ panel is the node's control surface — it must show what the node actually runs, and every control on it must work. - [✓ 3.16.0] Node Config: effective config in the form (settings defaults ← saved overlay — no more blanks; ClickHouse URL + MinIO values default to the docker-network settings) + field trimming (Node Identity → provider/CORS/token-expiry; Stripe → mode + keys) + the dead Save button fixed (PATCH /config 405 → POST /config/update) + +### Lane: platform-telemetry (D56) +**Owns:** `marketing/marketing-ui/src/lib/analytics.ts`, `marketing/web10-social/src/lib/analytics.ts`, `ui/src/lib/analytics.ts`, the three frontends' `main.tsx` + `Dockerfile`, `ubuntu-deployment/docker-compose.ecosystem.yml` (frontend build args), `knowledge/knowledge-base/web10-v3/telemetry.md` + +Full-platform telemetry (D56): GA4 + Hotjar on every user-facing +surface, the recording content-blind by construction (maskAllText + +blockAllImages), GA4 events content-free by convention, max tracking +with `advertising_id: 'OFF'` as the single kept flag. The trade is +terms-level, not a consent popup. Supersedes the old "platform surfaces +stay recording-free" rule. The KB is the spec — read it first: +`knowledge/knowledge-base/web10-v3/telemetry.md`. + +- [✓ 3.27.1] Decision: D56 (`knowledge/strategy/decisions.md`) — every surface tracked; recording content-blind by construction; GA4 events content-free by convention; `advertising_id: 'OFF'` kept; the trade is terms-level +- [✓ 3.27.1] KB: `knowledge-base/web10-v3/telemetry.md` — the why (compete with Meta/TikTok on UX), the use case, the technical how (GA4 + masked Hotjar, env-gated, per-app `src/lib/analytics.ts`), the line it does not cross, logistics +- [✓ 3.27.1] Build: all three surfaces — web10-social gains masked Hotjar (GA4 already there, max-tracking config); marketing-ui gains GA4 (in-house beacon + Hotjar already there, Hotjar moved to the canonical masked init); the authenticator gains both (new `ui/src/lib/analytics.ts` + initial pageview — query-parameter-driven, no router); `hotjarIdentify(username)` on login in web10-social; unit tests per app (no-op without env, script load, masking config pinned, idempotency, identify) +- [✓ 3.27.1] Deploy wiring: `VITE_GA4_MEASUREMENT_ID` + `VITE_HOTJAR_SITE_ID` baked at build time — Dockerfile ARG/ENV on all three frontends, compose passes `GA4_MEASUREMENT_ID` / `HOTJAR_SITE_ID` per environment (empty = tracking off), env examples updated +- [✓ 3.27.2] Positioning realignment: the docs stop reading "anti-analytics" — thesis.md gains the "and it tracks hard (D56)" section; the manifesto's "nobody is mining you" is narrowed to content (never scanned/sold/fed to the ad machine) + the candid telemetry parenthetical; AGENTS.md gains the Telemetry (D56) operating rule; the README premise table gains the "Built like the best, owned like yours" row; design.md drops the stale "privacy-first" justifications +- [✓ 3.27.3] Runtime-configurable IDs: the GA4/Hotjar IDs live in `node_config` (ClickHouse), set in the Node Config UI (Telemetry card), resolved at page load via a public `GET /telemetry` (node authoritative, build-time env is the dev fallback) — no rebuild to change them. Also fixed the Node Config save (flat body vs the API's `{token:{token}, update:{...}}` — every save 422'd) +- [ ] Terms copy: the tracking disclosure on the marketing site (the "wrong platform for you if you arent ok with that" line) — gated on a terms surface existing (there is no terms page yet) diff --git a/knowledge/strategy/plan.md b/knowledge/strategy/plan.md index 95e32857..e491ad85 100644 --- a/knowledge/strategy/plan.md +++ b/knowledge/strategy/plan.md @@ -222,6 +222,33 @@ the ad object + feed read + dissemination are in `social/ads.md` (D50 + D51 - [ ] **Composer pin control (web10-social)** — the "Pin an ad" control in `PostComposer`: pick an ad (from an album or all) to pin to the post, or none (sets the post's `ad_preference`); the ad block renders under the post (creative + offer + disclosure, disclosure never hidden). `marketing/web10-social/src/components/Feed/`. - [ ] **E2E** — the torture gauntlet: create an ad → pin it to a post → follower sees the post with the ad block + disclosure → unpin → it's gone → non-follower never sees the ad (I3) → an ad in two albums shows in both. `e2e/tests/ads.spec.ts`. +## Platform Telemetry (D56) — Platform + +web10 tracks hard — GA4 + Hotjar on **every** user-facing surface +(marketing-ui, web10-social, the authenticator `ui/`) — because web10 +competes with Meta and TikTok for the same attention, and their UX is +the output of a decade of aggressive telemetry. The recording is +**content-blind by construction**: Hotjar runs `maskAllText: true` + +`blockAllImages: true` (text blurred, images blocked — the operator +sees cursor + layout + timing, never words or pictures), and GA4 events +are content-free by convention (paths, actions, counts — never post +text, media URLs, or PII). Max tracking, one exception: GA4 +`advertising_id: 'OFF'` — we do not feed Google's ad network (the only +sponsors a fan sees are the creator's, D50/D55). The trade is +terms-level, not a consent popup: "it is the wrong platform for you if +you arent ok with that." Supersedes the old "platform surfaces stay +recording-free" rule. Spec'd in `knowledge-base/web10-v3/telemetry.md`; +the decision is D56. Lane is `platform-telemetry` in +`parallel-execution.md`. + +- [✓ 3.27.1] **Decision: D56** (`knowledge/strategy/decisions.md`) — every surface tracked (GA4 + Hotjar); recording content-blind by construction (maskAllText + blockAllImages); GA4 events content-free by convention; max tracking with `advertising_id: 'OFF'` as the single kept flag; the trade is terms-level, not a consent popup. +- [✓ 3.27.1] **KB** (`knowledge-base/web10-v3/telemetry.md`) — the why (compete with Meta/TikTok on UX), the specific use case (the operator who can't see bounces), the technical how (GA4 + masked Hotjar, env-gated, per-app `src/lib/analytics.ts`), the line it does not cross (content never tracked, not sold, trade stated), logistics. +- [✓ 3.27.1] **Build: all three surfaces** — web10-social gains masked Hotjar (GA4 already there, max-tracking config); marketing-ui gains GA4 (in-house beacon + Hotjar already there, Hotjar moved to the canonical masked init); the authenticator gains both (new `ui/src/lib/analytics.ts` + initial pageview — it's query-parameter-driven, no router). `hotjarIdentify(username)` on login in web10-social. Unit tests per app (no-op without env, script load, masking config pinned, idempotency, identify). +- [✓ 3.27.1] **Deploy wiring** — `VITE_GA4_MEASUREMENT_ID` + `VITE_HOTJAR_SITE_ID` baked at build time: Dockerfile ARG/ENV on all three frontends, compose passes `GA4_MEASUREMENT_ID` / `HOTJAR_SITE_ID` per environment (empty = tracking off), env examples updated. +- [✓ 3.27.2] **Positioning realignment** — the strategy/KB/README docs stop reading "anti-analytics" and say the D56 game out loud (influencer-friendly: the incumbents' UX is the output of a decade of telemetry, and web10 now runs the same engine with a data policy they can't offer): thesis.md gains the "and it tracks hard (D56)" section; the manifesto's "nobody is mining you" is narrowed to content (never scanned/sold/fed to the ad machine) + the candid telemetry parenthetical; AGENTS.md gains the Telemetry (D56) operating rule; the README premise table gains the "Built like the best, owned like yours" row; design.md drops the stale "privacy-first" justifications. +- [✓ 3.27.3] **Runtime-configurable IDs** — the GA4/Hotjar IDs live in `node_config` (ClickHouse), set in the Node Config UI (Telemetry card), resolved at page load via a public `GET /telemetry` (node authoritative, build-time env is the dev fallback). No rebuild to change the IDs. Also fixed the Node Config save (flat body vs the API's `{token:{token}, update:{...}}` — every save 422'd). +- [ ] **Terms copy** — the tracking disclosure on the marketing site (the "wrong platform for you if you arent ok with that" line, verbatim or close). Gated on a terms surface existing — there is no terms page yet. + ## Phase 4 — Production Cutover: v2 → v3, then merge to main **Where:** `knowledge/knowledge-base/web10-v3/` (migration model), `api/` (migration tooling), `ubuntu-deployment/` (prod deploy) diff --git a/knowledge/strategy/thesis.md b/knowledge/strategy/thesis.md index 4e0187bb..4e1df318 100644 --- a/knowledge/strategy/thesis.md +++ b/knowledge/strategy/thesis.md @@ -80,6 +80,28 @@ visible, and the operator is on the hook for it. that is a feature — it is what makes web10 a real, verifiable network instead of a pile of encrypted black boxes no one can find, search, or hold accountable. +## and it tracks hard (D56) + +the data-policy frame is not a privacy frame — and it is not a +no-telemetry frame either. web10 competes with Meta and TikTok for the +same attention, and their UX is the output of a decade of aggressive +telemetry. so web10 tracks hard, platform-wide: GA4 + Hotjar on every +user-facing surface (marketing site, social app, authenticator). the +recording is **content-blind by construction** — text masked, images +blocked; the operator sees cursor + layout + timing, never words or +pictures. GA4 events are **content-free by convention**: paths, +actions, counts — never post text, media URLs, or PII. + +the line: **content is never tracked.** posts, DMs, media — not in the +recordings, not in the events, not sold, not fed to any ad machine (GA4 +advertising features stay off; the only sponsors a fan sees are the +creator's, D50/D55). the trade is stated in the terms, not hidden +behind a consent popup: the platform watches how you use it so it can +keep being the best version of itself — if that is not for you, this +is the wrong platform for you. + +full model: `knowledge-base/web10-v3/telemetry.md`. decision: D56. + ## what this buys (the value) - **creators:** own the audience, own the data, own the terms, 100% diff --git a/marketing/marketing-ui/Dockerfile b/marketing/marketing-ui/Dockerfile index 2c5ee216..a498aa8c 100644 --- a/marketing/marketing-ui/Dockerfile +++ b/marketing/marketing-ui/Dockerfile @@ -29,6 +29,7 @@ ARG VITE_SOCIAL_URL ARG VITE_MARKETING_URL ARG VITE_HOTJAR_SITE_ID ARG VITE_HOTJAR_VERSION +ARG VITE_GA4_MEASUREMENT_ID ARG STATUS_VERSION ENV VITE_API_URL=$VITE_API_URL \ VITE_MARKETING_API=$VITE_MARKETING_API \ @@ -38,6 +39,7 @@ ENV VITE_API_URL=$VITE_API_URL \ VITE_MARKETING_URL=$VITE_MARKETING_URL \ VITE_HOTJAR_SITE_ID=$VITE_HOTJAR_SITE_ID \ VITE_HOTJAR_VERSION=$VITE_HOTJAR_VERSION \ + VITE_GA4_MEASUREMENT_ID=$VITE_GA4_MEASUREMENT_ID \ STATUS_VERSION=$STATUS_VERSION # Copy the marketing-ui source (only) so the build context — now the diff --git a/marketing/marketing-ui/src/lib/analytics.test.ts b/marketing/marketing-ui/src/lib/analytics.test.ts index 9d1f3567..16dd5dd6 100644 --- a/marketing/marketing-ui/src/lib/analytics.test.ts +++ b/marketing/marketing-ui/src/lib/analytics.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { trackPageview, trackFunnel, reportError, installErrorBeacon, installHotjar, hotjarIdentify } from './analytics' +import { trackPageview, trackFunnel, reportError, installErrorBeacon, loadGa4, loadHotjar, resolveTelemetryIds, installTelemetry, hotjarIdentify } from './analytics' describe('analytics', () => { beforeEach(() => { @@ -104,55 +104,138 @@ describe('analytics', () => { }) }) - describe('installHotjar', () => { + describe('loadGa4', () => { + let appendChildSpy: ReturnType + + beforeEach(() => { + delete (window as any).dataLayer + delete (window as any).gtag + document.head.querySelectorAll('script[src*="googletagmanager"]').forEach((s) => s.remove()) + appendChildSpy = vi.spyOn(document.head, 'appendChild') + }) + + afterEach(() => { + appendChildSpy.mockRestore() + delete (window as any).dataLayer + delete (window as any).gtag + }) + + it('is a no-op for an empty measurement ID', () => { + loadGa4('') + expect(appendChildSpy).not.toHaveBeenCalled() + expect((window as any).gtag).toBeUndefined() + }) + + it('loads the GA4 script for the given ID', () => { + loadGa4('G-MKT123') + expect(appendChildSpy).toHaveBeenCalledTimes(1) + const script = appendChildSpy.mock.calls[0][0] as HTMLScriptElement + expect(script.src).toBe('https://www.googletagmanager.com/gtag/js?id=G-MKT123') + expect(script.async).toBe(true) + }) + + it('sets up dataLayer and gtag', () => { + loadGa4('G-MKT456') + expect((window as any).gtag).toBeDefined() + expect(Array.isArray((window as any).dataLayer)).toBe(true) + }) + + it('only installs once (idempotent)', () => { + loadGa4('G-MKT789') + loadGa4('G-OTHER') + expect(appendChildSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('loadHotjar', () => { let appendChildSpy: ReturnType beforeEach(() => { delete (window as any).hj - delete (window as any).hjs + document.head.querySelectorAll('script[src*="hotjar"]').forEach((s) => s.remove()) appendChildSpy = vi.spyOn(document.head, 'appendChild') - // Clear env before each test - vi.stubEnv('VITE_HOTJAR_SITE_ID', undefined) - vi.stubEnv('VITE_HOTJAR_VERSION', undefined) }) afterEach(() => { appendChildSpy.mockRestore() - vi.unstubAllEnvs() + delete (window as any).hj }) - it('is a no-op when VITE_HOTJAR_SITE_ID is not set', () => { - installHotjar() + it('is a no-op for a zero site ID', () => { + loadHotjar(0) expect(appendChildSpy).not.toHaveBeenCalled() expect((window as any).hj).toBeUndefined() }) - it('loads the Hotjar script when site ID is set', () => { - vi.stubEnv('VITE_HOTJAR_SITE_ID', '12345') - installHotjar() + it('loads the Hotjar script for the given ID', () => { + loadHotjar(12345) expect(appendChildSpy).toHaveBeenCalledTimes(1) const script = appendChildSpy.mock.calls[0][0] as HTMLScriptElement - expect(script.src).toBe('https://script.hotjar.com/12345.js') + expect(script.src).toBe('https://static.hotjar.com/c/hotjar-12345.js?sv=6') expect(script.async).toBe(true) }) - it('initialises Hotjar with site ID and default version', () => { - vi.stubEnv('VITE_HOTJAR_SITE_ID', '12345') - installHotjar() - expect((window as any).hjs).toContainEqual(['initialize', 12345, 1]) + it('initialises with full content masking (D56: text blurred, images blocked)', () => { + loadHotjar(12345) + expect((window as any).hj.q).toContainEqual(['init', { hjid: 12345, maskAllText: true, blockAllImages: true }]) }) - it('uses VITE_HOTJAR_VERSION when provided', () => { - vi.stubEnv('VITE_HOTJAR_SITE_ID', '12345') - vi.stubEnv('VITE_HOTJAR_VERSION', '3') - installHotjar() - expect((window as any).hjs).toContainEqual(['initialize', 12345, 3]) + it('only installs once (idempotent)', () => { + loadHotjar(12345) + loadHotjar(99999) + expect(appendChildSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('resolveTelemetryIds', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('prefers the node config when the node is reachable', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ga4_measurement_id: 'G-NODE', hotjar_site_id: '777' }), + })) + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-ENV') + vi.stubEnv('VITE_HOTJAR_SITE_ID', '555') + const ids = await resolveTelemetryIds() + expect(ids).toEqual({ ga4: 'G-NODE', hotjar: 777 }) + }) + + it('falls back to env when the node is unreachable', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-ENV') + vi.stubEnv('VITE_HOTJAR_SITE_ID', '555') + const ids = await resolveTelemetryIds() + expect(ids).toEqual({ ga4: 'G-ENV', hotjar: 555 }) + }) + + it('returns empty IDs when neither node nor env configure them', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + const ids = await resolveTelemetryIds() + expect(ids).toEqual({ ga4: '', hotjar: 0 }) + }) + }) + + describe('installTelemetry', () => { + afterEach(() => { + vi.unstubAllGlobals() + delete (window as any).gtag + delete (window as any).hj + delete (window as any).dataLayer }) - it('sets up the hjs queue array', () => { - vi.stubEnv('VITE_HOTJAR_SITE_ID', '12345') - installHotjar() - expect(Array.isArray((window as any).hjs)).toBe(true) + it('loads both instruments when the node configures them', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ga4_measurement_id: 'G-NODE', hotjar_site_id: '777' }), + })) + installTelemetry() + await new Promise((r) => setTimeout(r, 0)) + expect((window as any).gtag).toBeDefined() + expect((window as any).hj).toBeDefined() }) }) @@ -177,11 +260,11 @@ describe('analytics', () => { expect(mockHj).toHaveBeenCalledWith('identify', 'user-123', { plan: 'pro' }) }) - it('calls hj identify with minimal args when no props given', () => { + it('calls hj identify without a props arg when no props given', () => { const mockHj = vi.fn() ;(window as any).hj = mockHj hotjarIdentify('user-456') - expect(mockHj).toHaveBeenCalledWith('identify', 'user-456', undefined) + expect(mockHj).toHaveBeenCalledWith('identify', 'user-456') }) }) }) diff --git a/marketing/marketing-ui/src/lib/analytics.ts b/marketing/marketing-ui/src/lib/analytics.ts index 43e86363..e6eff44f 100644 --- a/marketing/marketing-ui/src/lib/analytics.ts +++ b/marketing/marketing-ui/src/lib/analytics.ts @@ -1,6 +1,8 @@ -// Centralized analytics for marketing-ui. -// Full funnel analytics + JS error beacon — marketing-ui is pre-signup, -// so full tracking is fair game (plan.txt ux telemetry spec). +// Centralized analytics for marketing-ui (D56: full-platform telemetry). +// In-house funnel analytics + JS error beacon (first-party, to the +// marketing-api) + GA4 + masked Hotjar (session replay + heatmaps, +// content-blind: text blurred, images blocked). See +// knowledge-base/web10-v3/telemetry.md. const MARKETING_API = (typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('marketing_api')) || @@ -90,36 +92,127 @@ export function installErrorBeacon() { } // --------------------------------------------------------------------------- -// Hotjar — session replay + heatmaps (marketing-ui ONLY). -// Platform surfaces (ui/ + web10-social) remain recording-free. -// Site ID is required: VITE_HOTJAR_SITE_ID. Version defaults to 1. +// GA4 + Hotjar (D56: full-platform telemetry). Max tracking, one exception: +// advertising_id OFF (we don't feed the ad machine). Hotjar is content-blind +// (maskAllText + blockAllImages — cursor + layout + timing, never words or +// pictures). The IDs are resolved at RUNTIME from the node (GET /telemetry) +// so an operator can change them live in the Node Config UI without a +// rebuild; the build-time env is the fallback for pure frontend dev. // --------------------------------------------------------------------------- +import { API_ORIGIN } from '@/lib/origins' + +interface GtagQueue { + (command: 'config', measurementId: string, config?: Record): void; + (command: 'event', eventName: string, params?: Record): void; + (command: 'js', timestamp: number): void; +} + +declare global { + interface Window { + dataLayer?: unknown[][]; + gtag?: GtagQueue; + } +} + +export interface TelemetryIds { + ga4: string + hotjar: number +} + +function envIds(): TelemetryIds { + const ga4 = + typeof import.meta.env?.VITE_GA4_MEASUREMENT_ID === 'string' + ? import.meta.env.VITE_GA4_MEASUREMENT_ID.trim() + : '' + const raw = import.meta.env?.VITE_HOTJAR_SITE_ID + const hotjar = raw ? parseInt(raw, 10) : 0 + return { ga4, hotjar: isNaN(hotjar) ? 0 : hotjar } +} + /** - * Load the Hotjar snippet dynamically and initialise it. - * No-op when VITE_HOTJAR_SITE_ID is not set (dev without env vars). + * Resolve the telemetry IDs. The node's GET /telemetry is authoritative when + * reachable (an admin set the IDs in the Node Config UI — empty = off). When + * the node is unreachable (pure frontend dev), fall back to the build-time + * env. Never throws — telemetry must never break the app. */ -export function installHotjar() { - if (typeof window === 'undefined') return - const siteIdRaw = import.meta.env?.VITE_HOTJAR_SITE_ID - const siteId = siteIdRaw ? parseInt(siteIdRaw, 10) : 0 - if (!siteId || isNaN(siteId)) return +export async function resolveTelemetryIds(): Promise { + try { + const resp = await fetch(`${API_ORIGIN}/telemetry`) + if (!resp.ok) throw new Error(String(resp.status)) + const data = await resp.json() + return { + ga4: String(data.ga4_measurement_id || '').trim(), + hotjar: parseInt(String(data.hotjar_site_id || ''), 10) || 0, + } + } catch { + return envIds() + } +} + +/** + * Load the GA4 snippet and initialise it with the given measurement ID. + * Idempotent — a second call is a no-op. + */ +export function loadGa4(measurementId: string): void { + if (typeof document === 'undefined') return + if (!measurementId || (window as any).gtag) return + + window.dataLayer = window.dataLayer || [] + window.gtag = function (...args: unknown[]) { + window.dataLayer!.push(args as unknown[]) + } as GtagQueue + window.gtag('js', new Date().getTime()) + window.gtag('config', measurementId, { + // D56: max tracking, one exception — we do not feed Google's ad + // network. The only sponsors a fan sees are the creator's (D50/D55). + advertising_id: 'OFF', + }) + + const s = document.createElement('script') + s.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}` + s.async = true + document.head.appendChild(s) +} - const versionRaw = import.meta.env?.VITE_HOTJAR_VERSION - const version = versionRaw ? parseInt(versionRaw, 10) : 1 +/** + * Load the Hotjar snippet and initialise it with full content masking + * (D56): all text blurred, all images blocked. Idempotent — a second call + * is a no-op. + */ +export function loadHotjar(siteId: number): void { + if (typeof window === 'undefined') return + if (!siteId || (window as any).hj) return - // Standard Hotjar queue pattern - ;(window as any).hjs = (window as any).hjs || [] - ;(window as any).hj = function (...args: unknown[]) { - ;(window as any).hjs.push(args) + // Canonical Hotjar queue pattern (the real script drains hj.q on load). + ;(window as any).hj = (window as any).hj || function (...args: unknown[]) { + ;((window as any).hj.q = (window as any).hj.q || []).push(args) } const s = document.createElement('script') - s.src = `https://script.hotjar.com/${siteId}.js` + s.src = `https://static.hotjar.com/c/hotjar-${siteId}.js?sv=6` s.async = true document.head.appendChild(s) - ;(window as any).hj('initialize', siteId, version) + // D56: the recording is content-blind — blur all text, block all images. + ;(window as any).hj('init', { + hjid: siteId, + maskAllText: true, + blockAllImages: true, + }) +} + +/** + * Kick off telemetry: resolve the IDs (node config, env fallback) and load + * whichever instruments are configured. Fire-and-forget — never blocks + * render, never throws. + */ +export function installTelemetry(): void { + if (typeof document === 'undefined') return + resolveTelemetryIds().then(({ ga4, hotjar }) => { + if (ga4) loadGa4(ga4) + if (hotjar) loadHotjar(hotjar) + }) } /** @@ -128,5 +221,9 @@ export function installHotjar() { */ export function hotjarIdentify(userId: string, props?: Record) { if (typeof window === 'undefined' || !(window as any).hj) return - ;(window as any).hj('identify', userId, props) + if (props) { + ;(window as any).hj('identify', userId, props) + } else { + ;(window as any).hj('identify', userId) + } } diff --git a/marketing/marketing-ui/src/main.tsx b/marketing/marketing-ui/src/main.tsx index ab4bc1b1..c669368b 100644 --- a/marketing/marketing-ui/src/main.tsx +++ b/marketing/marketing-ui/src/main.tsx @@ -5,14 +5,15 @@ import App from './App.tsx' import { ErrorBoundary } from './components/ErrorBoundary' import { ReportBug } from './components/ReportBug' import { Button } from './components/ui/button' -import { trackPageview, installErrorBeacon, installHotjar } from './lib/analytics' +import { trackPageview, installErrorBeacon, installTelemetry } from './lib/analytics' import './index.css' // Install JS error beacon (window.onerror + unhandledrejection) installErrorBeacon() -// Install Hotjar session recording (marketing-ui only — platform surfaces stay recording-free) -installHotjar() +// D56: full-platform telemetry — GA4 + masked Hotjar (content-blind). IDs +// resolved at runtime from the node (GET /telemetry), env fallback in dev. +installTelemetry() function AnalyticsTracker() { const location = useLocation() diff --git a/marketing/web10-social/Dockerfile b/marketing/web10-social/Dockerfile index 9d80e47b..eb788118 100644 --- a/marketing/web10-social/Dockerfile +++ b/marketing/web10-social/Dockerfile @@ -20,12 +20,17 @@ ARG VITE_RTC_ORIGIN ARG VITE_MARKETING_API ARG VITE_MARKETING_ORIGIN ARG VITE_GIT_COMMIT +# D56: full-platform telemetry — GA4 + Hotjar IDs (empty = tracking off). +ARG VITE_GA4_MEASUREMENT_ID +ARG VITE_HOTJAR_SITE_ID ENV VITE_API_ORIGIN=$VITE_API_ORIGIN \ VITE_AUTH_ORIGIN=$VITE_AUTH_ORIGIN \ VITE_RTC_ORIGIN=$VITE_RTC_ORIGIN \ VITE_MARKETING_API=$VITE_MARKETING_API \ VITE_MARKETING_ORIGIN=$VITE_MARKETING_ORIGIN \ - VITE_GIT_COMMIT=$VITE_GIT_COMMIT + VITE_GIT_COMMIT=$VITE_GIT_COMMIT \ + VITE_GA4_MEASUREMENT_ID=$VITE_GA4_MEASUREMENT_ID \ + VITE_HOTJAR_SITE_ID=$VITE_HOTJAR_SITE_ID RUN bun run build diff --git a/marketing/web10-social/src/App.tsx b/marketing/web10-social/src/App.tsx index 795c9809..15dcc648 100644 --- a/marketing/web10-social/src/App.tsx +++ b/marketing/web10-social/src/App.tsx @@ -15,7 +15,7 @@ import { ErrorBoundary } from '@/components/shared/ErrorBoundary'; import { ReportBug } from '@/components/shared/ReportBug'; import { getWapi, getV3Client } from '@/data'; import { resolveMediaRefs } from '@/data/posts'; -import { trackEvent } from '@/lib/analytics'; +import { trackEvent, hotjarIdentify } from '@/lib/analytics'; import { PostLightbox } from '@/components/Bio/PostLightbox'; import type { PostRecord, MediaRecord, Visibility } from '@/data/types'; @@ -225,6 +225,8 @@ function App() { auth.authListen(() => { setSignedIn(true); trackEvent('login'); + const who = auth.readToken(); + if (who) hotjarIdentify(who.username); }); const handler = (e: Event) => { diff --git a/marketing/web10-social/src/__tests__/lib/analytics.test.ts b/marketing/web10-social/src/__tests__/lib/analytics.test.ts index 8cb58ea4..b642dc15 100644 --- a/marketing/web10-social/src/__tests__/lib/analytics.test.ts +++ b/marketing/web10-social/src/__tests__/lib/analytics.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { installGa4, trackPageview, trackEvent } from '../../lib/analytics'; +import { + loadGa4, + loadHotjar, + resolveTelemetryIds, + installTelemetry, + trackPageview, + trackEvent, + hotjarIdentify, +} from '../../lib/analytics'; describe('analytics', () => { let appendChildSpy: ReturnType; @@ -7,42 +15,33 @@ describe('analytics', () => { beforeEach(() => { delete (window as any).dataLayer; delete (window as any).gtag; + delete (window as any).hj; document.head.querySelectorAll('script[src*="googletagmanager"]').forEach((s) => s.remove()); + document.head.querySelectorAll('script[src*="hotjar"]').forEach((s) => s.remove()); appendChildSpy = vi.spyOn(document.head, 'appendChild'); vi.stubEnv('VITE_GA4_MEASUREMENT_ID', undefined); + vi.stubEnv('VITE_HOTJAR_SITE_ID', undefined); }); afterEach(() => { appendChildSpy.mockRestore(); vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); delete (window as any).dataLayer; delete (window as any).gtag; + delete (window as any).hj; }); - describe('installGa4', () => { - it('is a no-op when VITE_GA4_MEASUREMENT_ID is not set', () => { - const result = installGa4(); - expect(result).toBeNull(); + describe('loadGa4', () => { + it('is a no-op for an empty measurement ID', () => { + loadGa4(''); expect((window as any).gtag).toBeUndefined(); expect(appendChildSpy).not.toHaveBeenCalled(); }); - it('is a no-op for empty measurement ID', () => { - vi.stubEnv('VITE_GA4_MEASUREMENT_ID', ''); - const result = installGa4(); - expect(result).toBeNull(); - }); - - it('is a no-op for whitespace-only measurement ID', () => { - vi.stubEnv('VITE_GA4_MEASUREMENT_ID', ' '); - const result = installGa4(); - expect(result).toBeNull(); - }); - - it('loads the GA4 script when measurement ID is set', () => { - vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-TEST123'); - const result = installGa4(); - expect(result).toBe('G-TEST123'); + it('loads the GA4 script for the given ID', () => { + loadGa4('G-TEST123'); expect(appendChildSpy).toHaveBeenCalledTimes(1); const script = appendChildSpy.mock.calls[0][0] as HTMLScriptElement; expect(script.src).toBe('https://www.googletagmanager.com/gtag/js?id=G-TEST123'); @@ -50,22 +49,113 @@ describe('analytics', () => { }); it('sets up dataLayer and gtag', () => { - vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-TEST456'); - installGa4(); + loadGa4('G-TEST456'); expect((window as any).gtag).toBeDefined(); expect(Array.isArray((window as any).dataLayer)).toBe(true); }); it('only installs once (idempotent)', () => { - vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-TEST789'); - installGa4(); - // Second call sees window.gtag already set → no-op - const second = installGa4(); - expect(second).toBeNull(); + loadGa4('G-TEST789'); + loadGa4('G-OTHER'); expect(appendChildSpy).toHaveBeenCalledTimes(1); }); }); + describe('loadHotjar', () => { + it('is a no-op for a zero site ID', () => { + loadHotjar(0); + expect((window as any).hj).toBeUndefined(); + expect(appendChildSpy).not.toHaveBeenCalled(); + }); + + it('loads the Hotjar script for the given ID', () => { + loadHotjar(123456); + expect(appendChildSpy).toHaveBeenCalledTimes(1); + const script = appendChildSpy.mock.calls[0][0] as HTMLScriptElement; + expect(script.src).toBe('https://static.hotjar.com/c/hotjar-123456.js?sv=6'); + expect(script.async).toBe(true); + }); + + it('initialises with full content masking (D56: text blurred, images blocked)', () => { + loadHotjar(123456); + const q = (window as any).hj.q as unknown[][]; + expect(q).toHaveLength(1); + expect(q[0]).toEqual(['init', { hjid: 123456, maskAllText: true, blockAllImages: true }]); + }); + + it('only installs once (idempotent)', () => { + loadHotjar(123456); + loadHotjar(999999); + expect(appendChildSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('resolveTelemetryIds', () => { + it('prefers the node config when the node is reachable', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ga4_measurement_id: 'G-NODE', hotjar_site_id: '777' }), + }); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-ENV'); + vi.stubEnv('VITE_HOTJAR_SITE_ID', '555'); + const ids = await resolveTelemetryIds(); + expect(ids).toEqual({ ga4: 'G-NODE', hotjar: 777 }); + }); + + it('falls back to env when the node is unreachable', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-ENV'); + vi.stubEnv('VITE_HOTJAR_SITE_ID', '555'); + const ids = await resolveTelemetryIds(); + expect(ids).toEqual({ ga4: 'G-ENV', hotjar: 555 }); + }); + + it('falls back to env when the node returns an error status', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-ENV'); + vi.stubEnv('VITE_HOTJAR_SITE_ID', undefined); + const ids = await resolveTelemetryIds(); + expect(ids).toEqual({ ga4: 'G-ENV', hotjar: 0 }); + }); + + it('returns empty IDs when neither node nor env configure them', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + const ids = await resolveTelemetryIds(); + expect(ids).toEqual({ ga4: '', hotjar: 0 }); + }); + }); + + describe('installTelemetry', () => { + it('loads both instruments when the node configures them', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ga4_measurement_id: 'G-NODE', hotjar_site_id: '777' }), + }), + ); + installTelemetry(); + await new Promise((r) => setTimeout(r, 0)); + expect((window as any).gtag).toBeDefined(); + expect((window as any).hj).toBeDefined(); + }); + + it('loads nothing when the node configures neither', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ga4_measurement_id: '', hotjar_site_id: '' }), + }), + ); + installTelemetry(); + await new Promise((r) => setTimeout(r, 0)); + expect((window as any).gtag).toBeUndefined(); + expect((window as any).hj).toBeUndefined(); + }); + }); + describe('trackPageview', () => { it('sends a page_view event with page_path', () => { const mockGtag = vi.fn(); @@ -88,44 +178,22 @@ describe('analytics', () => { expect(mockGtag).toHaveBeenCalledWith('event', 'login', {}); }); - it('sends a logout event', () => { - const mockGtag = vi.fn(); - (window as any).gtag = mockGtag; - trackEvent('logout'); - expect(mockGtag).toHaveBeenCalledWith('event', 'logout', {}); - }); - - it('sends a post_created event', () => { - const mockGtag = vi.fn(); - (window as any).gtag = mockGtag; - trackEvent('post_created'); - expect(mockGtag).toHaveBeenCalledWith('event', 'post_created', {}); - }); - - it('sends a follow event', () => { - const mockGtag = vi.fn(); - (window as any).gtag = mockGtag; - trackEvent('follow'); - expect(mockGtag).toHaveBeenCalledWith('event', 'follow', {}); - }); - - it('sends an unfollow event', () => { - const mockGtag = vi.fn(); - (window as any).gtag = mockGtag; - trackEvent('unfollow'); - expect(mockGtag).toHaveBeenCalledWith('event', 'unfollow', {}); + it('is a no-op when gtag is not installed', () => { + delete (window as any).gtag; + expect(() => trackEvent('login')).not.toThrow(); }); + }); - it('sends post_created with visibility param', () => { - const mockGtag = vi.fn(); - (window as any).gtag = mockGtag; - trackEvent('post_created', { visibility: 'public' }); - expect(mockGtag).toHaveBeenCalledWith('event', 'post_created', { visibility: 'public' }); + describe('hotjarIdentify', () => { + it('queues an identify call when Hotjar is installed', () => { + loadHotjar(123456); + hotjarIdentify('alice', { plan: 'pro' }); + const q = (window as any).hj.q as unknown[][]; + expect(q[q.length - 1]).toEqual(['identify', 'alice', { plan: 'pro' }]); }); - it('is a no-op when gtag is not installed', () => { - delete (window as any).gtag; - expect(() => trackEvent('login')).not.toThrow(); + it('is a no-op when Hotjar is not installed', () => { + expect(() => hotjarIdentify('alice')).not.toThrow(); }); }); }); \ No newline at end of file diff --git a/marketing/web10-social/src/lib/analytics.ts b/marketing/web10-social/src/lib/analytics.ts index b28192e9..1b811fca 100644 --- a/marketing/web10-social/src/lib/analytics.ts +++ b/marketing/web10-social/src/lib/analytics.ts @@ -1,7 +1,16 @@ -// GA4 analytics for web10-social. -// Aggregate-only, anonymous, content-free events. No recording. -// Platform surfaces stay recording-free (plan.txt ux telemetry spec). -// No-op when VITE_GA4_MEASUREMENT_ID is not set (dev-safe). +// GA4 + Hotjar analytics for web10-social (D56: full-platform telemetry). +// GA4: pageviews + content-free structural events. Hotjar: session +// recordings + heatmaps, content-blind by construction (maskAllText + +// blockAllImages — the operator sees cursor + layout + timing, never words +// or pictures). See knowledge-base/web10-v3/telemetry.md. +// +// The IDs are resolved at RUNTIME from the node (GET /telemetry) so an +// operator can change them live in the Node Config UI without a rebuild. +// The node is authoritative when reachable; the build-time env +// (VITE_GA4_MEASUREMENT_ID / VITE_HOTJAR_SITE_ID) is the fallback for pure +// frontend dev where the node is unreachable. No-op when both are empty. + +import { API_ORIGIN } from './origins'; // --------------------------------------------------------------------------- // GA4 gtag types (minimal — we only need what we use) @@ -21,20 +30,55 @@ declare global { } // --------------------------------------------------------------------------- -// Install +// ID resolution (runtime node config, env fallback) // --------------------------------------------------------------------------- +export interface TelemetryIds { + ga4: string; + hotjar: number; +} + +function envIds(): TelemetryIds { + const ga4 = + typeof import.meta.env?.VITE_GA4_MEASUREMENT_ID === 'string' + ? import.meta.env.VITE_GA4_MEASUREMENT_ID.trim() + : ''; + const raw = import.meta.env?.VITE_HOTJAR_SITE_ID; + const hotjar = raw ? parseInt(raw, 10) : 0; + return { ga4, hotjar: isNaN(hotjar) ? 0 : hotjar }; +} + /** - * Load the GA4 snippet dynamically and initialise it. - * No-op when VITE_GA4_MEASUREMENT_ID is not set (dev without env vars) - * or when gtag is already present (already installed, SSR, etc.). + * Resolve the telemetry IDs. The node's GET /telemetry is authoritative when + * reachable (an admin set the IDs in the Node Config UI — empty = off). When + * the node is unreachable (pure frontend dev), fall back to the build-time + * env. Never throws — telemetry must never break the app. */ -export function installGa4(): string | null { - if (typeof document === 'undefined') return null; - if ((window as any).gtag) return null; +export async function resolveTelemetryIds(): Promise { + try { + const resp = await fetch(`${API_ORIGIN}/telemetry`); + if (!resp.ok) throw new Error(String(resp.status)); + const data = await resp.json(); + return { + ga4: String(data.ga4_measurement_id || '').trim(), + hotjar: parseInt(String(data.hotjar_site_id || ''), 10) || 0, + }; + } catch { + return envIds(); + } +} - const measurementId = import.meta.env?.VITE_GA4_MEASUREMENT_ID; - if (!measurementId || typeof measurementId !== 'string' || !measurementId.trim()) return null; +// --------------------------------------------------------------------------- +// Install +// --------------------------------------------------------------------------- + +/** + * Load the GA4 snippet and initialise it with the given measurement ID. + * Idempotent — a second call is a no-op. + */ +export function loadGa4(measurementId: string): void { + if (typeof document === 'undefined') return; + if (!measurementId || (window as any).gtag) return; // Standard GA4 dataLayer boot window.dataLayer = window.dataLayer || []; @@ -43,9 +87,9 @@ export function installGa4(): string | null { } as GtagQueue; window.gtag('js', new Date().getTime()); window.gtag('config', measurementId, { - // Disable GA4 advertising features — we only need aggregate pageviews + events + // D56: max tracking, one exception — we do not feed Google's ad + // network. The only sponsors a fan sees are the creator's (D50/D55). advertising_id: 'OFF', - anonymize_ip: true, }); // Load gtag script dynamically (non-blocking) @@ -53,8 +97,46 @@ export function installGa4(): string | null { s.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`; s.async = true; document.head.appendChild(s); +} + +/** + * Load the Hotjar snippet and initialise it with full content masking + * (D56): all text blurred, all images blocked. Idempotent — a second call + * is a no-op. + */ +export function loadHotjar(siteId: number): void { + if (typeof document === 'undefined') return; + if (!siteId || (window as any).hj) return; + + // Canonical Hotjar queue pattern (the real script drains hj.q on load). + (window as any).hj = (window as any).hj || function (...args: unknown[]) { + ((window as any).hj.q = (window as any).hj.q || []).push(args); + }; + + const s = document.createElement('script'); + s.src = `https://static.hotjar.com/c/hotjar-${siteId}.js?sv=6`; + s.async = true; + document.head.appendChild(s); - return measurementId; + // D56: the recording is content-blind — blur all text, block all images. + (window as any).hj('init', { + hjid: siteId, + maskAllText: true, + blockAllImages: true, + }); +} + +/** + * Kick off telemetry: resolve the IDs (node config, env fallback) and load + * whichever instruments are configured. Fire-and-forget — never blocks + * render, never throws. + */ +export function installTelemetry(): void { + if (typeof document === 'undefined') return; + resolveTelemetryIds().then(({ ga4, hotjar }) => { + if (ga4) loadGa4(ga4); + if (hotjar) loadHotjar(hotjar); + }); } // --------------------------------------------------------------------------- @@ -82,4 +164,17 @@ export function trackEvent( ) { if (!window.gtag) return; window.gtag('event', event, params || {}); +} + +/** + * Identify a known user in Hotjar (e.g., after login). + * Safe no-op when Hotjar is not installed. + */ +export function hotjarIdentify(userId: string, props?: Record) { + if (typeof window === 'undefined' || !(window as any).hj) return; + if (props) { + (window as any).hj('identify', userId, props); + } else { + (window as any).hj('identify', userId); + } } \ No newline at end of file diff --git a/marketing/web10-social/src/main.tsx b/marketing/web10-social/src/main.tsx index d7599005..1d7ae047 100644 --- a/marketing/web10-social/src/main.tsx +++ b/marketing/web10-social/src/main.tsx @@ -6,10 +6,11 @@ import '@fontsource-variable/inter/standard.css'; import '@fontsource-variable/space-grotesk'; import './index.css'; import App from './App'; -import { installGa4, trackPageview } from './lib/analytics'; +import { installTelemetry, trackPageview } from './lib/analytics'; -// Install GA4 (aggregate-only, anonymous, content-free — no recording) -installGa4(); +// D56: full-platform telemetry — GA4 + masked Hotjar (content-blind). IDs +// resolved at runtime from the node (GET /telemetry), env fallback in dev. +installTelemetry(); function AnalyticsTracker() { const location = useLocation(); diff --git a/ubuntu-deployment/.env.example b/ubuntu-deployment/.env.example index aa1b325c..f025a1c9 100644 --- a/ubuntu-deployment/.env.example +++ b/ubuntu-deployment/.env.example @@ -29,3 +29,10 @@ NPM_PASSWORD= # --- Minio root passwords, one per env (S3 API is internet-facing) --- MINIO_PASSWORD_DEV= MINIO_PASSWORD_PROD= + +# --- D56: full-platform telemetry (GA4 + Hotjar, content-masked) --- +# Baked into the three frontend builds (ui, social, marketing-ui). +# Empty = tracking off for that env. First-party properties only — +# see knowledge/knowledge-base/web10-v3/telemetry.md. +GA4_MEASUREMENT_ID= +HOTJAR_SITE_ID= diff --git a/ubuntu-deployment/docker-compose.ecosystem.yml b/ubuntu-deployment/docker-compose.ecosystem.yml index 3b53a10a..11cfb60c 100644 --- a/ubuntu-deployment/docker-compose.ecosystem.yml +++ b/ubuntu-deployment/docker-compose.ecosystem.yml @@ -126,6 +126,9 @@ services: REACT_APP_AUTH_ORIGIN: ${AUTH_ORIGIN:?e.g. https://auth.dev.web10.app} REACT_APP_RTC_ORIGIN: ${RTC_ORIGIN:?e.g. https://rtc.dev.web10.app} REACT_APP_DEFAULT_API: ${API_HOST:?host only, e.g. api.dev.web10.app} + # D56: full-platform telemetry (empty = tracking off for this env) + VITE_GA4_MEASUREMENT_ID: ${GA4_MEASUREMENT_ID:-} + VITE_HOTJAR_SITE_ID: ${HOTJAR_SITE_ID:-} environment: PORT: "80" restart: unless-stopped @@ -161,6 +164,9 @@ services: VITE_RTC_ORIGIN: ${RTC_ORIGIN} VITE_MARKETING_API: ${MARKETING_API_ORIGIN:?e.g. https://marketing-api.dev.web10.app} VITE_MARKETING_ORIGIN: ${MARKETING_UI_ORIGIN:-https://marketing.web10.app} + # D56: full-platform telemetry (empty = tracking off for this env) + VITE_GA4_MEASUREMENT_ID: ${GA4_MEASUREMENT_ID:-} + VITE_HOTJAR_SITE_ID: ${HOTJAR_SITE_ID:-} restart: unless-stopped networks: proxy: @@ -185,6 +191,9 @@ services: VITE_SOCIAL_URL: ${SOCIAL_ORIGIN:-} VITE_MARKETING_URL: ${MARKETING_UI_ORIGIN:-} STATUS_VERSION: ${STATUS_VERSION:-} + # D56: full-platform telemetry (empty = tracking off for this env) + VITE_GA4_MEASUREMENT_ID: ${GA4_MEASUREMENT_ID:-} + VITE_HOTJAR_SITE_ID: ${HOTJAR_SITE_ID:-} restart: unless-stopped networks: proxy: diff --git a/ui/.env_example b/ui/.env_example index d0f257c0..b6829bd4 100644 --- a/ui/.env_example +++ b/ui/.env_example @@ -7,4 +7,8 @@ REACT_APP_LOGO_DARK="/YourOrgsLogo/generic_school_logo_white.png" # VITE_API_ORIGIN / VITE_RTC_ORIGIN are accepted as aliases too. REACT_APP_AUTH_ORIGIN="https://auth.web10.app" REACT_APP_API_ORIGIN="https://api.web10.app" -REACT_APP_RTC_ORIGIN="https://rtc.web10.app" \ No newline at end of file +REACT_APP_RTC_ORIGIN="https://rtc.web10.app" + +# D56: full-platform telemetry — GA4 + Hotjar (content-masked). Empty = off. +VITE_GA4_MEASUREMENT_ID= +VITE_HOTJAR_SITE_ID= \ No newline at end of file diff --git a/ui/Dockerfile b/ui/Dockerfile index 7014b536..97a49552 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -26,10 +26,15 @@ ARG REACT_APP_AUTH_ORIGIN ARG REACT_APP_API_ORIGIN ARG REACT_APP_RTC_ORIGIN ARG REACT_APP_DEFAULT_API +# D56: full-platform telemetry — GA4 + Hotjar IDs (empty = tracking off). +ARG VITE_GA4_MEASUREMENT_ID +ARG VITE_HOTJAR_SITE_ID ENV REACT_APP_AUTH_ORIGIN=${REACT_APP_AUTH_ORIGIN} \ REACT_APP_API_ORIGIN=${REACT_APP_API_ORIGIN} \ REACT_APP_RTC_ORIGIN=${REACT_APP_RTC_ORIGIN} \ - REACT_APP_DEFAULT_API=${REACT_APP_DEFAULT_API} + REACT_APP_DEFAULT_API=${REACT_APP_DEFAULT_API} \ + VITE_GA4_MEASUREMENT_ID=${VITE_GA4_MEASUREMENT_ID} \ + VITE_HOTJAR_SITE_ID=${VITE_HOTJAR_SITE_ID} RUN bun run build diff --git a/ui/src/__tests__/analytics.test.ts b/ui/src/__tests__/analytics.test.ts new file mode 100644 index 00000000..cf03f7c7 --- /dev/null +++ b/ui/src/__tests__/analytics.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + loadGa4, + loadHotjar, + resolveTelemetryIds, + installTelemetry, + trackPageview, + hotjarIdentify, +} from '../lib/analytics' + +describe('analytics (ui)', () => { + let appendChildSpy: ReturnType + + beforeEach(() => { + delete (window as any).dataLayer + delete (window as any).gtag + delete (window as any).hj + document.head.querySelectorAll('script[src*="googletagmanager"]').forEach((s) => s.remove()) + document.head.querySelectorAll('script[src*="hotjar"]').forEach((s) => s.remove()) + appendChildSpy = vi.spyOn(document.head, 'appendChild') + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', undefined) + vi.stubEnv('VITE_HOTJAR_SITE_ID', undefined) + }) + + afterEach(() => { + appendChildSpy.mockRestore() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.restoreAllMocks() + delete (window as any).dataLayer + delete (window as any).gtag + delete (window as any).hj + }) + + describe('loadGa4', () => { + it('is a no-op for an empty measurement ID', () => { + loadGa4('') + expect((window as any).gtag).toBeUndefined() + expect(appendChildSpy).not.toHaveBeenCalled() + }) + + it('loads the GA4 script for the given ID', () => { + loadGa4('G-UI123') + expect(appendChildSpy).toHaveBeenCalledTimes(1) + const script = appendChildSpy.mock.calls[0][0] as HTMLScriptElement + expect(script.src).toBe('https://www.googletagmanager.com/gtag/js?id=G-UI123') + expect(script.async).toBe(true) + }) + + it('only installs once (idempotent)', () => { + loadGa4('G-UI456') + loadGa4('G-OTHER') + expect(appendChildSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('loadHotjar', () => { + it('is a no-op for a zero site ID', () => { + loadHotjar(0) + expect((window as any).hj).toBeUndefined() + expect(appendChildSpy).not.toHaveBeenCalled() + }) + + it('loads the Hotjar script for the given ID', () => { + loadHotjar(654321) + expect(appendChildSpy).toHaveBeenCalledTimes(1) + const script = appendChildSpy.mock.calls[0][0] as HTMLScriptElement + expect(script.src).toBe('https://static.hotjar.com/c/hotjar-654321.js?sv=6') + expect(script.async).toBe(true) + }) + + it('initialises with full content masking (D56: text blurred, images blocked)', () => { + loadHotjar(654321) + const q = (window as any).hj.q as unknown[][] + expect(q).toHaveLength(1) + expect(q[0]).toEqual(['init', { hjid: 654321, maskAllText: true, blockAllImages: true }]) + }) + }) + + describe('resolveTelemetryIds', () => { + it('prefers the node config when the node is reachable', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ga4_measurement_id: 'G-NODE', hotjar_site_id: '777' }), + })) + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-ENV') + vi.stubEnv('VITE_HOTJAR_SITE_ID', '555') + const ids = await resolveTelemetryIds() + expect(ids).toEqual({ ga4: 'G-NODE', hotjar: 777 }) + }) + + it('falls back to env when the node is unreachable', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + vi.stubEnv('VITE_GA4_MEASUREMENT_ID', 'G-ENV') + vi.stubEnv('VITE_HOTJAR_SITE_ID', '555') + const ids = await resolveTelemetryIds() + expect(ids).toEqual({ ga4: 'G-ENV', hotjar: 555 }) + }) + }) + + describe('installTelemetry', () => { + it('loads both instruments when the node configures them', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ga4_measurement_id: 'G-NODE', hotjar_site_id: '777' }), + })) + await installTelemetry() + expect((window as any).gtag).toBeDefined() + expect((window as any).hj).toBeDefined() + }) + }) + + describe('trackPageview', () => { + it('sends a page_view event with the given path', () => { + const mockGtag = vi.fn() + ;(window as any).gtag = mockGtag + trackPageview('/?auth=1') + expect(mockGtag).toHaveBeenCalledWith('event', 'page_view', { page_path: '/?auth=1' }) + }) + + it('is a no-op when gtag is not installed', () => { + delete (window as any).gtag + expect(() => trackPageview()).not.toThrow() + }) + }) + + describe('hotjarIdentify', () => { + it('queues an identify call when Hotjar is installed', () => { + loadHotjar(654321) + hotjarIdentify('bob') + const q = (window as any).hj.q as unknown[][] + expect(q[q.length - 1]).toEqual(['identify', 'bob']) + }) + + it('is a no-op when Hotjar is not installed', () => { + expect(() => hotjarIdentify('bob')).not.toThrow() + }) + }) +}) \ No newline at end of file diff --git a/ui/src/__tests__/configTelemetry.test.tsx b/ui/src/__tests__/configTelemetry.test.tsx new file mode 100644 index 00000000..acaea55c --- /dev/null +++ b/ui/src/__tests__/configTelemetry.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import React from 'react' + +vi.mock('axios', () => ({ + default: { + post: vi.fn(), + patch: vi.fn(), + }, +})) + +import axios from 'axios' +import ConfigPage from '../components/Config/ConfigPage' + +const mockI = { + isAdmin: true, + v3: { + state: { token: 'admin-token' }, + readToken: () => ({ provider: 'api.localhost', username: 'admin' }), + }, +} + +function mockLoad(cfg: Record = {}) { + ;(axios.post as any).mockImplementation((url: string) => { + if (url.includes('/config')) return Promise.resolve({ data: { admins: ['admin'], ...cfg } }) + if (url.includes('/apps/admin')) return Promise.resolve({ data: { apps: [] } }) + if (url.includes('/v3/groups/hidden')) return Promise.resolve({ data: { hidden: [] } }) + if (url.includes('/v3/read')) return Promise.resolve({ data: [] }) + if (url.includes('/config/update')) return Promise.resolve({ data: { status: 'updated' } }) + return Promise.resolve({ data: {} }) + }) +} + +// The body axios.post received for a given path substring. +function updateBody(): any { + const call = (axios.post as any).mock.calls.find((c: any[]) => + String(c[0]).includes('/config/update'), + ) + return call ? call[1] : undefined +} + +describe('ConfigPage telemetry (D56)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the GA4 + Hotjar fields with loaded values', async () => { + mockLoad({ ga4_measurement_id: 'G-EXISTING', hotjar_site_id: '42' }) + render() + await waitFor(() => + expect(screen.getByTestId('config-telemetry-card')).toBeInTheDocument(), + ) + expect( + (screen.getByTestId('config-ga4-id') as HTMLInputElement).value, + ).toBe('G-EXISTING') + expect( + (screen.getByTestId('config-hotjar-id') as HTMLInputElement).value, + ).toBe('42') + }) + + it('Save sends the nested {token:{token}, update:{...}} shape with the changed telemetry field', async () => { + mockLoad({ ga4_measurement_id: '', hotjar_site_id: '' }) + render() + const ga4 = await screen.findByTestId('config-ga4-id') + fireEvent.change(ga4, { target: { value: 'G-NEW' } }) + fireEvent.click(screen.getByTestId('config-save-button')) + await waitFor(() => expect(updateBody()).toBeDefined()) + // The API takes two body models — token (nested) + update (the diff). + expect(updateBody()).toEqual({ + token: { token: 'admin-token' }, + update: { ga4_measurement_id: 'G-NEW' }, + }) + }) + + it('an unchanged field stays off the wire (diff-only save)', async () => { + mockLoad({ ga4_measurement_id: 'G-SAME', hotjar_site_id: '7' }) + render() + const hotjar = await screen.findByTestId('config-hotjar-id') + fireEvent.change(hotjar, { target: { value: '8' } }) + fireEvent.click(screen.getByTestId('config-save-button')) + await waitFor(() => expect(updateBody()).toBeDefined()) + // Only the changed field is sent; ga4_measurement_id is untouched. + expect(updateBody()).toEqual({ + token: { token: 'admin-token' }, + update: { hotjar_site_id: '8' }, + }) + }) + + it('adding an admin sends the nested shape with update.admins', async () => { + mockLoad({}) + render() + const addInput = await screen.findByTestId('config-admin-add-input') + fireEvent.change(addInput, { target: { value: 'newadmin' } }) + fireEvent.click(screen.getByTestId('config-admin-add-button')) + await waitFor(() => expect(updateBody()).toBeDefined()) + expect(updateBody()).toEqual({ + token: { token: 'admin-token' }, + update: { admins: ['admin', 'newadmin'] }, + }) + }) +}) \ No newline at end of file diff --git a/ui/src/components/Config/ConfigPage.tsx b/ui/src/components/Config/ConfigPage.tsx index 83c3f747..e59518d6 100644 --- a/ui/src/components/Config/ConfigPage.tsx +++ b/ui/src/components/Config/ConfigPage.tsx @@ -103,6 +103,14 @@ function ConfigPage({ I }: { I: Record }) { }); }; + // /config/update takes TWO body models — `token: Token` (a nested object + // carrying the JWT) and `update: ConfigUpdate` (the field changes). FastAPI + // therefore expects { token: { token }, update: {...} }, NOT a flat + // { token, ...fields }. This helper builds the correct shape so every save + // path (main Save + Admins) persists instead of 422ing. + const configUpdate = (fields: Record) => + nodePost("/config/update", { token: { token: I.v3.state.token }, update: fields }); + const loadConfig = async () => { try { const resp = await nodePost("/config", { token: I.v3.state.token }); @@ -240,9 +248,7 @@ function ConfigPage({ I }: { I: Record }) { setSaving(true); setError(null); try { - const decoded = I.v3.readToken(); - const protocol = window.location.protocol; - await nodePost("/config/update", { token: I.v3.state.token, admins: next }); + await configUpdate({ admins: next }); setConfig(prev => ({ ...prev, admins: next })); setLoadedConfig(prev => ({ ...prev, admins: next })); } catch (e: any) { @@ -274,15 +280,15 @@ function ConfigPage({ I }: { I: Record }) { setSaving(true); setError(null); try { - const payload: Record = { token: I.v3.state.token }; + const update: Record = {}; for (const key of Object.keys(config || {})) { - if (key === "admins") continue; // admins saved via /admins above + if (key === "admins") continue; // admins saved via the Admins card const next = (config as any)[key]; const prev = loadedConfig[key]; if (JSON.stringify(next) === JSON.stringify(prev)) continue; - payload[key] = next; + update[key] = next; } - await nodePost("/config/update", payload); + await configUpdate(update); setLoadedConfig({ ...config }); setSaved(true); setTimeout(() => setSaved(false), 3000); @@ -662,6 +668,27 @@ function ConfigPage({ I }: { I: Record }) { + + + Telemetry (D56) + + +

+ Usage analytics for the whole platform — GA4 (pageviews + events) + and Hotjar (session recordings, content-masked). Set an ID to turn + that instrument on; leave blank to turn it off. Changes apply live + on the next page load — no rebuild. These are public identifiers, + not secrets. +

+ + updateField("ga4_measurement_id", e.target.value)} placeholder="G-XXXXXXXXXX" data-testid="config-ga4-id" /> + + + updateField("hotjar_site_id", e.target.value)} placeholder="123456" data-testid="config-hotjar-id" /> + +
+
+ Database diff --git a/ui/src/lib/analytics.ts b/ui/src/lib/analytics.ts new file mode 100644 index 00000000..1276e2cd --- /dev/null +++ b/ui/src/lib/analytics.ts @@ -0,0 +1,153 @@ +// GA4 + Hotjar analytics for the authenticator (D56: full-platform +// telemetry). GA4: pageviews of the (query-parameter-driven) screen. +// Hotjar: session recordings + heatmaps, content-blind by construction +// (maskAllText + blockAllImages — the operator sees cursor + layout + +// timing, never words or pictures). See +// knowledge-base/web10-v3/telemetry.md. +// +// The IDs are resolved at RUNTIME from the node (GET /telemetry) so an +// operator can change them live in the Node Config UI without a rebuild. +// The node is authoritative when reachable; the build-time env is the +// fallback for pure frontend dev where the node is unreachable. + +import { config } from '../config' + +const API_ORIGIN = config.REACT_APP_API_ORIGIN + +interface GtagQueue { + (command: 'config', measurementId: string, config?: Record): void + (command: 'event', eventName: string, params?: Record): void + (command: 'js', timestamp: number): void +} + +declare global { + interface Window { + dataLayer?: unknown[][] + gtag?: GtagQueue + } +} + +export interface TelemetryIds { + ga4: string + hotjar: number +} + +function envIds(): TelemetryIds { + const ga4 = + typeof import.meta.env?.VITE_GA4_MEASUREMENT_ID === 'string' + ? import.meta.env.VITE_GA4_MEASUREMENT_ID.trim() + : '' + const raw = import.meta.env?.VITE_HOTJAR_SITE_ID + const hotjar = raw ? parseInt(raw, 10) : 0 + return { ga4, hotjar: isNaN(hotjar) ? 0 : hotjar } +} + +/** + * Resolve the telemetry IDs. The node's GET /telemetry is authoritative when + * reachable (an admin set the IDs in the Node Config UI — empty = off). When + * the node is unreachable (pure frontend dev), fall back to the build-time + * env. Never throws — telemetry must never break the app. + */ +export async function resolveTelemetryIds(): Promise { + try { + const resp = await fetch(`${API_ORIGIN}/telemetry`) + if (!resp.ok) throw new Error(String(resp.status)) + const data = await resp.json() + return { + ga4: String(data.ga4_measurement_id || '').trim(), + hotjar: parseInt(String(data.hotjar_site_id || ''), 10) || 0, + } + } catch { + return envIds() + } +} + +/** + * Load the GA4 snippet and initialise it with the given measurement ID. + * Idempotent — a second call is a no-op. + */ +export function loadGa4(measurementId: string): void { + if (typeof document === 'undefined') return + if (!measurementId || (window as any).gtag) return + + window.dataLayer = window.dataLayer || [] + window.gtag = function (...args: unknown[]) { + window.dataLayer!.push(args as unknown[]) + } as GtagQueue + window.gtag('js', new Date().getTime()) + window.gtag('config', measurementId, { + // D56: max tracking, one exception — we do not feed Google's ad + // network. The only sponsors a fan sees are the creator's (D50/D55). + advertising_id: 'OFF', + }) + + const s = document.createElement('script') + s.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}` + s.async = true + document.head.appendChild(s) +} + +/** + * Load the Hotjar snippet and initialise it with full content masking + * (D56): all text blurred, all images blocked. Idempotent — a second call + * is a no-op. + */ +export function loadHotjar(siteId: number): void { + if (typeof document === 'undefined') return + if (!siteId || (window as any).hj) return + + // Canonical Hotjar queue pattern (the real script drains hj.q on load). + ;(window as any).hj = (window as any).hj || function (...args: unknown[]) { + ;((window as any).hj.q = (window as any).hj.q || []).push(args) + } + + const s = document.createElement('script') + s.src = `https://static.hotjar.com/c/hotjar-${siteId}.js?sv=6` + s.async = true + document.head.appendChild(s) + + // D56: the recording is content-blind — blur all text, block all images. + ;(window as any).hj('init', { + hjid: siteId, + maskAllText: true, + blockAllImages: true, + }) +} + +/** + * Kick off telemetry: resolve the IDs (node config, env fallback) and load + * whichever instruments are configured. Fire-and-forget — never blocks + * render, never throws. + */ +export function installTelemetry(): Promise { + if (typeof document === 'undefined') return Promise.resolve() + return resolveTelemetryIds().then(({ ga4, hotjar }) => { + if (ga4) loadGa4(ga4) + if (hotjar) loadHotjar(hotjar) + }) +} + +/** + * Track a pageview. The authenticator is query-parameter-driven (no + * router), so the screen IS the URL — path + search. + */ +export function trackPageview(path?: string) { + if (!window.gtag) return + const p = path ?? (typeof window !== 'undefined' ? window.location.pathname + window.location.search : '') + window.gtag('event', 'page_view', { + page_path: p, + }) +} + +/** + * Identify a known user in Hotjar (e.g., after login). + * Safe no-op when Hotjar is not installed. + */ +export function hotjarIdentify(userId: string, props?: Record) { + if (typeof window === 'undefined' || !(window as any).hj) return + if (props) { + ;(window as any).hj('identify', userId, props) + } else { + ;(window as any).hj('identify', userId) + } +} \ No newline at end of file diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 8d9fd864..48968de0 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -2,12 +2,19 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import ErrorBoundary from './components/shared/ErrorBoundary' +// D56: full-platform telemetry — GA4 + masked Hotjar (content-blind). +import { installTelemetry, trackPageview } from './lib/analytics' // Self-hosted variable fonts (design.md §5) — never a font CDN. import '@fontsource-variable/inter' import '@fontsource-variable/space-grotesk' import '@fontsource-variable/jetbrains-mono' import './index.css' +// IDs resolved at runtime from the node (GET /telemetry), env fallback in +// dev. The authenticator is query-parameter-driven (no router) — the screen +// IS the URL, so one pageview per load, fired after GA4 is ready. +installTelemetry().then(() => trackPageview()) + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 85004f5a..5a0b43fc 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/env.ts","./src/global.d.ts","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/boardmoderation.test.tsx","./src/__tests__/config.test.ts","./src/__tests__/consentview.test.tsx","./src/__tests__/groups.test.tsx","./src/__tests__/mockinterface.test.tsx","./src/__tests__/mocks.test.ts","./src/__tests__/recoverynudge.test.tsx","./src/__tests__/recoveryphone.test.tsx","./src/__tests__/setup.ts","./src/__tests__/studio.test.tsx","./src/components/config/configpage.tsx","./src/components/consent/consentview.tsx","./src/components/contracts/contractpage.tsx","./src/components/contracts/requestpage.tsx","./src/components/credentialpage/credentialpage.tsx","./src/components/credentialpage/credentialstatus.tsx","./src/components/credentialpage/forgotform.tsx","./src/components/credentialpage/loginform.tsx","./src/components/credentialpage/signupform.tsx","./src/components/credentialpage/forminputs/betacode.tsx","./src/components/credentialpage/forminputs/confirmationpass.tsx","./src/components/credentialpage/forminputs/password.tsx","./src/components/credentialpage/forminputs/phone.tsx","./src/components/credentialpage/forminputs/provider.tsx","./src/components/credentialpage/forminputs/retypepass.tsx","./src/components/credentialpage/forminputs/username.tsx","./src/components/groups/groupcard.tsx","./src/components/groups/groupmembersdialog.tsx","./src/components/groups/grouprolesdialog.tsx","./src/components/groups/groupspage.tsx","./src/components/settings/changepassword.tsx","./src/components/settings/changephone.tsx","./src/components/settings/changelog.tsx","./src/components/settings/devpay.tsx","./src/components/settings/recoverycontact.tsx","./src/components/settings/settings.tsx","./src/components/settings/subscription.tsx","./src/components/settings/verifyphone.tsx","./src/components/settings/forminputs/newpassword.tsx","./src/components/settings/forminputs/retypenewpass.tsx","./src/components/setupwizard/setupwizard.tsx","./src/components/studio/amazontagcard.tsx","./src/components/studio/directdealscard.tsx","./src/components/studio/laddercard.tsx","./src/components/studio/membershipscard.tsx","./src/components/studio/studiopage.tsx","./src/components/studio/studio-data.ts","./src/components/shared/appshell.tsx","./src/components/shared/branding.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/mobilenav.tsx","./src/components/shared/recoverynudgebanner.tsx","./src/components/shared/sidebar.tsx","./src/components/shared/topbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/interfaces/interface.tsx","./src/interfaces/mockinterface.tsx","./src/interfaces/authadapter.ts","./src/lib/group-utils.ts","./src/lib/links.ts","./src/lib/utils.ts","./src/mocks/mockmedia.ts","./src/mocks/mockrequests.ts","./src/mocks/mockservices.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/env.ts","./src/global.d.ts","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/analytics.test.ts","./src/__tests__/boardmoderation.test.tsx","./src/__tests__/config.test.ts","./src/__tests__/configtelemetry.test.tsx","./src/__tests__/consentview.test.tsx","./src/__tests__/groups.test.tsx","./src/__tests__/mockinterface.test.tsx","./src/__tests__/mocks.test.ts","./src/__tests__/recoverynudge.test.tsx","./src/__tests__/recoveryphone.test.tsx","./src/__tests__/setup.ts","./src/__tests__/studio.test.tsx","./src/components/config/configpage.tsx","./src/components/consent/consentview.tsx","./src/components/contracts/contractpage.tsx","./src/components/contracts/requestpage.tsx","./src/components/credentialpage/credentialpage.tsx","./src/components/credentialpage/credentialstatus.tsx","./src/components/credentialpage/forgotform.tsx","./src/components/credentialpage/loginform.tsx","./src/components/credentialpage/signupform.tsx","./src/components/credentialpage/forminputs/betacode.tsx","./src/components/credentialpage/forminputs/confirmationpass.tsx","./src/components/credentialpage/forminputs/password.tsx","./src/components/credentialpage/forminputs/phone.tsx","./src/components/credentialpage/forminputs/provider.tsx","./src/components/credentialpage/forminputs/retypepass.tsx","./src/components/credentialpage/forminputs/username.tsx","./src/components/groups/groupcard.tsx","./src/components/groups/groupmembersdialog.tsx","./src/components/groups/grouprolesdialog.tsx","./src/components/groups/groupsettingsdialog.tsx","./src/components/groups/groupspage.tsx","./src/components/settings/changepassword.tsx","./src/components/settings/changephone.tsx","./src/components/settings/changelog.tsx","./src/components/settings/devpay.tsx","./src/components/settings/recoverycontact.tsx","./src/components/settings/settings.tsx","./src/components/settings/subscription.tsx","./src/components/settings/verifyphone.tsx","./src/components/settings/forminputs/newpassword.tsx","./src/components/settings/forminputs/retypenewpass.tsx","./src/components/setupwizard/setupwizard.tsx","./src/components/studio/amazontagcard.tsx","./src/components/studio/directdealscard.tsx","./src/components/studio/laddercard.tsx","./src/components/studio/membershipscard.tsx","./src/components/studio/studiopage.tsx","./src/components/studio/studio-data.ts","./src/components/shared/appshell.tsx","./src/components/shared/branding.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/mobilenav.tsx","./src/components/shared/recoverynudgebanner.tsx","./src/components/shared/sidebar.tsx","./src/components/shared/topbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/interfaces/interface.tsx","./src/interfaces/mockinterface.tsx","./src/interfaces/authadapter.ts","./src/lib/analytics.ts","./src/lib/group-utils.ts","./src/lib/links.ts","./src/lib/utils.ts","./src/mocks/mockmedia.ts","./src/mocks/mockrequests.ts","./src/mocks/mockservices.ts"],"version":"5.8.3"} \ No newline at end of file