From 5f5fa6415d7cef0f7157c738577ed7cbe0492a45 Mon Sep 17 00:00:00 2001 From: Pedro Heyerdahl Date: Fri, 27 Mar 2026 11:47:20 -0300 Subject: [PATCH 1/4] feat(tracking): set first_product_signup person property for KiloClaw signups --- src/components/PostHogProvider.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/PostHogProvider.tsx b/src/components/PostHogProvider.tsx index b999901f64..ba7baa2632 100644 --- a/src/components/PostHogProvider.tsx +++ b/src/components/PostHogProvider.tsx @@ -102,6 +102,14 @@ function IdentifyUser() { if (currentAnonymousId && currentAnonymousId !== session.user.email) { posthog.alias(session.user.email, currentAnonymousId); } + // Stamp which product the user signed up for. Uses $set_once so only the + // first call wins — safe to re-run on subsequent session refreshes. + // The effect deps don't include pathname, so this only fires on auth + // state changes, not on in-app navigation. + if (session.isNewUser && window.location.pathname.startsWith('/claw')) { + posthog.setPersonProperties(undefined, { first_product_signup: 'kiloclaw' }); + } + // Re-fetch feature flags now that the user is identified. // Without this, flags evaluated for the anonymous ID remain cached, // so user-targeted flags would stay false until the next natural reload. From 76b62f9feb6f49e2e4915040da6da5a1cc3a8581 Mon Sep 17 00:00:00 2001 From: Pedro Heyerdahl Date: Fri, 27 Mar 2026 11:58:45 -0300 Subject: [PATCH 2/4] feat(tracking): set first_product_signup server-side at signup entry point Move product attribution from the client-side IdentifyUser effect (where session.isNewUser persists for the full JWT lifetime) to the after-sign-in route handler, where has_validation_stytch === null is a true one-shot new-user signal. Resolves the product from callbackPath and source params, tagging only known product flows (kiloclaw, cloud-agent, code-reviews, app-builder, kilo-code) and leaving the property unset for generic signups. --- src/app/users/after-sign-in/route.tsx | 37 +++++++++++++++++++++++++++ src/components/PostHogProvider.tsx | 8 ------ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/app/users/after-sign-in/route.tsx b/src/app/users/after-sign-in/route.tsx index 60ead413cb..69f1b82e8f 100644 --- a/src/app/users/after-sign-in/route.tsx +++ b/src/app/users/after-sign-in/route.tsx @@ -1,10 +1,30 @@ import { getProfileRedirectPath, getUserFromAuth } from '@/lib/user.server'; import { isValidCallbackPath } from '@/lib/getSignInCallbackUrl'; import { maybeInterceptWithSurvey } from '@/lib/survey-redirect'; +import PostHogClient from '@/lib/posthog'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { APP_URL } from '@/lib/constants'; +/** + * Resolves a product identifier from the signup entry point. Returns null when + * the entry point is generic (e.g. /get-started, /profile) so we leave the + * property unset rather than guessing. + */ +function resolveSignupProduct( + callbackPath: string | null, + hasSource: boolean +): string | null { + if (hasSource) return 'kilo-code'; // IDE install flow + if (!callbackPath) return null; + if (callbackPath.startsWith('/claw')) return 'kiloclaw'; + if (callbackPath.startsWith('/cloud')) return 'cloud-agent'; + if (callbackPath.startsWith('/code-reviews')) return 'code-reviews'; + if (callbackPath.startsWith('/app-builder')) return 'app-builder'; + if (callbackPath.startsWith('/install')) return 'kilo-code'; + return null; +} + export async function GET(request: NextRequest) { const url = new URL(request.url); const { user } = await getUserFromAuth({ adminOnly: false, DANGEROUS_allowBlockedUsers: true }); @@ -27,6 +47,23 @@ export async function GET(request: NextRequest) { } if (user.has_validation_stytch === null) { + // New user: stamp which product they signed up for, based on the + // callbackPath that carried them through the auth flow. This runs + // exactly once per signup because has_validation_stytch is set after + // account verification completes. + const product = resolveSignupProduct(callbackPath, !!url.searchParams.get('source')); + if (product) { + PostHogClient().capture({ + distinctId: user.google_user_email, + event: 'signup_product_attributed', + properties: { + first_product_signup: product, + callback_path: callbackPath, + $set_once: { first_product_signup: product }, + }, + }); + } + // For new users needing verification, only pass callbackPath if explicitly provided. // Otherwise, account-verification will redirect to /get-started by default. if (callbackPath && isValidCallbackPath(callbackPath)) { diff --git a/src/components/PostHogProvider.tsx b/src/components/PostHogProvider.tsx index ba7baa2632..b999901f64 100644 --- a/src/components/PostHogProvider.tsx +++ b/src/components/PostHogProvider.tsx @@ -102,14 +102,6 @@ function IdentifyUser() { if (currentAnonymousId && currentAnonymousId !== session.user.email) { posthog.alias(session.user.email, currentAnonymousId); } - // Stamp which product the user signed up for. Uses $set_once so only the - // first call wins — safe to re-run on subsequent session refreshes. - // The effect deps don't include pathname, so this only fires on auth - // state changes, not on in-app navigation. - if (session.isNewUser && window.location.pathname.startsWith('/claw')) { - posthog.setPersonProperties(undefined, { first_product_signup: 'kiloclaw' }); - } - // Re-fetch feature flags now that the user is identified. // Without this, flags evaluated for the anonymous ID remain cached, // so user-targeted flags would stay false until the next natural reload. From d459ca3e9d0259b8368e5aa03587695bd4fc5aad Mon Sep 17 00:00:00 2001 From: Pedro Heyerdahl Date: Fri, 27 Mar 2026 12:02:51 -0300 Subject: [PATCH 3/4] style: format after-sign-in route --- src/app/users/after-sign-in/route.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/users/after-sign-in/route.tsx b/src/app/users/after-sign-in/route.tsx index 69f1b82e8f..c5005624fd 100644 --- a/src/app/users/after-sign-in/route.tsx +++ b/src/app/users/after-sign-in/route.tsx @@ -11,10 +11,7 @@ import { APP_URL } from '@/lib/constants'; * the entry point is generic (e.g. /get-started, /profile) so we leave the * property unset rather than guessing. */ -function resolveSignupProduct( - callbackPath: string | null, - hasSource: boolean -): string | null { +function resolveSignupProduct(callbackPath: string | null, hasSource: boolean): string | null { if (hasSource) return 'kilo-code'; // IDE install flow if (!callbackPath) return null; if (callbackPath.startsWith('/claw')) return 'kiloclaw'; From a681c548a861d8b3e55300e4cfca877b988a4815 Mon Sep 17 00:00:00 2001 From: Pedro Heyerdahl Date: Fri, 27 Mar 2026 12:19:11 -0300 Subject: [PATCH 4/4] fix(tracking): derive product from validated responsePath, not raw callbackPath --- src/app/users/after-sign-in/route.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/users/after-sign-in/route.tsx b/src/app/users/after-sign-in/route.tsx index c5005624fd..a241a2912a 100644 --- a/src/app/users/after-sign-in/route.tsx +++ b/src/app/users/after-sign-in/route.tsx @@ -44,18 +44,19 @@ export async function GET(request: NextRequest) { } if (user.has_validation_stytch === null) { - // New user: stamp which product they signed up for, based on the - // callbackPath that carried them through the auth flow. This runs - // exactly once per signup because has_validation_stytch is set after - // account verification completes. - const product = resolveSignupProduct(callbackPath, !!url.searchParams.get('source')); + // New user: stamp which product they signed up for. Derived from + // responsePath (the already-validated destination) rather than the raw + // callbackPath query param, so the value cannot be user-tampered. This + // runs exactly once per signup because has_validation_stytch is set + // after account verification completes. + const product = resolveSignupProduct(responsePath, !!url.searchParams.get('source')); if (product) { PostHogClient().capture({ distinctId: user.google_user_email, event: 'signup_product_attributed', properties: { first_product_signup: product, - callback_path: callbackPath, + signup_destination: responsePath, $set_once: { first_product_signup: product }, }, });