-
Notifications
You must be signed in to change notification settings - Fork 6
feat(analytics): first-touch attribution capture + consent_choice event #4572
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
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
72780c9
feat(analytics): first-touch attribution + consent_choice event (#4520)
PierreBrisorgueil 23406e6
fix(analytics): sanitize stored attribution at read time + restore lo…
PierreBrisorgueil e794f9f
fix(analytics): strip credential-carrying query params from landingPath
PierreBrisorgueil 1a82c13
fix(attribution): harden sessionStorage guard and sanitize referrer q…
PierreBrisorgueil 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| /** | ||
| * attribution.js | ||
| * ============== | ||
| * Write-once first-touch attribution capture (issue #4520). | ||
| * | ||
| * Captures referrer / landing path / UTM params from the very first page a | ||
| * visitor lands on, so signup() can send them along with the payload. Uses | ||
| * sessionStorage ONLY — never cookies, never localStorage, no persistent | ||
| * identifier. First-touch wins: once a record exists for the session, later | ||
| * navigations never overwrite it. | ||
| */ | ||
|
|
||
| /** sessionStorage key used to persist the first-touch attribution record. */ | ||
| export const ATTRIBUTION_SS_KEY = 'attribution_v1'; | ||
|
|
||
| /** Max length enforced on `referrer` / `landingPath`. */ | ||
| const URL_FIELD_MAX_LENGTH = 2048; | ||
| /** Max length enforced on each individual UTM field. */ | ||
| const UTM_FIELD_MAX_LENGTH = 256; | ||
|
|
||
| /** Maps URL query param names to their camelCase wire field name. */ | ||
| const UTM_PARAM_MAP = { | ||
| utm_source: 'utmSource', | ||
| utm_medium: 'utmMedium', | ||
| utm_campaign: 'utmCampaign', | ||
| utm_term: 'utmTerm', | ||
| utm_content: 'utmContent', | ||
| }; | ||
|
|
||
| /** Whitelist of the 7 known wire keys, mapped to their max length. Any other key is dropped. */ | ||
| const KNOWN_FIELDS = { | ||
| referrer: URL_FIELD_MAX_LENGTH, | ||
| landingPath: URL_FIELD_MAX_LENGTH, | ||
| utmSource: UTM_FIELD_MAX_LENGTH, | ||
| utmMedium: UTM_FIELD_MAX_LENGTH, | ||
| utmCampaign: UTM_FIELD_MAX_LENGTH, | ||
| utmTerm: UTM_FIELD_MAX_LENGTH, | ||
| utmContent: UTM_FIELD_MAX_LENGTH, | ||
| }; | ||
|
|
||
| /** | ||
| * @desc Returns true when running in a browser environment with sessionStorage available. | ||
| * Guards against a throwing `sessionStorage` getter (e.g. sandboxed iframes / storage | ||
| * partitioning policies) so callers never see an uncaught exception before their own try/catch. | ||
| * @returns {boolean} | ||
| */ | ||
| function isBrowser() { | ||
| if (typeof window === 'undefined') return false; | ||
| try { | ||
| return typeof sessionStorage !== 'undefined'; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @desc Trim a value and cap it to maxLength. Non-string / empty-after-trim input yields undefined. | ||
| * @param {*} value - Raw value to normalise. | ||
| * @param {number} maxLength - Maximum length to keep. | ||
| * @returns {string|undefined} | ||
| */ | ||
| function trimAndCap(value, maxLength) { | ||
| if (typeof value !== 'string') return undefined; | ||
| const trimmed = value.trim(); | ||
| if (!trimmed) return undefined; | ||
| return trimmed.slice(0, maxLength); | ||
| } | ||
|
|
||
| /** | ||
| * @desc Determine whether a referrer URL shares the current page's origin. | ||
| * @param {string} referrer - `document.referrer` value. | ||
| * @returns {boolean} | ||
| */ | ||
| function isSameOrigin(referrer) { | ||
| try { | ||
| return new URL(referrer).origin === window.location.origin; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** Query keys whose values are credentials/single-use tokens — never persisted in landingPath. */ | ||
| const SENSITIVE_QUERY_KEY_PATTERN = /token|secret|password|code|key/i; | ||
|
|
||
| /** | ||
| * @desc Rebuild a search string with credential-carrying params removed, so a landing | ||
| * URL like `/signup?inviteToken=...` never duplicates the token into stored attribution. | ||
| * @param {string} search - `window.location.search` value. | ||
| * @returns {string} Sanitized search string ('' or '?...'). | ||
| */ | ||
| function stripSensitiveParams(search) { | ||
| const params = new URLSearchParams(search); | ||
| [...params.keys()].forEach((key) => { | ||
| if (SENSITIVE_QUERY_KEY_PATTERN.test(key)) params.delete(key); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const rebuilt = params.toString(); | ||
| return rebuilt ? `?${rebuilt}` : ''; | ||
| } | ||
|
|
||
| /** | ||
| * @desc Sanitize a cross-origin referrer for storage: parse it, strip credential-carrying | ||
| * query params (same rule as landingPath), and cap the result. A cross-origin referrer's | ||
| * own query string can carry `token`/`code`/reset values just like the landing URL does, so | ||
| * it needs the same filtering. Malformed referrers are dropped rather than stored raw. | ||
| * @param {string} referrer - `document.referrer` value (already confirmed cross-origin). | ||
| * @returns {string|undefined} | ||
| */ | ||
| function sanitizeReferrer(referrer) { | ||
| try { | ||
| const url = new URL(referrer); | ||
| return trimAndCap(`${url.origin}${url.pathname}${stripSensitiveParams(url.search)}`, URL_FIELD_MAX_LENGTH); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @desc Build the first-touch attribution record from the current document/location. | ||
| * @returns {object|null} The record, or null when there is nothing to capture. | ||
| */ | ||
| function buildAttribution() { | ||
| const record = {}; | ||
|
|
||
| const referrer = document.referrer; | ||
| if (referrer && !isSameOrigin(referrer)) { | ||
| const capped = sanitizeReferrer(referrer); | ||
| if (capped) record.referrer = capped; | ||
| } | ||
|
PierreBrisorgueil marked this conversation as resolved.
|
||
|
|
||
| const landingPath = trimAndCap(`${window.location.pathname}${stripSensitiveParams(window.location.search)}`, URL_FIELD_MAX_LENGTH); | ||
| if (landingPath) record.landingPath = landingPath; | ||
|
|
||
| const params = new URLSearchParams(window.location.search); | ||
| Object.entries(UTM_PARAM_MAP).forEach(([queryKey, field]) => { | ||
| const capped = trimAndCap(params.get(queryKey), UTM_FIELD_MAX_LENGTH); | ||
| if (capped) record[field] = capped; | ||
| }); | ||
|
|
||
| return Object.keys(record).length > 0 ? record : null; | ||
| } | ||
|
|
||
| /** | ||
| * @desc Sanitize a raw parsed attribution record read back from sessionStorage: whitelist | ||
| * to the 7 known wire keys, drop any other key, drop non-string values, re-apply trim + | ||
| * length caps. Guards against a tampered/extension-injected key reaching the strict Zod | ||
| * signup endpoint (an unexpected key would 422 the whole payload). | ||
| * @param {object} parsed - Raw parsed JSON object. | ||
| * @returns {object|null} Sanitized record, or null when nothing valid remains. | ||
| */ | ||
| function sanitizeAttribution(parsed) { | ||
| const record = {}; | ||
| Object.entries(KNOWN_FIELDS).forEach(([key, maxLength]) => { | ||
| const capped = trimAndCap(parsed[key], maxLength); | ||
| if (capped) record[key] = capped; | ||
| }); | ||
| return Object.keys(record).length > 0 ? record : null; | ||
| } | ||
|
|
||
| /** | ||
| * @desc Capture first-touch attribution (referrer, landing path, UTM params) into | ||
| * sessionStorage. Write-once: if a record already exists for this session, does | ||
| * nothing. Never uses cookies or localStorage, never stores a persistent identifier. | ||
| * Safe no-op when storage/window is unavailable (private mode, SSR, unit tests). | ||
| * @returns {void} | ||
| */ | ||
| export function captureFirstTouch() { | ||
| if (!isBrowser()) return; | ||
| try { | ||
| if (sessionStorage.getItem(ATTRIBUTION_SS_KEY) !== null) return; | ||
| const record = buildAttribution(); | ||
| if (record) sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify(record)); | ||
| } catch { | ||
| // sessionStorage unavailable (private mode / sandboxed) — silent no-op | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @desc Returns the stored first-touch attribution record. | ||
| * @returns {object|null} The record, or null when absent, unavailable, or malformed. | ||
| */ | ||
| export function getAttribution() { | ||
| if (!isBrowser()) return null; | ||
| try { | ||
| const raw = sessionStorage.getItem(ATTRIBUTION_SS_KEY); | ||
| if (!raw) return null; | ||
| const parsed = JSON.parse(raw); | ||
| if (!parsed || typeof parsed !== 'object') return null; | ||
| return sanitizeAttribution(parsed); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Exports. | ||
| */ | ||
| export default { captureFirstTouch, getAttribution }; | ||
Oops, something went wrong.
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.