Skip to content

SURF-1415 feat(store): first-touch UTM persistence in the Surface tag#67

Open
paragmore wants to merge 2 commits into
mainfrom
feat/first-touch-utm
Open

SURF-1415 feat(store): first-touch UTM persistence in the Surface tag#67
paragmore wants to merge 2 commits into
mainfrom
feat/first-touch-utm

Conversation

@paragmore

Copy link
Copy Markdown
Collaborator

Problem

Customers running paid campaigns lose attribution before a form is ever opened: the tag reads window.location.search fresh on every STORE_UPDATE, so a visitor who lands on a paid landing page with utm_source/utm_medium/click-id params and then navigates loses them — especially when internal CTAs replace the query string with internal tags (e.g. ?utm_content=home_homepage-hero). Verified in Yuma's prod data: across two LinkedIn campaign windows, zero genuine prospect submissions carried LinkedIn-paid params. Paid Social shows zero in their HubSpot; contacts land as Direct/Offline.

Ticket: SURF-1415

Change

  • src/store/first-touch.ts (new): on page load, if the URL carries qualifying attribution params, persist { params, url, referrer, at } in first-party cookie surface_first_touch (30-day max-age, base-domain scoped via getJourneyCookieDomain(), values capped at 256 chars).
    • Qualifying = utm_source, utm_medium, or a click-id (gclid, fbclid, li_fat_id, msclkid, ttclid). utm_content alone does not qualify — internal CTA tags would poison the slot.
    • Capture is skipped when document.referrer is same-origin (internal navigation), and is write-once until expiry (strict first-touch, matching HubSpot Original Source semantics).
    • Capture runs on page load only, not on SPA route changes — a route change with params is internal navigation by definition, and document.referrer is stale on pushState so the same-origin guard can't protect that path.
  • SurfaceStore.getPayload(): merge { ...getFirstTouchParams(), ...this.urlParams } — current-page params win, first-touch fills gaps. Merging here (not in sendPayloadToIframes()) also covers the direct notifyIframe() call on the embed partial-fill path.
  • test/first-touch.html (new): manual test page showing the cookie and the merged payload params.

Downstream (form-render URL_DATA.params → HubSpot UTM hidden fields) is unchanged — the merged params flow through the existing plumbing.

Verification (Playwright against the built bundle)

  • Landing with ?utm_source=linkedin&utm_medium=paid_social&utm_campaign=Test+Campaign&li_fat_id=test-123&utm_content=ad-variant-a → cookie written with all params.
  • Navigating to ?utm_content=home_homepage-hero → merged payload keeps all LinkedIn params, current page's utm_content wins.
  • Same-origin navigation carrying ?utm_source=sneaky-internal → cookie NOT written.
  • utm_content-only landing with no referrer → cookie NOT written.
  • Second external landing with ?utm_source=google&gclid=… while cookie set → stored record unchanged (write-once); merged payload shows current-page google params winning, first-touch filling utm_medium.
  • pnpm run typecheck + pnpm run build clean; surface_tag.js/surface_embed_v1.js rebuilt.

Rollout note

Consumers load @latest from jsDelivr — purge https://purge.jsdelivr.net/gh/trysurface/scripts@latest/surface_tag.min.js after merge to make it land same-day.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@notion-workspace

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds first-touch campaign attribution persistence to the Surface tag. The main changes are:

  • A new surface_first_touch cookie for landing-page UTM and click-id params.
  • First-touch params merged into iframe payload URL data.
  • Fresh URL param reads inside getPayload() for direct iframe notifications.
  • Updated built bundles and a manual first-touch test page.

Confidence Score: 4/5

This is close, but the oversized-cookie fallback should be fixed before merging.

  • Fresh URL params are now read in getPayload(), which covers the direct iframe notification path.
  • The cookie domain retry handles hosts that reject the base-domain attribute.
  • Very large encoded attribution records can still skip the cookie write entirely, so first-touch attribution can still be lost for valid campaign URLs.

src/store/first-touch.ts

Reviews (2): Last reviewed commit: "fix(store): address review — fresh urlPa..." | Re-trigger Greptile

Comment thread src/store/store.ts
Comment thread src/store/first-touch.ts Outdated
Comment thread src/store/first-touch.ts
…in fallback

- 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 <noreply@anthropic.com>
Comment thread src/store/first-touch.ts
record.params[key] = record.params[key].slice(0, FALLBACK_VALUE_LENGTH);
});
serialized = JSON.stringify(record);
if (encodeURIComponent(serialized).length > MAX_ENCODED_COOKIE_BYTES) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Oversized Params Still Drop When a qualifying landing URL contains several long non-ASCII UTM values, the fallback can still leave the encoded JSON above MAX_ENCODED_COOKIE_BYTES. This branch then returns without writing any cookie, so later pages receive no first-touch params at all. That preserves the attribution loss this size guard is meant to avoid; the fallback should keep degrading or preserve a minimal qualifying attribution record instead of silently dropping the capture.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant