Status (2026-07-21): Phases 0–2, 4, 5 shipped and deployed. Consent Mode v2, the capture module, journey attachment to every lead form, the workspace Attribution dashboard, and GA4
generate_leadconversion events are all built (unit + browser-E2E verified) and live. Only Phase 3 (Salesforce write-back) remains — blocked on the SF custom fields. Console follow-ups: mark the GA4 conversion, LinkedIn Insight Tag/retargeting (WEB-86), optional Looker board. Full design:docs/superpowers/specs/2026-07-20-lead-attribution-design.md; plans underdocs/superpowers/plans/2026-07-21-*.
The team needs to answer, for every single lead: where did it come from, which ad/campaign produced it, and what path did the person walk (which blog posts, which pages, over how many sessions) before converting. This is a per-lead attribution capability for the whole GTM/marketing/sales team — not a single report.
GA4 answers aggregate/session questions but cannot join "this specific person who booked a demo" to "the Groww LinkedIn ad + these 3 blog posts." That join is what this system builds.
GTM-5WWQQTPD, injected in production from app/layout.tsx
(skips /workspace). Consent is now handled by Google Consent Mode v2
(components/analytics/ConsentModeInit.tsx + ConsentUpdateBridge.tsx, Phase 0 shipped):
analytics defaults granted (essential), ad signals stay denied until marketing consent. The old
unwired AnalyticsGuard.tsx has been removed.lib/datasheet-tracking.ts;
webinar forms capture some; the demo form forwards only a raw query string.lead_source only./blogs/[slug] with no lead form or attribution attached.The full multi-touch journey lives client-side in a durable first-party cookie plus a capped,
schema-versioned localStorage timeline. The entire journey is transmitted only when a lead
form is submitted, at which point it is joined to the now-known lead. Anonymous journey capture
is always-on (functional); the join to PII and all third-party pixels are consent-gated.
Non-converter volume (denominators for conversion rates) comes from GA4 aggregate — no custom
PII event stream is needed.
Any page (incl. /blogs/[slug])
└─ lib/attribution client module (planned)
├─ ensures durable first-party visitor cookie (mai_vid, ~2yr)
├─ captures per-session first-touch + last-touch (utm×5, gclid, fbclid, li_fat_id, referrer, landing)
└─ appends a capped, ordered pageview to the localStorage journey
│ (on ANY lead form submit)
▼
Form → existing API route (demo / datasheets / whitepapers / webinars / contact)
├─ Mongo lead record ← FULL journey blob (unified shape)
├─ Salesforce Web-to-Lead ← COMPACT summary via hidden inputs
└─ Slack (demo only, unchanged)
│
apps/workspace ── Marketing Attribution dashboard (reads Mongo)
GA4 / Looker ── aggregate funnels + denominators + LinkedIn retargeting
LeadJourney model (planned — types/attribution.ts)#A single schema-versioned shape shared by the client, every API route, MongoDB, and the
dashboard: visitorId, firstTouch, lastTouch, sessionCount, and a capped ordered
pageviews[] (each carrying path, sessionId, contentType, and slug — the slug is what
powers "which blog post generated the lead"). Truncation (when a journey exceeds the cap) is
recorded explicitly so the dashboard never implies a complete path when it isn't.
ConsentManager grant, enforced with Google Consent Mode v2.
This work also fixes the currently-dead AnalyticsGuard wiring.PENDING_TASKS.md).Every paid or campaign link must carry UTMs, or the click is unattributable (this is the direct cause of the "52 LinkedIn clicks, 0 in GA" gap). Standard:
| Param | Meaning | Rule / examples |
|---|---|---|
utm_source | platform | lowercase — linkedin, google, newsletter |
utm_medium | channel type | paid_social, cpc, email, organic_social, referral |
utm_campaign | campaign | {topic}-{yyyymm} — e.g. groww-casestudy-202607 |
utm_content | creative / variant | pinned-comment, hero-video, carousel-a |
utm_term | keyword | paid search only |
Rules: lowercase, hyphen-separated, no spaces. Put the UTM on the destination link in the post or pinned comment — not on the image (image taps don't carry it). Verify a test click in GA4 DebugView / Realtime; standard reports lag 24–48h.
Example:
https://matters.ai/case-studies/credit-saison?utm_source=linkedin&utm_medium=paid_social&utm_campaign=groww-casestudy-202607&utm_content=pinned-comment
Added inside the GTM container GTM-5WWQQTPD (not in code), fired on all pages via a trigger
gated on marketing consent (Consent Mode ad_storage = granted). It builds the retargeting
audience and confirms LinkedIn traffic. Capturing li_fat_id from the URL into the lead journey
lands in Phase 1. Google Consent Mode v2 defaults are set in code before GTM
(components/analytics/ConsentModeInit.tsx) and updated from the cookie banner via
components/analytics/ConsentUpdateBridge.tsx.
A compact summary (first/last source·medium·campaign, landing page, click id, session count, and
Attribution_Visitor_Id__c as the join key) is pushed via hidden Web-to-Lead inputs. This
requires ~10 custom Lead fields created by a Salesforce admin — field spec in PENDING_TASKS.md.
Web-to-Lead is fire-and-forget, so MongoDB is the source of truth and SF is best-effort. A
follow-up ticket upgrades this to full bidirectional JWT REST sync (reusing the partners-portal
pattern).
apps/workspace — shipped)#A team-wide module (org:marketing:view RBAC), themed through @matters/theme, at
/marketing-attribution ("Lead Attribution" in the sidebar + Command Center). Reads the four
wired lead collections (demo, datasheet, whitepaper, webinar registrations) and shows: total /
attributed / attribution-rate / top-source tiles, a first-touch source donut, campaigns
bar, blog → lead bar (which posts precede conversions), and a recent-lead feed with each
lead's first source, campaign, session + pageview counts, and blogs read. It also has a
date-range filter (7/30/90/all), an attributed-leads-over-time trend, a first-touch vs
last-touch source pair (how they found us vs what converted them), and each recent-lead row
expands to its ordered journey path
(e.g. linkedin → /blogs/credit-saison → /blogs/groww → /demo). An Export CSV button
downloads the attributed leads for the selected range (attribution columns + masked email;
raw-lead export stays in the Sales module). It further surfaces funnel stats (avg pages/lead,
avg sessions/lead, avg time-to-convert, multi-session rate), a content-type mix, lead-type
mix, channel/medium, top landing pages, and a pages-before-converting distribution.
Aggregation:
lib/services/marketing/attribution-aggregate.ts (pure, unit-tested) +
attribution.service.ts (Mongo reads). Note: there is no GA4 API integration in the
dashboard — conversion-rate denominators (total traffic) still come from reading the GA4 console
directly. Nothing in the dashboard displays a GA4-derived number.
ConsentModeInit + ConsentUpdateBridge), dead
AnalyticsGuard removed, UTM naming convention. (LinkedIn Insight Tag itself is a GTM-console
task — WEB-86.)lib/attribution capture module (parse/journey/collect) +
types/attribution.ts LeadJourney + JourneyRecorder. Unit-tested + browser-verified.apps/workspace → Lead Attribution
(/marketing-attribution, org:marketing:view): source/campaign donut+bars, blog → lead
attribution, and a recent-lead feed with per-lead journey summary. Reads the four wired lead
collections via lib/services/marketing/attribution.service.ts.generate_lead event on success
(lib/analytics.ts TRACKING_CONFIG + LEAD_GEN_FORMS; wired into demo, datasheet,
whitepaper, and webinar forms), which GTM can forward to the LinkedIn / Google Ads conversion
tags. Remaining = console: mark generate_lead as a Key Event/conversion in GA4, stand up the
LinkedIn retargeting audience (WEB-86), and (optional) a Looker Studio board.Two surfaces render the multi-touch journey, both through one shared component
(apps/workspace/components/workspace/attribution/JourneyVisual.tsx) so they never drift:
leadJourney rendered as a raw JSON
blob. Applies to every collection that carries a journey (demo, datasheet, whitepaper, webinar,
case-study).Visual shows a pipeline strip (first source -> N sessions / N pages -> last source -> Converted)
plus a session-grouped timeline: one block per session, each pageview a node colour-coded by
content type with its slug and time. Data keeps the compact chip path.
The dashboard also renders a Sankey (WsSankey, a themed recharts primitive) of
source -> primary content -> lead type. Each lead contributes exactly one unit, attributed to
its most meaningful content touch (gated assets outrank blogs via CONTENT_PRIORITY), so the
diagram conserves flow instead of double-counting a lead across every page type it grazed. Node keys
are stage-qualified (s:/c:/t:) so a source and a content type sharing a label can't collide.
scoreIntent() — deliberately simple so sales trusts it:
repeat visits (sessions x 8, capped at 6), research depth (pageviews x 2, capped at 15),
+15 per gated asset type touched (datasheet / whitepaper / webinar), +10 for product
pages, +5 if they arrived on a tracked campaign. Banded hot (>=70) / warm (40-69) /
cool. Shown as a badge on every lead row and included in the CSV export.mai_vid is per-device, so researching on mobile then converting on desktop would read as two
unrelated strangers — and under-score the lead. mergeJourneys() fixes this at read time only
(nothing stored is mutated): leads sharing an email (case-insensitive) collapse into one person —
earliest first touch, latest last touch, pageviews unioned in chronological order and
de-duped on ts+path so re-reads cannot inflate depth, sessions summed. The recent-leads feed shows
one row per person with a device count, and the intent score is computed on the merged journey
(so it is no longer artificially low). Leads without an email stay separate.
Deliberate boundary: the headline totalLeads / attributedLeads figures still count lead
rows, not people — someone submitting two forms genuinely is two leads. Only the feed and the
account rollup are person-aware, which is why the accounts table shows People and Leads
as separate columns.
The Slack notifications that already fire on lead capture now carry an Attribution line built
by formatJourneyForAlert() (lib/services/marketing/lead-alert.ts) — band + score, channel,
campaign, sessions/pages, and the content they actually viewed. Wired into demo, datasheet
and whitepaper alerts.
Design constraint: this must never be able to break lead capture, so the formatter is pure and
fully defensive — a missing or malformed journey returns null and the field is simply omitted,
and every call site already sits inside a try/catch. Plain text only (no emoji), matching the
existing notification convention. Covered by tests including malformed-input cases.
demo (and contact, same route), datasheet, whitepaper (both forms), webinar register + subscribe,
gated case-study downloads, and Overture popup leads (overture_leads). Not every lead
collection has one — newsletter subscribers and career applications are intentionally out of scope.
Attribution is not retroactive: leads captured before the rollout have no journey, so the
dashboard's attribution rate climbs over time rather than starting high.
mattersai-datasheets.datasheet_leads / mattersai-whitepapers.whitepaper_leads and $incs a
download_count on the content doc. Surfaced in workspace → Datasheets / Whitepapers →
Analytics (Total Downloads, Avg, Top Performers), fed by
apps/workspace/app/api/{datasheets,whitepapers}/admin/stats/route.ts. These leads also carry
leadJourney, so they appear in the Lead Attribution dashboard too.read_what_matters___case_studies), rendered
via BlogPostTemplate (isCaseStudy) at the catch-all /[...slug]. The PDF (acf.pdf_download_url)
is gated: the Download button opens an anchored popover (components/case-studies/CaseStudyDownloadGate.tsx)
— rendered in a portal with position: fixed at the button's rect (z-[100], so it sits above
every section, not a center-screen modal), closes on click-away / Esc / Cancel. On submit it
records a lead via POST /api/case-studies/download → mattersai-leads.case_study_leads, fires
the GA4 + dataLayer case_study_download event, then opens the PDF in a tab that was pre-opened
inside the click (avoids the pop-up blocker) and shows a success view.download_count — never a
duplicate row. A GET /api/case-studies/download returns { known } by looking up the durable
first-party mai_vid cookie against case_study_leads; the page fetches it on load so a returning
visitor skips the form (decided by the server, not forgeable client storage).app/[...slug]/page.tsx advertises only GET /api/case-studies/file/[slug], which
verifies the caller has a case_study_leads record for their mai_vid cookie, resolves the real
url server-side and streams the bytes (never redirects, since a redirect would leak it).
Fails closed on a DB error (503). Verified: 403 with no cookie, 403 with a forged cookie,
200 + real PDF bytes with a genuine lead cookie, and no .pdf url anywhere in the page payload.post.acf is serialized into client
components (BlogHero reads the author fields), so pdf_download_url is stripped from the
client-bound post; and getPostBySlug used to logger.debug the entire posts array, which
Next forwards into the RSC stream in dev — that alone leaked the url (and ~100KB) into the page.
So case-study downloads capture who (a deduped lead in Lead Attribution) and how many
(GA4 + download_count). Case-study page views remain GA4 pageviews (via GTM GTM-5WWQQTPD).Geo / B2B-data-provider coverage (Indian/UAE leads), full bidirectional Salesforce REST sync (future ticket), and reverse-IP company deanonymization of non-converters.