product-analytics
Kortix product analytics and campaign attribution in PostHog (EU Cloud): which events exist, where they fire (web or API), the privacy rules every event must keep, how a promo video or ad link must be
SKILL.md.agents/skills/product-analytics/SKILL.md
GitHubProduct analytics
One PostHog project on EU Cloud collects kortix.com: Kortix, project
334925 (https://eu.posthog.com/project/334925, free plan). The web app and
the API send to it with the same distinct id, the Supabase user id, so the
browser journey, the server events and the first-touch campaign meet on one
person.
| Part | File |
|---|---|
| Hosts that report, consent, URL scrubbing | apps/web/src/lib/analytics/posthog-config.ts (+ .test.ts) |
| The browser client (init, consent switch, masking) | apps/web/src/lib/analytics/posthog-client.ts |
| Start, identify, per-page rules | apps/web/src/components/analytics/posthog-host.tsx |
| Browser events (closed list) | apps/web/src/lib/track.ts |
| Server events (closed list) | apps/api/src/shared/product-analytics.ts |
| Events that know only the account (billing, trigger fires) | apps/api/src/iam/account-analytics.ts |
| Route events for the person behind a request | apps/api/src/projects/routes/product-events.ts |
The request surface (source) |
apps/api/src/middleware/analytics-source.ts |
| Dashboards as code | infra/posthog/ |
Same-origin proxy /ingest |
apps/web/next.config.ts rewrites |
Rules
- No customer data in an event. Properties are kinds, counts, USD amounts,
our own route names and our own ids. Never a name, an email, a prompt, a
message, a file name, a title, a URL query, a trigger or connector slug, a
secret name, or anything a customer typed.
identifysends the user id only (decision 2026-11-04: data minimization). The CLAUDE.md PII rule applies to PostHog too. - Every event name is in a closed list. Web:
PANEL_EVENTSorJOURNEY_EVENTSintrack.ts. API: theProductEventunion. Never callposthog.capturedirectly. - A business fact is an API event. Sign-up, project, session, message, checkout and payment events fire on the server after the database write. The browser loses events to ad blockers; the server does not.
- Only production reports (decision 2026-11-06; the free plan has one
project). The web client reports on
kortix.comonly; dev, staging, self-host, white-label and preview hosts never download posthog-js. The API sends only wherePOSTHOG_PROJECT_KEYis set: the prod blob, not dev or staging. Every event carriesenvironment. A self-hosted, white-label or preview host never downloads posthog-js. The API sends only whenPOSTHOG_PROJECT_KEYis set. - URLs are reduced before they leave the browser. On signed-in and
capability-token pages (
isAnalyticsExcludedPath) a path becomes a route template (/projects/:id/sessions/:id). Onlyutm_*and ad click ids survive a query string. Autocapture text and link targets are removed on those pages. - Consent decides storage. Until the CookieYes banner grants analytics,
the anonymous id lives in memory only (no cookie, no local storage). A grant
switches to
localStorage+cookie. A signed-in user without consent starts with their own user id (bootstrap), so no anonymous id is merged per page. - No session replay (decision 2026-11-04). The recorder is not bundled, and the project setting is off. Turning it on is a privacy-policy change.
- A person's events only. Route events go through
capturePersonEvent(projects/routes/product-events.ts): an agent's session token, an API key, a service account or an OAuth app sends nothing. Server-side session and turn events count only sessions withinitiator_type = member.
Events
| Event | From | Fires when | Properties |
|---|---|---|---|
$pageview, $pageleave, $autocapture |
web | navigation, clicks | scrubbed URL, environment, platform (web/desktop), app_version |
cta_signup_clicked |
web | a sign-up button on a marketing page | location |
auth_completed |
web | the browser lands after sign-up or sign-in | kind (signup/login), method |
panel events (panel_opened, …) |
web (@kortix/ui) |
session panel and conversation UI | source, kind, count |
user_signed_up |
API | a new person's first GET /v1/accounts with no workspace (accounts/core/signup-event.ts) |
method (email, google, github, apple), invited (an invitation to the email was open at sign-up); sets the person property signup_kind (invited, self_serve) once |
user_logged_in |
API | a CLI device login or a headless sign-in (not a token refresh) | method |
project_created |
API | a project is provisioned or linked | origin (blank, template, marketplace, github_import), provider |
session_created |
API | a person creates a session (not triggers, channels, agents) | session_id, source, with_prompt |
session_started |
API | the session's runtime answers for the first time in one boot: first open, every wake after sleep, every restart. It counts boots, not sessions | provider, boot_ms |
session_restarted |
API | a person restarts a session | provider |
session_stopped |
API | a person's session's sandbox stops | reason, active_ms |
prompt_sent |
API | a person sends a prompt: the prompt POST, or inside the session create (workspace home, mobile, sessions board) | model (default = the project default), attachment_count, at_create |
turn_completed |
API | an agent turn of a person's session ends | status (ok, error, aborted), end_reason, duration_ms |
secret_created |
API | a new project secret (never its name or value) | name_kind (provider, other), strategy |
provider_configured |
API | an LLM provider is connected | provider, method (api_key, oauth, subscription) |
provider_verified |
API | a provider credential check | provider, state |
model_changed |
API | a person switches a session's model | from, to, live_push |
connector_added |
API | a connector is added (never its slug) | provider, app |
connector_connected |
API | a connector finishes connecting | method, provider |
trigger_created |
API | a trigger is created (never its slug) | type |
trigger_fired |
API | a trigger started or queued a run (account owner) | fire_source, status, deduped |
app_deployed |
API | an app deployment is queued | app_kind, hosting_provider, actor_type |
feature_flag_toggled |
API | a project feature flag is set or cleared | key, enabled |
billing_gate_hit |
API | a person is refused for billing | reason, where (session_create, session_start, prompt, model_switch) |
checkout_started |
API | a Stripe checkout is created | kind, tier, amount_usd |
subscription_activated |
API | a paid plan becomes active (Stripe or app store) | tier, amount_usd, store, via |
subscription_cancelled |
API | a paid plan ends | store, previous_tier, amount_usd |
credits_topped_up |
API | a one-off credit purchase is paid | amount_usd, store |
member_joined |
API | a person joins a workspace someone else made | via (invite, auto_claim, direct_add, project_invite, access_request), role |
Request-driven events carry source (web, cli, mobile, desktop). API events
that two code paths can report carry a fixed uuid (dedupeKey), and the
Stripe activation and credit grant report only when their keyed wallet grant
was not a replay.
Activation funnels
The Kortix · Activation dashboard has two funnels, both inside 24 h. A
completed turn is turn_completed with status = ok: a failed turn (no
model, a plan refusal) also fires turn_completed.
| Funnel | Steps |
|---|---|
| Self-serve | user_signed_up (invited ≠ true) → project_created → provider_configured (optional step) → session_created → prompt_sent → turn_completed ok |
| Invited | member_joined → session_created → prompt_sent → turn_completed ok |
provider_configuredis optional because only the Free plan needs a connected provider: it cannot use Kortix models (plan_upgrade_required). A paid plan skips the step and still counts.- The funnels use
session_created, notsession_started: one session boots many times. - An invited person never creates a workspace, so the self-serve funnel would
count them as lost at
project_created.
Campaign attribution
PostHog stores the first campaign a person arrived with as person properties
($initial_utm_source, $initial_utm_campaign, $initial_gclid,
$initial_fbclid, $initial_referring_domain, …). Every later event, including
subscription_activated from the API, can be broken down by them. Ad platforms
that add a click id (gclid, fbclid, msclkid, li_fat_id, ttclid) need
no tagging beyond their own auto-tagging.
Tag every link we publish. Lowercase, _ between words, no spaces:
| Parameter | Meaning | Example values |
|---|---|---|
utm_source |
where the link sits | youtube, x, linkedin, instagram, tiktok, newsletter, podcast, producthunt |
utm_medium |
the kind of placement | video, social, paid_social, cpc, email, partner |
utm_campaign |
the launch or campaign | launch_2026_11, promo_video_1 |
utm_content |
the variant, when there are several | cut_30s, thumbnail_b, pinned_comment |
Examples:
- YouTube video description:
https://kortix.com/?utm_source=youtube&utm_medium=video&utm_campaign=promo_video_1 - The same video's pinned comment:
…&utm_content=pinned_comment - A paid Meta ad:
?utm_source=meta&utm_medium=paid_social&utm_campaign=launch_2026_11&utm_content=cut_30s
A viewer who types kortix.com by hand arrives as $direct. The PostHog
survey "Acquisition: how did you hear about us" catches them: one question,
shown once, 15 s after an app page (/projects…, now /workspaces) loads, to
people whose signed_up_at is within 7 days — and only once a workspace
exists: automatic survey display is off (posthog-client.ts) because the
survey pinned the create-first-workspace landing of an empty account behind
itself (dogfood signature dogfood:auth-signup-onboarding:survey-modal-blocks-first-landing).
The chooser displays it through apps/web/src/lib/analytics/survey-gate.ts.
The answer is the event property
$survey_response_709452a5-5925-4704-a85a-0618d2f78ed3 on survey sent.
Where to look
The dashboards are code: infra/posthog/ (Terraform, state in S3; see its
README). They follow the design of the closed PR #7144.
| Question | Dashboard |
|---|---|
| Do new users reach a completed turn, and how fast? Self-serve and invited apart | Kortix · Activation |
| DAU/WAU/MAU, retention, stickiness, surface mix | Kortix · Engagement |
| Providers, key checks, prompts by model | Kortix · Providers & models |
| Boot time, active time, turn outcomes | Kortix · Sessions |
| Connectors, secrets, triggers, apps, flags, billing gates | Kortix · Setup & adoption |
| Visitors, referrers, UTM, landing pages, first-touch signups, survey answers | Kortix · Acquisition |
| Goals, visitor → paid, signup → paid, time to paid | Kortix · Conversion |
| One person's journey | PostHog → People → the person → Events |
Every insight sets filterTestAccounts: environment = production, no
@kortix.ai / @kortix.com person, not the internal cohort. Local, dev and
staging events never reach the charts.
PostHog project settings (set 2026-11-04)
- Session replay off. The recording domain (
https://kortix.com), the 2 s minimum, console and network capture off and the private-path blocklist stay set, so turning it on later starts from safe settings. - Surveys on. Authorized URL
https://kortix.com(toolbar, heatmaps). - IP anonymization on; GeoIP country and city still resolve.
- Free plan hard limits: 1 M events a month. Past it PostHog drops events until the next cycle; nothing is billed. Add a card and a billing limit before traffic approaches it.
- A survey created through the API with
start_datekeeps its targeting flag inactive. Activate the flag (PATCH /api/projects/334925/feature_flags/<id>/ {"active": true}) or launch from the UI.
Where the key lives
- Web:
POSTHOG_PROJECT_KEYinposthog-config.ts, used onkortix.comonly. No hosting setting is involved. - API:
POSTHOG_PROJECT_KEYin the prod Secrets Manager blobkortix-prod-env(eu-west-2), mirrored inapps/api/.env.prod(the dotenvx-secrets skill).kortix-dev-envandkortix-staging-envdo not carry it (removed 2026-11-06), so dev and staging send nothing. Unset = no server events.
PostHog MCP
claude mcp add --transport http posthog https://mcp.posthog.com/mcp -s user,
then /mcp in Claude Code to log in (OAuth, picks the EU region). It answers
analytics questions and edits insights, dashboards and surveys directly.
Verify an event
Never point a test at the Kortix project. Use your own free PostHog project, or a local capture sink that logs what the API sends.
- Web, local: put your own project's
NEXT_PUBLIC_POSTHOG_KEY=phc_…inapps/web/.env.local. A localhost run reports only with this variable, asenvironment=local, and keeps bot traffic (scripted browsers) tagged$browser_type: bot. - In the browser console run
localStorage.setItem('ph_debug', 'true')and reload: posthog-js logs every event with its properties before it sends it. - API, local: set
POSTHOG_PROJECT_KEY(your project, or anyphc_value) andPOSTHOG_HOST=http://localhost:<port>(a sink that logs each/batch/body) inapps/api/.env.local, trigger the action, read the sink. - Production: PostHog → Activity, filter
environment = production. The dashboards apply the project's test-account filter by default.