Repository navigation
Measure privacy-bounded SuperDoc referral CTA clicks #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jelkes1
wants to merge
6
commits into
superdoc:main
Choose a base branch
from
jelkes1:feat/growth-cta-attribution
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4011092
Measure privacy-bounded SuperDoc referral CTA clicks
jelkes1 7bca1c0
Capture client-routed CTA intent without duplicate listeners
jelkes1 60b6180
Match OOXML formatter and verify routed CTA capture
jelkes1 d493062
Bound inherited analytics page context to static project labels
jelkes1 b001f8e
Format analytics context safeguards
jelkes1 cf5c51e
Honor independent privacy signals and verify bounded CTA attribution
jelkes1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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', () => { | ||
|
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); | ||
| } | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.