From acd664f06f134c223645ccc66f912ef20c186970 Mon Sep 17 00:00:00 2001 From: Parag More Date: Tue, 7 Jul 2026 17:42:02 +0530 Subject: [PATCH 1/2] SURF-1415 feat(store): first-touch UTM persistence Persist landing-page attribution params (utm_*, ad click-ids) in a first-party cookie and merge them into the payload sent to form iframes, so paid-source UTMs survive internal navigation to whichever page hosts the form. Current-page params always win; utm_content alone or a same-origin referrer never claims the first-touch slot (internal CTA tags like ?utm_content=home_homepage-hero must not poison it). Write-once with a 30-day expiry, matching HubSpot Original Source semantics. Co-Authored-By: Claude Fable 5 --- src/constants.ts | 3 ++ src/store/first-touch.ts | 102 +++++++++++++++++++++++++++++++++++++++ src/store/store.ts | 8 ++- surface_embed_v1.js | 72 ++++++++++++++++++++++++++- surface_tag.js | 72 ++++++++++++++++++++++++++- test/first-touch.html | 84 ++++++++++++++++++++++++++++++++ test/index.html | 6 +++ 7 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 src/store/first-touch.ts create mode 100644 test/first-touch.html diff --git a/src/constants.ts b/src/constants.ts index 3de3b5b..5c561fb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,3 +26,6 @@ export const VALID_EMBED_TYPES = [ export const LEAD_DATA_TTL = 10 * 60 * 1000; // 10 minutes export const JOURNEY_COOKIE_MAX_AGE = 5184000; // 60 days export const RECENT_VISIT_COOKIE_MAX_AGE = 86400; // 1 day + +export const FIRST_TOUCH_COOKIE_NAME = "surface_first_touch"; +export const FIRST_TOUCH_COOKIE_MAX_AGE = 2592000; // 30 days diff --git a/src/store/first-touch.ts b/src/store/first-touch.ts new file mode 100644 index 0000000..9e6ea26 --- /dev/null +++ b/src/store/first-touch.ts @@ -0,0 +1,102 @@ +import { + FIRST_TOUCH_COOKIE_NAME, + FIRST_TOUCH_COOKIE_MAX_AGE, +} from "../constants"; +import { getCookie, setCookie } from "../utils/cookies"; +import { getUrlParams } from "../utils/url"; +import { getJourneyCookieDomain } from "./journey-cookies"; + +// Attribution params persisted from the landing URL. +const ATTRIBUTION_PARAMS = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "gclid", + "fbclid", + "li_fat_id", + "msclkid", + "ttclid", +] as const; + +// A visit only counts as a first touch when it carries a source/medium or an +// ad click-id. utm_content alone is commonly used to tag internal CTAs +// (e.g. ?utm_content=home_homepage-hero) and must not claim the slot. +const QUALIFYING_PARAMS = [ + "utm_source", + "utm_medium", + "gclid", + "fbclid", + "li_fat_id", + "msclkid", + "ttclid", +] as const; + +const MAX_VALUE_LENGTH = 256; + +interface FirstTouchRecord { + params: Record; + url: string; + referrer: string; + at: string; +} + +// Sites sometimes append utm-style params to internal links; a same-origin +// referrer means this navigation is not a real inbound touch. +function isInternalNavigation(): boolean { + if (!document.referrer) return false; + + try { + return new URL(document.referrer).origin === window.location.origin; + } catch { + return false; + } +} + +/** + * Persist the landing page's attribution params in a first-party cookie so + * they survive navigation to whichever page hosts the form. Write-once: an + * existing unexpired record is never overwritten (strict first-touch, + * matching how HubSpot computes Original Source). + */ +export function captureFirstTouch(): void { + if (getCookie(FIRST_TOUCH_COOKIE_NAME)) return; + if (isInternalNavigation()) return; + + const urlParams = getUrlParams(); + if (!QUALIFYING_PARAMS.some((key) => urlParams[key])) return; + + const params: Record = {}; + ATTRIBUTION_PARAMS.forEach((key) => { + const value = urlParams[key]; + if (value) params[key] = value.slice(0, MAX_VALUE_LENGTH); + }); + + const record: FirstTouchRecord = { + params, + url: window.location.href.slice(0, MAX_VALUE_LENGTH), + referrer: document.referrer.slice(0, MAX_VALUE_LENGTH), + at: new Date().toISOString(), + }; + + setCookie(FIRST_TOUCH_COOKIE_NAME, JSON.stringify(record), { + maxAge: FIRST_TOUCH_COOKIE_MAX_AGE, + sameSite: "lax", + domain: getJourneyCookieDomain(), + }); +} + +export function getFirstTouchParams(): Record { + const raw = getCookie(FIRST_TOUCH_COOKIE_NAME); + if (!raw) return {}; + + try { + const record = JSON.parse(raw) as FirstTouchRecord; + return record?.params && typeof record.params === "object" + ? record.params + : {}; + } catch { + return {}; + } +} diff --git a/src/store/store.ts b/src/store/store.ts index be5b8c0..2e4b616 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -6,6 +6,7 @@ import { getUrlParams } from "../utils/url"; import { onRouteChange } from "../utils/route-observer"; import { getLeadDataWithTTL, isIdentifyInProgress } from "../lead/identify"; import { initializeMessageListener } from "./message-listener"; +import { captureFirstTouch, getFirstTouchParams } from "./first-touch"; import { initializeUserJourneyTracking, updateUserJourneyOnRouteChange, @@ -49,6 +50,10 @@ export class SurfaceStore { initializeMessageListener(this); + if (!this.isCurrentOriginSurfaceDomain()) { + captureFirstTouch(); + } + if ( (this.cachedIdentifyData || !isIdentifyInProgress()) && !this.isCurrentOriginSurfaceDomain() @@ -126,7 +131,8 @@ export class SurfaceStore { : this.cookies, origin: this.origin, questionIds: this.partialFilledData, - urlParams: this.urlParams, + // First-touch attribution fills gaps; current-page params always win. + urlParams: { ...getFirstTouchParams(), ...this.urlParams }, surfaceLeadData: getLeadDataWithTTL(), userJourneyId: this.userJourneyId, }; diff --git a/surface_embed_v1.js b/surface_embed_v1.js index 0b29114..532c4ff 100644 --- a/surface_embed_v1.js +++ b/surface_embed_v1.js @@ -37,6 +37,8 @@ var LEAD_DATA_TTL = 10 * 60 * 1e3; var JOURNEY_COOKIE_MAX_AGE = 5184e3; var RECENT_VISIT_COOKIE_MAX_AGE = 86400; + var FIRST_TOUCH_COOKIE_NAME = "surface_first_touch"; + var FIRST_TOUCH_COOKIE_MAX_AGE = 2592e3; // src/utils/hash.ts async function getHash(input) { @@ -325,6 +327,70 @@ return getCookie(SURFACE_USER_JOURNEY_COOKIE_NAME); } + // src/store/first-touch.ts + var ATTRIBUTION_PARAMS = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "gclid", + "fbclid", + "li_fat_id", + "msclkid", + "ttclid" + ]; + var QUALIFYING_PARAMS = [ + "utm_source", + "utm_medium", + "gclid", + "fbclid", + "li_fat_id", + "msclkid", + "ttclid" + ]; + var MAX_VALUE_LENGTH = 256; + function isInternalNavigation() { + if (!document.referrer) return false; + try { + return new URL(document.referrer).origin === window.location.origin; + } catch { + return false; + } + } + function captureFirstTouch() { + if (getCookie(FIRST_TOUCH_COOKIE_NAME)) return; + if (isInternalNavigation()) return; + const urlParams = getUrlParams(); + if (!QUALIFYING_PARAMS.some((key) => urlParams[key])) return; + const params = {}; + ATTRIBUTION_PARAMS.forEach((key) => { + const value = urlParams[key]; + if (value) params[key] = value.slice(0, MAX_VALUE_LENGTH); + }); + const record = { + params, + url: window.location.href.slice(0, MAX_VALUE_LENGTH), + referrer: document.referrer.slice(0, MAX_VALUE_LENGTH), + at: (/* @__PURE__ */ new Date()).toISOString() + }; + setCookie(FIRST_TOUCH_COOKIE_NAME, JSON.stringify(record), { + maxAge: FIRST_TOUCH_COOKIE_MAX_AGE, + sameSite: "lax", + domain: getJourneyCookieDomain() + }); + } + function getFirstTouchParams() { + const raw = getCookie(FIRST_TOUCH_COOKIE_NAME); + if (!raw) return {}; + try { + const record = JSON.parse(raw); + return record?.params && typeof record.params === "object" ? record.params : {}; + } catch { + return {}; + } + } + // src/store/user-journey.ts function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId) { try { @@ -470,6 +536,9 @@ this.environmentId = environmentId3; this.log = createLogger("Surface Store"); initializeMessageListener(this); + if (!this.isCurrentOriginSurfaceDomain()) { + captureFirstTouch(); + } if ((this.cachedIdentifyData || !isIdentifyInProgress()) && !this.isCurrentOriginSurfaceDomain()) { initializeUserJourneyTracking( this.environmentId, @@ -532,7 +601,8 @@ cookies: Object.keys(this.cookies).length === 0 ? parseCookies() : this.cookies, origin: this.origin, questionIds: this.partialFilledData, - urlParams: this.urlParams, + // First-touch attribution fills gaps; current-page params always win. + urlParams: { ...getFirstTouchParams(), ...this.urlParams }, surfaceLeadData: getLeadDataWithTTL(), userJourneyId: this.userJourneyId }; diff --git a/surface_tag.js b/surface_tag.js index 0b29114..532c4ff 100644 --- a/surface_tag.js +++ b/surface_tag.js @@ -37,6 +37,8 @@ var LEAD_DATA_TTL = 10 * 60 * 1e3; var JOURNEY_COOKIE_MAX_AGE = 5184e3; var RECENT_VISIT_COOKIE_MAX_AGE = 86400; + var FIRST_TOUCH_COOKIE_NAME = "surface_first_touch"; + var FIRST_TOUCH_COOKIE_MAX_AGE = 2592e3; // src/utils/hash.ts async function getHash(input) { @@ -325,6 +327,70 @@ return getCookie(SURFACE_USER_JOURNEY_COOKIE_NAME); } + // src/store/first-touch.ts + var ATTRIBUTION_PARAMS = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "gclid", + "fbclid", + "li_fat_id", + "msclkid", + "ttclid" + ]; + var QUALIFYING_PARAMS = [ + "utm_source", + "utm_medium", + "gclid", + "fbclid", + "li_fat_id", + "msclkid", + "ttclid" + ]; + var MAX_VALUE_LENGTH = 256; + function isInternalNavigation() { + if (!document.referrer) return false; + try { + return new URL(document.referrer).origin === window.location.origin; + } catch { + return false; + } + } + function captureFirstTouch() { + if (getCookie(FIRST_TOUCH_COOKIE_NAME)) return; + if (isInternalNavigation()) return; + const urlParams = getUrlParams(); + if (!QUALIFYING_PARAMS.some((key) => urlParams[key])) return; + const params = {}; + ATTRIBUTION_PARAMS.forEach((key) => { + const value = urlParams[key]; + if (value) params[key] = value.slice(0, MAX_VALUE_LENGTH); + }); + const record = { + params, + url: window.location.href.slice(0, MAX_VALUE_LENGTH), + referrer: document.referrer.slice(0, MAX_VALUE_LENGTH), + at: (/* @__PURE__ */ new Date()).toISOString() + }; + setCookie(FIRST_TOUCH_COOKIE_NAME, JSON.stringify(record), { + maxAge: FIRST_TOUCH_COOKIE_MAX_AGE, + sameSite: "lax", + domain: getJourneyCookieDomain() + }); + } + function getFirstTouchParams() { + const raw = getCookie(FIRST_TOUCH_COOKIE_NAME); + if (!raw) return {}; + try { + const record = JSON.parse(raw); + return record?.params && typeof record.params === "object" ? record.params : {}; + } catch { + return {}; + } + } + // src/store/user-journey.ts function initializeUserJourneyTracking(environmentId3, log2, getJourneyId, setJourneyId) { try { @@ -470,6 +536,9 @@ this.environmentId = environmentId3; this.log = createLogger("Surface Store"); initializeMessageListener(this); + if (!this.isCurrentOriginSurfaceDomain()) { + captureFirstTouch(); + } if ((this.cachedIdentifyData || !isIdentifyInProgress()) && !this.isCurrentOriginSurfaceDomain()) { initializeUserJourneyTracking( this.environmentId, @@ -532,7 +601,8 @@ cookies: Object.keys(this.cookies).length === 0 ? parseCookies() : this.cookies, origin: this.origin, questionIds: this.partialFilledData, - urlParams: this.urlParams, + // First-touch attribution fills gaps; current-page params always win. + urlParams: { ...getFirstTouchParams(), ...this.urlParams }, surfaceLeadData: getLeadDataWithTTL(), userJourneyId: this.userJourneyId }; diff --git a/test/first-touch.html b/test/first-touch.html new file mode 100644 index 0000000..fdffbee --- /dev/null +++ b/test/first-touch.html @@ -0,0 +1,84 @@ + + + + + + + First-Touch UTM Persistence Test - Surface Tag + + + + +
+

First-Touch UTM Persistence Test

+

← Back to Test Suite

+ +
+ How to test: +
    +
  1. Capture: paste this into the address bar (a real ad landing has no same-origin referrer; clicking a link here would trip the internal-navigation guard):
    first-touch.html?utm_source=linkedin&utm_medium=paid_social&utm_campaign=Test+Campaign&li_fat_id=test-123
    The cookie panel should populate.
  2. +
  3. Persistence: then navigate with only an internal tag — merged params should keep utm_source=linkedin, with utm_content from the current page winning.
  4. +
  5. Internal-tag guard: clear the cookie, then click the internal-tag link above (same-origin referrer) — the cookie must NOT be written, even if the link carries utm_source.
  6. +
  7. Write-once: with the cookie set, land again with ?utm_source=google&gclid=x — the stored record must not change.
  8. +
+
+ +

surface_first_touch cookie

+
+ + +
+

+
+      

Merged payload urlParams (what iframes receive)

+

+    
+ + + + diff --git a/test/index.html b/test/index.html index 215482f..7f23cc1 100644 --- a/test/index.html +++ b/test/index.html @@ -44,6 +44,12 @@

4. Input Trigger Embed

Test input trigger that opens form with pre-filled email data.

Test Input Trigger → + + From 03401f16273f6fba8c879eefecf8d4d5620816ef Mon Sep 17 00:00:00 2001 From: Parag More Date: Tue, 7 Jul 2026 17:50:29 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix(store):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20fresh=20urlParams,=20cookie=20size=20guard,=20domain=20fallb?= =?UTF-8?q?ack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getPayload() now reads current-page params fresh so the direct notifyIframe() path and post-route-change payloads never let stale params override first-touch values - degrade oversized first-touch records (drop url/referrer, truncate values) instead of letting the browser reject the cookie - retry the cookie write host-only when the base-domain attribute is rejected (public-suffix hosts like *.github.io, IP hosts) Co-Authored-By: Claude Fable 5 --- src/store/first-touch.ts | 31 +++++++++++++++++++++++++++++-- src/store/store.ts | 9 ++++++--- surface_embed_v1.js | 26 ++++++++++++++++++++++---- surface_tag.js | 26 ++++++++++++++++++++++---- test/first-touch.html | 6 ------ 5 files changed, 79 insertions(+), 19 deletions(-) diff --git a/src/store/first-touch.ts b/src/store/first-touch.ts index 9e6ea26..9682f32 100644 --- a/src/store/first-touch.ts +++ b/src/store/first-touch.ts @@ -34,6 +34,10 @@ const QUALIFYING_PARAMS = [ ] as const; const MAX_VALUE_LENGTH = 256; +// Browsers cap a cookie (name + encoded value + attributes) at ~4093 bytes; +// stay well under so the write is never silently rejected. +const MAX_ENCODED_COOKIE_BYTES = 3500; +const FALLBACK_VALUE_LENGTH = 64; interface FirstTouchRecord { params: Record; @@ -80,11 +84,34 @@ export function captureFirstTouch(): void { at: new Date().toISOString(), }; - setCookie(FIRST_TOUCH_COOKIE_NAME, JSON.stringify(record), { + let serialized = JSON.stringify(record); + if (encodeURIComponent(serialized).length > MAX_ENCODED_COOKIE_BYTES) { + // Degrade rather than let the browser reject the oversized cookie: + // attribution params matter more than the diagnostic url/referrer. + record.url = ""; + record.referrer = ""; + Object.keys(record.params).forEach((key) => { + record.params[key] = record.params[key].slice(0, FALLBACK_VALUE_LENGTH); + }); + serialized = JSON.stringify(record); + if (encodeURIComponent(serialized).length > MAX_ENCODED_COOKIE_BYTES) return; + } + + const options = { maxAge: FIRST_TOUCH_COOKIE_MAX_AGE, - sameSite: "lax", + sameSite: "lax" as const, + }; + + setCookie(FIRST_TOUCH_COOKIE_NAME, serialized, { + ...options, domain: getJourneyCookieDomain(), }); + + // Public-suffix hosts (e.g. *.github.io) and IP hosts reject a base-domain + // attribute outright; retry host-only so the cookie still lands. + if (!getCookie(FIRST_TOUCH_COOKIE_NAME)) { + setCookie(FIRST_TOUCH_COOKIE_NAME, serialized, options); + } } export function getFirstTouchParams(): Record { diff --git a/src/store/store.ts b/src/store/store.ts index 2e4b616..520d28a 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -95,9 +95,6 @@ export class SurfaceStore { const iframes = document.querySelectorAll("iframe"); if (iframes.length === 0) return; - this.urlParams = getUrlParams(); - this.urlParams.url = window.location.href; - this.log.info({ message: "Updating iframe params", response: { type, iframeCount: iframes.length } }); iframes.forEach((iframe) => this.notifyIframe(iframe, type)); @@ -122,6 +119,12 @@ export class SurfaceStore { } getPayload(): StorePayload { + // Read params fresh: the direct notifyIframe() path can run before + // sendPayloadToIframes() ever refreshed this.urlParams, and after an SPA + // route change the cached value may belong to the previous page. + this.urlParams = getUrlParams(); + this.urlParams.url = window.location.href; + return { windowUrl: this.windowUrl, referrer: this.referrer, diff --git a/surface_embed_v1.js b/surface_embed_v1.js index 532c4ff..3c53a2d 100644 --- a/surface_embed_v1.js +++ b/surface_embed_v1.js @@ -350,6 +350,8 @@ "ttclid" ]; var MAX_VALUE_LENGTH = 256; + var MAX_ENCODED_COOKIE_BYTES = 3500; + var FALLBACK_VALUE_LENGTH = 64; function isInternalNavigation() { if (!document.referrer) return false; try { @@ -374,11 +376,27 @@ referrer: document.referrer.slice(0, MAX_VALUE_LENGTH), at: (/* @__PURE__ */ new Date()).toISOString() }; - setCookie(FIRST_TOUCH_COOKIE_NAME, JSON.stringify(record), { + let serialized = JSON.stringify(record); + if (encodeURIComponent(serialized).length > MAX_ENCODED_COOKIE_BYTES) { + record.url = ""; + record.referrer = ""; + Object.keys(record.params).forEach((key) => { + record.params[key] = record.params[key].slice(0, FALLBACK_VALUE_LENGTH); + }); + serialized = JSON.stringify(record); + if (encodeURIComponent(serialized).length > MAX_ENCODED_COOKIE_BYTES) return; + } + const options = { maxAge: FIRST_TOUCH_COOKIE_MAX_AGE, - sameSite: "lax", + sameSite: "lax" + }; + setCookie(FIRST_TOUCH_COOKIE_NAME, serialized, { + ...options, domain: getJourneyCookieDomain() }); + if (!getCookie(FIRST_TOUCH_COOKIE_NAME)) { + setCookie(FIRST_TOUCH_COOKIE_NAME, serialized, options); + } } function getFirstTouchParams() { const raw = getCookie(FIRST_TOUCH_COOKIE_NAME); @@ -574,8 +592,6 @@ sendPayloadToIframes(type) { const iframes = document.querySelectorAll("iframe"); if (iframes.length === 0) return; - this.urlParams = getUrlParams(); - this.urlParams.url = window.location.href; this.log.info({ message: "Updating iframe params", response: { type, iframeCount: iframes.length } }); iframes.forEach((iframe) => this.notifyIframe(iframe, type)); } @@ -595,6 +611,8 @@ return getUrlParams(); } getPayload() { + this.urlParams = getUrlParams(); + this.urlParams.url = window.location.href; return { windowUrl: this.windowUrl, referrer: this.referrer, diff --git a/surface_tag.js b/surface_tag.js index 532c4ff..3c53a2d 100644 --- a/surface_tag.js +++ b/surface_tag.js @@ -350,6 +350,8 @@ "ttclid" ]; var MAX_VALUE_LENGTH = 256; + var MAX_ENCODED_COOKIE_BYTES = 3500; + var FALLBACK_VALUE_LENGTH = 64; function isInternalNavigation() { if (!document.referrer) return false; try { @@ -374,11 +376,27 @@ referrer: document.referrer.slice(0, MAX_VALUE_LENGTH), at: (/* @__PURE__ */ new Date()).toISOString() }; - setCookie(FIRST_TOUCH_COOKIE_NAME, JSON.stringify(record), { + let serialized = JSON.stringify(record); + if (encodeURIComponent(serialized).length > MAX_ENCODED_COOKIE_BYTES) { + record.url = ""; + record.referrer = ""; + Object.keys(record.params).forEach((key) => { + record.params[key] = record.params[key].slice(0, FALLBACK_VALUE_LENGTH); + }); + serialized = JSON.stringify(record); + if (encodeURIComponent(serialized).length > MAX_ENCODED_COOKIE_BYTES) return; + } + const options = { maxAge: FIRST_TOUCH_COOKIE_MAX_AGE, - sameSite: "lax", + sameSite: "lax" + }; + setCookie(FIRST_TOUCH_COOKIE_NAME, serialized, { + ...options, domain: getJourneyCookieDomain() }); + if (!getCookie(FIRST_TOUCH_COOKIE_NAME)) { + setCookie(FIRST_TOUCH_COOKIE_NAME, serialized, options); + } } function getFirstTouchParams() { const raw = getCookie(FIRST_TOUCH_COOKIE_NAME); @@ -574,8 +592,6 @@ sendPayloadToIframes(type) { const iframes = document.querySelectorAll("iframe"); if (iframes.length === 0) return; - this.urlParams = getUrlParams(); - this.urlParams.url = window.location.href; this.log.info({ message: "Updating iframe params", response: { type, iframeCount: iframes.length } }); iframes.forEach((iframe) => this.notifyIframe(iframe, type)); } @@ -595,6 +611,8 @@ return getUrlParams(); } getPayload() { + this.urlParams = getUrlParams(); + this.urlParams.url = window.location.href; return { windowUrl: this.windowUrl, referrer: this.referrer, diff --git a/test/first-touch.html b/test/first-touch.html index fdffbee..e2d2e66 100644 --- a/test/first-touch.html +++ b/test/first-touch.html @@ -67,12 +67,6 @@

Merged payload urlParams (what iframes receive)

: "(not set)"; const store = window.SurfaceTagStore; - if (store) { - // Mirror sendPayloadToIframes(): refresh current-page params first, - // since this page hosts no iframe to trigger the real send path. - store.urlParams = store.getUrlParams(); - store.urlParams.url = window.location.href; - } document.getElementById("payloadPanel").textContent = store ? JSON.stringify(store.getPayload().urlParams, null, 2) : "(SurfaceTagStore not initialized)";