From 62b86d05bb047c5883d2752d47cebdd0502829c4 Mon Sep 17 00:00:00 2001 From: lifeodyssey Date: Sat, 26 Sep 2026 01:11:14 +0800 Subject: [PATCH] Enable analytics by default with preserved opt-outs --- README.md | 2 +- docs/analytics/measurement-contract.md | 16 ++++--- docs/analytics/operations.md | 16 ++++--- src/client/AnalyticsNotice.tsx | 14 +++--- src/client/analytics.ts | 16 +++++-- tests/client/analytics-notice.test.tsx | 63 ++++++++++++++++++++++++++ tests/client/analytics.test.ts | 61 +++++++++++++++++++++++-- tests/client/webmcp.test.ts | 15 +++++- 8 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 tests/client/analytics-notice.test.tsx diff --git a/README.md b/README.md index 0e1722f..1943bc5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Usage research is available at [`/report/0923`](https://sharehtml.zhenjia.dev/report/0923). The September 23, 2026 report uses a frozen snapshot and publishes only aggregated findings and anonymized use cases. -First-party analytics separates browser reports, HTTP/MCP business outcomes, acquisition signals, and bot evidence. See the [measurement contract](docs/analytics/measurement-contract.md) and [operations guide](docs/analytics/operations.md) for consent, data limits, retention, and GA4 configuration. Apply the analytics migrations before deploying with `ANALYTICS_ENABLED=true`. Consenting browser events use the separate Share HTML GA4 property; enhanced measurement is disabled and private share pages do not send Google events. +First-party analytics separates browser reports, HTTP/MCP business outcomes, acquisition signals, and bot evidence. See the [measurement contract](docs/analytics/measurement-contract.md) and [operations guide](docs/analytics/operations.md) for analytics preferences, data limits, retention, and GA4 configuration. Apply the analytics migrations before deploying with `ANALYTICS_ENABLED=true`. Browser analytics is enabled by default when the service is enabled and can be turned off in Analytics preferences; existing opt-outs, DNT/GPC, and unreadable preference storage prevent collection. Eligible browser events use the separate Share HTML GA4 property; enhanced measurement is disabled and private share pages do not send Google events.

diff --git a/docs/analytics/measurement-contract.md b/docs/analytics/measurement-contract.md index 9241f2a..f83389d 100644 --- a/docs/analytics/measurement-contract.md +++ b/docs/analytics/measurement-contract.md @@ -4,13 +4,17 @@ Version 1, prospective only. `ANALYTICS_ENABLED=true` activates first-party tele ## Browser contract +When service analytics is enabled, an unset browser preference defaults to enabled. Users can turn it off in Analytics preferences; stored opt-outs are preserved. DNT/GPC override the default, and unreadable preference storage prevents collection. Turning analytics off clears the tab session/acquisition context. This policy also gates browser GA, whose dedicated resource and private-share-page exclusions remain separate requirements. + +The September 26, 2026 client change expands collection coverage from affirmative opt-in to default-on with opt-out. The confirmed production timestamp is recorded in Cloudflare deployment history and the release pull request. Comparisons of browser, WebMCP or GA counts across the confirmed deployment boundary must account for this coverage change and must not label the difference as product growth. Frozen historical reports are unchanged. + Same-origin `POST /api/analytics/events`, `Content-Type: application/json`, matching `Origin` required. Maximum streamed body is 4096 bytes. Body: ```json {"event":"page_view","event_id":"aab11f0d-0baa-417b-8271-620fa97ef198","session_id":"bab11f0d-0baa-417b-8271-620fa97ef198","route":"/","acquisition":{"source":"google","medium":"organic","campaign":"launch","referrer_domain":"google.com"}} ``` -Ordinary browser events allowlisted: `page_view`, `upload_started`, `upload_failed`, `share_link_copied`. The same bounded, rate-limited endpoint separately allows consented `webmcp_available`, `webmcp_registered`, `webmcp_call`, `webmcp_result` with the strict combinations below. UUID `event_id` is globally idempotent; UUID `session_id` denotes a sessionStorage tab session, never a person. All are client self-reports. Uploads may include JSON form field `analytics` with `{session_id, acquisition}`; invalid analytics never blocks upload. `source`, `medium`, `campaign` accept lowercase `[a-z0-9_-]` tokens, maximum 64 characters; referrer is a domain only. Everything else is dropped. Routes are allowlisted; `/s/` and `/v//...` become `/s/:slug`, `/v/:slug`; unknown routes become `other`. No title, HTML, filename, raw IP/UA, key, claim/auth token, slug, full referrer URL, or arbitrary query parameter is stored in the new telemetry. +Ordinary browser events allowlisted: `page_view`, `upload_started`, `upload_failed`, `share_link_copied`. The same bounded, rate-limited endpoint separately allows preference-eligible `webmcp_available`, `webmcp_registered`, `webmcp_call`, `webmcp_result` with the strict combinations below. UUID `event_id` is globally idempotent; UUID `session_id` denotes a sessionStorage tab session, never a person. All are client self-reports. Uploads may include JSON form field `analytics` with `{session_id, acquisition}`; invalid analytics never blocks upload. `source`, `medium`, `campaign` accept lowercase `[a-z0-9_-]` tokens, maximum 64 characters; referrer is a domain only. Everything else is dropped. Routes are allowlisted; `/s/` and `/v//...` become `/s/:slug`, `/v/:slug`; unknown routes become `other`. No title, HTML, filename, raw IP/UA, key, claim/auth token, slug, full referrer URL, or arbitrary query parameter is stored in the new telemetry. The browser endpoint returns 204 on accepted or disabled telemetry; 400 invalid body; 403 origin; 413 oversized; 415 content type; 429 rate limit; 503 unavailable storage. The atomic PostgreSQL RPC limits 30 events per tab-session per minute and 120 per daily HMAC IP abuse bucket per minute across Worker isolates. Retries consume rate allowance, but duplicate event IDs never create additional event rows. Abuse buckets are separate from analytics, expire after one day via maintenance, and are never a people metric. Non-browser clients can forge Origin/session IDs; rate limits mitigate pollution, not establish identity. @@ -27,7 +31,7 @@ MCP initialize stores only an allowlisted client family (`claude`, `codex`, `cur ## Event meaning -`page_served` counts successful server GET responses for the HTML homepage, marketing pages and `/s/:slug` wrappers. It is independent of browser consent, so a crawler that only reads ordinary HTML is included with the same actor/evidence caveats. HEAD, error responses, uploaded `/v` content, report pages and discovery documents are excluded from this event. A private wrapper is only a generic shell: serving it does not prove access to protected HTML. It is a response-count denominator, not a person, rendered page, browser session or conversion. `page_view` is a consenting browser's self-reported view; one navigation can emit both, while cached/client-only navigation and missing consent can make coverage differ. Never add these two event counts together. Use server `page_served` with server outcomes for coarse request activity and consented tab-session events for the separate browser funnel. The `page_served` CHECK-constraint migration must be applied before deploying this event. +`page_served` counts successful server GET responses for the HTML homepage, marketing pages and `/s/:slug` wrappers. It is independent of browser analytics preferences, so a crawler that only reads ordinary HTML is included with the same actor/evidence caveats. HEAD, error responses, uploaded `/v` content, report pages and discovery documents are excluded from this event. A private wrapper is only a generic shell: serving it does not prove access to protected HTML. It is a response-count denominator, not a person, rendered page, browser session or conversion. `page_view` is an eligible browser's self-reported view; one navigation can emit both, while cached/client-only navigation and opt-outs or privacy/storage restrictions can make coverage differ. Never add these two event counts together. Use server `page_served` with server outcomes for coarse request activity and eligible tab-session events for the separate browser funnel. The `page_served` CHECK-constraint migration must be applied before deploying this event. `share_created` means the server completed share metadata, object storage, asset metadata, and scan-state updates. It includes automatically blocked uploads (HTTP 202), which are not successful public delivery. HTTP status and outcome retain that distinction. `share_create_failed` includes validation/rate-limit/server failures. MCP tool result `isError` determines tool failure; HTTP status alone cannot determine MCP success. `preview_served` means GET passed access checks and obtained an HTML object, not that a person rendered or read it. HEAD is excluded. `discovery_read` means a successful GET of a discovery document; it is not a user, lead, install, or conversion. MCP initialize is not an upload. Browser upload_failed can overlap a server failure and must not be summed as unique failures. @@ -47,13 +51,13 @@ order by start_time desc limit 10; select event_name, transport, actor_category, count(*) from public.analytics_events group by 1,2,3; ``` -No GA Measurement Protocol requests originate from the server. If enabled separately, browser GA4 remains a separate, consent/privacy-dependent data source and is never injected into uploaded `/v` HTML. Historical traffic without this instrumentation remains unknown. +No GA Measurement Protocol requests originate from the server. If enabled separately, browser GA4 remains a separate, preference/privacy-dependent data source and is never injected into uploaded `/v` HTML. Historical traffic without this instrumentation remains unknown. ## Browser WebMCP observations The WebMCP migration expands the existing event/transport/tool constraints and the same ingestion RPC's fixed combinations. It adds no tables, privileges, public reads, new retention policy or alternate rate limits. Apply it before the client release. Existing HTML tool definitions are extracted into `src/client/webmcp.ts` and wrapped without changing their inputs, HTTP requests, returned content or thrown errors. -All four events require affirmative optional analytics consent, enabled first-party analytics, and no DNT/GPC. Tools work even when telemetry is off. Availability and registration state can be reported once per consented tab context after the user grants consent; prior tool calls are never replayed. Each invocation rechecks consent, so revoking before a result suppresses that result. +All four events require enabled first-party analytics, an enabled browser preference, readable preference storage, and no DNT/GPC. An unset preference defaults to enabled; a stored opt-out remains disabled. Tools work even when telemetry is off. Availability and registration state can be reported once per eligible tab context when analytics is enabled; prior tool calls are never replayed. Each invocation rechecks eligibility, so turning analytics off before a result suppresses that result. | Event | Meaning | Required outcome/tool | | --- | --- | --- | @@ -64,7 +68,7 @@ All four events require affirmative optional analytics consent, enabled first-pa Transport is `webmcp`, legacy_source is fixed `webmcp`, and tool is one of `describe_share_html`, `get_public_share`, `access_private_share`, `create_share`. MCP method/client are null. No arguments, response text, HTML, title, slug, key, raw exception, claimed agent name or other dynamic tool metadata is sent. These events never use the GA sender. Actor evidence remains the request's coarse UA/Cloudflare evidence, independent of the client-reported transport. -Anyone can imitate these client reports; availability is not active usage, registration is not a tool call, and tool invocation is not proof of an AI agent or human. `webmcp_result=success` preserves the tool's existing `isError` semantics, so a server 202 automatically blocked creation can still be a successful tool response. Use the independent server `share_created` status/outcome to distinguish accepted-but-blocked storage from publicly usable creation. With consent, WebMCP creation attaches the same sanitized tab session/acquisition context as ordinary browser uploads, permitting tab-level association with server outcomes; denial/DNT/GPC omits it. Server HTTP creations remain `transport=http_api, legacy_source=webmcp`; do not sum them with WebMCP result counts. Source is now parsed immediately after form decoding so missing-file/extension validation failures retain the label; errors before form decoding cannot be reliably attributed. +Anyone can imitate these client reports; availability is not active usage, registration is not a tool call, and tool invocation is not proof of an AI agent or human. `webmcp_result=success` preserves the tool's existing `isError` semantics, so a server 202 automatically blocked creation can still be a successful tool response. Use the independent server `share_created` status/outcome to distinguish accepted-but-blocked storage from publicly usable creation. When browser analytics is eligible, WebMCP creation attaches the same sanitized tab session/acquisition context as ordinary browser uploads, permitting tab-level association with server outcomes; opt-out/DNT/GPC or unavailable preference storage omits it. Server HTTP creations remain `transport=http_api, legacy_source=webmcp`; do not sum them with WebMCP result counts. Source is now parsed immediately after form decoding so missing-file/extension validation failures retain the label; errors before form decoding cannot be reliably attributed. All optional events are best effort, with no retries or guaranteed delivery. Rate limiting, unloads, denial, unavailable storage, or a lost request/result can produce unmatched counts. There is no durable per-call correlation ID: concurrent calls and server creations cannot be joined exactly from session/tool/time alone. Report aggregate calls/results with these gaps, never infer historical non-use from zero WebMCP tags. @@ -74,4 +78,4 @@ All optional events are best effort, with no retries or guaranteed delivery. Rat Raw events and daily aggregates retain this country dimension, with the existing 90-day/730-day retention and permission model. Pre-instrumentation rows receive `ZZ`; historical countries are never reconstructed from stored hashes or guesses. Rollups group country separately and continue aggregating all complete raw dates before deleting expired raw rows. -This describes the network request's apparent country or region, not citizenship, residence, language, ethnicity, age or a person. VPNs, corporate proxies, datacenters and AI/cloud fetchers can identify an exit location rather than the end user's location. Report country/region distributions of observed requests, separated by actor/transport and explicit internal traffic where appropriate; do not label them user population demographics or count requests as people. Optional browser/WebMCP events still require consent; coarse server events keep their existing service-measurement policy. +This describes the network request's apparent country or region, not citizenship, residence, language, ethnicity, age or a person. VPNs, corporate proxies, datacenters and AI/cloud fetchers can identify an exit location rather than the end user's location. Report country/region distributions of observed requests, separated by actor/transport and explicit internal traffic where appropriate; do not label them user population demographics or count requests as people. Optional browser/WebMCP events still follow the browser preference, storage and DNT/GPC eligibility checks; coarse server events keep their existing service-measurement policy. diff --git a/docs/analytics/operations.md b/docs/analytics/operations.md index fcbee88..7c7df2f 100644 --- a/docs/analytics/operations.md +++ b/docs/analytics/operations.md @@ -4,7 +4,7 @@ The historical report is frozen at 2026-09-23 15:27:28.147672 UTC (23:27 Taipei) ## Why first-party measurement is the primary store -GA4 remains useful for consenting browser acquisition and conversion reporting. It automatically excludes known bot traffic and does not report how much was excluded. It therefore cannot serve as the primary agent-use ledger. The first-party Worker records actual MCP/HTTP outcomes and minimized request evidence in Supabase; its business records remain authoritative if background telemetry fails. +GA4 remains useful for eligible browser acquisition and conversion reporting. It automatically excludes known bot traffic and does not report how much was excluded. It therefore cannot serve as the primary agent-use ledger. The first-party Worker records actual MCP/HTTP outcomes and minimized request evidence in Supabase; its business records remain authoritative if background telemetry fails. Official references checked September 23, 2026: @@ -26,25 +26,29 @@ Official references checked September 23, 2026: ## Optional browser measurement -The preference notice is non-blocking. Optional browser/session analytics starts only after an affirmative choice, respects Do Not Track / Global Privacy Control, and can be turned off again from Analytics preferences. A sessionStorage UUID connects events within a tab; it is not a person or durable cross-device identity. No filenames, document titles, uploaded HTML, share IDs/slugs, fragments, access keys or arbitrary query strings enter the analytics payload. Routes are templates. Only bounded UTM tokens and an external referrer hostname are retained. Denial removes the optional tab context; coarse service events remain independent. +The preference notice is non-blocking. When service analytics is enabled, browser/session analytics is on by default if no preference is stored, and can be turned off from Analytics preferences. Existing opt-outs remain off. Do Not Track / Global Privacy Control override the default, and unreadable preference storage prevents collection. A sessionStorage UUID connects events within a tab; it is not a person or durable cross-device identity. No filenames, document titles, uploaded HTML, share IDs/slugs, fragments, access keys or arbitrary query strings enter the analytics payload. Routes are templates. Only bounded UTM tokens and an external referrer hostname are retained. Denial removes the optional tab context; coarse service events remain independent. -The production configuration targets the dedicated **Share HTML** GA4 property (555549679), separate from the **zhenjia.dev** blog property. Its only web stream is **Share HTML web**, `https://sharehtml.zhenjia.dev`, measurement ID `G-8B4LL2C8L7`. On September 24 (Taipei), the stream settings were reopened and Enhanced measurement was confirmed **off**, with zero connected site tags. The resource's Events page also confirmed `share_created` as a key event. Cookie names use the `sharehtml` prefix and are scoped to the current product hostname rather than the shared parent domain. The consent notice names Google Analytics. See the validation record for actual deployment and collection evidence. +The production configuration targets the dedicated **Share HTML** GA4 property (555549679), separate from the **zhenjia.dev** blog property. Its only web stream is **Share HTML web**, `https://sharehtml.zhenjia.dev`, measurement ID `G-8B4LL2C8L7`. On September 24 (Taipei), the stream settings were reopened and Enhanced measurement was confirmed **off**, with zero connected site tags. The resource's Events page also confirmed `share_created` as a key event. Cookie names use the `sharehtml` prefix and are scoped to the current product hostname rather than the shared parent domain. The analytics preference notice names Google Analytics. See the validation record for actual deployment and collection evidence. To configure a replacement stream safely: 1. Use a dedicated Share HTML property and web stream in the user's GA account (Editor access needed for stream settings). Do not reuse the blog property, tag, or cookie namespace. 2. Disable **Enhanced measurement**, including automatic form, download, click and history events. This app handles private links and must not let automatic collection inspect raw URLs or form interactions. 3. Set public Worker variable `GA4_MEASUREMENT_ID` to that stream's `G-...` ID and `GA4_AUTOMATIC_EVENTS_DISABLED=true` only after verifying the setting. No GA API secret is needed. -4. Check GA DebugView/realtime with consent granted. Only manually sanitized events are sent: `page_view`, `upload_started`, `upload_failed`, `share_link_copied`, `share_created`. Confirm `share_created` as a key event if desired. Add event-scoped custom dimensions for `transport`, `actor_evidence`, `referrer_domain` and `acquisition_*` as useful. +4. Check GA DebugView/realtime with analytics enabled and DNT/GPC absent. Only manually sanitized events are sent: `page_view`, `upload_started`, `upload_failed`, `share_link_copied`, `share_created`. Confirm `share_created` as a key event if desired. Add event-scoped custom dimensions for `transport`, `actor_evidence`, `referrer_domain` and `acquisition_*` as useful. 5. Verify denial makes no browser telemetry/Google requests and private `/s/` pages never initialize GA. Uploaded `/v/` HTML is never modified to inject analytics. Without an ID and verified auto-collection setting, the server does not expose an ID and the browser never loads Google's script. GA configuration is optional for the primary telemetry to function. No historic data is backfilled to GA. +## Coverage change on September 26, 2026 + +The September 26 client change switches the unset browser preference from off to on; stored opt-outs remain off, and DNT/GPC and unreadable storage still prevent collection. Use the Cloudflare deployment history and the release pull request for the confirmed production timestamp. Use the confirmed deployment time as the coverage boundary, not the change date. Browser, WebMCP and GA counts before and after that boundary are not directly comparable as product growth: the eligible collection population changed. Server measurement remains independent. The historical report cutoff and its data remain frozen. + ## Interpretation -- `page_served` covers successful GET HTML responses for homepage, marketing pages and share wrappers, including crawler requests without browser consent. It excludes HEAD and errors, is separate from `discovery_read` and `preview_served`, and does not prove a human or protected-preview access. Apply the additive `page_served` event-name constraint migration before deploying this coverage. +- `page_served` covers successful GET HTML responses for homepage, marketing pages and share wrappers, including crawler requests regardless of browser analytics preferences. It excludes HEAD and errors, is separate from `discovery_read` and `preview_served`, and does not prove a human or protected-preview access. Apply the additive `page_served` event-name constraint migration before deploying this coverage. - The internal `preview_served` event and historical `viewed` counter both describe successful HTML content requests, including iframe loads and direct document opens. Use “content requests” in product reports. Historical counters do not distinguish the two contexts; a share-wrapper response alone is a separate `page_served` event. -- Browser `page_view` and server `page_served` can describe the same navigation; never sum them. Server response counts provide a request-activity denominator; consented session events provide a different browser-funnel denominator. Neither counts unique people, and no-consent/cache/client-navigation differences prevent one-to-one reconciliation. +- Browser `page_view` and server `page_served` can describe the same navigation; never sum them. Server response counts provide a request-activity denominator; eligible tab-session events provide a different browser-funnel denominator. Neither counts unique people, and opt-out, privacy-signal, storage, cache and client-navigation differences prevent one-to-one reconciliation. - Compare canonical persisted share creations with `share_created` event coverage; timeouts can lose background telemetry. The Google browser key event is emitted only for an `active` upload result. First-party server `share_created` records retain both success and blocked outcomes; filter `outcome='success'` when counting completed creations. - `transport='mcp'` is established by the MCP handler. A client-controlled source label cannot change it. HTTP uploads include browser and automation clients. diff --git a/src/client/AnalyticsNotice.tsx b/src/client/AnalyticsNotice.tsx index 865d1c5..62d169b 100644 --- a/src/client/AnalyticsNotice.tsx +++ b/src/client/AnalyticsNotice.tsx @@ -8,7 +8,7 @@ export function AnalyticsNotice() { const { data: config } = useConfig(); const pathname = useRouterState({ select: (state) => state.location.pathname }); const [consent, setConsent] = useState(readAnalyticsConsent); - const [open, setOpen] = useState(consent === null); + const [open, setOpen] = useState(false); useEffect(() => { configureAnalytics(config ?? {}); trackPage(pathname); @@ -22,15 +22,15 @@ export function AnalyticsNotice() { }; return

; } diff --git a/src/client/analytics.ts b/src/client/analytics.ts index 1802d9d..0623ff8 100644 --- a/src/client/analytics.ts +++ b/src/client/analytics.ts @@ -1,7 +1,7 @@ type Acquisition = { source?: string; medium?: string; campaign?: string; referrer_domain?: string }; type Config = { analyticsEnabled?: boolean; ga4MeasurementId?: string }; export type BrowserEvent = "page_view" | "upload_started" | "upload_failed" | "share_link_copied"; -export type Consent = "granted" | "denied" | null; +export type Consent = "granted" | "denied"; type Context = { session_id: string; acquisition: Acquisition }; const CONSENT_KEY = "sharehtml.analytics.consent"; @@ -12,6 +12,7 @@ let config: Config = {}; let context: Context | null = null; let configuredGa: string | null = null; let lastPage: string | null = null; +let inMemoryConsent: Consent | null = null; type AnalyticsWindow = Window & { dataLayer?: unknown[]; @@ -25,14 +26,21 @@ export function privacySignal(): boolean { export function readAnalyticsConsent(): Consent { if (typeof window === "undefined" || privacySignal()) return "denied"; + if (inMemoryConsent) return inMemoryConsent; try { const value = window.localStorage.getItem(CONSENT_KEY); - return value === "granted" || value === "denied" ? value : null; + return value === "granted" || value === "denied" ? value : "granted"; } catch { return "denied"; } } -export function setAnalyticsConsent(consent: Exclude): void { - try { window.localStorage.setItem(CONSENT_KEY, consent); } catch { return; } +export function setAnalyticsConsent(consent: Consent): void { + try { + window.localStorage.setItem(CONSENT_KEY, consent); + inMemoryConsent = null; + } catch { + // A failed preference write must not prevent opting out in this page. + inMemoryConsent = consent; + } if (consent === "denied") { context = null; lastPage = null; diff --git a/tests/client/analytics-notice.test.tsx b/tests/client/analytics-notice.test.tsx new file mode 100644 index 0000000..3fd63a6 --- /dev/null +++ b/tests/client/analytics-notice.test.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@tanstack/react-router", () => ({ useRouterState: () => "/" })); +vi.mock("../../src/client/queries", () => ({ + useConfig: () => ({ data: { analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" } }), +})); +vi.mock("../../src/client/webmcp", () => ({ reportWebMcpState: vi.fn() })); + +beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + sessionStorage.clear(); + Object.defineProperty(navigator, "doNotTrack", { configurable: true, value: "0" }); + Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, value: false }); + delete (window as Window & { dataLayer?: unknown }).dataLayer; + delete (window as Window & { gtag?: unknown }).gtag; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + document.querySelectorAll('script[src*="googletagmanager"]').forEach(script => script.remove()); +}); + +test("a visitor is measured by default and can turn analytics off and back on through the notice", async () => { + const { AnalyticsNotice } = await import("../../src/client/AnalyticsNotice"); + const user = userEvent.setup(); + render(); + expect(fetch).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Analytics: on · Preferences" })); + expect(screen.getByText(/including Google Analytics/).textContent).toContain("on by default"); + await user.click(screen.getByRole("button", { name: "Turn off analytics" })); + expect(localStorage.getItem("sharehtml.analytics.consent")).toBe("denied"); + expect(sessionStorage.length).toBe(0); + expect(fetch).toHaveBeenCalledTimes(1); + expect((window as unknown as Record)["ga-disable-G-TEST12345"]).toBe(true); + await user.click(screen.getByRole("button", { name: "Analytics: off · Preferences" })); + await user.click(screen.getByRole("button", { name: "Turn on analytics" })); + expect(localStorage.getItem("sharehtml.analytics.consent")).toBe("granted"); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +test("an existing opt-out is visible and opening preferences does not start collection", async () => { + localStorage.setItem("sharehtml.analytics.consent", "denied"); + const { AnalyticsNotice } = await import("../../src/client/AnalyticsNotice"); + render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Analytics: off · Preferences" })); + expect(fetch).not.toHaveBeenCalled(); + expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull(); +}); + +test("a browser privacy signal keeps collection off and disables the enable button", async () => { + Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, value: true }); + const { AnalyticsNotice } = await import("../../src/client/AnalyticsNotice"); + render(); + await userEvent.setup().click(screen.getByRole("button", { name: "Analytics: off · Preferences" })); + expect((screen.getByRole("button", { name: "Turn on analytics" }) as HTMLButtonElement).disabled).toBe(true); + expect(fetch).not.toHaveBeenCalled(); +}); diff --git a/tests/client/analytics.test.ts b/tests/client/analytics.test.ts index 074423c..2c53027 100644 --- a/tests/client/analytics.test.ts +++ b/tests/client/analytics.test.ts @@ -6,21 +6,73 @@ beforeEach(() => { sessionStorage.clear(); delete (window as Window & { dataLayer?: unknown }).dataLayer; delete (window as Window & { gtag?: unknown }).gtag; + delete (window as unknown as Record)["ga-disable-G-TEST12345"]; + Object.defineProperty(navigator, "doNotTrack", { configurable: true, value: "0" }); + Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, value: false }); window.history.replaceState({}, "", "/"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); }); -afterEach(() => { vi.unstubAllGlobals(); document.querySelectorAll('script[src*="googletagmanager"]').forEach((s) => s.remove()); }); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.querySelectorAll('script[src*="googletagmanager"]').forEach((s) => s.remove()); }); -test("optional telemetry stays off until consent; denial prevents both session storage and network calls", async () => { +test("a new visitor gets sanitized browser and Google analytics without a preference click", async () => { const a = await import("../../src/client/analytics"); - a.configureAnalytics({ analyticsEnabled: true }); + a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" }); + a.trackPage("/"); + expect(a.readAnalyticsConsent()).toBe("granted"); + expect(localStorage.getItem("sharehtml.analytics.consent")).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(document.querySelector('script[src*="googletagmanager"]')).not.toBeNull(); + const layer = (window as unknown as { dataLayer: unknown[] }).dataLayer; + expect(layer.map(entry => Array.from(entry as ArrayLike))) + .toContainEqual(["event", "page_view", expect.objectContaining({ page_location: window.location.origin + "/" })]); +}); + +test("an existing opt-out prevents session storage, browser events and Google loading", async () => { + localStorage.setItem("sharehtml.analytics.consent", "denied"); + const a = await import("../../src/client/analytics"); + a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" }); a.trackBrowserEvent("page_view"); expect(fetch).not.toHaveBeenCalled(); expect(a.uploadAnalyticsContext()).toBeNull(); - a.setAnalyticsConsent("denied"); a.trackBrowserEvent("upload_started"); expect(fetch).not.toHaveBeenCalled(); expect(sessionStorage.length).toBe(0); + expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull(); +}); + +test.each(["doNotTrack", "globalPrivacyControl"])("%s overrides the default and explicit enablement", async (signal) => { + Object.defineProperty(navigator, signal, { configurable: true, value: signal === "doNotTrack" ? "1" : true }); + const a = await import("../../src/client/analytics"); + a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" }); + a.trackPage("/"); + a.setAnalyticsConsent("granted"); + a.trackBrowserEvent("upload_started"); + expect(a.readAnalyticsConsent()).toBe("denied"); + expect(fetch).not.toHaveBeenCalled(); + expect(sessionStorage.length).toBe(0); + expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull(); +}); + +test("opting out still disables an initialized Google tag when saving the preference fails", async () => { + const a = await import("../../src/client/analytics"); + a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" }); + a.trackPage("/"); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); }); + a.setAnalyticsConsent("denied"); + a.trackBrowserEvent("upload_started"); + expect(a.readAnalyticsConsent()).toBe("denied"); + expect(fetch).toHaveBeenCalledTimes(1); + expect(sessionStorage.length).toBe(0); + expect((window as unknown as Record)["ga-disable-G-TEST12345"]).toBe(true); +}); + +test("unreadable saved preferences do not enable analytics", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Storage unavailable"); }); + const a = await import("../../src/client/analytics"); + a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" }); + a.trackPage("/"); + expect(fetch).not.toHaveBeenCalled(); + expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull(); }); test("attribution allowlist keeps only campaign tokens and external hostname, never secrets or referrer paths", async () => { @@ -52,7 +104,6 @@ test("share route telemetry uses a template and never loads Google or forwards t const a = await import("../../src/client/analytics"); window.history.replaceState({}, "", "/s/a-private-slug?accessKey=secret#key=other-secret"); a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" }); - a.setAnalyticsConsent("granted"); a.trackPage(window.location.pathname); const payload = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)); expect(payload.route).toBe("/s/:slug"); diff --git a/tests/client/webmcp.test.ts b/tests/client/webmcp.test.ts index 8d566a4..8c7f107 100644 --- a/tests/client/webmcp.test.ts +++ b/tests/client/webmcp.test.ts @@ -101,9 +101,8 @@ test("consented WebMCP creation joins only sanitized tab context to the server u expect(JSON.stringify(context)).not.toMatch(/secret|private|document|title|fragment|conversation/); expect(result).toEqual({content:[{type:"text",text:'{"ok":true}'}],isError:false}); }); -test.each(["absent","denied","dnt","gpc"])("%s WebMCP creation omits analytics form context",async(signal)=>{ +test.each(["denied","dnt","gpc"])("%s WebMCP creation omits analytics form context",async(signal)=>{ const analytics=await enable(); - if(signal==="absent") localStorage.clear(); if(signal==="denied") analytics.setAnalyticsConsent("denied"); if(signal==="dnt") Object.defineProperty(navigator,"doNotTrack",{value:"1"}); if(signal==="gpc") Object.defineProperty(navigator,"globalPrivacyControl",{value:true}); @@ -113,3 +112,15 @@ test.each(["absent","denied","dnt","gpc"])("%s WebMCP creation omits analytics f const upload=vi.mocked(fetch).mock.calls.find(([url])=>url==="/api/shares"); expect((upload?.[1]?.body as FormData).has("analytics")).toBe(false); }); + +test("default-on WebMCP creation attaches sanitized context without a saved preference", async () => { + const analytics = await import("../../src/client/analytics"); + analytics.configureAnalytics({ analyticsEnabled: true }); + vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 201 })); + const { webMcpTools } = await import("../../src/client/webmcp"); + await webMcpTools()[3].execute({ html: "hello" }); + const upload = vi.mocked(fetch).mock.calls.find(([url]) => url === "/api/shares"); + const context = JSON.parse(String((upload?.[1]?.body as FormData).get("analytics"))); + expect(context).toEqual({ session_id: expect.any(String), acquisition: {} }); + expect(localStorage.getItem("sharehtml.analytics.consent")).toBeNull(); +});