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..9682f32 --- /dev/null +++ b/src/store/first-touch.ts @@ -0,0 +1,129 @@ +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; +// 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; + 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(), + }; + + 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" 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 { + 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..520d28a 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() @@ -90,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)); @@ -117,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, @@ -126,7 +134,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..3c53a2d 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,88 @@ 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; + var MAX_ENCODED_COOKIE_BYTES = 3500; + var FALLBACK_VALUE_LENGTH = 64; + 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() + }; + 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" + }; + 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); + 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 +554,9 @@ this.environmentId = environmentId3; this.log = createLogger("Surface Store"); initializeMessageListener(this); + if (!this.isCurrentOriginSurfaceDomain()) { + captureFirstTouch(); + } if ((this.cachedIdentifyData || !isIdentifyInProgress()) && !this.isCurrentOriginSurfaceDomain()) { initializeUserJourneyTracking( this.environmentId, @@ -505,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)); } @@ -526,13 +611,16 @@ return getUrlParams(); } getPayload() { + this.urlParams = getUrlParams(); + this.urlParams.url = window.location.href; return { windowUrl: this.windowUrl, referrer: this.referrer, 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..3c53a2d 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,88 @@ 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; + var MAX_ENCODED_COOKIE_BYTES = 3500; + var FALLBACK_VALUE_LENGTH = 64; + 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() + }; + 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" + }; + 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); + 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 +554,9 @@ this.environmentId = environmentId3; this.log = createLogger("Surface Store"); initializeMessageListener(this); + if (!this.isCurrentOriginSurfaceDomain()) { + captureFirstTouch(); + } if ((this.cachedIdentifyData || !isIdentifyInProgress()) && !this.isCurrentOriginSurfaceDomain()) { initializeUserJourneyTracking( this.environmentId, @@ -505,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)); } @@ -526,13 +611,16 @@ return getUrlParams(); } getPayload() { + this.urlParams = getUrlParams(); + this.urlParams.url = window.location.href; return { windowUrl: this.windowUrl, referrer: this.referrer, 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..e2d2e66 --- /dev/null +++ b/test/first-touch.html @@ -0,0 +1,78 @@ + + + + + + + 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 → + +