From 787ca0430d59076f888bb635e1a73148917efcae Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 16:03:24 -0700 Subject: [PATCH 1/2] feat(analytics): attribute sign-ups and demo requests to their acquisition source - Record first and last marketing touch (campaign params, referring domain, landing path) in consent-gated first-party cookies on marketing and sign-in pages; attach them to user_created and the PostHog person - Capture $pageview on marketing routes, landing_demo_request_submitted, landing_demo_booked, external_sign_in_started, and email_type on identify - Add useCaptureWhenReady so view events captured on mount are no longer dropped before PostHog initializes - Include attribution in the demo-request sales notification - List the attribution cookies in the cookie policy --- .../components/social-login-buttons.tsx | 110 ++---- apps/sim/app/(auth)/layout.tsx | 8 +- apps/sim/app/(auth)/login/login-form.tsx | 10 +- apps/sim/app/(auth)/signup/signup-form.tsx | 10 +- .../components/auth-modal/auth-modal.tsx | 13 +- .../cookie-policy/cookie-policy-content.tsx | 8 +- .../demo-scheduler/demo-scheduler.tsx | 2 + apps/sim/app/(landing)/landing-analytics.tsx | 8 +- .../(landing)/landing-page-view-tracker.tsx | 15 + apps/sim/app/(landing)/layout.tsx | 4 + .../_shell/consent/attribution-capture.tsx | 45 +++ .../consent/attribution-cookie-guard.tsx | 21 ++ .../app/_shell/consent/consent-provider.tsx | 2 + apps/sim/app/api/demo-requests/route.ts | 4 +- .../[workspaceId]/knowledge/[id]/base.tsx | 16 +- .../settings/[section]/settings.tsx | 17 +- .../components/table-grid/table-grid.tsx | 14 +- .../settings/account-settings-renderer.tsx | 10 +- .../settings/selfhost-settings-renderer.tsx | 10 +- apps/sim/ee/sso/components/sso-form.tsx | 2 + apps/sim/hooks/queries/demo-requests.ts | 6 + apps/sim/hooks/use-capture-when-ready.ts | 40 +++ apps/sim/lib/analytics/attribution.test.ts | 275 +++++++++++++++ apps/sim/lib/analytics/attribution.ts | 330 ++++++++++++++++++ apps/sim/lib/analytics/campaign-parameters.ts | 15 + apps/sim/lib/auth/auth.ts | 17 +- apps/sim/lib/auth/social-sign-in.ts | 29 ++ apps/sim/lib/consent/google-context.ts | 14 +- apps/sim/lib/navigation/paths.ts | 3 +- apps/sim/lib/posthog/client.ts | 23 ++ apps/sim/lib/posthog/events.ts | 23 +- 31 files changed, 942 insertions(+), 162 deletions(-) create mode 100644 apps/sim/app/(landing)/landing-page-view-tracker.tsx create mode 100644 apps/sim/app/_shell/consent/attribution-capture.tsx create mode 100644 apps/sim/app/_shell/consent/attribution-cookie-guard.tsx create mode 100644 apps/sim/hooks/use-capture-when-ready.ts create mode 100644 apps/sim/lib/analytics/attribution.test.ts create mode 100644 apps/sim/lib/analytics/attribution.ts create mode 100644 apps/sim/lib/analytics/campaign-parameters.ts create mode 100644 apps/sim/lib/auth/social-sign-in.ts diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 37df7815ed7..916777085c6 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -5,7 +5,7 @@ import { Chip, cn } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' -import { client } from '@/lib/auth/auth-client' +import { type SocialSignInProvider, startSocialSignIn } from '@/lib/auth/social-sign-in' import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' @@ -15,107 +15,63 @@ interface SocialLoginButtonsProps { githubAvailable: boolean googleAvailable: boolean microsoftAvailable: boolean + view: 'login' | 'signup' callbackURL?: string children?: ReactNode } +/** Display order of the provider buttons. */ +const PROVIDERS = [ + { provider: 'google', label: 'Google', icon: GoogleIcon }, + { provider: 'microsoft', label: 'Microsoft', icon: MicrosoftIcon }, + { provider: 'github', label: 'GitHub', icon: GithubIcon }, +] as const + export function SocialLoginButtons({ githubAvailable, googleAvailable, microsoftAvailable, + view, callbackURL = DEFAULT_POST_AUTH_ROUTE, children, }: SocialLoginButtonsProps) { - const [isGithubLoading, setIsGithubLoading] = useState(false) - const [isGoogleLoading, setIsGoogleLoading] = useState(false) - const [isMicrosoftLoading, setIsMicrosoftLoading] = useState(false) - - async function signInWithGithub() { - if (!githubAvailable) return + const [loadingProvider, setLoadingProvider] = useState(null) - setIsGithubLoading(true) - try { - await client.signIn.social({ provider: 'github', callbackURL }) - } catch (err) { - logger.error('GitHub sign-in failed', { error: getErrorMessage(err) }) - } finally { - setIsGithubLoading(false) - } + const available: Record = { + github: githubAvailable, + google: googleAvailable, + microsoft: microsoftAvailable, } - async function signInWithGoogle() { - if (!googleAvailable) return - - setIsGoogleLoading(true) + async function signIn(provider: SocialSignInProvider, label: string) { + setLoadingProvider(provider) try { - await client.signIn.social({ provider: 'google', callbackURL }) + await startSocialSignIn({ provider, view, surface: 'auth_page', callbackURL }) } catch (err) { - logger.error('Google sign-in failed', { error: getErrorMessage(err) }) + logger.error(`${label} sign-in failed`, { error: getErrorMessage(err) }) } finally { - setIsGoogleLoading(false) + setLoadingProvider(null) } } - async function signInWithMicrosoft() { - if (!microsoftAvailable) return - - setIsMicrosoftLoading(true) - try { - await client.signIn.social({ provider: 'microsoft', callbackURL }) - } catch (err) { - logger.error('Microsoft sign-in failed', { error: getErrorMessage(err) }) - } finally { - setIsMicrosoftLoading(false) - } - } - - const githubButton = ( - - {isGithubLoading ? 'Connecting…' : 'GitHub'} - - ) - - const googleButton = ( - - {isGoogleLoading ? 'Connecting…' : 'Google'} - - ) - - const microsoftButton = ( - - {isMicrosoftLoading ? 'Connecting…' : 'Microsoft'} - - ) - - const hasAnyOAuthProvider = githubAvailable || googleAvailable || microsoftAvailable - - if (!hasAnyOAuthProvider && !children) { + if (!githubAvailable && !googleAvailable && !microsoftAvailable && !children) { return null } return (
- {googleAvailable && googleButton} - {microsoftAvailable && microsoftButton} - {githubAvailable && githubButton} + {PROVIDERS.filter(({ provider }) => available[provider]).map(({ provider, label, icon }) => ( + signIn(provider, label)} + > + {loadingProvider === provider ? 'Connecting…' : label} + + ))} {children}
) diff --git a/apps/sim/app/(auth)/layout.tsx b/apps/sim/app/(auth)/layout.tsx index 833c56b430c..d4a9dddb977 100644 --- a/apps/sim/app/(auth)/layout.tsx +++ b/apps/sim/app/(auth)/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { AttributionCapture } from '@/app/_shell/consent/attribution-capture' import { AuthShell } from '@/app/(auth)/components' export const metadata: Metadata = { @@ -6,5 +7,10 @@ export const metadata: Metadata = { } export default function AuthLayout({ children }: { children: React.ReactNode }) { - return {children} + return ( + + {children} + + + ) } diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 38e6f95e7f7..061f68b6b0e 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { ChipModal, ChipModalBody, @@ -21,7 +21,6 @@ import { isSsoEnabled } from '@/lib/core/config/env-flags' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' -import { captureClientEvent } from '@/lib/posthog/client' import { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { AuthDivider, @@ -37,6 +36,7 @@ import { SocialLoginButtons, SSOLoginButton, } from '@/app/(auth)/components' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' const logger = createLogger('LoginForm') @@ -97,6 +97,7 @@ export default function LoginPage({ }) { const router = useRouter() const searchParams = useSearchParams() + useCaptureWhenReady('login_page_viewed', {}) const [isLoading, setIsLoading] = useState(false) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) @@ -134,10 +135,6 @@ export default function LoginPage({ : null ) - useEffect(() => { - captureClientEvent('login_page_viewed', {}) - }, []) - const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value setEmail(newEmail) @@ -448,6 +445,7 @@ export default function LoginPage({ {showBottomSection && ( { - captureClientEvent('signup_page_viewed', {}) - }, []) const [password, setPassword] = useState('') const [passwordErrors, setPasswordErrors] = useState([]) const [showValidationError, setShowValidationError] = useState(false) @@ -485,6 +484,7 @@ function SignupFormContent({ {showBottomSection && ( (defaultView) const [providerStatus, setProviderStatus] = useState(null) - const [socialLoading, setSocialLoading] = useState<'github' | 'google' | 'microsoft' | null>(null) + const [socialLoading, setSocialLoading] = useState(null) const brand = getBrandConfig() useEffect(() => { @@ -141,10 +141,15 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal }) } - async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') { + async function handleSocialLogin(provider: SocialSignInProvider) { setSocialLoading(provider) try { - await client.signIn.social({ provider, callbackURL: APP_ENTRY_PATH }) + await startSocialSignIn({ + provider, + view: effectiveView, + surface: 'auth_modal', + callbackURL: APP_ENTRY_PATH, + }) } catch (error) { logger.warn('Social sign-in did not complete', { provider, error }) } finally { diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index 563b7b7f9f3..c2de630fcba 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -44,7 +44,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { title: 'Cookie Policy', description: 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.', - lastUpdated: 'September 17, 2026', + lastUpdated: 'October 8, 2026', intro: [ { kind: 'paragraph', @@ -195,6 +195,12 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { 'Coordinates analytics state for the current browser tab.', 'Session', ], + [ + 'sim_attribution_first / sim_attribution_last', + 'Sim', + 'Records the campaign or website that first and most recently brought you to sim.ai, so a sign-up or demo request can be credited to it. Holds campaign parameters, the referring site’s domain, and the landing page path only.', + '90 days', + ], ]), cookieTable('Marketing', [ [ diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx index 330f694b3c0..495fecacc25 100644 --- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx @@ -6,6 +6,7 @@ import { useTheme } from 'next-themes' import { trackGoogleAdsConversion, trackGoogleEvent } from '@/lib/analytics/google' import { X_DEMO_BOOKED_EVENT_ID } from '@/lib/consent/scripts' import { useTrackingConsent } from '@/lib/consent/tracking-consent' +import { captureClientEvent } from '@/lib/posthog/client' import type { DemoLead } from '@/app/(landing)/demo/components/demo-form' const CAL_NAMESPACE = 'demo' @@ -100,6 +101,7 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) { const trackDemoBooked = () => { if (cancelled) return if (measurement) { + captureClientEvent('landing_demo_booked', {}) trackGoogleEvent('get_a_demo', { page_path: '/demo', form_name: 'sim_demo', diff --git a/apps/sim/app/(landing)/landing-analytics.tsx b/apps/sim/app/(landing)/landing-analytics.tsx index cdbb59e853e..3ea0a7d0cbc 100644 --- a/apps/sim/app/(landing)/landing-analytics.tsx +++ b/apps/sim/app/(landing)/landing-analytics.tsx @@ -1,12 +1,8 @@ 'use client' -import { useEffect } from 'react' -import { captureClientEvent } from '@/lib/posthog/client' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' export function LandingAnalytics() { - useEffect(() => { - captureClientEvent('landing_page_viewed', {}) - }, []) - + useCaptureWhenReady('landing_page_viewed', {}) return null } diff --git a/apps/sim/app/(landing)/landing-page-view-tracker.tsx b/apps/sim/app/(landing)/landing-page-view-tracker.tsx new file mode 100644 index 00000000000..ae50bc663d1 --- /dev/null +++ b/apps/sim/app/(landing)/landing-page-view-tracker.tsx @@ -0,0 +1,15 @@ +'use client' + +import { usePathname } from 'next/navigation' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' + +/** + * Sends PostHog's `$pageview` for every marketing route, including client + * navigations between them, so pricing, demo, and enterprise visits feed + * PostHog funnels and web analytics. `capture_pageview` stays off app-wide; + * this layout is the only place page views are wanted. + */ +export function LandingPageViewTracker() { + useCaptureWhenReady('$pageview', {}, usePathname()) + return null +} diff --git a/apps/sim/app/(landing)/layout.tsx b/apps/sim/app/(landing)/layout.tsx index e1aed12faf7..285dc501f2e 100644 --- a/apps/sim/app/(landing)/layout.tsx +++ b/apps/sim/app/(landing)/layout.tsx @@ -2,8 +2,10 @@ import type { ReactNode } from 'react' import type { Metadata } from 'next' import { isHosted } from '@/lib/core/config/env-flags' import { SITE_URL } from '@/lib/core/utils/urls' +import { AttributionCapture } from '@/app/_shell/consent/attribution-capture' import { LandingShell } from '@/app/(landing)/components' import { LandingConsentTracking } from '@/app/(landing)/landing-consent-tracking' +import { LandingPageViewTracker } from '@/app/(landing)/landing-page-view-tracker' /** * Shared layout for all public marketing routes, including platform, solutions, @@ -19,6 +21,8 @@ export default function LandingLayout({ children }: { children: ReactNode }) { return ( {children} + + {isHosted && } ) diff --git a/apps/sim/app/_shell/consent/attribution-capture.tsx b/apps/sim/app/_shell/consent/attribution-capture.tsx new file mode 100644 index 00000000000..787a36c8dd1 --- /dev/null +++ b/apps/sim/app/_shell/consent/attribution-capture.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useEffect } from 'react' +import { recordAttributionTouch } from '@/lib/analytics/attribution' +import { useTrackingConsent } from '@/lib/consent/tracking-consent' + +let landingHref: string | undefined +let hasRecordedTouch = false + +/** + * The page this document was loaded on, with its original query. Consent can + * resolve after a client navigation has dropped the campaign parameters, so + * the navigation entry is preferred — but only when this component mounted on + * that very page. Otherwise the entry may be an app or token-bearing utility + * page, and the current page (a marketing or sign-in page, by where this is + * mounted) is used instead. + */ +function resolveLandingHref(): string { + const [navigation] = performance.getEntriesByType('navigation') + if (navigation?.name) { + try { + if (new URL(navigation.name).pathname === window.location.pathname) return navigation.name + } catch {} + } + return window.location.href +} + +/** + * Records the landing touch for this document once measurement consent is + * granted. Mounted only by the marketing and sign-in layouts, so app pages, + * customers' deployed chats, and token-bearing utility links are never + * recorded as landing pages. + */ +export function AttributionCapture() { + const { isResolved, measurement } = useTrackingConsent() + + useEffect(() => { + landingHref ??= resolveLandingHref() + if (!isResolved || !measurement || hasRecordedTouch) return + hasRecordedTouch = true + recordAttributionTouch({ href: landingHref, referrer: document.referrer, now: new Date() }) + }, [isResolved, measurement]) + + return null +} diff --git a/apps/sim/app/_shell/consent/attribution-cookie-guard.tsx b/apps/sim/app/_shell/consent/attribution-cookie-guard.tsx new file mode 100644 index 00000000000..0cf27aeb580 --- /dev/null +++ b/apps/sim/app/_shell/consent/attribution-cookie-guard.tsx @@ -0,0 +1,21 @@ +'use client' + +import { useEffect } from 'react' +import { clearAttributionCookies } from '@/lib/analytics/attribution' +import { useTrackingConsent } from '@/lib/consent/tracking-consent' + +/** + * Drops both attribution cookies once consent resolves without measurement, + * on every route, so a withdrawn or expired grant can never be attributed by + * the server, which only sees the cookies. Withdrawal reloads the page, so + * this runs before any later sign-up. + */ +export function AttributionCookieGuard() { + const { isResolved, measurement } = useTrackingConsent() + + useEffect(() => { + if (isResolved && !measurement) clearAttributionCookies() + }, [isResolved, measurement]) + + return null +} diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index b3ef3fd9e54..d274ff4e101 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import { TrackingConsentProvider } from '@/lib/consent/tracking-consent' +import { AttributionCookieGuard } from '@/app/_shell/consent/attribution-cookie-guard' import { ConsentBanner } from '@/app/_shell/consent/consent-banner' import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' import { FreebuffClickIdGuard } from '@/app/_shell/consent/freebuff-click-id-guard' @@ -24,6 +25,7 @@ export function ConsentProvider({ children }: ConsentProviderProps) { {children} + diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index 56c1bbededb..2c5a1905364 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { formatAttributionForNotification } from '@/lib/analytics/attribution' import { getDemoRequestCompanySizeLabel, submitDemoRequestContract, @@ -60,6 +61,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { firstName, lastName, companyEmail, phoneNumber, companySize, details } = parsed.data.body + const attribution = formatAttributionForNotification((name) => req.cookies.get(name)?.value) logger.info(`[${requestId}] Processing demo request`, { email: `${companyEmail.substring(0, 3)}***`, @@ -75,7 +77,7 @@ Company size: ${getDemoRequestCompanySizeLabel(companySize)} Details: ${details} -` +${attribution ? `\nAttribution:\n${attribution}\n` : ''}` const emailResult = await sendEmail({ to: [`enterprise@${env.EMAIL_DOMAIN || getEmailDomain()}`], diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index d9121eeb437..fc1bd021394 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -40,7 +40,6 @@ import { generateId } from '@sim/utils/id' import { format } from 'date-fns' import { useParams, useRouter } from 'next/navigation' import { useQueryState, useQueryStates } from 'nuqs' -import { usePostHog } from 'posthog-js/react' import { getDocumentIcon } from '@/components/icons/document-icons' import { ALL_TAG_SLOTS, @@ -55,7 +54,6 @@ import { } from '@/lib/knowledge/documents/types' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import type { DocumentData } from '@/lib/knowledge/types' -import { captureEvent } from '@/lib/posthog/client' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { @@ -132,6 +130,7 @@ import { useUpdateDocument, useUpdateKnowledgeBase, } from '@/hooks/queries/kb/knowledge' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' import { useContextMenu } from '@/hooks/use-context-menu' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -317,14 +316,11 @@ export function KnowledgeBase({ addConnectorParam.key, addConnectorParam.parser ) - const posthog = usePostHog() - - useEffect(() => { - captureEvent(posthog, 'knowledge_base_opened', { - knowledge_base_id: id, - knowledge_base_name: passedKnowledgeBaseName ?? 'Unknown', - }) - }, [id, passedKnowledgeBaseName, posthog]) + useCaptureWhenReady( + 'knowledge_base_opened', + { knowledge_base_id: id, knowledge_base_name: passedKnowledgeBaseName ?? 'Unknown' }, + id + ) useOAuthReturnForKBConnectors(id) const userPermissions = useUserPermissionsContext() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index a6354b4d3fd..abc83969fed 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -1,12 +1,9 @@ 'use client' -import { useEffect } from 'react' import dynamic from 'next/dynamic' -import { usePostHog } from 'posthog-js/react' import { getSettingsPermissionConfigKey } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' -import { captureEvent } from '@/lib/posthog/client' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' import { SettingsSectionProvider } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' @@ -16,6 +13,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/navigation' import { SECTION_MODULES } from '@/app/workspace/[workspaceId]/settings/section-warmers' import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' const Admin = dynamic(() => SECTION_MODULES.admin().then((m) => m.Admin)) const ApiKeys = dynamic(() => SECTION_MODULES.apikeys().then((m) => m.ApiKeys)) @@ -84,7 +82,6 @@ function SettingsPageContent({ section }: SettingsPageProps) { const { data: session, isPending: sessionLoading } = useSession() const hostContext = useWorkspaceHostContext() const { billingEnabled } = useDeploymentShape() - const posthog = usePostHog() const isAdminRole = session?.user?.role === 'admin' const normalizedSection: SettingsSection = @@ -100,13 +97,11 @@ function SettingsPageContent({ section }: SettingsPageProps) { const organizationId = hostContext.hostOrganizationId const meta = getSettingsSectionMeta(effectiveSection) - useEffect(() => { - if (sessionLoading) return - captureEvent(posthog, 'settings_tab_viewed', { - plane: 'workspace', - section: effectiveSection, - }) - }, [effectiveSection, sessionLoading, posthog]) + useCaptureWhenReady( + 'settings_tab_viewed', + sessionLoading ? null : { plane: 'workspace', section: effectiveSection }, + effectiveSection + ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 8136fb81d92..051f9cb9660 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -10,10 +10,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' -import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' import { attachSelectionContextToClipboard } from '@/lib/mothership/chat/selection-clipboard' -import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, Predicate, @@ -52,6 +50,7 @@ import { useUpdateWorkflowGroup, } from '@/hooks/queries/tables' import { useAddToChat } from '@/hooks/use-add-to-chat' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' import { useInlineRename } from '@/hooks/use-inline-rename' import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo' import type { ChatContext } from '@/stores/panel' @@ -494,12 +493,11 @@ export function TableGrid({ workspaceIdRef.current = workspaceId const tableIdRef = useRef(tableId) tableIdRef.current = tableId - const posthog = usePostHog() - - useEffect(() => { - if (!tableId || !workspaceId) return - captureEvent(posthog, 'table_opened', { table_id: tableId, workspace_id: workspaceId }) - }, [tableId, workspaceId, posthog]) + useCaptureWhenReady( + 'table_opened', + tableId && workspaceId ? { table_id: tableId, workspace_id: workspaceId } : null, + tableId + ) const [editingCell, setEditingCell] = useState(null) const [initialCharacter, setInitialCharacter] = useState(null) diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 7cefeba5d47..136d291c15f 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -1,11 +1,9 @@ 'use client' -import { useEffect } from 'react' import dynamic from 'next/dynamic' -import { usePostHog } from 'posthog-js/react' import type { AccountSettingsSection } from '@/components/settings/navigation' -import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' const Billing = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( @@ -33,11 +31,7 @@ interface AccountSettingsRendererProps { } export function AccountSettingsRenderer({ section }: AccountSettingsRendererProps) { - const posthog = usePostHog() - - useEffect(() => { - captureEvent(posthog, 'settings_tab_viewed', { plane: 'account', section }) - }, [posthog, section]) + useCaptureWhenReady('settings_tab_viewed', { plane: 'account', section }, section) if (section === 'general') return if (section === 'billing') return diff --git a/apps/sim/components/settings/selfhost-settings-renderer.tsx b/apps/sim/components/settings/selfhost-settings-renderer.tsx index 3258c37ebb7..846941cd885 100644 --- a/apps/sim/components/settings/selfhost-settings-renderer.tsx +++ b/apps/sim/components/settings/selfhost-settings-renderer.tsx @@ -1,11 +1,9 @@ 'use client' -import { useEffect } from 'react' import dynamic from 'next/dynamic' -import { usePostHog } from 'posthog-js/react' import type { SelfHostSettingsSection } from '@/components/settings/navigation' -import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' +import { useCaptureWhenReady } from '@/hooks/use-capture-when-ready' const Billing = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( @@ -23,11 +21,7 @@ interface SelfHostSettingsRendererProps { } export function SelfHostSettingsRenderer({ section }: SelfHostSettingsRendererProps) { - const posthog = usePostHog() - - useEffect(() => { - captureEvent(posthog, 'settings_tab_viewed', { plane: 'selfhost', section }) - }, [posthog, section]) + useCaptureWhenReady('settings_tab_viewed', { plane: 'selfhost', section }, section) if (section === 'general') return if (section === 'billing') return diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 450865afc1b..aa81e5a8ddd 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -12,6 +12,7 @@ import { client } from '@/lib/auth/auth-client' import { getEnv, isFalsy } from '@/lib/core/config/env' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { captureClientEvent } from '@/lib/posthog/client' import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { AuthFormMessage, AuthSubmitButton } from '@/app/(auth)/components' @@ -160,6 +161,7 @@ function SSOFormContent({ return } + captureClientEvent('external_sign_in_started', { provider: 'sso', surface: 'sso_page' }) const result = await client.signIn.sso({ email: emailValue, providerId: resolved.providerId, diff --git a/apps/sim/hooks/queries/demo-requests.ts b/apps/sim/hooks/queries/demo-requests.ts index 70080a70dd0..654aaf13e14 100644 --- a/apps/sim/hooks/queries/demo-requests.ts +++ b/apps/sim/hooks/queries/demo-requests.ts @@ -6,6 +6,7 @@ import { type DemoRequestResult, submitDemoRequestContract, } from '@/lib/api/contracts/demo-requests' +import { captureClientEvent } from '@/lib/posthog/client' const logger = createLogger('DemoRequestMutation') @@ -20,6 +21,11 @@ export function useSubmitDemoRequest() { mutationFn: async (variables: DemoRequestBody): Promise => { return requestJson(submitDemoRequestContract, { body: variables }) }, + onSuccess: (_result, variables) => { + captureClientEvent('landing_demo_request_submitted', { + company_size: variables.companySize, + }) + }, onError: (error) => { logger.error('Failed to submit demo request:', error) }, diff --git a/apps/sim/hooks/use-capture-when-ready.ts b/apps/sim/hooks/use-capture-when-ready.ts new file mode 100644 index 00000000000..410b025c904 --- /dev/null +++ b/apps/sim/hooks/use-capture-when-ready.ts @@ -0,0 +1,40 @@ +import { useEffect, useEffectEvent, useRef, useSyncExternalStore } from 'react' +import { + captureClientEvent, + isPostHogClientReady, + subscribePostHogClient, +} from '@/lib/posthog/client' +import type { PostHogEventMap, PostHogEventName } from '@/lib/posthog/events' + +/** + * Captures a view-style event once per `key`, as soon as the consented PostHog + * client is published. A capture made directly in a mount effect is dropped on + * a hard load: `PostHogProvider` publishes the client in its own effect once + * consent resolves, and React runs a component's mount effects before its + * ancestors'. Nothing is buffered, so a visitor who never consents is never + * captured. + * + * @param properties - The event's properties, or `null` to hold the capture + * until its inputs exist (a loading session, an unresolved route param). + * @param key - Re-captures when it changes, e.g. the pathname or the open + * resource's id. Defaults to once per mount. + */ +export function useCaptureWhenReady( + event: E, + properties: PostHogEventMap[E] | null, + key: string = event +): void { + const isReady = useSyncExternalStore(subscribePostHogClient, isPostHogClientReady, () => false) + const lastCapturedKeyRef = useRef(null) + const hasProperties = properties !== null + + const capture = useEffectEvent(() => { + if (properties) captureClientEvent(event, properties) + }) + + useEffect(() => { + if (!isReady || !hasProperties || lastCapturedKeyRef.current === key) return + lastCapturedKeyRef.current = key + capture() + }, [isReady, hasProperties, key]) +} diff --git a/apps/sim/lib/analytics/attribution.test.ts b/apps/sim/lib/analytics/attribution.test.ts new file mode 100644 index 00000000000..54f21e0f12b --- /dev/null +++ b/apps/sim/lib/analytics/attribution.test.ts @@ -0,0 +1,275 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearAttributionCookies, + formatAttributionForNotification, + readAttributionProperties, + recordAttributionTouch, +} from '@/lib/analytics/attribution' + +const NOW = new Date('2026-10-08T12:00:00.000Z') +const LATER = new Date('2026-10-09T12:00:00.000Z') + +/** Reads the raw, still URL-encoded value, as a request's `Cookie` header carries it. */ +function readBrowserCookie(name: string): string | undefined { + return document.cookie + .split('; ') + .find((entry) => entry.startsWith(`${name}=`)) + ?.slice(name.length + 1) +} + +function recordedProperties() { + return readAttributionProperties(readBrowserCookie) +} + +/** A request whose first-touch cookie holds `raw` and whose last-touch cookie is absent. */ +function firstTouchCookie(raw: string) { + return (name: string) => (name === 'sim_attribution_first' ? raw : undefined) +} + +beforeEach(() => { + clearAttributionCookies() +}) + +describe('recordAttributionTouch', () => { + it.each([ + [ + 'an SSO identity provider bounce back to login', + 'https://www.sim.ai/login', + 'https://acme.okta.com/', + ], + ['a payment provider return', 'https://www.sim.ai/pricing', 'https://checkout.stripe.com/'], + ['navigation inside the site', 'https://www.sim.ai/demo', 'https://www.sim.ai/pricing'], + ['the apex redirect into www', 'https://www.sim.ai/', 'https://sim.ai/'], + ['a direct visit', 'https://www.sim.ai/', ''], + [ + 'an external referral into the signed-in app', + 'https://www.sim.ai/home', + 'https://news.ycombinator.com/', + ], + ['an unparseable location', 'not a url', 'https://news.ycombinator.com/'], + [ + "a customer's campaign link to their deployed chat", + 'https://www.sim.ai/chat/acme-support?utm_source=acme_newsletter', + '', + ], + [ + 'a campaign link to a shared file', + 'https://www.sim.ai/f/share-token-123?utm_medium=email', + '', + ], + ])('records nothing for %s', (_case, href, referrer) => { + recordAttributionTouch({ href, referrer, now: NOW }) + + expect(recordedProperties()).toEqual({}) + }) + + it('records an external referral as its hostname only', () => { + recordAttributionTouch({ + href: 'https://www.sim.ai/comparisons/n8n', + referrer: 'https://news.ycombinator.com/item?id=42', + now: NOW, + }) + + expect(recordedProperties()).toMatchObject({ + first_touch_referring_domain: 'news.ycombinator.com', + first_touch_landing_path: '/comparisons/n8n', + first_touch_touched_at: NOW.toISOString(), + }) + }) + + it('records campaign parameters on any path without keeping the rest of the query', () => { + recordAttributionTouch({ + href: 'https://www.sim.ai/signup?utm_source=newsletter&utm_medium=email&email=jane%40acme.com', + referrer: 'https://accounts.google.com/', + now: NOW, + }) + + const properties = recordedProperties() + expect(properties).toEqual({ + first_touch_utm_source: 'newsletter', + first_touch_utm_medium: 'email', + first_touch_landing_path: '/signup', + first_touch_touched_at: NOW.toISOString(), + last_touch_utm_source: 'newsletter', + last_touch_utm_medium: 'email', + last_touch_landing_path: '/signup', + last_touch_touched_at: NOW.toISOString(), + }) + expect(document.cookie).not.toContain('acme.com') + }) + + it('records which ad network clicked through but never the click id itself', () => { + recordAttributionTouch({ + href: 'https://www.sim.ai/?gclid=Cj0KCQ-secret-click-id', + referrer: 'https://www.google.com/', + now: NOW, + }) + + expect(recordedProperties()).toMatchObject({ + first_touch_click_id_type: 'gclid', + first_touch_referring_domain: 'www.google.com', + }) + expect(document.cookie).not.toContain('secret-click-id') + }) + + it('keeps the first touch and replaces only the last touch on a later visit', () => { + recordAttributionTouch({ + href: 'https://www.sim.ai/?utm_source=youtube', + referrer: '', + now: NOW, + }) + recordAttributionTouch({ + href: 'https://www.sim.ai/pricing', + referrer: 'https://www.linkedin.com/', + now: LATER, + }) + + expect(recordedProperties()).toMatchObject({ + first_touch_utm_source: 'youtube', + first_touch_touched_at: NOW.toISOString(), + last_touch_referring_domain: 'www.linkedin.com', + last_touch_touched_at: LATER.toISOString(), + }) + expect(recordedProperties()).not.toHaveProperty('last_touch_utm_source') + }) + + it('never lets a referral from another Sim site replace a real last touch', () => { + recordAttributionTouch({ + href: 'https://www.sim.ai/?utm_source=linkedin', + referrer: '', + now: NOW, + }) + recordAttributionTouch({ + href: 'https://www.sim.ai/pricing', + referrer: 'https://docs.sim.ai/introduction', + now: LATER, + }) + + expect(recordedProperties()).toMatchObject({ + last_touch_utm_source: 'linkedin', + last_touch_touched_at: NOW.toISOString(), + }) + }) + + it('still credits another Sim site when it is the only source', () => { + recordAttributionTouch({ + href: 'https://www.sim.ai/pricing', + referrer: 'https://docs.sim.ai/introduction', + now: NOW, + }) + + expect(recordedProperties()).toMatchObject({ + first_touch_referring_domain: 'docs.sim.ai', + last_touch_referring_domain: 'docs.sim.ai', + }) + }) + + it('repairs a first-touch cookie that no longer parses', () => { + document.cookie = 'sim_attribution_first=%7Btruncated; Path=/' + + recordAttributionTouch({ + href: 'https://www.sim.ai/?utm_source=youtube', + referrer: '', + now: NOW, + }) + + expect(recordedProperties()).toMatchObject({ first_touch_utm_source: 'youtube' }) + }) + + it('keeps a touch far below the cookie size limit whatever script a link carries', () => { + const wide = encodeURIComponent('"漢字"'.repeat(1000)) + recordAttributionTouch({ + href: `https://www.sim.ai/?utm_campaign=${wide}&utm_content=${wide}&utm_term=${wide}&utm_source=${wide}`, + referrer: '', + now: NOW, + }) + + const stored = readBrowserCookie('sim_attribution_first') + expect(stored).toBeDefined() + expect(stored?.length).toBeLessThan(2048) + }) + + it('bounds attacker-sized campaign values', () => { + recordAttributionTouch({ + href: `https://www.sim.ai/?utm_campaign=${'x'.repeat(5000)}`, + referrer: '', + now: NOW, + }) + + expect(recordedProperties().first_touch_utm_campaign).toHaveLength(100) + }) +}) + +describe('readAttributionProperties', () => { + const touch = { + utm_source: 'linkedin', + utm_campaign: 'q4 agents & governance', + referring_domain: 'www.linkedin.com', + landing_path: '/enterprise', + touched_at: NOW.toISOString(), + } + const expected = { + first_touch_utm_source: 'linkedin', + first_touch_utm_campaign: 'q4 agents & governance', + first_touch_referring_domain: 'www.linkedin.com', + first_touch_landing_path: '/enterprise', + first_touch_touched_at: NOW.toISOString(), + } + + it('reads a cookie value whether or not the cookie layer already URL-decoded it', () => { + const decoded = JSON.stringify(touch) + + expect(readAttributionProperties(firstTouchCookie(decoded))).toEqual(expected) + expect(readAttributionProperties(firstTouchCookie(encodeURIComponent(decoded)))).toEqual( + expected + ) + }) + + it.each([ + ['non-JSON', 'utm_source=google'], + ['a JSON array', '[]'], + [ + 'a non-string landing path', + JSON.stringify({ landing_path: 5, touched_at: NOW.toISOString() }), + ], + [ + 'a landing path that is not a path', + JSON.stringify({ landing_path: 'https://evil.example/', touched_at: NOW.toISOString() }), + ], + ['an invalid timestamp', JSON.stringify({ landing_path: '/', touched_at: 'yesterday' })], + ])('ignores a cookie holding %s', (_case, raw) => { + expect(readAttributionProperties(firstTouchCookie(raw))).toEqual({}) + }) + + it('drops unknown keys and clamps oversized values from a tampered cookie', () => { + const tampered = JSON.stringify({ + landing_path: '/', + touched_at: NOW.toISOString(), + utm_source: 'y'.repeat(5000), + utm_medium: 'cpc\r\nPriority: urgent', + $set: { plan: 'enterprise' }, + }) + + expect(readAttributionProperties(firstTouchCookie(tampered))).toEqual({ + first_touch_landing_path: '/', + first_touch_touched_at: NOW.toISOString(), + first_touch_utm_source: 'y'.repeat(100), + first_touch_utm_medium: 'cpcPriority: urgent', + }) + }) +}) + +describe('formatAttributionForNotification', () => { + it('cannot be made to forge extra lines in the notification', () => { + const tampered = JSON.stringify({ + landing_path: '/', + touched_at: NOW.toISOString(), + utm_source: 'google\nLast touch: utm_source=forged\u2028Last touch: utm_medium=forged\u0085x', + }) + + expect( + formatAttributionForNotification(firstTouchCookie(tampered)).split(/[\n\r\u0085\u2028\u2029]/) + ).toHaveLength(1) + }) +}) diff --git a/apps/sim/lib/analytics/attribution.ts b/apps/sim/lib/analytics/attribution.ts new file mode 100644 index 00000000000..6ebc566a58d --- /dev/null +++ b/apps/sim/lib/analytics/attribution.ts @@ -0,0 +1,330 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import { isRecordLike } from '@sim/utils/object' +import { GOOGLE_CLICK_ID_PARAMETERS, UTM_PARAMETERS } from '@/lib/analytics/campaign-parameters' +import { isNoindexPath, isPathOrDescendant } from '@/lib/navigation/paths' + +/** + * Marketing attribution for sign-ups and sales leads. The browser records the + * campaign or external site that brought a visitor in a first-party cookie, + * written only under measurement consent; the server reads it back when the + * account is created (or a demo is requested) and attaches it to that record. + * + * Server-side events cannot see the visitor's landing page, and the OAuth round + * trip replaces `document.referrer` with the identity provider, so without this + * cookie every Google sign-up looks like it came from `accounts.google.com`. + * + * Only campaign parameters, the referring hostname, and the landing pathname + * are kept — never a full URL, query string, or ad click id — matching the URL + * stripping `preparePostHogEvent` applies to every PostHog event. + */ + +const ATTRIBUTION_FIRST_TOUCH_COOKIE = 'sim_attribution_first' +const ATTRIBUTION_LAST_TOUCH_COOKIE = 'sim_attribution_last' + +/** Matches the Google Ads click cookies listed alongside it in the cookie policy. */ +const ATTRIBUTION_MAX_AGE_SECONDS = 90 * 24 * 60 * 60 + +/** + * Bounds on each value's stored size — its URL-encoded JSON form, which is + * what the cookie actually holds — so a touch stays far below the browser's + * 4 KB cookie limit whatever script or punctuation a link carries. A cookie + * over the limit is dropped without an error. + */ +const ATTRIBUTION_VALUE_MAX_BYTES = 200 +const LANDING_PATH_MAX_BYTES = 400 +const ATTRIBUTION_VALUE_MAX_LENGTH = 100 +const LANDING_PATH_MAX_LENGTH = 200 + +const CAMPAIGN_PARAMETERS = [...UTM_PARAMETERS, 'ref'] as const + +/** + * Ad-network click ids. Only which network clicked through is kept: the id + * itself identifies one ad interaction and is never needed downstream. + */ +const CLICK_ID_PARAMETERS = [ + ...GOOGLE_CLICK_ID_PARAMETERS, + 'msclkid', + 'fbclid', + 'li_fat_id', + 'ttclid', + 'twclid', + 'rdt_cid', + 'bfcid', +] as const + +/** + * Hosts a visitor passes through mid-session — sign-in and checkout — rather + * than hosts that sent them. Arriving from one says nothing about acquisition. + */ +const INTERMEDIARY_REFERRER_HOSTS = new Set([ + 'accounts.google.com', + 'login.microsoftonline.com', + 'login.live.com', + 'appleid.apple.com', + 'checkout.stripe.com', + 'billing.stripe.com', +]) + +/** Sign-in surfaces an identity provider redirects back to, never an acquisition landing page. */ +const AUTH_PATH_ROOTS = ['/login', '/signup', '/sso', '/verify', '/reset-password', '/oauth'] + +type CampaignParameter = (typeof CAMPAIGN_PARAMETERS)[number] +type ClickIdParameter = (typeof CLICK_ID_PARAMETERS)[number] + +type AttributionTouch = Partial> & { + click_id_type?: ClickIdParameter + referring_domain?: string + landing_path: string + touched_at: string +} + +type AttributionTouchKey = keyof AttributionTouch + +/** Flattened PostHog properties: `first_touch_utm_source`, `last_touch_landing_path`, … */ +export type AttributionProperties = Partial< + Record<`first_touch_${AttributionTouchKey}` | `last_touch_${AttributionTouchKey}`, string> +> + +/** Reads one request or browser cookie by name. */ +type CookieReader = (name: string) => string | null | undefined + +function encodedSize(value: string): number { + return encodeURIComponent(JSON.stringify(value)).length +} + +/** + * Strips control and line-separator characters, then bounds the value by + * length and by stored size. Values come from URL-decoded query strings and + * reach a plain-text sales email, where any line break would forge extra lines. + */ +function bound( + value: string, + maxLength = ATTRIBUTION_VALUE_MAX_LENGTH, + maxBytes = ATTRIBUTION_VALUE_MAX_BYTES +): string { + const characters = Array.from( + value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') + ).slice(0, maxLength) + while (characters.length > 0 && encodedSize(characters.join('')) > maxBytes) characters.pop() + return characters.join('') +} + +function normalizeHost(hostname: string): string { + return hostname.toLowerCase().replace(/^www\./, '') +} + +/** Another of Sim's own sites, such as `docs.sim.ai` seen from `www.sim.ai`. */ +function isOwnPropertyHost(host: string, currentHost: string): boolean { + return normalizeHost(host).endsWith(`.${normalizeHost(currentHost)}`) +} + +/** + * Identity providers return to sign-in routes and the browser hides their path, + * so a referral into those routes cannot be told apart from a sign-in round trip. + */ +function isReferralLandingPath(pathname: string): boolean { + return !AUTH_PATH_ROOTS.some((root) => isPathOrDescendant(pathname, root)) +} + +function getExternalReferringDomain(referrer: string, currentHost: string): string | undefined { + if (!referrer) return undefined + let url: URL + try { + url = new URL(referrer) + } catch { + return undefined + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined + + const host = url.hostname.toLowerCase() + if (normalizeHost(host) === normalizeHost(currentHost)) return undefined + if (INTERMEDIARY_REFERRER_HOSTS.has(host)) return undefined + return bound(host) +} + +interface BuildAttributionTouchInput { + /** The URL the document was loaded from — the landing page, before any client navigation. */ + href: string + /** `document.referrer` for that load. */ + referrer: string + now: Date +} + +/** + * The touch a page load represents, or `null` when it carries no acquisition + * signal: a direct visit, an internal navigation, a sign-in or checkout round + * trip, or an app or customer-owned page (deployed chats, shared files). + * Campaign links also count on sign-in pages; bare referrals do not (see + * {@link isReferralLandingPath}). + */ +function buildAttributionTouch({ + href, + referrer, + now, +}: BuildAttributionTouchInput): AttributionTouch | null { + let url: URL + try { + url = new URL(href) + } catch { + return null + } + + const campaign: Partial> = {} + for (const parameter of CAMPAIGN_PARAMETERS) { + const value = url.searchParams.get(parameter)?.trim() + if (value) campaign[parameter] = bound(value) + } + const clickIdType = CLICK_ID_PARAMETERS.find((parameter) => url.searchParams.get(parameter)) + const referringDomain = getExternalReferringDomain(referrer, url.hostname) + + if (isNoindexPath(url.pathname)) return null + const hasCampaignSignal = Object.keys(campaign).length > 0 || clickIdType !== undefined + if (!hasCampaignSignal && (!referringDomain || !isReferralLandingPath(url.pathname))) { + return null + } + + return { + ...campaign, + ...(clickIdType ? { click_id_type: clickIdType } : {}), + ...(referringDomain ? { referring_domain: referringDomain } : {}), + landing_path: bound(url.pathname, LANDING_PATH_MAX_LENGTH, LANDING_PATH_MAX_BYTES), + touched_at: now.toISOString(), + } +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw) + } catch {} + try { + return JSON.parse(decodeURIComponent(raw)) + } catch { + return undefined + } +} + +/** + * Reads a touch back from a cookie value. The cookie is client-writable, so + * every field is re-validated and re-bounded and unknown keys are dropped. + * Accepts the value with or without URL encoding, since some cookie readers + * decode it and some do not. + */ +function parseAttributionTouch(raw: string | null | undefined): AttributionTouch | null { + if (!raw) return null + const value = parseJson(raw) + if (!isRecordLike(value)) return null + + const landingPath = toStringOrNull(value.landing_path) + const touchedAt = toStringOrNull(value.touched_at) + if (!landingPath?.startsWith('/') || !touchedAt || Number.isNaN(Date.parse(touchedAt))) { + return null + } + + const touch: AttributionTouch = { + landing_path: bound(landingPath, LANDING_PATH_MAX_LENGTH, LANDING_PATH_MAX_BYTES), + touched_at: new Date(touchedAt).toISOString(), + } + for (const key of [...CAMPAIGN_PARAMETERS, 'referring_domain'] as const) { + const field = toStringOrNull(value[key]) + if (field) touch[key] = bound(field) + } + const clickIdType = CLICK_ID_PARAMETERS.find((parameter) => parameter === value.click_id_type) + if (clickIdType) touch.click_id_type = clickIdType + return touch +} + +function readTouches(getCookie: CookieReader) { + return { + first: parseAttributionTouch(getCookie(ATTRIBUTION_FIRST_TOUCH_COOKIE)), + last: parseAttributionTouch(getCookie(ATTRIBUTION_LAST_TOUCH_COOKIE)), + } +} + +function prefixTouch( + prefix: 'first_touch' | 'last_touch', + touch: AttributionTouch | null +): AttributionProperties { + return Object.fromEntries( + Object.entries(touch ?? {}).map(([key, value]) => [`${prefix}_${key}`, value]) + ) +} + +/** + * Reads both attribution cookies through the caller's cookie accessor and + * flattens them into PostHog properties. Empty when the visitor never granted + * measurement consent, since the cookies are only ever written under it. + */ +export function readAttributionProperties(getCookie: CookieReader): AttributionProperties { + const { first, last } = readTouches(getCookie) + return { ...prefixTouch('first_touch', first), ...prefixTouch('last_touch', last) } +} + +/** + * Plain-text attribution lines for a notification a person reads, such as the + * sales inbox's demo-request email. Empty when the visitor left no touch. + */ +export function formatAttributionForNotification(getCookie: CookieReader): string { + const { first, last } = readTouches(getCookie) + const touches = [ + ['First touch', first], + ['Last touch', last], + ] as const + return touches + .flatMap(([label, touch]) => + touch + ? [ + `${label}: ${Object.entries(touch) + .map(([key, value]) => `${key}=${value}`) + .join(', ')}`, + ] + : [] + ) + .join('\n') +} + +/** + * `SameSite=None` because a SAML identity provider returns by cross-site POST, + * which drops `Lax` cookies, and that callback is when the account is created. + * The cookies hold no credential, so cross-site delivery exposes nothing. + * Browsers only accept `None` with `Secure`, so plain-HTTP development keeps `Lax`. + */ +function writeCookie(name: string, value: string, maxAgeSeconds = ATTRIBUTION_MAX_AGE_SECONDS) { + const sameSite = window.location.protocol === 'https:' ? 'SameSite=None; Secure' : 'SameSite=Lax' + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAgeSeconds}; Path=/; ${sameSite}` +} + +function readBrowserCookie(name: string): string | undefined { + return document.cookie + .split('; ') + .find((entry) => entry.startsWith(`${name}=`)) + ?.slice(name.length + 1) +} + +/** + * Records this page load's touch in the browser. Call only after the caller has + * verified measurement consent. A valid first touch is never replaced, and a + * referral from another of Sim's own sites never replaces a real last touch — + * it only fills an empty slot. + */ +export function recordAttributionTouch(input: BuildAttributionTouchInput): void { + const touch = buildAttributionTouch(input) + if (!touch) return + + const { first, last } = readTouches(readBrowserCookie) + const isOwnPropertyReferral = + touch.referring_domain !== undefined && + isOwnPropertyHost(touch.referring_domain, new URL(input.href).hostname) && + !CAMPAIGN_PARAMETERS.some((parameter) => touch[parameter]) && + !touch.click_id_type + + const value = JSON.stringify(touch) + if (!first) writeCookie(ATTRIBUTION_FIRST_TOUCH_COOKIE, value) + if (!last || !isOwnPropertyReferral) writeCookie(ATTRIBUTION_LAST_TOUCH_COOKIE, value) +} + +/** Deletes both attribution cookies once measurement consent is withdrawn or expires. */ +export function clearAttributionCookies(): void { + for (const name of [ATTRIBUTION_FIRST_TOUCH_COOKIE, ATTRIBUTION_LAST_TOUCH_COOKIE]) { + if (readBrowserCookie(name) !== undefined) writeCookie(name, '', 0) + } +} diff --git a/apps/sim/lib/analytics/campaign-parameters.ts b/apps/sim/lib/analytics/campaign-parameters.ts new file mode 100644 index 00000000000..93908def352 --- /dev/null +++ b/apps/sim/lib/analytics/campaign-parameters.ts @@ -0,0 +1,15 @@ +/** + * Campaign query parameters shared by the Google tag's sanitized page location + * and Sim's own attribution cookies, so a new parameter reaches both. + */ + +export const UTM_PARAMETERS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_id', + 'utm_term', + 'utm_content', +] as const + +export const GOOGLE_CLICK_ID_PARAMETERS = ['gclid', 'dclid', 'gbraid', 'wbraid'] as const diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index fac5e58e8a2..71aa1dc3fe5 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -34,6 +34,7 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' +import { readAttributionProperties } from '@/lib/analytics/attribution' import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff' import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' @@ -357,10 +358,17 @@ export const auth = betterAuth({ try { const client = getPostHogClient() if (client) { + // Lazy so the ~90 KB domain list parses only when a sign-up is tracked. + const { isFreeEmailDomain } = await import('@/lib/messaging/email/free-email') client.identify({ distinctId: user.id, properties: { - ...(user.email ? { email: user.email } : {}), + ...(user.email + ? { + email: user.email, + email_type: isFreeEmailDomain(user.email) ? 'personal' : 'work', + } + : {}), ...(user.name ? { name: user.name } : {}), }, }) @@ -626,14 +634,19 @@ export const auth = betterAuth({ ? 'sso' : 'oauth' + // Consent-gated cookies survive the IdP callback (OAuth GET, SAML cross-site + // POST), so this reads the landing touch, not the provider's redirect. + const attribution = readAttributionProperties((name) => context?.getCookie(name)) + captureServerEvent( account.userId, 'user_created', { auth_method: authMethod, ...(providerId !== 'credential' ? { provider: providerId } : {}), + ...attribution, }, - { setOnce: { signup_at: new Date().toISOString() } } + { setOnce: { signup_at: new Date().toISOString(), ...attribution } } ) } } catch (error) { diff --git a/apps/sim/lib/auth/social-sign-in.ts b/apps/sim/lib/auth/social-sign-in.ts new file mode 100644 index 00000000000..7dc1ff4e6dd --- /dev/null +++ b/apps/sim/lib/auth/social-sign-in.ts @@ -0,0 +1,29 @@ +import { client } from '@/lib/auth/auth-client' +import { captureClientEvent } from '@/lib/posthog/client' +import type { PostHogEventMap } from '@/lib/posthog/events' + +type ExternalSignInStarted = PostHogEventMap['external_sign_in_started'] + +export type SocialSignInProvider = Exclude + +interface StartSocialSignInOptions { + provider: SocialSignInProvider + view: NonNullable + surface: ExternalSignInStarted['surface'] + callbackURL: string +} + +/** + * Leaves for a social identity provider, recording the departure first so + * drop-off at the provider's consent screen is measurable against the + * server's `user_created`. + */ +export function startSocialSignIn({ + provider, + view, + surface, + callbackURL, +}: StartSocialSignInOptions) { + captureClientEvent('external_sign_in_started', { provider, view, surface }) + return client.signIn.social({ provider, callbackURL }) +} diff --git a/apps/sim/lib/consent/google-context.ts b/apps/sim/lib/consent/google-context.ts index 4433ecda4bb..a5c6e9d39eb 100644 --- a/apps/sim/lib/consent/google-context.ts +++ b/apps/sim/lib/consent/google-context.ts @@ -1,3 +1,4 @@ +import { GOOGLE_CLICK_ID_PARAMETERS, UTM_PARAMETERS } from '@/lib/analytics/campaign-parameters' import { isNoindexPath } from '@/lib/navigation/paths' const GOOGLE_ANALYTICS_ORIGINS = new Set(['https://sim.ai', 'https://www.sim.ai']) @@ -8,18 +9,7 @@ const TOKEN_UTILITY_PATHS = [ '/enterprise/claim', ] as const -const GOOGLE_CAMPAIGN_PARAMETERS = [ - 'utm_source', - 'utm_medium', - 'utm_campaign', - 'utm_id', - 'utm_term', - 'utm_content', - 'gclid', - 'dclid', - 'gbraid', - 'wbraid', -] as const +const GOOGLE_CAMPAIGN_PARAMETERS = [...UTM_PARAMETERS, ...GOOGLE_CLICK_ID_PARAMETERS] as const interface GooglePageContext { page_location: string diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index caa14610c32..24f06da5efe 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -49,7 +49,8 @@ export function organizationRoutes(organizationId: string) { } as const } -function isPathOrDescendant(pathname: string, root: string): boolean { +/** Whether `pathname` is `root` itself or a route beneath it. */ +export function isPathOrDescendant(pathname: string, root: string): boolean { return pathname === root || pathname.startsWith(`${root}/`) } diff --git a/apps/sim/lib/posthog/client.ts b/apps/sim/lib/posthog/client.ts index b6eb1062c26..d72b9a0c3a8 100644 --- a/apps/sim/lib/posthog/client.ts +++ b/apps/sim/lib/posthog/client.ts @@ -8,6 +8,8 @@ import type { PostHogEventMap, PostHogEventName } from '@/lib/posthog/events' */ let postHogClient: PostHog | null = null +const clientListeners = new Set<() => void>() + /** * Publishes or clears the consented PostHog client used by non-React callers. * Called only by `PostHogProvider`. @@ -15,7 +17,28 @@ let postHogClient: PostHog | null = null * @param instance - The initialized instance, or `null` when analytics is off. */ export function setPostHogClient(instance: PostHog | null): void { + if (postHogClient === instance) return postHogClient = instance + for (const listener of clientListeners) listener() +} + +/** + * Whether a consented client is published. A page-view capture keyed to this + * fires once consent and initialization settle instead of being dropped: + * `PostHogProvider` initializes in an effect, and React runs a page's own + * mount effects before its ancestors', so a capture made on mount always + * precedes initialization on a hard load. + */ +export function isPostHogClientReady(): boolean { + return postHogClient !== null +} + +/** Subscribes to client publication changes, in the `useSyncExternalStore` shape. */ +export function subscribePostHogClient(listener: () => void): () => void { + clientListeners.add(listener) + return () => { + clientListeners.delete(listener) + } } /** diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 4fb7752b34b..89f16cc55c9 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -6,11 +6,19 @@ * capture call site. */ +import type { AttributionProperties } from '@/lib/analytics/attribution' + export interface PostHogEventMap { user_created: { auth_method: 'email' | 'oauth' | 'sso' provider?: string - } + } & AttributionProperties + + /** + * PostHog's reserved page-view event, sent by hand on marketing routes because + * `capture_pageview` is off app-wide. Its URL properties come from the browser. + */ + $pageview: Record landing_page_viewed: Record @@ -29,6 +37,17 @@ export interface PostHogEventMap { destination: 'auth_modal' | 'demo_modal' | '/signup' | '/login' | '/workspace' | (string & {}) } + /** + * A sign-in left for an identity provider. Paired with `user_created` + * (server) to measure drop-off at the provider's consent screen. SSO serves + * both sign-in and sign-up, so it carries no `view`. + */ + external_sign_in_started: { + provider: 'github' | 'google' | 'microsoft' | 'sso' + view?: 'login' | 'signup' + surface: 'auth_page' | 'auth_modal' | 'sso_page' + } + auth_modal_opened: { view: 'login' | 'signup' source: @@ -47,6 +66,8 @@ export interface PostHogEventMap { company_size: string } + landing_demo_booked: Record + landing_contact_submitted: { topic: string } From 2f609100502ad85729bde734c067fb8f500109ec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 16:34:14 -0700 Subject: [PATCH 2/2] fix(analytics): cap attribution cookies at the consent grant, enforce stored field shapes, and lock sign-in buttons while pending --- .../components/social-login-buttons.tsx | 2 +- .../cookie-policy/cookie-policy-content.tsx | 2 +- .../_shell/consent/attribution-capture.tsx | 8 ++++- apps/sim/lib/analytics/attribution.test.ts | 24 +++++++++++++ apps/sim/lib/analytics/attribution.ts | 36 ++++++++++++++++--- apps/sim/lib/consent/storage.ts | 33 +++++++++++------ 6 files changed, 88 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 916777085c6..235a57f2c3d 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -66,7 +66,7 @@ export function SocialLoginButtons({ fullWidth leftIcon={icon} className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border)]')} - disabled={loadingProvider === provider} + disabled={loadingProvider !== null} onClick={() => signIn(provider, label)} > {loadingProvider === provider ? 'Connecting…' : label} diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index c2de630fcba..2bc433b9106 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -198,7 +198,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { [ 'sim_attribution_first / sim_attribution_last', 'Sim', - 'Records the campaign or website that first and most recently brought you to sim.ai, so a sign-up or demo request can be credited to it. Holds campaign parameters, the referring site’s domain, and the landing page path only.', + 'Records the campaign or website that first and most recently brought you to sim.ai, so a sign-up or demo request can be credited to it. Holds campaign parameters, which ad network a click came from (never the click ID), the referring site’s domain, the landing page path, and when the visit happened. Expires with your analytics consent if that ends sooner.', '90 days', ], ]), diff --git a/apps/sim/app/_shell/consent/attribution-capture.tsx b/apps/sim/app/_shell/consent/attribution-capture.tsx index 787a36c8dd1..e9e4f44615f 100644 --- a/apps/sim/app/_shell/consent/attribution-capture.tsx +++ b/apps/sim/app/_shell/consent/attribution-capture.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { recordAttributionTouch } from '@/lib/analytics/attribution' +import { getStoredConsentExpiry } from '@/lib/consent/storage' import { useTrackingConsent } from '@/lib/consent/tracking-consent' let landingHref: string | undefined @@ -38,7 +39,12 @@ export function AttributionCapture() { landingHref ??= resolveLandingHref() if (!isResolved || !measurement || hasRecordedTouch) return hasRecordedTouch = true - recordAttributionTouch({ href: landingHref, referrer: document.referrer, now: new Date() }) + recordAttributionTouch({ + href: landingHref, + referrer: document.referrer, + now: new Date(), + consentExpiresAt: getStoredConsentExpiry(), + }) }, [isResolved, measurement]) return null diff --git a/apps/sim/lib/analytics/attribution.test.ts b/apps/sim/lib/analytics/attribution.test.ts index 54f21e0f12b..7896c617679 100644 --- a/apps/sim/lib/analytics/attribution.test.ts +++ b/apps/sim/lib/analytics/attribution.test.ts @@ -190,6 +190,17 @@ describe('recordAttributionTouch', () => { expect(stored?.length).toBeLessThan(2048) }) + it('writes nothing once the consent grant it would be recorded under has lapsed', () => { + recordAttributionTouch({ + href: 'https://www.sim.ai/?utm_source=youtube', + referrer: '', + now: NOW, + consentExpiresAt: NOW.getTime() - 1, + }) + + expect(recordedProperties()).toEqual({}) + }) + it('bounds attacker-sized campaign values', () => { recordAttributionTouch({ href: `https://www.sim.ai/?utm_campaign=${'x'.repeat(5000)}`, @@ -242,6 +253,19 @@ describe('readAttributionProperties', () => { expect(readAttributionProperties(firstTouchCookie(raw))).toEqual({}) }) + it('keeps a tampered landing path to its path and drops a referrer that is not a hostname', () => { + const tampered = JSON.stringify({ + landing_path: '/demo?email=jane%40acme.com#top', + touched_at: NOW.toISOString(), + referring_domain: 'https://evil.example/?q=1', + }) + + expect(readAttributionProperties(firstTouchCookie(tampered))).toEqual({ + first_touch_landing_path: '/demo', + first_touch_touched_at: NOW.toISOString(), + }) + }) + it('drops unknown keys and clamps oversized values from a tampered cookie', () => { const tampered = JSON.stringify({ landing_path: '/', diff --git a/apps/sim/lib/analytics/attribution.ts b/apps/sim/lib/analytics/attribution.ts index 6ebc566a58d..82fa37b720e 100644 --- a/apps/sim/lib/analytics/attribution.ts +++ b/apps/sim/lib/analytics/attribution.ts @@ -65,6 +65,9 @@ const INTERMEDIARY_REFERRER_HOSTS = new Set([ 'billing.stripe.com', ]) +/** A bare DNS hostname: the only shape a stored referring domain may take. */ +const HOSTNAME_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/ + /** Sign-in surfaces an identity provider redirects back to, never an acquisition landing page. */ const AUTH_PATH_ROOTS = ['/login', '/signup', '/sso', '/verify', '/reset-password', '/oauth'] @@ -148,6 +151,12 @@ interface BuildAttributionTouchInput { /** `document.referrer` for that load. */ referrer: string now: Date + /** + * When the consent grant the touch is recorded under lapses (epoch ms), so + * the cookies never outlive it: a sign-up completed straight from an + * identity provider never loads a Sim page that could clear them. + */ + consentExpiresAt?: number } /** @@ -221,13 +230,21 @@ function parseAttributionTouch(raw: string | null | undefined): AttributionTouch } const touch: AttributionTouch = { - landing_path: bound(landingPath, LANDING_PATH_MAX_LENGTH, LANDING_PATH_MAX_BYTES), + landing_path: bound( + landingPath.split(/[?#]/, 1)[0], + LANDING_PATH_MAX_LENGTH, + LANDING_PATH_MAX_BYTES + ), touched_at: new Date(touchedAt).toISOString(), } - for (const key of [...CAMPAIGN_PARAMETERS, 'referring_domain'] as const) { + for (const key of CAMPAIGN_PARAMETERS) { const field = toStringOrNull(value[key]) if (field) touch[key] = bound(field) } + const referringDomain = toStringOrNull(value.referring_domain)?.toLowerCase() + if (referringDomain && HOSTNAME_PATTERN.test(referringDomain)) { + touch.referring_domain = bound(referringDomain) + } const clickIdType = CLICK_ID_PARAMETERS.find((parameter) => parameter === value.click_id_type) if (clickIdType) touch.click_id_type = clickIdType return touch @@ -317,9 +334,20 @@ export function recordAttributionTouch(input: BuildAttributionTouchInput): void !CAMPAIGN_PARAMETERS.some((parameter) => touch[parameter]) && !touch.click_id_type + const maxAgeSeconds = + input.consentExpiresAt === undefined + ? ATTRIBUTION_MAX_AGE_SECONDS + : Math.min( + ATTRIBUTION_MAX_AGE_SECONDS, + Math.floor((input.consentExpiresAt - input.now.getTime()) / 1000) + ) + if (maxAgeSeconds <= 0) return + const value = JSON.stringify(touch) - if (!first) writeCookie(ATTRIBUTION_FIRST_TOUCH_COOKIE, value) - if (!last || !isOwnPropertyReferral) writeCookie(ATTRIBUTION_LAST_TOUCH_COOKIE, value) + if (!first) writeCookie(ATTRIBUTION_FIRST_TOUCH_COOKIE, value, maxAgeSeconds) + if (!last || !isOwnPropertyReferral) { + writeCookie(ATTRIBUTION_LAST_TOUCH_COOKIE, value, maxAgeSeconds) + } } /** Deletes both attribution cookies once measurement consent is withdrawn or expires. */ diff --git a/apps/sim/lib/consent/storage.ts b/apps/sim/lib/consent/storage.ts index eff09b6a2b9..d87a8ba5aeb 100644 --- a/apps/sim/lib/consent/storage.ts +++ b/apps/sim/lib/consent/storage.ts @@ -5,21 +5,34 @@ export const CONSENT_STORAGE_CONFIG = { defaultExpiryDays: 365 } as const const CONSENT_MAX_AGE_MS = CONSENT_STORAGE_CONFIG.defaultExpiryDays * 24 * 60 * 60 * 1000 const PENDING_CONSENT_SYNC_KEY = 'c15t:pending-consent-sync' -function hasCurrentConsent(value: unknown): boolean { - if (!value || typeof value !== 'object' || !('consentInfo' in value)) return false +function getConsentTime(value: unknown): number | undefined { + if (!value || typeof value !== 'object' || !('consentInfo' in value)) return undefined const { consentInfo } = value - if (!consentInfo || typeof consentInfo !== 'object' || !('time' in consentInfo)) return false + if (!consentInfo || typeof consentInfo !== 'object' || !('time' in consentInfo)) return undefined const { time } = consentInfo + return typeof time === 'number' && Number.isFinite(time) && time > 0 ? time : undefined +} + +function hasCurrentConsent(value: unknown): boolean { + const time = getConsentTime(value) const now = Date.now() - return ( - typeof time === 'number' && - Number.isFinite(time) && - time > 0 && - time <= now && - now - time < CONSENT_MAX_AGE_MS - ) + return time !== undefined && time <= now && now - time < CONSENT_MAX_AGE_MS +} + +/** + * When the visitor's stored consent choice lapses, in epoch milliseconds, or + * `undefined` when no choice is stored and the jurisdiction's defaults apply. + * Lets data written under a grant expire no later than the grant itself. + */ +export function getStoredConsentExpiry(): number | undefined { + try { + const time = getConsentTime(getConsentFromStorage(CONSENT_STORAGE_CONFIG)) + return time === undefined ? undefined : time + CONSENT_MAX_AGE_MS + } catch { + return undefined + } } /**