From c7b3c7d2663fa8c10be4dae6f0c4a11daead4eb8 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 7 Sep 2026 00:27:39 -0700 Subject: [PATCH 1/2] attribution: carry session + campaign identity into shopify orders The marketing site and the shop are different origins, so Shopify sees none of our attribution: landing_site is just the cart permalink (/cart/c/) and note_attributes is empty on all of the last 250 orders. mc_cid reaches comma.ai fine (1,921 pageviews in 30d) and now survives the harness rewrite, but nothing forwards it into the cart. Capture each inbound touch to localStorage and write it into the cart, where it lands in order.note_attributes and can be queried from the Admin API alone. - first touch is frozen; last touch is overwritten ONLY on a real touch (tracking param or external referrer). A direct return must not clobber an earlier campaign touch -- 67% of buyers convert across more than one session and 33% take more than a day, so that case is the norm. - carts persist in localStorage across visits, so refresh attributes via cartAttributesUpdate at checkout rather than trusting cart-create time. - mc_eid identifies the person on an email click with no form submission; the signup form attaches the email. Both give cross-device stitching that device-local storage cannot. Verified in a browser: campaign touch survives a direct return with no params and no referrer, and cartAttributes() emits distinct_id, session_id, mc_cid and both touch blobs. Not included: the orders/create webhook that posts a purchase event back to PostHog. That closes the funnel and needs somewhere to run. --- src/lib/components/ShoppingCart.svelte | 7 ++ src/lib/email-updates.js | 2 + src/lib/utils/attribution.js | 112 +++++++++++++++++++++++++ src/lib/utils/shopify.js | 23 ++++- src/routes/+layout.svelte | 5 ++ 5 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/lib/utils/attribution.js diff --git a/src/lib/components/ShoppingCart.svelte b/src/lib/components/ShoppingCart.svelte index 3e39f14b..f95bd64e 100644 --- a/src/lib/components/ShoppingCart.svelte +++ b/src/lib/components/ShoppingCart.svelte @@ -13,6 +13,7 @@ import SteppableInput from "./SteppableInput.svelte"; import Space from "./Space.svelte"; import { formatCurrency } from "$lib/utils/currency"; + import { refreshCartAttributes } from "$lib/utils/shopify"; export let loading = false; @@ -31,6 +32,12 @@ async function checkout() { loading = true; + // never block checkout on analytics + try { + await refreshCartAttributes(); + } catch { + // ignore + } window.open(get(checkoutUrl), "_self"); loading = false; } diff --git a/src/lib/email-updates.js b/src/lib/email-updates.js index df0ed7e5..a3501bde 100644 --- a/src/lib/email-updates.js +++ b/src/lib/email-updates.js @@ -1,4 +1,5 @@ import { get, writable } from 'svelte/store'; +import { identifyByEmail } from '$lib/utils/attribution'; export const EMAIL_CATEGORIES = [ { key: 'product', label: 'Product updates', description: 'New products and sales', fieldName: 'group[54660][1]' }, @@ -84,6 +85,7 @@ export function createEmailUpdatesForm() { try { await submitEmailUpdates(get(email), get(selectedCategories), get(car).trim()); status.set('success'); + identifyByEmail(get(email)); } catch (error) { errorMessage.set(error.message); status.set('error'); diff --git a/src/lib/utils/attribution.js b/src/lib/utils/attribution.js new file mode 100644 index 00000000..71ae1c18 --- /dev/null +++ b/src/lib/utils/attribution.js @@ -0,0 +1,112 @@ +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'attribution'; +const MAX_ATTR_LEN = 255; + +// params that mark a real inbound touch, not internal navigation +const TOUCH_PARAMS = [ + 'mc_cid', 'mc_eid', + 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + 'fbclid', 'gclid', 'igshid', 'ttclid', 'msclkid', +]; + +const isInternalHost = (host) => /(^|\.)comma\.ai$/.test(host); + +function posthog() { + return browser ? window.posthog : null; +} + +// posthog's stub queues calls before load and returns undefined, so fall back to its cookie +function distinctId() { + const id = posthog()?.get_distinct_id?.(); + if (id) return id; + if (!browser) return null; + const match = document.cookie.match(/ph_[^=]+_posthog=([^;]+)/); + if (!match) return null; + try { + return JSON.parse(decodeURIComponent(match[1]))?.distinct_id || null; + } catch { + return null; + } +} + +function load() { + if (!browser) return {}; + try { + return JSON.parse(window.localStorage.getItem(STORAGE_KEY)) || {}; + } catch { + return {}; + } +} + +function save(value) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + // private browsing / storage disabled + } +} + +function readTouch() { + const url = new URL(window.location.href); + const touch = { ts: Date.now(), path: url.pathname }; + + for (const param of TOUCH_PARAMS) { + const value = url.searchParams.get(param); + if (value) touch[param] = value; + } + + try { + const host = document.referrer ? new URL(document.referrer).hostname : ''; + if (host && !isInternalHost(host)) touch.referrer = host; + } catch { + // malformed referrer + } + + return touch; +} + +// a direct visit or internal navigation must not clobber an earlier campaign touch +function isRealTouch(touch) { + return TOUCH_PARAMS.some((param) => touch[param]) || !!touch.referrer; +} + +export function captureTouch() { + if (!browser) return; + + const touch = readTouch(); + const attribution = load(); + + if (!attribution.first) attribution.first = touch; + if (isRealTouch(touch)) attribution.last = touch; + + save(attribution); + + // an email click identifies the person without them filling anything in + if (touch.mc_eid) posthog()?.identify?.(`mc:${touch.mc_eid}`); +} + +export function identifyByEmail(email) { + if (!browser || !email) return; + posthog()?.setPersonProperties?.({ email }); +} + +// written into the shopify cart so the order can be joined back to the session +export function cartAttributes() { + if (!browser) return []; + + const { first, last } = load(); + const attributes = []; + const add = (key, value) => { + if (value) attributes.push({ key, value: String(value).slice(0, MAX_ATTR_LEN) }); + }; + + add('_ph_distinct_id', distinctId()); + add('_ph_session_id', posthog()?.get_session_id?.()); + add('_mc_cid', last?.mc_cid || first?.mc_cid); + add('_utm_source', last?.utm_source || first?.utm_source); + add('_first_touch', first && JSON.stringify(first)); + add('_last_touch', last && JSON.stringify(last)); + + return attributes; +} diff --git a/src/lib/utils/shopify.js b/src/lib/utils/shopify.js index 91df19d9..8e6f0ea1 100644 --- a/src/lib/utils/shopify.js +++ b/src/lib/utils/shopify.js @@ -1,5 +1,6 @@ import { get } from 'svelte/store'; import { cartId, cartCreatedAt, checkoutUrl, cartTotalQuantity } from '../../store'; +import { cartAttributes } from './attribution'; // GraphQL fragments for error handling const USER_ERRORS_GQL = `userErrors { code field message }`; @@ -206,7 +207,10 @@ export async function createCart(referralCode = null) { } `, variables: { - input: { discountCodes: referralCode ? [referralCode] : [] } + input: { + discountCodes: referralCode ? [referralCode] : [], + attributes: cartAttributes(), + } } }).then(response => { cartId.set(response.body?.data?.cartCreate?.cart?.id) @@ -217,6 +221,23 @@ export async function createCart(referralCode = null) { } +// carts persist in localStorage across visits, so refresh attribution before handing off to checkout +export async function refreshCartAttributes() { + const id = get(cartId); + if (!id) return; + + return shopifyFetch({ + query: /* graphql */ ` + mutation cartAttributesUpdate($cartId: ID!, $attributes: [AttributeInput!]!) { + cartAttributesUpdate(cartId: $cartId, attributes: $attributes) { + ${USER_ERRORS_GQL} + } + } + `, + variables: { cartId: id, attributes: cartAttributes() } + }); +} + export async function updateCart({ cartId, lineId, variantId, quantity }) { return shopifyFetch({ query: /* graphql */ ` diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e54a74c8..3f9154e1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -22,6 +22,7 @@ import { createCart, updateCart } from '$lib/utils/shopify'; import { printConsoleBanner } from '$lib/utils/console'; import { getReferralCode } from '$lib/utils/referral'; + import { captureTouch } from '$lib/utils/attribution'; import HeaderBanner from "$lib/components/HeaderBanner.svelte"; import HeaderMenu from "$lib/components/HeaderMenu.svelte"; @@ -58,7 +59,11 @@ loading = false; } + // record the touch on every navigation, before anything can create a cart + $: if ($page.url) captureTouch(); + onMount(async () => { + captureTouch(); const referralCode = getReferralCode(); if (referralCode) await createCart(referralCode); await loadCart(); From cd671995ea5a04f76dad2495568315805e3d7191 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 7 Sep 2026 00:38:03 -0700 Subject: [PATCH 2/2] attribution: never overwrite an identity we already have A user can type anything into the email field, including someone else's address, so the email goes in as $set_once and can't replace a known one. Same for identify: a device already identified from an email click is left alone, otherwise a forwarded email or a shared machine merges two people. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QrpdEG6hDqBg3627kRawvq --- src/lib/utils/attribution.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/utils/attribution.js b/src/lib/utils/attribution.js index 71ae1c18..7afb32e9 100644 --- a/src/lib/utils/attribution.js +++ b/src/lib/utils/attribution.js @@ -81,14 +81,25 @@ export function captureTouch() { if (isRealTouch(touch)) attribution.last = touch; save(attribution); + identifyFromEmailClick(touch); +} + +// an email click identifies the person without them filling anything in. +// never re-identify an already-identified device: a forwarded email or a shared +// machine would otherwise merge two different people. +function identifyFromEmailClick(touch) { + if (!touch.mc_eid) return; + + const current = posthog()?.get_distinct_id?.(); + if (current && current.startsWith('mc:')) return; - // an email click identifies the person without them filling anything in - if (touch.mc_eid) posthog()?.identify?.(`mc:${touch.mc_eid}`); + posthog()?.identify?.(`mc:${touch.mc_eid}`); } +// $set_once, so a typo or someone else's address can never replace a known one export function identifyByEmail(email) { if (!browser || !email) return; - posthog()?.setPersonProperties?.({ email }); + posthog()?.setPersonProperties?.(undefined, { submitted_email: email }); } // written into the shopify cart so the order can be joined back to the session