Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/lib/growth-attribution.d.mts
Original file line number Diff line number Diff line change
@@ -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<string, string> | null;
export function installGrowthAttribution(input: {
sourceProject: string;
measurementId: string;
win?: any;
doc?: any;
}): () => void;
122 changes: 122 additions & 0 deletions apps/web/src/lib/growth-attribution.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
3 changes: 3 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: <Outlet />,
Expand Down
33 changes: 33 additions & 0 deletions docs/growth-attribution.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 100 additions & 0 deletions tests/growth-attribution.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import test from 'node:test';
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
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', () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
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);
}
});
Loading