Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(settings): Add nimbus experiments to AppContext #18524

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
24 changes: 24 additions & 0 deletions packages/fxa-settings/src/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
useState,
useCallback,
useMemo,
useContext,
} from 'react';

import { QueryParams } from '../..';
Expand All @@ -27,6 +28,7 @@ import * as Metrics from '../../lib/metrics';
import { MozServices } from '../../lib/types';

import {
AppContext,
Integration,
OAuthIntegration,
useConfig,
Expand Down Expand Up @@ -87,6 +89,8 @@ import InlineRecoveryKeySetupContainer from '../../pages/InlineRecoveryKeySetup/
import SetPasswordContainer from '../../pages/PostVerify/SetPassword/container';
import SigninRecoveryChoiceContainer from '../../pages/Signin/SigninRecoveryChoice/container';
import SigninRecoveryPhoneContainer from '../../pages/Signin/SigninRecoveryPhone/container';
import { initializeNimbus, NimbusContextT } from '../../lib/nimbus';
import { parseAcceptLanguage } from '../../../../../libs/shared/l10n/src';

const Settings = lazy(() => import('../Settings'));

Expand All @@ -96,6 +100,7 @@ export const App = ({
const config = useConfig();
const session = useSession();
const integration = useIntegration();
const { uniqueUserId } = useContext(AppContext);
const isSync = integration != null && integration.isSync();
const { data: isSignedInData } = useLocalSignedInQueryState();

Expand Down Expand Up @@ -234,6 +239,25 @@ export const App = ({
metricsEnabled,
]);

useMemo(() => {
// This can truthfully never be null in the current implementation,
// because we always generate a new one if we don't have it.
if (!uniqueUserId) {
return;
}

// We reuse parseAcceptLanguage with navigator.languages because
// that is the same as getting the headers directly as stated on MDN.
// See: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/languages
const [locale] = parseAcceptLanguage(navigator.languages.join(', '));
let [language, region] = locale.split('-');
if (region) {
region = region.toLowerCase();
}

initializeNimbus(uniqueUserId, { language, region } as NimbusContextT);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to get this into AppContext or some other kind of observable state so that I can write a very simple useExperiments which provides a consumer with the experiment info they need in that final form that they can use anywhere in the app.

I'm not sure what to do with the results from initializeNimbus here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, I should move this to AppContext.initializeAppContext?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the right direction for what we want: the experiments are fetched before the first render.

I can see this happen very early on now:

Screenshot 2025-03-10 at 2 56 55 PM

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest patch now has my current path forward - it doesn't seem like the worst idea since we make gql fetches at the same place, it's going to be one more request that happens now.

}, [uniqueUserId]);

// Wait until metrics is done loading, integration has been created, and isSignedIn has been determined.
if (metricsLoading || !integration || isSignedIn === undefined) {
return <LoadingSpinner fullScreen />;
Expand Down
64 changes: 64 additions & 0 deletions packages/fxa-settings/src/lib/nimbus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import * as Sentry from '@sentry/browser';

/**
* A collection of attributes about the client that will be used for
* targetting an experiment.
*/
export type NimbusContextT = {
language: string | null;
region: string | null;
};

/**
* Initializes Nimbus in the React app. This should be done before the first render
* so that experiments can be applied during first initialization.
*
* @param clientId A unique identifier that is typically stable.
* @param context See {@link NimbusContextT}.
* @returns the experiment and enrollment information for that `clientId`.
*/
export async function initializeNimbus(
clientId: string,
context: NimbusContextT
) {
const body = JSON.stringify({
client_id: clientId,
context,
});

let experiments;

try {
const resp = await fetch('/nimbus-experiments', {
method: 'POST',
body,
// A request to cirrus should not be more than 50ms,
// but we give it a large enough padding.
signal: AbortSignal.timeout(1000),
headers: {
'Content-Type': 'application/json',
},
});

if (resp.status !== 200) {
return;
}

experiments = await resp.json();
console.log('!!!', experiments); // TODO: remove before landing.
} catch (err) {
Sentry.withScope(() => {
let errorMsg = 'Experiment fetch error';
if (err.name === 'TimeoutError') {
errorMsg = 'Timeout: It took more than 1 seconds to get the result!';
}
Sentry.captureMessage(errorMsg, 'error');
});
}

return experiments;
}
71 changes: 71 additions & 0 deletions packages/fxa-settings/src/models/contexts/AppContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { KeyStretchExperiment } from '../experiments/key-stretch-experiment';
import { UrlQueryData } from '../../lib/model-data';
import { ReachRouterWindow } from '../../lib/window';
import { SensitiveDataClient } from '../../lib/sensitive-data-client';
import { v4 as uuid } from 'uuid';
import * as Sentry from '@sentry/browser';

// TODO, move some values from AppContext to SettingsContext after
// using container components, FXA-8107
Expand All @@ -24,13 +26,80 @@ export interface AppContextValue {
config?: Config;
account?: Account;
session?: Session; // used exclusively for test mocking
uniqueUserId?: string; // used for experiments
}

export interface SettingsContextValue {
alertBarInfo?: AlertBarInfo;
navigatorLanguages?: readonly string[];
}

/**
* Fetches or generates a new client ID that is stable for that browser client/cookie jar.
*
* N.B: Implemenation is taken from `fxa-content-server/.../models/unique-user-id.js` with
* inlined code that was written using Backbone utilities which could not immediately be transferred over.
* @returns a new or existing UUIDv4 for this user.
*/
function getUniqueUserId(): string {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Placed this here for now, but I can move it to some other place.

function resumeTokenFromSearchParams(): string | null {
// Check the url for a resume token, that might have a uniqueUserId
const searchParams = new URLSearchParams(window.location.search);
const resumeToken = searchParams.get('resume');
return resumeToken;
}

function maybePersistFromToken(resumeToken: string) {
// populateFromStringifiedResumeToken - fxa-content-server/.../mixins/resume-token.js
if (resumeToken) {
try {
// createFromStringifiedResumeToken - fxa-content-server/.../models/resume-token.js
const resumeTokenObj = JSON.parse(atob(resumeToken));
if (typeof resumeTokenObj?.uniqueUserId === 'string') {
// Key name is derived from the Local Storage implementation.
// fullKey - fxa-content-server/.../lib/storage.ts
localStorage.setItem(
`__fxa_storage.uniqueUserId`,
resumeTokenObj?.uniqueUserId
);
// Use uuid provided by resume token
return resumeTokenObj?.uniqueUserId;
}
} catch (error) {
Sentry.captureMessage('Failed parse resume token.', {
extra: {
resumeToken: resumeToken.substring(0, 10) + '...',
},
});
}
}
}

function fetch(): string | null {
return localStorage.getItem('__fxa_storage.uniqueUserId');
}

function persist(uniqueUserId: string) {
localStorage.setItem(`__fxa_storage.uniqueUserId`, uniqueUserId);
}

const resumeToken = resumeTokenFromSearchParams();
if (resumeToken) {
maybePersistFromToken(resumeToken);
}

// Check local storage for an existing resume token
let uniqueUserId = fetch();

// Generate a new token if one is not found!
if (!uniqueUserId) {
uniqueUserId = uuid();
persist(uniqueUserId);
}

return uniqueUserId;
}

export function initializeAppContext() {
readConfigMeta((name: string) => {
return document.head.querySelector(name);
Expand All @@ -46,6 +115,7 @@ export function initializeAppContext() {
const account = new Account(authClient, apolloClient);
const session = new Session(authClient, apolloClient);
const sensitiveDataClient = new SensitiveDataClient();
const uniqueUserId = getUniqueUserId();

const context: AppContextValue = {
authClient,
Expand All @@ -54,6 +124,7 @@ export function initializeAppContext() {
account,
session,
sensitiveDataClient,
uniqueUserId,
};

return context;
Expand Down
1 change: 1 addition & 0 deletions packages/fxa-settings/src/models/mocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export function mockAppContext(context?: AppContextValue) {
session: mockSession(),
config: getDefault(),
sensitiveDataClient: mockSensitiveDataClient(),
uniqueUserId: '4a9512ac-3110-43df-aa8a-958A3d210b9c3',
},
context
) as AppContextValue;
Expand Down