diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f9c812..8f5fbdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: - run: bunx --package vite-plus@0.2.9 vp install --frozen-lockfile + - name: Growth attribution privacy tests + run: bun run test:growth + - name: Check run: bun run check diff --git a/apps/web/src/lib/growth-attribution.d.mts b/apps/web/src/lib/growth-attribution.d.mts new file mode 100644 index 0000000..6aa71fd --- /dev/null +++ b/apps/web/src/lib/growth-attribution.d.mts @@ -0,0 +1,14 @@ +export function analyticsAllowed(win: any, sourceProject: string): boolean; +export function classifyDestination(href: string, origin: string): string | null; +export function ctaPayload(input: { + sourceProject: string; + measurementId: string; + destination: string; + placement: string; +}): Record | null; +export function installGrowthAttribution(input: { + sourceProject: string; + measurementId: string; + win?: any; + doc?: any; +}): () => void; diff --git a/apps/web/src/lib/growth-attribution.mjs b/apps/web/src/lib/growth-attribution.mjs new file mode 100644 index 0000000..eb04ca1 --- /dev/null +++ b/apps/web/src/lib/growth-attribution.mjs @@ -0,0 +1,122 @@ +// Growth contract v1: aggregate first-party CTA signals, never document/user content. +const PROJECTS = Object.freeze({ + homepage: ["superdoc.dev", "www.superdoc.dev"], + ooxml_dev: ["ooxml.dev", "www.ooxml.dev"], + docx_tools: ["docx.tools", "www.docx.tools"], + docx_corpus: ["docxcorp.us", "www.docxcorp.us"], + docs: ["docs.superdoc.dev"], +}); +const PLACEMENTS = new Set(["header", "footer", "hero", "content", "pricing"]); +const INSTALLED = new WeakMap(); + +export function analyticsAllowed(win, sourceProject) { + return Boolean( + Object.hasOwn(PROJECTS, sourceProject) && + PROJECTS[sourceProject]?.includes(win?.location?.hostname) && + typeof win?.gtag === "function" && + win?.navigator?.globalPrivacyControl !== true && + ![win?.navigator?.doNotTrack, win?.doNotTrack].some((value) => + ["1", "yes"].includes(String(value).trim().toLowerCase()), + ) && + win?.superdocAnalyticsOptOut !== true && + !/^\/(account|checkout|trial|sign-in|sign-up)(?:\/|$)/.test(win?.location?.pathname ?? ""), + ); +} + +// Classify locally. Observed URLs, queries, fragments, referrers, anchor text +// and document IDs are never copied into this event. +export function classifyDestination(href, origin) { + let url; + try { + url = new URL(href, origin); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password || url.port) return null; + if (url.hostname === "docs.superdoc.dev") + return [ + "/quickstart", + "/getting-started", + "/getting-started/quickstart", + "/editor/quickstart", + ].includes(url.pathname.replace(/\/+$/, "") || "/") + ? "quickstart" + : "docs"; + if (!["superdoc.dev", "www.superdoc.dev"].includes(url.hostname)) return null; + if (url.hash === "#pricing" || url.pathname === "/pricing") return "pricing"; + if (url.pathname === "/business-signup") return "signup"; + if (url.pathname === "/" && !url.hash) return "superdoc_home"; + return null; +} + +export function ctaPayload({ sourceProject, measurementId, destination, placement }) { + if ( + !Object.hasOwn(PROJECTS, sourceProject) || + !/^G-[A-Z0-9]+$/.test(measurementId ?? "") || + !["superdoc_home", "docs", "quickstart", "pricing", "signup"].includes(destination) || + !PLACEMENTS.has(placement) + ) + return null; + return Object.freeze({ + send_to: measurementId, + event_version: "growth-v1", + source_project: sourceProject, + destination, + placement, + // gtag otherwise inherits location, title and referrer from the page. + // These event-scoped constants do not change the site's existing tag config. + page_location: `https://${PROJECTS[sourceProject][0]}/`, + page_title: `SuperDoc / ${sourceProject}`, + page_referrer: "", + }); +} + +export function installGrowthAttribution({ + sourceProject, + measurementId, + win = globalThis.window, + doc = globalThis.document, +} = {}) { + if ( + !win || + !doc || + !Object.hasOwn(PROJECTS, sourceProject) || + !/^G-[A-Z0-9]+$/.test(measurementId ?? "") + ) + return () => {}; + if (INSTALLED.has(doc)) return () => {}; + const click = (event) => { + try { + if (!analyticsAllowed(win, sourceProject) || event.button > 0) return; + const anchor = event.target?.closest?.("a[href]"); + if (!anchor) return; + const destination = classifyDestination(anchor.getAttribute("href"), win.location.origin); + if (!destination) return; + // Only external referrals or a specific conversion CTA, never ordinary home navigation. + if (sourceProject === "homepage" && destination === "superdoc_home") return; + if (sourceProject === "docs" && ["docs", "quickstart"].includes(destination)) return; + const explicit = anchor + .closest("[data-growth-placement]") + ?.getAttribute("data-growth-placement"); + const placement = PLACEMENTS.has(explicit) + ? explicit + : anchor.closest("footer") + ? "footer" + : anchor.closest("header, nav") + ? "header" + : "content"; + const payload = ctaPayload({ sourceProject, measurementId, destination, placement }); + if (payload) win.gtag("event", "superdoc_cta_clicked", payload); + } catch { + /* Analytics must never interrupt navigation or the developer tool. */ + } + }; + doc.addEventListener("click", click, { passive: true, capture: true }); + INSTALLED.set(doc, click); + return () => { + if (INSTALLED.get(doc) === click) { + doc.removeEventListener("click", click, true); + INSTALLED.delete(doc); + } + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 88e097f..60879b3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,4 @@ +import { installGrowthAttribution } from "./lib/growth-attribution.mjs"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; @@ -10,6 +11,8 @@ import { NotFound } from "./pages/NotFound"; import { SpecExplorer } from "./pages/SpecExplorer"; import "./index.css"; +installGrowthAttribution({ sourceProject: "ooxml_dev", measurementId: "G-JFTMJ85VCW" }); + const router = createBrowserRouter([ { element: , diff --git a/docs/growth-attribution.md b/docs/growth-attribution.md new file mode 100644 index 0000000..a32ee73 --- /dev/null +++ b/docs/growth-attribution.md @@ -0,0 +1,33 @@ +# Growth attribution v1 + +This adds aggregate `superdoc_cta_clicked` events to the existing GA4 property. +It does not add another analytics tag, page_view, cookie, identifier or outreach +trigger. Existing UTM links remain unchanged. Anonymous engagement never creates +a named lead. These are CTA clicks, not signups, activation or successful payment. + +Payload: event_version, source_project, bounded destination and placement, send_to, +plus event-scoped page_location, page_title and page_referrer overrides. Location +is the fixed canonical project root, title is a fixed project label, and referrer +is empty. Observed page/link URLs, queries, fragments, referrers, anchor text, +identity, file names, document contents, OOXML, prompts and free text are not copied +into this event. Only exact production hostnames emit events. +GPC, DNT and `window.superdocAnalyticsOptOut` suppress these added events; existing +GA consent controls continue to apply. This change does not alter existing tags. +The existing Google tag may attach its normal consent-dependent technical/session +metadata; this is not a claim of anonymous collection or a change to other events. +See Google's [configuration reference](https://developers.google.com/analytics/devguides/collection/ga4/reference/config) +for the inherited page-context defaults these event-scoped constants replace. + +## Reviewer validation + +Run `node --test tests/growth-attribution.test.mjs` and the project's web build. +In a production-like browser journey, open GA4 DebugView with your own browser +Debug setting, follow an existing SuperDoc CTA, and verify exactly one event with +only the fields above and the correct site's measurement ID. No page_view should +be added by this module. Repeat with GPC/DNT/opt-out and confirm no added event. +Do not upload a real customer document for validation. + +After merge/deployment, compare referral clicks and downstream hand-raises by +source over complete 28-day periods. Keep source property counts separate; do not +sum them into unique people. Server-confirmed signup/activation/payment events +need their own authoritative receipt and deduplication; a CTA cannot stand in. diff --git a/package.json b/package.json index a52d53a..4175353 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "pdf:test": "python3 -m unittest discover -s tests/ingest-pdf && bun test tests/ingest-pdf/chunk-pages.test.ts", "xsd:fetch": "bun scripts/ingest-xsd/fetch.ts", "xsd:ingest": "bun scripts/ingest-xsd/ingest.ts", - "test": "export TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/ecma_spec} && bun test tests/web/ && bun test tests/db/ && bun test tests/ingest-xsd/ && bun test tests/mcp-server/" + "test": "export TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/ecma_spec} && bun test tests/web/ && bun test tests/db/ && bun test tests/ingest-xsd/ && bun test tests/mcp-server/", + "test:growth": "node --test tests/growth-attribution.test.mjs" }, "devDependencies": { "@changesets/cli": "3.0.0", diff --git a/tests/growth-attribution.test.mjs b/tests/growth-attribution.test.mjs new file mode 100644 index 0000000..ff24f8a --- /dev/null +++ b/tests/growth-attribution.test.mjs @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { analyticsAllowed, classifyDestination, ctaPayload, installGrowthAttribution } from "../apps/web/src/lib/growth-attribution.mjs"; +function surface() { + const calls = [], listeners = new Map(); + const navigation = { prevented: 0, stopped: 0, assigned: 0, replaced: 0 }; + const win = { location: { hostname: 'ooxml.dev', origin: 'https://ooxml.dev', pathname: '/', href: 'https://ooxml.dev/', assign() { navigation.assigned++; }, replace() { navigation.replaced++; } }, navigator: {}, gtag: (...args) => calls.push(args) }; + const doc = { + addEventListener(name, fn) { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); }, + removeEventListener(name, fn) { const callbacks = listeners.get(name); callbacks?.delete(fn); if (!callbacks?.size) listeners.delete(name); }, + }; + const anchor = { getAttribute: () => 'https://superdoc.dev/?utm_source=ooxml.dev&email=private@example.com', closest: () => null }; + const click = { target: { closest: () => anchor }, button: 0, defaultPrevented: false, + preventDefault() { navigation.prevented++; this.defaultPrevented = true; }, stopPropagation() { navigation.stopped++; } }; + const dispatch = () => { for (const callback of [...(listeners.get('click') ?? [])]) callback(click); }; + const listenerCount = () => [...listeners.values()].reduce((count, callbacks) => count + callbacks.size, 0); + return { calls, listeners, win, doc, click, anchor, navigation, dispatch, listenerCount }; +} +test('classifies exact destinations and excludes malformed/credentialed/unrelated URLs', () => { + assert.equal(classifyDestination('https://docs.superdoc.dev/getting-started/quickstart?secret=x', 'https://ooxml.dev'), 'quickstart'); + for (const href of ['javascript:alert(1)', 'https://superdoc.dev.evil.test/', 'https://private@superdoc.dev/', 'https://superdoc.dev/account?email=x', 'https://superdoc.dev:8443/']) assert.equal(classifyDestination(href, 'https://ooxml.dev'), null); +}); +test('payload excludes arbitrary keys and only accepts bounded enums', () => { + const value = ctaPayload({ sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC', destination: 'superdoc_home', placement: 'footer', email: 'private@example.com', url: 'secret' }); + assert.deepEqual(Object.keys(value).sort(), ['send_to', 'event_version', 'source_project', 'destination', 'placement', 'page_location', 'page_title', 'page_referrer'].sort()); + assert.equal(value.page_location, 'https://ooxml.dev/'); + assert.equal(value.page_referrer, ''); + assert.equal(ctaPayload({ sourceProject: 'user@example.com', measurementId: 'G-SYNTHETIC', destination: 'superdoc_home', placement: 'footer' }), null); +}); +test('respects opt-out, DNT, GPC and private/non-production surfaces', () => { + for (const mutate of [w => { w.superdocAnalyticsOptOut = true; }, w => { w.navigator.doNotTrack = '1'; }, w => { w.navigator.globalPrivacyControl = true; }, w => { w.location.hostname = 'localhost'; }, w => { w.location.pathname = '/account'; }, w => { w.gtag = undefined; }]) { + const { win } = surface(); mutate(win); assert.equal(analyticsAllowed(win, 'ooxml_dev'), false); + } +}); +test('single listener preserves navigation and never emits raw link/query data', () => { + const s = surface(); const stop = installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + assert.equal(s.listenerCount(), 1); s.dispatch(); assert.equal(s.calls.length, 1); + assert.equal(s.calls[0][1], 'superdoc_cta_clicked'); assert.doesNotMatch(JSON.stringify(s.calls), /private|email=|utm_/); + assert.equal(s.click.defaultPrevented, false); + assert.deepEqual(s.navigation, { prevented: 0, stopped: 0, assigned: 0, replaced: 0 }); + assert.equal(s.win.location.href, 'https://ooxml.dev/'); + let otherClicks = 0; const otherListener = () => { otherClicks++; }; + s.doc.addEventListener('click', otherListener); + assert.equal(s.listenerCount(), 2); + stop(); assert.equal(s.listenerCount(), 1); + s.dispatch(); assert.equal(otherClicks, 1); assert.equal(s.calls.length, 1); + s.doc.removeEventListener('click', otherListener); assert.equal(s.listenerCount(), 0); +}); +test('event context replaces inherited page URL, title and referrer with constants', () => { + const s = surface(); + s.win.location.href = 'https://ooxml.dev/private-file?email=private@example.com#secret'; + s.doc.title = 'Private document title'; + s.doc.referrer = 'https://customer.example/private-case'; + const inherited = { page_location: s.win.location.href, page_title: s.doc.title, page_referrer: s.doc.referrer }; + s.win.gtag = (_command, _name, payload) => s.calls.push({ ...inherited, ...payload }); + installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.dispatch(); + assert.equal(s.calls[0].page_location, 'https://ooxml.dev/'); + assert.equal(s.calls[0].page_title, 'SuperDoc / ooxml_dev'); + assert.equal(s.calls[0].page_referrer, ''); + assert.doesNotMatch(JSON.stringify(s.calls), /private|Private|secret|customer\.example/); +}); +test('client-side router prevention does not hide CTA intent', () => { + const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.click.defaultPrevented = true; s.dispatch(); assert.equal(s.calls.length, 1); +}); +test('provider errors and privacy changes after installation never break a click', () => { + const s = surface(); installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.win.gtag = () => { throw new Error('blocked'); }; assert.doesNotThrow(() => s.dispatch()); + s.win.superdocAnalyticsOptOut = true; s.win.gtag = (...args) => s.calls.push(args); s.dispatch(); assert.equal(s.calls.length, 0); +}); + +test('either modern or legacy DNT opt-out suppresses the added event', () => { + for (const [modern, legacy] of [['', '1'], ['0', 'yes'], ['unspecified', 'YES'], ['1', '0']]) { + const s = surface(); s.win.navigator.doNotTrack = modern; s.win.doNotTrack = legacy; + installGrowthAttribution({ ...s, sourceProject: 'ooxml_dev', measurementId: 'G-SYNTHETIC' }); + s.dispatch(); assert.equal(s.calls.length, 0); + } +}); +test('inherited project names are rejected at each analytics boundary', () => { + for (const sourceProject of ['constructor', '__proto__', 'toString']) { + const s = surface(); assert.equal(analyticsAllowed(s.win, sourceProject), false); + assert.equal(ctaPayload({ sourceProject, measurementId: 'G-SYNTHETIC', destination: 'pricing', placement: 'footer' }), null); + assert.doesNotThrow(() => installGrowthAttribution({ ...s, sourceProject, measurementId: 'G-SYNTHETIC' })); + assert.equal(s.listenerCount(), 0); + } +}); +test('only explicit quickstart paths count as quickstart, including the current editor route', () => { + for (const path of ['/editor/quickstart', '/editor/quickstart/', '/getting-started/quickstart']) assert.equal(classifyDestination('https://docs.superdoc.dev' + path + '?private=x', 'https://ooxml.dev'), 'quickstart'); + for (const path of ['/resources/quickstart-notes', '/editor/quickstart-other', '/resources/getting-started-security']) assert.equal(classifyDestination('https://docs.superdoc.dev' + path, 'https://ooxml.dev'), 'docs'); +}); +test('a docs install does not turn ordinary in-docs navigation into referral traffic', () => { + for (const href of ['https://docs.superdoc.dev/editor/comments', 'https://docs.superdoc.dev/editor/quickstart']) { + const s = surface(); s.win.location.hostname = 'docs.superdoc.dev'; s.win.location.origin = 'https://docs.superdoc.dev'; + s.anchor.getAttribute = () => href; + installGrowthAttribution({ ...s, sourceProject: 'docs', measurementId: 'G-SYNTHETIC' }); + s.dispatch(); assert.equal(s.calls.length, 0); + } +});