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
31 changes: 31 additions & 0 deletions .claude/rules/metrics-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
paths:
- "packages/fxa-settings/src/lib/metrics.ts"
- "packages/fxa-settings/src/lib/metrics-flow.ts"
- "packages/fxa-settings/src/lib/glean/**"
- "packages/fxa-settings/src/lib/passkeys/**"
- "packages/fxa-settings/src/pages/Index/**"
- "packages/fxa-settings/src/pages/Signin/**"
---

# FXA Settings — flow metrics context

When building a `metricsContext` or forwarding flow identity (`flowId`,
`flowBeginTime`, `deviceId`) to the auth-server or Glean, source it from the
frozen `flowQueryParams` snapshot (threaded from `App`, the same value the Glean
SDK is initialized with) — **not** a live `location.search` read.

The SPA can strip flow params from the URL after load, so a later
`location.search` read yields a `flow_id` that no longer matches the client's
Glean events. The server event (e.g. `login.complete`) then fails to join the
client funnel and conversion analytics silently break. (Incident: FXA-14093.)

```ts
// Wrong — live URL read; may have lost flow params since load
queryParamsToMetricsContext(searchParams(location.search));
// Right — frozen snapshot; matches the Glean SDK flow_id
queryParamsToMetricsContext(flowQueryParams);
```

`location.search` is still correct for navigation; the trap is metrics only.
Pattern: `lib/passkeys/signin-flow.ts`, `Signin/SigninPasskeyFallback/container.tsx`.
10 changes: 6 additions & 4 deletions packages/fxa-settings/src/lib/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,12 +518,14 @@ export function addExperiment(choice: string, group: string) {
/**
* Pick out the MetricsContext key-values from URL query params. Accepts either
* a raw query-param record or a typed {@link QueryParams} object so callers can
* pass `flowQueryParams` directly. The typed shape declares a few fields as
* number/boolean, but at runtime every value is a string parsed from the URL,
* so it's read through the same string-record contract the constructor expects.
* pass `flowQueryParams` directly, or `undefined` (treated as empty), so an
* optional `flowQueryParams` can be forwarded without a caller-side default.
* The typed shape declares a few fields as number/boolean, but at runtime every
* value is a string parsed from the URL, so it's read through the same
* string-record contract the constructor expects.
*/
export function queryParamsToMetricsContext(
queryParams: Record<string, string | undefined> | QueryParams
queryParams?: Record<string, string | undefined> | QueryParams
): Partial<MetricsContext> {
const context = new MetricsContext(
queryParams as Record<string, string | undefined>
Expand Down
34 changes: 34 additions & 0 deletions packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { getCredential, isWebAuthnLevel3Supported } from './webauthn';
import { storeAccountData } from '../storage-utils';
import { AuthUiErrors } from '../auth-errors/auth-errors';
import GleanMetrics from '../glean';
import { queryParamsToMetricsContext } from '../metrics';
import type { QueryParams } from '../..';
import { IntegrationType } from '../../models';
import { OAuthNativeServices } from '@fxa/accounts/oauth';
import {
Expand Down Expand Up @@ -113,6 +115,11 @@ const MOCK_CREDENTIAL = {
response: {},
clientExtensionResults: {},
};
const MOCK_FLOW_QUERY_PARAMS = {
flowId: 'f'.repeat(64),
flowBeginTime: '1700000000000',
deviceId: 'd'.repeat(32),
} as unknown as QueryParams;

const buildArgs = (
overrides: Partial<Parameters<typeof usePasskeySignIn>[0]> = {}
Expand Down Expand Up @@ -814,6 +821,33 @@ describe('usePasskeySignIn', () => {
expect(Sentry.captureException as jest.Mock).not.toHaveBeenCalled();
});

describe('metricsContext flow identity', () => {
it('builds metricsContext from flowQueryParams, not the live location.search', async () => {
// location.search carries a different flow; it must be ignored so the
// server login.complete joins the client passkey metrics on flow_id.
const { args, spies } = buildArgs({
queryParams:
'?flowId=stale-url-flow&flowBeginTime=1&deviceId=stale-url-device',
flowQueryParams: MOCK_FLOW_QUERY_PARAMS,
});

const { result } = renderHook(() => usePasskeySignIn(args));
await act(async () => {
await result.current.onClick();
});

expect(spies.completePasskeyAuthentication).toHaveBeenCalledWith(
MOCK_CREDENTIAL,
CHALLENGE,
{
service: 'service-id',
keysRequired: false,
metricsContext: queryParamsToMetricsContext(MOCK_FLOW_QUERY_PARAMS),
}
);
});
});

describe('Glean metrics', () => {
it.each([
['emailfirst' as const, 'emailfirst'],
Expand Down
11 changes: 7 additions & 4 deletions packages/fxa-settings/src/lib/passkeys/signin-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from '../../pages/Signin/utils';
import { resolveServiceOrClientId } from '../../models/integrations/utils';
import { queryParamsToMetricsContext } from '../metrics';
import { searchParams } from '../utilities';
import type { QueryParams } from '../..';
import {
getCredential,
handleWebAuthnError,
Expand Down Expand Up @@ -210,6 +210,9 @@ export interface UsePasskeySignInArgs {
ftlMsgResolver: FtlMsgResolver;
navigateWithQuery: ReturnType<typeof useNavigateWithQuery>;
queryParams: string;
// Frozen load-time flow snapshot for the metrics join; not the live
// `queryParams` above, which the SPA may strip of flow params after load.
flowQueryParams?: QueryParams;
surface: PasskeySignInSurface;
isButtonVisible?: boolean;
supportsKeysOptionalLogin?: boolean;
Expand All @@ -228,6 +231,7 @@ export function usePasskeySignIn({
ftlMsgResolver,
navigateWithQuery,
queryParams,
flowQueryParams,
surface,
isButtonVisible = false,
supportsKeysOptionalLogin,
Expand Down Expand Up @@ -326,9 +330,7 @@ export function usePasskeySignIn({
}

const serviceForRequest = resolvePasskeyService(integration);
const metricsContext = queryParamsToMetricsContext(
searchParams(queryParams) as Record<string, string | undefined>
);
const metricsContext = queryParamsToMetricsContext(flowQueryParams);

const keysRequired = integration.requiresPasswordForLogin(
supportsKeysOptionalLogin
Expand Down Expand Up @@ -484,6 +486,7 @@ export function usePasskeySignIn({
ftlMsgResolver,
navigateWithQuery,
queryParams,
flowQueryParams,
setLocalizedError,
surface,
supportsKeysOptionalLogin,
Expand Down
1 change: 1 addition & 0 deletions packages/fxa-settings/src/pages/Index/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const Index = ({
ftlMsgResolver,
navigateWithQuery,
queryParams: location.search,
flowQueryParams,
surface: 'emailfirst',
isButtonVisible: showPasskeySignin,
supportsKeysOptionalLogin: useFxAStatusResult.supportsKeysOptionalLogin,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const SigninPasswordlessCode = ({
ftlMsgResolver,
navigateWithQuery,
queryParams: location.search,
flowQueryParams,
surface: 'login_otp',
isButtonVisible: showPasskeySignin,
supportsKeysOptionalLogin: useFxAStatusResult.supportsKeysOptionalLogin,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const SigninAlternativeAuthOptions = ({
ftlMsgResolver,
navigateWithQuery,
queryParams: location.search,
flowQueryParams,
surface: 'alternative_auth',
isButtonVisible: showPasskeySignin,
supportsKeysOptionalLogin,
Expand Down
1 change: 1 addition & 0 deletions packages/fxa-settings/src/pages/Signin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const Signin = ({
ftlMsgResolver,
navigateWithQuery,
queryParams: location.search,
flowQueryParams,
surface: 'login',
isButtonVisible: showPasskeySignin,
supportsKeysOptionalLogin,
Expand Down
Loading