Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
44 changes: 44 additions & 0 deletions apps/calendar-web/src/auth/posthog/posthog-audience-filter.util.ts
Original file line number Diff line number Diff line change
@@ -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);
}
16 changes: 16 additions & 0 deletions apps/calendar-web/src/auth/posthog/posthog.bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -58,6 +64,7 @@ export function initPosthog(): PostHog | undefined {
css_selector_ignorelist: [...CALENDAR_GRID_DEAD_CLICK_IGNORE_SELECTORS],
},
before_send: [
filterPosthogAudience,
filterPosthogBeforeSend,
filterPosthogDeadClick,
filterPosthogCalendarGridGestures,
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion apps/calendar-web/src/auth/posthog/useIdentifyUser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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 });
});
});
7 changes: 7 additions & 0 deletions apps/calendar-web/src/auth/posthog/useIdentifyUser.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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]);
}
6 changes: 6 additions & 0 deletions apps/calendar-web/src/common/constants/storage.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion apps/calendar-web/src/components/RootShell/RootShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -165,6 +171,7 @@ export function RootShell() {
authenticated,
isWelcomeFirstVisitOpen,
guestMeetingSetupActive,
welcomeOnboardingPathBlocked,
),
shortcutShowcase: selectShortcutShowcaseSurfaceEligible(
showCalendarOnboarding,
Expand All @@ -187,6 +194,7 @@ export function RootShell() {
isWelcomeGuideOpen,
isWelcomeFirstVisitOpen,
guestMeetingSetupActive,
welcomeOnboardingPathBlocked,
connectCalendarPromptEligible,
firstEventPromptEligible,
isLifeView,
Expand Down
35 changes: 18 additions & 17 deletions apps/calendar-web/src/components/WelcomeModal/WelcomeModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<WelcomeStep>(1);
const { closing, beginDismiss, cancelDismiss } =
Expand Down Expand Up @@ -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<WelcomeStep>());
// 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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading