Skip to content

feat(analytics): enrich PostHog person profiles and capture first-touch attribution - #224

Open
AminDhouib wants to merge 1 commit into
mainfrom
feat/identity-enrichment
Open

AminDhouib wants to merge 1 commit into
mainfrom
feat/identity-enrichment

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

Why

Opening a PostHog person profile for a Caramel shopper told you one thing: their email. Not who they are, not which surface or build they are on, not where they came from. This lands section B of the fleet-wide Identity Enrichment spec for caramel-app.

What PostHog now receives

posthog.identify(userId, $set, $set_once) — built in apps/caramel-app/src/lib/analytics/identityProperties.ts, called from identity.ts identifyUser, which the PostHogClientProvider drives off the Better Auth session.

$set (current truth, safe to re-send)

Property Source
email session user email
$email same value, kept for continuity with existing cohorts/dashboards written against the old integration
name session name, else firstName+lastName, else username. Never the email.
rc_app_user_id the internal user UUID (== distinct_id), so the analytics/customer join is explicit
app_id APP_ID (caramel)
app_version APP_VERSION
platform web
environment the resolved capture target's environment (production / e2e)
locale navigator.language
timezone Intl.DateTimeFormat().resolvedOptions().timeZone
user_created_at session createdAt, ISO-8601

$set_once (acquisition story, written once by PostHog)

signup_date (account creation, else first identify on this page load), first_platform, first_app_version, first_utm_source, first_utm_medium, first_utm_campaign, first_utm_term, first_utm_content, first_ref, first_gclid, first_fbclid, first_referrer_domain, first_landing_path.

Super propertiesapp_id, app_version, environment, platform were already registered in initPosthogBrowser and are unchanged.

First-touch attribution

New module apps/caramel-app/src/lib/analytics/firstTouch.ts. On the first page load it records utm_*, ref, gclid, fbclid, the external referrer hostname and the landing path into localStorage under caramel.first_touch, and never overwrites that record — a later organic session cannot rewrite the campaign a user originally arrived from. Two deliberate choices:

  • It is captured before the capture-target check in initPosthogBrowser, so an attribution-bearing landing is still banked on a deploy where PostHog capture is disabled.
  • The record is written on every first load, not only on a load carrying attribution params. Otherwise first_landing_path would never exist for an organic visitor, and a later campaign visit would be mislabelled as their first touch.

A same-origin referrer resolves to nothing rather than recording ourselves as the acquisition source.

Safety properties

  • PII: distinct_id stays the internal user UUID. Email is a property only, and resolveDisplayName explicitly refuses to fall back to it.
  • Only what we have: compactProperties drops nullish values, empty/whitespace strings, the literal strings undefined/null, non-finite numbers and non-scalars. No placeholders ever reach a profile.
  • Idempotent: a fingerprint of the payload short-circuits a re-send, so the profile is not re-POSTed on every React commit or session refresh. resetPosthogIdentity clears it so the next login re-sends.
  • Never throws: identifyUser and the first-touch reads/writes are wrapped; failures go to console.error plus Sentry.captureException with a coarse operation tag and are swallowed.

Skipped, and why

  • Section A (RevenueCat) and section C (RC webhook / server RC client): this repo has no RevenueCat project and no RC webhook route. Nothing to wire.
  • plan_tier / subscription_status / subscription_expires_at (both as person properties and as a super property): Caramel has no billing of any kind — no Stripe, no subscription or plan model in prisma/schema.prisma, no premium concept in the app. There is no source for these values, so they are not sent rather than sent empty. posthog.setPersonProperties on a plan change has no trigger to hook to for the same reason.
  • phone: not stored on the user.
  • auth_provider: the Better Auth client session does not expose the account provider, and fetching it would mean an extra authenticated request on every identify. Left out rather than paid for; it would be cheap to add if the provider is ever surfaced on the session.
  • apps/caramel-extension: has no PostHog integration at all (no posthog reference anywhere in the package), so there is no identify path to enrich there.

Base branch

Opened against main, not dev. dev is 93 commits behind main with nothing of its own (git rev-list --left-right --count origin/dev...origin/main0 93), it is not the repo default branch, and the entire src/lib/analytics/ module this change extends does not exist on it.

Verification

  • npx tsc --noEmit (caramel-app): TypeScript: No errors found
  • npx vitest run tests/unit/firstTouch.test.ts tests/unit/identityProperties.test.ts: Test Files 2 passed (2), Tests 23 passed (23)
  • npx eslint on the six changed files: clean
  • npx oxlint on src/lib/analytics: exit 0
  • npx knip (caramel-app): clean
  • npx prettier --check on the changed area: All matched files use Prettier code style!
  • Husky pre-commit ran on the commit (type-check both packages, knip both packages, prisma validate, lint-staged).

No E2E/browser run, per the task.

…ch attribution

PostHog only ever knew a person's email, so a human opening a profile could
not tell who the shopper was, on what surface/version, or where they came
from. identify now sends a full $set (email, display name, rc_app_user_id,
app_id, app_version, platform, environment, locale, timezone,
user_created_at) plus a $set_once acquisition story (signup_date, first
platform/app_version, first-touch utm_*/ref/gclid/fbclid, referrer domain,
landing path).

First-touch attribution is banked on the very first page load into
localStorage (caramel.first_touch) and never overwritten, so a later organic
session cannot rewrite the campaign a user originally arrived from. It is
captured before the capture-target check, so an attribution-bearing landing
still survives a deploy with analytics disabled.

The payload builders are pure and unit-tested: they drop every nullish,
empty and placeholder value, keep the distinct_id as the internal UUID, and
never fall back to the email for a display name. A payload fingerprint stops
an unchanged profile being re-sent on every React commit, and every call is
wrapped so an analytics failure reports to Sentry instead of reaching a
render.
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