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
Expand Up @@ -10,6 +10,8 @@ public enum ProductEvent: String, Codable, Hashable, Sendable, CaseIterable {
case localModeSelected = "local_mode_selected"
case signupCompleted = "signup_completed"
case oauthRedirectStarted = "oauth_redirect_started"
case oauthCallbackReceived = "oauth_callback_received"
case oauthFailed = "oauth_failed"
case oauthReturn = "oauth_return"
case signupStepViewed = "signup_step_viewed"
case signupFailed = "signup_failed"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public enum ProductEventNativeCoverage: Sendable {
.localModeSelected,
.signupCompleted,
.oauthRedirectStarted,
.oauthCallbackReceived,
.oauthFailed,
.oauthReturn,
.signupStepViewed,
.signupFailed,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import {
type SignupMethod,
trackSignupFailed,
} from "@web/auth/posthog/signup-funnel";
import { type ProviderKind } from "@core/types/sync/identity.contracts";
import { trackOAuthSignupFailed } from "@web/auth/posthog/oauth-signup-funnel";
import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes";
import { showErrorToast } from "@web/common/utils/toast/error-toast.util";

type ReportAuthCallbackCrashOptions = {
method: SignupMethod;
method: ProviderKind;
errorMessage: string;
replace: (path: string) => void;
};
Expand All @@ -22,8 +20,9 @@ type ReportAuthCallbackCrashOptions = {
export const reportAuthCallbackCrash =
({ method, errorMessage, replace }: ReportAuthCallbackCrashOptions) =>
(error: unknown): void => {
trackSignupFailed("oauth_callback_crashed", {
method,
trackOAuthSignupFailed({
provider: method,
reason: "oauth_callback_crashed",
error: error instanceof Error ? error.message : String(error),
});
showErrorToast(errorMessage);
Expand Down
19 changes: 19 additions & 0 deletions apps/calendar-web/src/auth/posthog/oauth-signup-funnel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { oauthFailedReasonFromSignupFailure } from "@web/auth/posthog/oauth-signup-funnel";
import { describe, expect, it } from "bun:test";

describe("oauth-signup-funnel", () => {
it("maps signup failure reasons to oauth_failed reasons", () => {
expect(oauthFailedReasonFromSignupFailure("oauth_user_cancelled")).toBe(
"access_denied",
);
expect(oauthFailedReasonFromSignupFailure("oauth_missing_state")).toBe(
"invalid_state",
);
expect(oauthFailedReasonFromSignupFailure("oauth_missing_code")).toBe(
"missing_code",
);
expect(oauthFailedReasonFromSignupFailure("oauth_exchange_failed")).toBe(
"token_exchange_error",
);
});
});
45 changes: 45 additions & 0 deletions apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type OAuthFailedReason } from "@core/analytics/oauth-signup.events";
import { type ProviderKind } from "@core/types/sync/identity.contracts";
import {
type SignupFailureReason,
type SignupStep,
trackSignupFailed,
} from "@web/auth/posthog/signup-funnel";
import { track } from "@web/auth/posthog/track";

const SIGNUP_FAILURE_TO_OAUTH_FAILED: Partial<
Record<SignupFailureReason, OAuthFailedReason>
> = {
oauth_user_cancelled: "access_denied",
oauth_missing_state: "invalid_state",
oauth_missing_intent: "invalid_state",
oauth_missing_code: "missing_code",
oauth_exchange_failed: "token_exchange_error",
oauth_missing_scopes: "other",
oauth_callback_crashed: "other",
};

export function oauthFailedReasonFromSignupFailure(
reason: SignupFailureReason,
): OAuthFailedReason {
return SIGNUP_FAILURE_TO_OAUTH_FAILED[reason] ?? "other";
}

export function trackOAuthSignupFailed(input: {
provider: ProviderKind;
reason: SignupFailureReason;
providerError?: string;
step?: SignupStep;
error?: string;
}): void {
track("oauth_failed", {
provider: input.provider,
reason: oauthFailedReasonFromSignupFailure(input.reason),
...(input.providerError ? { provider_error: input.providerError } : {}),
});
trackSignupFailed(input.reason, {
method: input.provider,
step: input.step,
error: input.error,
});
}
13 changes: 13 additions & 0 deletions apps/calendar-web/src/auth/posthog/posthog-distinct-id.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getPosthogClient } from "@web/auth/posthog/posthog.bootstrap";

/** Best-effort anon distinct id for tying server-side OAuth events to the browser. */
export function readPosthogDistinctIdForOAuth(): string | undefined {
try {
const distinctId = getPosthogClient()?.get_distinct_id?.();
return typeof distinctId === "string" && distinctId.length > 0
? distinctId
: undefined;
} catch {
return undefined;
}
}
7 changes: 7 additions & 0 deletions apps/calendar-web/src/auth/posthog/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { getPosthogClient } from "@web/auth/posthog/posthog.bootstrap";
* `trial_step_viewed`, `trial_step_completed`, `trial_step_abandoned`)
* - `signup_failed`: `reason_code`, optional `method`, `step`, `error`
*
* OAuth signup drop-off (server + client; `packages/core/src/analytics/oauth-signup.events.ts`):
* - `oauth_redirect_started`: `provider`, `intent`
* - `oauth_callback_received`: `provider`
* - `oauth_failed`: `provider`, `reason`, optional `provider_error`
*
* Billing:
* - `trial_converted`: `source`, optional `feature_area`, `action_id`, `trial`
*/
Expand All @@ -22,6 +27,8 @@ export type ProductEvent =
| "local_mode_selected"
| "signup_completed"
| "oauth_redirect_started"
| "oauth_callback_received"
| "oauth_failed"
| "oauth_return"
| "signup_step_viewed"
| "signup_failed"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry";
import { afterEach, describe, expect, it, mock } from "bun:test";

describe("relayProviderOAuthCallbackTelemetry", () => {
const realFetch = globalThis.fetch;

afterEach(() => {
globalThis.fetch = realFetch;
});

it("requests backend callback telemetry with the relay header", () => {
const fetchMock = mock(async () => new Response(null, { status: 204 }));
globalThis.fetch = fetchMock as unknown as typeof fetch;

relayProviderOAuthCallbackTelemetry("google", "?state=test-state&code=abc");

expect(fetchMock).toHaveBeenCalledTimes(1);
const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined();
const [url, init] = firstCall as unknown as [string, RequestInit];
expect(url).toContain("/auth/google/callback?state=test-state");
expect(init.headers).toEqual(
expect.objectContaining({ "X-Compass-OAuth-Telemetry": "1" }),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { type ProviderKind } from "@core/types/sync/identity.contracts";
import { providerAuthCallbackPath } from "@web/auth/providers/authorization/provider-authorization.constants";
import { ENV_WEB } from "@web/common/constants/env.constants";

const TELEMETRY_HEADER = "X-Compass-OAuth-Telemetry";

/**
* Records server-side OAuth callback funnel events on the backend using the
* same query Google (or the provider) returned to the SPA callback route.
*/
export function relayProviderOAuthCallbackTelemetry(
provider: ProviderKind,
search: string,
): void {
const query = search.startsWith("?") ? search : `?${search}`;
const url = `${ENV_WEB.BACKEND_BASEURL}${providerAuthCallbackPath(provider)}${query}`;

try {
void fetch(url, {
method: "GET",
credentials: "omit",
headers: { [TELEMETRY_HEADER]: "1" },
keepalive: true,
});
} catch {
// Analytics must never block the OAuth callback UI.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { buildOAuthStateForClient } from "@core/desktop/desktop-oauth-state.util
import { GOOGLE_SCOPES } from "@core/providers/google.scopes";
import { MICROSOFT_SCOPES } from "@core/providers/microsoft.scopes";
import { type ProviderKind } from "@core/types/sync/identity.contracts";
import { readPosthogDistinctIdForOAuth } from "@web/auth/posthog/posthog-distinct-id.util";
import { trackSignupStep } from "@web/auth/posthog/signup-funnel";
import { track } from "@web/auth/posthog/track";
import { rememberSignupTrialMethod } from "@web/billing/signup-trial.util";
Expand Down Expand Up @@ -52,6 +53,11 @@ type AuthorizationSession = {
redirectUri: string;
};

type CreateOAuthState = (
redirectUri: string,
posthogDistinctId?: string,
) => string;

/**
* Pins the OAuth `state` and callback URL for the life of the mount, so the
* value handed to the consent screen is the one the callback later looks up.
Expand All @@ -60,11 +66,14 @@ type AuthorizationSession = {
*/
function useAuthorizationSession(
provider: ProviderKind,
createState: (redirectUri: string) => string,
createState: CreateOAuthState,
): AuthorizationSession {
const [session] = useState<AuthorizationSession>(() => {
const redirectUri = buildProviderAuthCallbackUrl(provider);
return { state: createState(redirectUri), redirectUri };
return {
state: createState(redirectUri, readPosthogDistinctIdForOAuth()),
redirectUri,
};
});
return session;
}
Expand Down Expand Up @@ -118,7 +127,7 @@ type RedirectAuthorizationSpec = {
*/
getClientId: () => string;
notConfiguredMessage: string;
createState: (redirectUri: string) => string;
createState: CreateOAuthState;
buildAuthorizationUrl: (
args: AuthorizationSession & {
clientId: string;
Expand Down Expand Up @@ -179,8 +188,10 @@ const useGoogleProviderAuthorizationStrategy: ProviderAuthorizationStrategy = ({
prompt,
}) => {
const [loading, setLoading] = useState(false);
const { state, redirectUri } = useAuthorizationSession("google", () =>
buildOAuthStateForClient(false),
const { state, redirectUri } = useAuthorizationSession(
"google",
(_redirectUri, posthogDistinctId) =>
buildOAuthStateForClient(false, posthogDistinctId),
);

const loginOptions = useMemo<
Expand Down Expand Up @@ -223,7 +234,8 @@ const useMicrosoftProviderAuthorizationStrategy =
provider: "microsoft",
getClientId: () => getMicrosoftSignInClientId(),
notConfiguredMessage: "Microsoft sign-in is not configured",
createState: () => buildOAuthStateForClient(false),
createState: (_redirectUri, posthogDistinctId) =>
buildOAuthStateForClient(false, posthogDistinctId),
buildAuthorizationUrl: ({ clientId, redirectUri, state, prompt }) =>
buildMicrosoftAuthorizationUrl({
clientId,
Expand All @@ -241,11 +253,12 @@ const useAppleProviderAuthorizationStrategy =
notConfiguredMessage: "Apple sign-in is not configured",
// Apple returns to the backend form_post route, so the frontend callback it
// should land on afterwards rides along inside the state.
createState: (redirectUri) =>
createState: (redirectUri, posthogDistinctId) =>
btoa(
JSON.stringify({
frontendRedirectURI: redirectUri,
nonce: crypto.randomUUID(),
...(posthogDistinctId ? { ph: posthogDistinctId } : {}),
}),
),
buildAuthorizationUrl: ({ clientId, state }) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("useStartProviderAuthorizationImpl", () => {
key.includes("googleAuthorizationIntent"),
);
expect(intents).toHaveLength(1);
const state = intents[0]?.split(".").at(-1);
const state = intents[0]?.replace("compass.googleAuthorizationIntent.", "");
expect(state).toBeDefined();
expect(readProviderAuthorizationIntent("google", state!)).toEqual(
expect.objectContaining({
Expand Down Expand Up @@ -95,7 +95,10 @@ describe("useStartProviderAuthorizationImpl", () => {
key.includes("providerAuthorizationIntent.microsoft"),
);
expect(intents).toHaveLength(1);
const state = intents[0]?.split(".").at(-1);
const state = intents[0]?.replace(
"compass.providerAuthorizationIntent.microsoft.",
"",
);
expect(readProviderAuthorizationIntent("microsoft", state!)).toEqual(
expect.objectContaining({
intent: "signIn",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import {
import { registerToastPort } from "@web/common/utils/toast/toast.port";
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";

mock.module(
"@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry",
() => ({
relayProviderOAuthCallbackTelemetry: () => undefined,
}),
);

const mockLoginOrSignup = mock();
const realLoginOrSignup = AuthApi.loginOrSignup;
AuthApi.loginOrSignup = mockLoginOrSignup as typeof AuthApi.loginOrSignup;
Expand All @@ -17,8 +24,7 @@ afterAll(() => {

const { port, mocks } = createTestToastPort();

const { completeAppleAuthCallback } =
require("./AppleAuthCallback") as typeof import("./AppleAuthCallback");
const { completeAppleAuthCallback } = await import("./AppleAuthCallback");

const callbackSearch = (state: string) =>
`?state=${encodeURIComponent(state)}&code=auth-code`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
clearProviderAuthorizationIntent,
readProviderAuthorizationIntent,
} from "@web/auth/providers/authorization/provider-authorization.storage";
import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry";
import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes";
import { showErrorToast } from "@web/common/utils/toast/error-toast.util";

Expand All @@ -33,7 +34,9 @@ export async function completeAppleAuthCallback({
navigate,
search,
}: CompleteAppleAuthCallbackOptions): Promise<void> {
relayProviderOAuthCallbackTelemetry("apple", search);
trackSignupStep("oauth_callback_returned", { method: "apple" });
track("oauth_callback_received", { provider: "apple" });

const params = new URLSearchParams(search);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,19 @@ import {
useMissingPermissionsStore,
} from "@web/auth/providers/missing-permissions.store";
import { registerToastPort } from "@web/common/utils/toast/toast.port";
import { completeProviderAuthCallback } from "@web/views/ProviderAuthCallback/ProviderAuthCallback";
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";

mock.module(
"@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry",
() => ({
relayProviderOAuthCallbackTelemetry: () => undefined,
}),
);

const { completeProviderAuthCallback } = await import(
"@web/views/ProviderAuthCallback/ProviderAuthCallback"
);

const GOOGLE_SCOPES_REQUIRED = PROVIDER_AUTH_SCOPES_REQUIRED.google;

const mockLoginOrSignup = mock();
Expand Down
Loading
Loading