From 27c0ffb4421b94a5962a7990a16ab06827d5c258 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 03:29:42 +0000 Subject: [PATCH] fix(web): suppress welcome on meet paths and tighten PostHog funnel Keep the welcome modal off public /meet routes and meetingSetup entry so booking setup is not interrupted. Session-scope welcome intro analytics so remounts and reloads do not duplicate funnel events. Flag internal traffic and drop bot user agents in PostHog before_send. Add environment on sync oauth_callback captures. Co-authored-by: Tyler Dane --- .../posthog-audience-filter.util.test.ts | 46 ++++++++++++ .../posthog/posthog-audience-filter.util.ts | 44 +++++++++++ .../src/auth/posthog/posthog.bootstrap.ts | 16 ++++ .../src/auth/posthog/useIdentifyUser.test.ts | 13 +++- .../src/auth/posthog/useIdentifyUser.ts | 7 ++ .../src/common/constants/storage.constants.ts | 6 ++ .../src/components/RootShell/RootShell.tsx | 10 ++- .../components/WelcomeModal/WelcomeModal.tsx | 35 ++++----- .../welcome-analytics.util.test.ts | 58 +++++++++++++++ .../WelcomeModal/welcome-analytics.util.ts | 74 +++++++++++++++++++ .../WelcomeModal/welcome.modal.util.test.ts | 20 ++++- .../WelcomeModal/welcome.modal.util.ts | 38 ++++++++++ .../analytics/posthog-audience.util.test.ts | 42 +++++++++++ .../src/analytics/posthog-audience.util.ts | 41 ++++++++++ packages/sync/src/app.ts | 1 + .../src/server/connection.routes.db.test.ts | 1 + packages/sync/src/server/connection.routes.ts | 3 + 17 files changed, 432 insertions(+), 23 deletions(-) create mode 100644 apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.test.ts create mode 100644 apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.ts create mode 100644 apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.test.ts create mode 100644 apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.ts create mode 100644 packages/core/src/analytics/posthog-audience.util.test.ts create mode 100644 packages/core/src/analytics/posthog-audience.util.ts diff --git a/apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.test.ts b/apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.test.ts new file mode 100644 index 0000000000..0d678dd6cd --- /dev/null +++ b/apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.test.ts @@ -0,0 +1,46 @@ +import { + filterPosthogAudience, + POSTHOG_INTERNAL_OR_TEST_USER_PROPERTY, + posthogInternalOrTestUserRegistration, +} from "@web/auth/posthog/posthog-audience-filter.util"; +import { describe, expect, it } from "bun:test"; + +describe("filterPosthogAudience", () => { + it("drops events from bot user agents", () => { + const original = navigator.userAgent; + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 (compatible; YandexBot/3.0)", + }); + try { + expect( + filterPosthogAudience({ + event: "welcome_modal_shown", + properties: {}, + } as never), + ).toBeNull(); + } finally { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: original, + }); + } + }); + + it("passes events from normal browsers through", () => { + expect( + filterPosthogAudience({ + event: "welcome_modal_shown", + properties: {}, + } as never), + ).not.toBeNull(); + }); +}); + +describe("posthogInternalOrTestUserRegistration", () => { + it("uses the PostHog internal-or-test person property name", () => { + expect(posthogInternalOrTestUserRegistration()).toEqual({ + [POSTHOG_INTERNAL_OR_TEST_USER_PROPERTY]: true, + }); + }); +}); diff --git a/apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.ts b/apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.ts new file mode 100644 index 0000000000..e39a5e49db --- /dev/null +++ b/apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.ts @@ -0,0 +1,44 @@ +import { type CaptureResult } from "posthog-js"; +import { + isInternalOrTestEmail, + isInternalOrTestHostname, + isNonHumanUserAgent, +} from "@core/analytics/posthog-audience.util"; + +export const POSTHOG_INTERNAL_OR_TEST_USER_PROPERTY = + "$internal_or_test_user" as const; + +/** Super property registered for localhost, e2e, and flagged team accounts. */ +export function posthogInternalOrTestUserRegistration(): Record< + string, + boolean +> { + return { [POSTHOG_INTERNAL_OR_TEST_USER_PROPERTY]: true }; +} + +/** + * Drop automated traffic before it reaches PostHog. Internal and test users + * stay captured but carry `$internal_or_test_user` for cohort filters. + */ +export function filterPosthogAudience( + event: CaptureResult | null, +): CaptureResult | null { + if (!event) return null; + if (typeof navigator !== "undefined") { + if (isNonHumanUserAgent(navigator.userAgent)) return null; + } + return event; +} + +export function shouldRegisterInternalOrTestUserFromBrowser(): boolean { + if (typeof window === "undefined") return false; + if (window.__COMPASS_E2E_TEST__) return true; + return isInternalOrTestHostname(window.location.hostname); +} + +export function shouldRegisterInternalOrTestUserFromEmail( + email: string | null | undefined, +): boolean { + if (!email) return false; + return isInternalOrTestEmail(email); +} diff --git a/apps/calendar-web/src/auth/posthog/posthog.bootstrap.ts b/apps/calendar-web/src/auth/posthog/posthog.bootstrap.ts index 55dae9abd1..56b0064d74 100644 --- a/apps/calendar-web/src/auth/posthog/posthog.bootstrap.ts +++ b/apps/calendar-web/src/auth/posthog/posthog.bootstrap.ts @@ -1,5 +1,11 @@ import { type PostHog } from "posthog-js"; +import { isNonHumanUserAgent } from "@core/analytics/posthog-audience.util"; import * as posthogUtil from "@web/auth/posthog/posthog.util"; +import { + filterPosthogAudience, + posthogInternalOrTestUserRegistration, + shouldRegisterInternalOrTestUserFromBrowser, +} from "@web/auth/posthog/posthog-audience-filter.util"; import { filterPosthogBookingTelemetry } from "@web/auth/posthog/posthog-booking-filter.util"; import { CALENDAR_GRID_DEAD_CLICK_IGNORE_SELECTORS } from "@web/auth/posthog/posthog-calendar-grid.util"; import { filterPosthogDeadClick } from "@web/auth/posthog/posthog-dead-click-filter.util"; @@ -58,6 +64,7 @@ export function initPosthog(): PostHog | undefined { css_selector_ignorelist: [...CALENDAR_GRID_DEAD_CLICK_IGNORE_SELECTORS], }, before_send: [ + filterPosthogAudience, filterPosthogBeforeSend, filterPosthogDeadClick, filterPosthogCalendarGridGestures, @@ -112,6 +119,15 @@ export function initPosthog(): PostHog | undefined { environment: resolvePosthogEnvironment(), version: APP_VERSION, }); + if (shouldRegisterInternalOrTestUserFromBrowser()) { + posthog.register(posthogInternalOrTestUserRegistration()); + } + if ( + typeof navigator !== "undefined" && + isNonHumanUserAgent(navigator.userAgent) + ) { + posthog.opt_out_capturing(); + } client = posthog; return client; diff --git a/apps/calendar-web/src/auth/posthog/useIdentifyUser.test.ts b/apps/calendar-web/src/auth/posthog/useIdentifyUser.test.ts index 3988442289..4baa0f630c 100644 --- a/apps/calendar-web/src/auth/posthog/useIdentifyUser.test.ts +++ b/apps/calendar-web/src/auth/posthog/useIdentifyUser.test.ts @@ -2,7 +2,8 @@ import { renderHook } from "@testing-library/react"; import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; const identify = mock(); -const usePostHog = mock(() => ({ identify })); +const register = mock(); +const usePostHog = mock(() => ({ identify, register })); // bun's mock.module is global and leaks into every other test file in the // run. Spread the real module so unrelated suites (e.g. anything rendering @@ -33,6 +34,8 @@ const { useIdentifyUser } = await import("./useIdentifyUser"); describe("useIdentifyUser", () => { beforeEach(() => { identify.mockClear(); + register.mockClear(); + usePostHog.mockReturnValue({ identify, register }); }); it("identifies with userId as the distinct_id and email as a property", () => { @@ -57,4 +60,12 @@ describe("useIdentifyUser", () => { expect(identify).not.toHaveBeenCalled(); window.history.pushState({}, "", "/week"); }); + + it("flags internal team accounts for PostHog cohort filters", () => { + renderHook(() => + useIdentifyUser("tyler@keepsoftwaresimple.com", "user-123"), + ); + + expect(register).toHaveBeenCalledWith({ $internal_or_test_user: true }); + }); }); diff --git a/apps/calendar-web/src/auth/posthog/useIdentifyUser.ts b/apps/calendar-web/src/auth/posthog/useIdentifyUser.ts index 79b56f91ca..d1a5ad5ca4 100644 --- a/apps/calendar-web/src/auth/posthog/useIdentifyUser.ts +++ b/apps/calendar-web/src/auth/posthog/useIdentifyUser.ts @@ -1,5 +1,9 @@ import { useEffect } from "react"; import { isPublicBookingPath } from "@core/booking/booking-telemetry"; +import { + posthogInternalOrTestUserRegistration, + shouldRegisterInternalOrTestUserFromEmail, +} from "@web/auth/posthog/posthog-audience-filter.util"; import { usePostHog } from "@web/auth/posthog/posthog-react"; /** @@ -27,6 +31,9 @@ export function useIdentifyUser( typeof posthog.identify === "function" ) { posthog.identify(userId, { email: profileEmail, user_id: userId }); + if (shouldRegisterInternalOrTestUserFromEmail(profileEmail)) { + posthog.register(posthogInternalOrTestUserRegistration()); + } } }, [profileEmail, posthog, userId]); } diff --git a/apps/calendar-web/src/common/constants/storage.constants.ts b/apps/calendar-web/src/common/constants/storage.constants.ts index ee411fba85..a3db8f1765 100644 --- a/apps/calendar-web/src/common/constants/storage.constants.ts +++ b/apps/calendar-web/src/common/constants/storage.constants.ts @@ -9,6 +9,12 @@ export const STORAGE_KEYS = { AUTH: "compass.auth", HAS_SEEN_WELCOME: "compass.onboarding.has-seen-welcome", + /** Session guard for welcome intro funnel events (modal shown, step 1). */ + WELCOME_INTRO_ANALYTICS_SESSION: + "compass.onboarding.welcome-intro-analytics-session", + /** JSON array of welcome step ids viewed this tab session. */ + WELCOME_STEPS_VIEWED_SESSION: + "compass.onboarding.welcome-steps-viewed-session", HAS_SEEN_LIFE_VIEW_INTRO: "compass.onboarding.has-seen-life-view-intro", WELCOME_EXIT: "compass.onboarding.welcome-exit", HAS_SEEN_ANONYMOUS_SAVE_TOAST: diff --git a/apps/calendar-web/src/components/RootShell/RootShell.tsx b/apps/calendar-web/src/components/RootShell/RootShell.tsx index 1be8da911f..bb9aa56c23 100644 --- a/apps/calendar-web/src/components/RootShell/RootShell.tsx +++ b/apps/calendar-web/src/components/RootShell/RootShell.tsx @@ -56,6 +56,7 @@ import { } from "@web/components/WelcomeModal/welcome.guide.store"; import { hasSeenWelcome, + isWelcomeOnboardingPathBlocked, selectWelcomeModalSurfaceEligible, } from "@web/components/WelcomeModal/welcome.modal.util"; import { useUpcomingEventNotifier } from "@web/notifications/useUpcomingEventNotifier"; @@ -92,7 +93,12 @@ export function RootShell() { const guestMeetingSetupActive = useSettingsStore( selectGuestMeetingSetupActive, ); - const { auth: authParam } = useSearch({ from: "__root__" }); + const rootSearch = useSearch({ from: "__root__" }); + const { auth: authParam } = rootSearch; + const welcomeOnboardingPathBlocked = isWelcomeOnboardingPathBlocked( + pathname, + rootSearch, + ); const { isOpen: isAuthModalOpen } = useAuthModalState(); const signupTrialStepActive = authenticated && authParam?.toLowerCase() === VIEW_TO_PARAM.startTrial; @@ -165,6 +171,7 @@ export function RootShell() { authenticated, isWelcomeFirstVisitOpen, guestMeetingSetupActive, + welcomeOnboardingPathBlocked, ), shortcutShowcase: selectShortcutShowcaseSurfaceEligible( showCalendarOnboarding, @@ -187,6 +194,7 @@ export function RootShell() { isWelcomeGuideOpen, isWelcomeFirstVisitOpen, guestMeetingSetupActive, + welcomeOnboardingPathBlocked, connectCalendarPromptEligible, firstEventPromptEligible, isLifeView, diff --git a/apps/calendar-web/src/components/WelcomeModal/WelcomeModal.tsx b/apps/calendar-web/src/components/WelcomeModal/WelcomeModal.tsx index 7304071950..0a81ee0922 100644 --- a/apps/calendar-web/src/components/WelcomeModal/WelcomeModal.tsx +++ b/apps/calendar-web/src/components/WelcomeModal/WelcomeModal.tsx @@ -1,10 +1,7 @@ import { useContext, useEffect, useId, useRef, useState } from "react"; import { type ProviderKind } from "@core/types/sync/identity.contracts"; import { SessionContext } from "@web/auth/compass/session/session.context"; -import { - trackSignupStartedAtClick, - trackSignupStep, -} from "@web/auth/posthog/signup-funnel"; +import { trackSignupStartedAtClick } from "@web/auth/posthog/signup-funnel"; import { track } from "@web/auth/posthog/track"; import { CONNECT_CALENDAR_LABEL, @@ -28,12 +25,20 @@ import { WelcomeFaqList } from "./WelcomeFaqList"; import { WelcomeLinks } from "./WelcomeLinks"; import { WelcomeSteps } from "./WelcomeSteps"; import { welcomeGuideActions } from "./welcome.guide.store"; -import { hasSeenWelcome, markWelcomeSeen } from "./welcome.modal.util"; +import { + hasSeenWelcome, + isWelcomeOnboardingPathBlockedFromWindow, + markWelcomeSeen, +} from "./welcome.modal.util"; import { welcomeTrialCardTermsLine, welcomeTrialPrimaryLabel, welcomeTrialPrimaryProvider, } from "./welcome-account-choice.util"; +import { + emitWelcomeIntroAnalyticsOnce, + emitWelcomeStepViewedOnce, +} from "./welcome-analytics.util"; import { shouldDelegateEnterToWelcomePrimary } from "./welcome-modal-enter.util"; import { nextWelcomeStep, @@ -71,7 +76,11 @@ export function WelcomeModal() { // runs before ShowcasePlayLink's consume effect can mark welcome seen, so // the param itself has to keep this modal closed. const [isOpen, setIsOpen] = useState( - () => !authenticated && !hasSeenWelcome() && !hasPlayDeepLink(), + () => + !authenticated && + !hasSeenWelcome() && + !hasPlayDeepLink() && + !isWelcomeOnboardingPathBlockedFromWindow(), ); const [step, setStep] = useState(1); const { closing, beginDismiss, cancelDismiss } = @@ -117,28 +126,20 @@ export function WelcomeModal() { setHandingOff(false); }, [isLoading]); - const shownRef = useRef(false); useEffect(() => { if (visible) { skipFocusRestoreRef.current = false; - if (!shownRef.current) { - shownRef.current = true; - track("welcome_modal_shown"); - trackSignupStep("welcome_viewed"); - } + emitWelcomeIntroAnalyticsOnce(); } }, [visible]); // Each screen seats its own primary button (OverlayPanel only seats focus // once, on mount) so Enter keeps meaning "the obvious next thing", and - // counts as viewed once no matter how often Back revisits it. - const viewedStepsRef = useRef(new Set()); + // counts as viewed once per tab session no matter how often Back revisits it. useEffect(() => { if (!visible) return; primaryRef.current?.focus(); - if (viewedStepsRef.current.has(step)) return; - viewedStepsRef.current.add(step); - track("welcome_step_viewed", { step }); + emitWelcomeStepViewedOnce(step); }, [step, visible]); useEffect(() => { diff --git a/apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.test.ts b/apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.test.ts new file mode 100644 index 0000000000..3d0006fc1d --- /dev/null +++ b/apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.test.ts @@ -0,0 +1,58 @@ +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { sessionBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { + emitWelcomeIntroAnalyticsOnce, + emitWelcomeStepViewedOnce, + resetWelcomeAnalyticsForTests, +} from "@web/components/WelcomeModal/welcome-analytics.util"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +const track = mock(); +const trackSignupStep = mock(); + +mock.module("@web/auth/posthog/track", () => ({ track })); +mock.module("@web/auth/posthog/signup-funnel", () => ({ + trackSignupStep, +})); + +describe("welcome analytics session guards", () => { + beforeEach(() => { + sessionStorage.clear(); + resetWelcomeAnalyticsForTests(); + track.mockClear(); + trackSignupStep.mockClear(); + }); + + afterEach(() => { + sessionStorage.clear(); + resetWelcomeAnalyticsForTests(); + }); + + it("emits intro funnel events only once per session", () => { + emitWelcomeIntroAnalyticsOnce(); + emitWelcomeIntroAnalyticsOnce(); + + expect(track).toHaveBeenCalledTimes(2); + expect(track).toHaveBeenCalledWith("welcome_modal_shown"); + expect(track).toHaveBeenCalledWith("welcome_step_viewed", { step: 1 }); + expect(trackSignupStep).toHaveBeenCalledTimes(1); + expect(trackSignupStep).toHaveBeenCalledWith("welcome_viewed"); + expect( + sessionBrowserStore.get(STORAGE_KEYS.WELCOME_INTRO_ANALYTICS_SESSION), + ).toBe("true"); + }); + + it("emits each welcome step viewed once per session", () => { + emitWelcomeStepViewedOnce(2); + emitWelcomeStepViewedOnce(2); + emitWelcomeStepViewedOnce(3); + + expect(track).toHaveBeenCalledTimes(2); + expect(track).toHaveBeenNthCalledWith(1, "welcome_step_viewed", { + step: 2, + }); + expect(track).toHaveBeenNthCalledWith(2, "welcome_step_viewed", { + step: 3, + }); + }); +}); diff --git a/apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.ts b/apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.ts new file mode 100644 index 0000000000..44350ccc4c --- /dev/null +++ b/apps/calendar-web/src/components/WelcomeModal/welcome-analytics.util.ts @@ -0,0 +1,74 @@ +import { trackSignupStep } from "@web/auth/posthog/signup-funnel"; +import { track } from "@web/auth/posthog/track"; +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { sessionBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { type WelcomeStep } from "@web/components/WelcomeModal/welcome-steps"; + +const viewedStepsKey = STORAGE_KEYS.WELCOME_STEPS_VIEWED_SESSION; + +let introEmittedInMemory = false; +const stepsEmittedInMemory = new Set(); + +function readViewedSteps(): Set { + if (!sessionBrowserStore.isAvailable()) return new Set(stepsEmittedInMemory); + const raw = sessionBrowserStore.get(viewedStepsKey); + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter((step): step is WelcomeStep => typeof step === "number"), + ); + } catch { + return new Set(); + } +} + +function writeViewedSteps(steps: Set): void { + if (!sessionBrowserStore.isAvailable()) return; + sessionBrowserStore.set(viewedStepsKey, JSON.stringify([...steps])); +} + +function claimIntroOnce(): boolean { + if (sessionBrowserStore.isAvailable()) { + if (sessionBrowserStore.get(STORAGE_KEYS.WELCOME_INTRO_ANALYTICS_SESSION)) { + return false; + } + sessionBrowserStore.set( + STORAGE_KEYS.WELCOME_INTRO_ANALYTICS_SESSION, + "true", + ); + return true; + } + if (introEmittedInMemory) return false; + introEmittedInMemory = true; + return true; +} + +/** Once per tab session: modal shown, welcome_viewed, and step 1 viewed. */ +export function emitWelcomeIntroAnalyticsOnce(): void { + if (!claimIntroOnce()) return; + track("welcome_modal_shown"); + trackSignupStep("welcome_viewed"); + emitWelcomeStepViewedOnce(1); +} + +/** Once per tab session for each welcome screen step (including revisits). */ +export function emitWelcomeStepViewedOnce(step: WelcomeStep): void { + const steps = readViewedSteps(); + if (steps.has(step)) return; + if (!sessionBrowserStore.isAvailable()) { + if (stepsEmittedInMemory.has(step)) return; + stepsEmittedInMemory.add(step); + } else { + steps.add(step); + writeViewedSteps(steps); + } + track("welcome_step_viewed", { step }); +} + +/** Test-only: reset in-memory guards when sessionStorage is cleared. */ +export function resetWelcomeAnalyticsForTests(): void { + introEmittedInMemory = false; + stepsEmittedInMemory.clear(); +} diff --git a/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.test.ts b/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.test.ts index 2d643addf4..593e95e046 100644 --- a/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.test.ts +++ b/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.test.ts @@ -1,6 +1,7 @@ import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; import { + isWelcomeOnboardingPathBlocked, markWelcomeSeen, readWelcomeExit, selectWelcomeModalSurfaceEligible, @@ -14,12 +15,23 @@ describe("welcome modal storage", () => { }); it("keeps the welcome modal away from a first visit that came for meeting setup", () => { - expect(selectWelcomeModalSurfaceEligible(true, false, false, false)).toBe( + expect( + selectWelcomeModalSurfaceEligible(true, false, false, false, false), + ).toBe(true); + expect( + selectWelcomeModalSurfaceEligible(true, false, false, true, false), + ).toBe(false); + expect( + selectWelcomeModalSurfaceEligible(true, false, false, false, true), + ).toBe(false); + }); + + it("blocks welcome on public /meet paths and meetingSetup links", () => { + expect(isWelcomeOnboardingPathBlocked("/meet/host")).toBe(true); + expect(isWelcomeOnboardingPathBlocked("/week")).toBe(false); + expect(isWelcomeOnboardingPathBlocked("/week", { meetingSetup: 1 })).toBe( true, ); - expect(selectWelcomeModalSurfaceEligible(true, false, false, true)).toBe( - false, - ); }); it("records the exit CTA when welcome is dismissed", () => { diff --git a/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.ts b/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.ts index 79b4bf6c3e..540c1f3d8e 100644 --- a/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.ts +++ b/apps/calendar-web/src/components/WelcomeModal/welcome.modal.util.ts @@ -1,7 +1,43 @@ +import { isPublicBookingPath } from "@core/booking/booking-telemetry"; +import { isMeetingSetupRequested } from "@web/booking/meeting-setup.search"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; import { hasPlayDeepLink } from "@web/components/ShortcutShowcase/play-link"; +function meetingSetupRequestedFromSearch(search?: { + meetingSetup?: unknown; +}): boolean { + if (!search) return false; + const value = search.meetingSetup; + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "boolean" + ) { + return false; + } + return isMeetingSetupRequested({ meetingSetup: value }); +} + +export function isWelcomeOnboardingPathBlocked( + pathname: string, + search?: { meetingSetup?: unknown }, +): boolean { + if (isPublicBookingPath(pathname)) return true; + if (meetingSetupRequestedFromSearch(search)) return true; + return false; +} + +/** Synchronous read for WelcomeModal's first paint before router effects run. */ +export function isWelcomeOnboardingPathBlockedFromWindow(): boolean { + if (typeof window === "undefined") return false; + const params = new URLSearchParams(window.location.search); + const meetingSetup = params.get("meetingSetup"); + return isWelcomeOnboardingPathBlocked(window.location.pathname, { + meetingSetup: meetingSetup ?? undefined, + }); +} + export function hasSeenWelcome(): boolean { if (!persistentBrowserStore.isAvailable()) return true; return persistentBrowserStore.get(STORAGE_KEYS.HAS_SEEN_WELCOME) === "true"; @@ -30,10 +66,12 @@ export function selectWelcomeModalSurfaceEligible( authenticated: boolean, isFirstVisitOpen: boolean, guestMeetingSetupActive: boolean, + onboardingPathBlocked: boolean, ): boolean { if (!showCalendarOnboarding) return false; if (authenticated) return false; if (guestMeetingSetupActive) return false; + if (onboardingPathBlocked) return false; if (hasPlayDeepLink()) return false; if (!hasSeenWelcome()) return true; // Explore marks welcome seen before the fade finishes; keep the slot until diff --git a/packages/core/src/analytics/posthog-audience.util.test.ts b/packages/core/src/analytics/posthog-audience.util.test.ts new file mode 100644 index 0000000000..fa1396e16b --- /dev/null +++ b/packages/core/src/analytics/posthog-audience.util.test.ts @@ -0,0 +1,42 @@ +import { + isInternalOrTestEmail, + isInternalOrTestHostname, + isNonHumanUserAgent, +} from "@core/analytics/posthog-audience.util"; +import { describe, expect, it } from "bun:test"; + +describe("posthog audience helpers", () => { + it("flags internal team email domains", () => { + expect(isInternalOrTestEmail("tyler@keepsoftwaresimple.com")).toBe(true); + expect(isInternalOrTestEmail("guest@example.com")).toBe(false); + }); + + it("flags local hostnames", () => { + expect(isInternalOrTestHostname("localhost")).toBe(true); + expect(isInternalOrTestHostname("127.0.0.1")).toBe(true); + expect(isInternalOrTestHostname("staging.compasscalendar.com")).toBe(false); + }); + + it("treats common bots and agent browsers as non-human", () => { + expect( + isNonHumanUserAgent( + "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)", + ), + ).toBe(true); + expect( + isNonHumanUserAgent( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 HeadlessChrome/120.0.0.0", + ), + ).toBe(true); + expect( + isNonHumanUserAgent( + "Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://anthropic.com)", + ), + ).toBe(true); + expect( + isNonHumanUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + ), + ).toBe(false); + }); +}); diff --git a/packages/core/src/analytics/posthog-audience.util.ts b/packages/core/src/analytics/posthog-audience.util.ts new file mode 100644 index 0000000000..3323c1bb4d --- /dev/null +++ b/packages/core/src/analytics/posthog-audience.util.ts @@ -0,0 +1,41 @@ +const INTERNAL_EMAIL_DOMAINS = new Set(["keepsoftwaresimple.com"]); + +const NON_HUMAN_UA_PATTERN = + /(?:bot|crawler|spider|headless|yandex(?:bot|images|direct|media|video|search)?|playwright|puppeteer|slimerjs|phantomjs|selenium|webdriver|curl\/|wget\/|python-requests|go-http-client|java\/|libwww|httpclient|okhttp|postmanruntime|insomnia|claude(?:bot|search|web)?|anthropic-ai|gptbot|chatgpt-user|ccbot|bytespider|petalbot|amazonbot|bingbot|duckduckbot|googlebot|applebot|facebookexternalhit|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|twitterbot|semrush|ahrefsbot|mj12bot|dotbot|rogerbot|screaming frog|lighthouse|pagespeed|chrome-lighthouse|prerender|phantom|nightmare|electron\/)/i; + +/** + * Team and fixture inboxes that should not pollute product funnels. PostHog + * cohorts filter on `$internal_or_test_user`; this helper flags them at + * identify time rather than dropping their events silently. + */ +export function isInternalOrTestEmail(email: string): boolean { + const at = email.lastIndexOf("@"); + if (at < 0) return false; + const domain = email + .slice(at + 1) + .trim() + .toLowerCase(); + return INTERNAL_EMAIL_DOMAINS.has(domain); +} + +/** Local and loopback hosts share the same PostHog flag as internal users. */ +export function isInternalOrTestHostname(hostname: string): boolean { + const host = hostname.trim().toLowerCase(); + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "[::1]" || + host.endsWith(".local") + ); +} + +/** + * Crawlers, automation harnesses, and headless browsers that are not real + * visitors. Their events are dropped in PostHog `before_send`. + */ +export function isNonHumanUserAgent(userAgent: string | undefined): boolean { + if (userAgent === undefined) return true; + const ua = userAgent.trim(); + if (ua.length === 0) return true; + return NON_HUMAN_UA_PATTERN.test(ua); +} diff --git a/packages/sync/src/app.ts b/packages/sync/src/app.ts index c6eac91312..884d831905 100644 --- a/packages/sync/src/app.ts +++ b/packages/sync/src/app.ts @@ -182,6 +182,7 @@ export function createSyncService( postConnectRedirectUrl: config.POST_CONNECT_REDIRECT_URL ?? config.CALLBACK_BASE_URL, posthog: deps.posthog ?? null, + nodeEnv: config.NODE_ENV, } : undefined; diff --git a/packages/sync/src/server/connection.routes.db.test.ts b/packages/sync/src/server/connection.routes.db.test.ts index 56dbf247c6..fbe9cab9e3 100644 --- a/packages/sync/src/server/connection.routes.db.test.ts +++ b/packages/sync/src/server/connection.routes.db.test.ts @@ -1196,6 +1196,7 @@ describe("GET /sync/google", () => { provider: "google", outcome: "connected", intent: "connect", + environment: NodeEnv.Test, }), ); expect(captured[0]!.properties).not.toHaveProperty("errorClass"); diff --git a/packages/sync/src/server/connection.routes.ts b/packages/sync/src/server/connection.routes.ts index c3114db418..d2d6118dd9 100644 --- a/packages/sync/src/server/connection.routes.ts +++ b/packages/sync/src/server/connection.routes.ts @@ -181,6 +181,8 @@ export interface ConnectionApiDeps { credentialAtRestKey?: string; // Same PostHog client as sync_health_snapshot. Null when the key is unset. posthog?: PostHogCaptureClient | null; + /** Mirrors backend/web `environment` on server-side product events. */ + nodeEnv: string; } // Internal, authenticated connection endpoints. The tenant/principal comes from @@ -1151,6 +1153,7 @@ async function captureOAuthCallbackThenRedirect( provider: input.provider, outcome: input.outcome, correlationId, + environment: deps.nodeEnv, }; if (intent) properties["intent"] = intent; if (input.outcome === "error") {