From ac65546acc961799b17bfbd8eb4fa46fa7f524f6 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Fri, 24 Oct 2025 10:18:04 -0400 Subject: [PATCH] feat(nimbus): Update nimbus experimentation code --- .../server/lib/beta-settings.js | 5 +- .../server/lib/configuration.js | 18 +- .../react-app/route-definition-index.js | 8 +- packages/fxa-settings/src/index.tsx | 9 +- packages/fxa-settings/src/lib/config.ts | 10 +- packages/fxa-settings/src/lib/nimbus/index.ts | 64 +++--- .../src/models/contexts/AppContext.ts | 34 --- .../models/contexts/NimbusContext.test.tsx | 208 ++++++++++++++++++ .../src/models/contexts/NimbusContext.ts | 152 +++++++++++++ packages/fxa-settings/src/models/hooks.ts | 31 +-- 10 files changed, 433 insertions(+), 106 deletions(-) create mode 100644 packages/fxa-settings/src/models/contexts/NimbusContext.test.tsx create mode 100644 packages/fxa-settings/src/models/contexts/NimbusContext.ts diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index b3676123995..c30adf9c01f 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -123,7 +123,10 @@ const settingsConfig = { 'featureFlags.paymentsNextSubscriptionManagement' ), }, - nimbusPreview: config.get('nimbusPreview'), + nimbus: { + enabled: config.get('nimbus.enabled'), + preview: config.get('nimbus.preview'), + }, cms: { enabled: config.get('cms.enabled'), l10nEnabled: config.get('cms.l10nEnabled'), diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 1644b88e17d..b612daba538 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -399,12 +399,6 @@ const conf = (module.exports = convict({ format: String, }, }, - nimbusPreview: { - default: false, - doc: 'Enables preview mode for nimbus experiments for development and testing.', - format: Boolean, - env: 'NIMBUS_PREVIEW', - }, glean: { enabled: { default: false, @@ -705,6 +699,18 @@ const conf = (module.exports = convict({ format: ['src', 'dist'], }, nimbus: { + enabled: { + default: false, + doc: 'Enables nimbus experiments', + env: 'NIMBUS_ENABLED', + format: Boolean, + }, + preview: { + default: true, + doc: 'Enables preview mode for nimbus experiments for development and testing.', + env: 'NIMBUS_PREVIEW', + format: Boolean, + }, host: { default: 'http://localhost:8001', doc: 'Base URI for cirrus (Nimbus experimentation endpoint).', diff --git a/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js index 7fdf4d1fe94..bd74ccf9fa7 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js @@ -58,7 +58,6 @@ function getIndexRouteDefinition(config) { const FEATURE_FLAGS_SHOW_LOCALE_TOGGLE = config.get( 'featureFlags.showLocaleToggle' ); - const NIMBUS_PREVIEW = config.get('nimbusPreview'); const GLEAN_ENABLED = config.get('glean.enabled'); const GLEAN_APPLICATION_ID = config.get('glean.applicationId'); const GLEAN_UPLOAD_ENABLED = config.get('glean.uploadEnabled'); @@ -68,6 +67,8 @@ function getIndexRouteDefinition(config) { const GLEAN_DEBUG_VIEW_TAG = config.get('glean.debugViewTag'); const CMS_ENABLED = config.get('cms.enabled'); const CMS_L10N_ENABLED = config.get('cms.l10nEnabled'); + const NIMBUS_ENABLED = config.get('nimbus.enabled'); + const NIMBUS_PREVIEW = config.get('nimbus.preview'); // Rather than relay all rollout rates, hand pick the ones that are applicable const ROLLOUT_RATES = config.get('rolloutRates'); @@ -129,7 +130,10 @@ function getIndexRouteDefinition(config) { enabled: CMS_ENABLED, l10nEnabled: CMS_L10N_ENABLED, }, - nimbusPreview: NIMBUS_PREVIEW, + nimbus: { + enabled: NIMBUS_ENABLED, + preview: NIMBUS_PREVIEW, + }, glean: { // feature toggle enabled: GLEAN_ENABLED, diff --git a/packages/fxa-settings/src/index.tsx b/packages/fxa-settings/src/index.tsx index 4ef7c565b07..72040c85b58 100644 --- a/packages/fxa-settings/src/index.tsx +++ b/packages/fxa-settings/src/index.tsx @@ -7,6 +7,7 @@ import { render } from 'react-dom'; import sentryMetrics from 'fxa-shared/sentry/browser'; import { AppErrorBoundary } from './components/ErrorBoundaries'; import App from './components/App'; +import { NimbusProvider } from './models/contexts/NimbusContext'; import config, { readConfigMeta } from './lib/config'; import { searchParams } from './lib/utilities'; import { AppContext, initializeAppContext } from './models'; @@ -86,9 +87,11 @@ try { > - - - + + + + + diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index 209492496f0..9a467237315 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -103,7 +103,10 @@ export interface Config { showLocaleToggle?: boolean; paymentsNextSubscriptionManagement?: boolean; }; - nimbusPreview: boolean; + nimbus: { + enabled: boolean; + preview: boolean; + }; cms: { enabled: boolean; l10nEnabled: boolean; @@ -199,7 +202,10 @@ export function getDefault() { enabled: false, l10nEnabled: false, }, - nimbusPreview: false, + nimbus: { + enabled: true, + preview: true, + }, } as Config; } diff --git a/packages/fxa-settings/src/lib/nimbus/index.ts b/packages/fxa-settings/src/lib/nimbus/index.ts index e3b1597ffbd..9c897dba652 100644 --- a/packages/fxa-settings/src/lib/nimbus/index.ts +++ b/packages/fxa-settings/src/lib/nimbus/index.ts @@ -2,7 +2,7 @@ * 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'; +import * as Sentry from '@sentry/browser'; /** * A collection of attributes about the client that will be used for @@ -30,43 +30,43 @@ export interface NimbusResult { * @returns the experiment and enrollment information for that `clientId`. */ export async function initializeNimbus( - _clientId: string, - _previewEnabled: boolean, - _context: NimbusContextT + clientId: string, + previewEnabled: boolean, + context: NimbusContextT ): Promise { // Disabling experiment request for now. Leaving code here for the future // when we re-enable. - // const body = JSON.stringify({ - // client_id: clientId, - // context, - // }); + const body = JSON.stringify({ + client_id: clientId, + context, + }); - // try { - // const query = - // previewEnabled === true ? `?nimbusPreview=${previewEnabled}` : ''; - // const resp = await fetch(`/nimbus-experiments${query}`, { - // method: 'POST', - // body, - // headers: { - // 'Content-Type': 'application/json', - // }, - // }); + try { + const query = + previewEnabled === true ? `?nimbusPreview=${previewEnabled}` : ''; + const resp = await fetch(`/nimbus-experiments${query}`, { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/json', + }, + }); - // if (resp.status === 200) { - // return (await resp.json()) as NimbusResult; - // } - // } catch (err) { - // // Important, if this fails it will just show up in Sentry as a - // // TypeError: NetworkError when attempting to fetch resource. - // // Look at the previous fetch bread crumb to understand what - // // request is actually failing. - // Sentry.captureException(err, { - // tags: { - // source: 'nimbus-experiments', - // }, - // }); - // } + if (resp.status === 200) { + return (await resp.json()) as NimbusResult; + } + } catch (err) { + // Important, if this fails it will just show up in Sentry as a + // TypeError: NetworkError when attempting to fetch resource. + // Look at the previous fetch bread crumb to understand what + // request is actually failing. + Sentry.captureException(err, { + tags: { + source: 'nimbus-experiments', + }, + }); + } return null; } diff --git a/packages/fxa-settings/src/models/contexts/AppContext.ts b/packages/fxa-settings/src/models/contexts/AppContext.ts index 67ff6ced13d..0e24ce1eca7 100644 --- a/packages/fxa-settings/src/models/contexts/AppContext.ts +++ b/packages/fxa-settings/src/models/contexts/AppContext.ts @@ -14,10 +14,7 @@ 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 { initializeNimbus, NimbusContextT } from '../../lib/nimbus'; -import { parseAcceptLanguage } from '../../../../../libs/shared/l10n/src'; import { getUniqueUserId } from '../../lib/cache'; -import { searchParams } from '../../lib/utilities'; // TODO, move some values from AppContext to SettingsContext after // using container components, FXA-8107 @@ -29,7 +26,6 @@ export interface AppContextValue { account?: Account; session?: Session; uniqueUserId?: string; // used for experiments - experiments?: Promise; // external response; not adding types } export interface SettingsContextValue { @@ -37,34 +33,6 @@ export interface SettingsContextValue { navigatorLanguages?: readonly string[]; } -/** - * Fetches nimbus experiments from the Cirrus container via content-server. - * - * N.B: external response; not adding types - * - * @param uniqueUserId the ID that is used to retrieve the experiments for that client. - * @returns a promise to the fetch JSON reponse. - */ -function fetchNimbusExperiments(uniqueUserId: string): Promise { - // 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(); - } - - const nimbusPreview = config.nimbusPreview - ? config.nimbusPreview - : searchParams(window.location.search).nimbusPreview === 'true'; - - return initializeNimbus(uniqueUserId, nimbusPreview, { - language, - region, - } as NimbusContextT); -} - export function initializeAppContext() { readConfigMeta((name: string) => { return document.head.querySelector(name); @@ -81,7 +49,6 @@ export function initializeAppContext() { const session = new Session(authClient, apolloClient); const sensitiveDataClient = new SensitiveDataClient(); const uniqueUserId = getUniqueUserId(); - const experiments = fetchNimbusExperiments(uniqueUserId); const context: AppContextValue = { authClient, @@ -91,7 +58,6 @@ export function initializeAppContext() { session, sensitiveDataClient, uniqueUserId, - experiments, }; return context; diff --git a/packages/fxa-settings/src/models/contexts/NimbusContext.test.tsx b/packages/fxa-settings/src/models/contexts/NimbusContext.test.tsx new file mode 100644 index 00000000000..dd955e84620 --- /dev/null +++ b/packages/fxa-settings/src/models/contexts/NimbusContext.test.tsx @@ -0,0 +1,208 @@ +/* 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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import { NimbusProvider, useNimbusContext } from './NimbusContext'; +import { AppContext } from './AppContext'; +import { useDynamicLocalization } from '../../contexts/DynamicLocalizationContext'; +import { initializeNimbus } from '../../lib/nimbus'; +import * as Sentry from '@sentry/react'; + +jest.mock('../../contexts/DynamicLocalizationContext'); +jest.mock('../../lib/nimbus'); +jest.mock('@sentry/react'); + +const mockUseDynamicLocalization = useDynamicLocalization as jest.MockedFunction; +const mockInitializeNimbus = initializeNimbus as jest.MockedFunction; +const mockSentryCaptureException = Sentry.captureException as jest.MockedFunction; + +const TestComponent = () => { + const { experiments, loading, error } = useNimbusContext(); + return ( +
+
{loading.toString()}
+
{experiments ? 'has-experiments' : 'no-experiments'}
+
{error ? error.message : 'no-error'}
+
+ ); +}; + +const mockAppContext = { + config: { + nimbus: { enabled: true, preview: false } + }, + uniqueUserId: 'test-user-id' +}; + +const renderWithProviders = (appContext = mockAppContext) => { + return render( + + + + + + ); +}; + +describe('NimbusContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDynamicLocalization.mockReturnValue({ + currentLocale: 'en-US', + switchLanguage: jest.fn(), + clearLanguagePreference: jest.fn(), + isLoading: false + } as any); + Object.defineProperty(window, 'location', { + value: { search: '' }, + writable: true + }); + }); + + describe('useNimbusContext without provider', () => { + it('returns default values', () => { + render(); + + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + expect(screen.getByTestId('experiments')).toHaveTextContent('no-experiments'); + expect(screen.getByTestId('error')).toHaveTextContent('no-error'); + }); + }); + + describe('NimbusProvider', () => { + it('throws error when config is missing', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + render( + + + + + + ); + }).toThrow('NimbusProvider requires AppContext with config'); + + consoleSpy.mockRestore(); + }); + + it('does not fetch when nimbus is disabled', async () => { + const disabledConfig = { + ...mockAppContext, + config: { nimbus: { enabled: false, preview: false } } + }; + + renderWithProviders(disabledConfig); + + expect(mockInitializeNimbus).not.toHaveBeenCalled(); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + }); + + it('does not fetch when uniqueUserId is missing', async () => { + const noUserIdConfig = { + ...mockAppContext, + uniqueUserId: null as any + }; + + renderWithProviders(noUserIdConfig); + + expect(mockInitializeNimbus).not.toHaveBeenCalled(); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + }); + + it('fetches experiments successfully', async () => { + const mockExperiments = { + Features: { 'test-feature': { enabled: true } }, + nimbusUserId: 'test-user-id' + }; + mockInitializeNimbus.mockResolvedValue(mockExperiments as any); + + renderWithProviders(); + + expect(mockInitializeNimbus).toHaveBeenCalledWith( + 'test-user-id', + false, + { language: 'en', region: 'us' } + ); + + await screen.findByTestId('experiments'); + expect(screen.getByTestId('experiments')).toHaveTextContent('has-experiments'); + }); + + it('handles API response with lowercase features', async () => { + const mockExperiments = { + features: { 'test-feature': { enabled: true } }, + nimbusUserId: 'test-user-id' + }; + mockInitializeNimbus.mockResolvedValue(mockExperiments as any); + + renderWithProviders(); + + await screen.findByTestId('experiments'); + expect(screen.getByTestId('experiments')).toHaveTextContent('has-experiments'); + }); + + it('handles null response', async () => { + mockInitializeNimbus.mockResolvedValue(null); + + renderWithProviders(); + + await screen.findByTestId('experiments'); + expect(screen.getByTestId('experiments')).toHaveTextContent('no-experiments'); + }); + + it('handles fetch error', async () => { + const error = new Error('Network error'); + mockInitializeNimbus.mockRejectedValue(error); + + renderWithProviders(); + + await screen.findByTestId('error'); + expect(screen.getByTestId('error')).toHaveTextContent('Network error'); + expect(mockSentryCaptureException).toHaveBeenCalledWith(error, expect.objectContaining({ + tags: { area: 'NimbusProvider', component: 'NimbusContext' } + })); + }); + + it('handles preview mode from config', async () => { + const previewConfig = { + ...mockAppContext, + config: { nimbus: { enabled: true, preview: true } } + }; + + renderWithProviders(previewConfig); + + expect(mockInitializeNimbus).toHaveBeenCalledWith( + 'test-user-id', + true, + { language: 'en', region: 'us' } + ); + }); + + it('handles preview mode from URL params', async () => { + Object.defineProperty(window, 'location', { + value: { search: '?nimbusPreview=true' }, + writable: true + }); + + renderWithProviders(); + + expect(mockInitializeNimbus).toHaveBeenCalledWith( + 'test-user-id', + true, + { language: 'en', region: 'us' } + ); + }); + + it('cleans up on unmount', () => { + const { unmount } = renderWithProviders(); + + unmount(); + + // Should not throw or cause memory leaks + expect(mockInitializeNimbus).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/fxa-settings/src/models/contexts/NimbusContext.ts b/packages/fxa-settings/src/models/contexts/NimbusContext.ts new file mode 100644 index 00000000000..03d3e9009cd --- /dev/null +++ b/packages/fxa-settings/src/models/contexts/NimbusContext.ts @@ -0,0 +1,152 @@ +/* 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/. */ + +/** + * NimbusContext - Provides Nimbus experiment data to the FxA Settings app + * + * Fetches and manages experiment data from the Nimbus API for A/B testing and feature flags. + * + * Usage: + * ```typescript + * const experiments = useExperiments(); + * const featureEnabled = experiments?.features?.['my-feature']?.enabled; + * ``` + */ + +import React, { createContext, useContext, ReactNode, useEffect, useState, useMemo } from 'react'; +import { NimbusResult, initializeNimbus, NimbusContextT } from '../../lib/nimbus'; +import { AppContext } from './AppContext'; +import { useDynamicLocalization } from '../../contexts/DynamicLocalizationContext'; +import { parseAcceptLanguage } from '@fxa/shared/l10n'; +import { searchParams } from '../../lib/utilities'; +import * as Sentry from '@sentry/react'; + +interface NimbusApiResponse { + Features?: Record; + features?: Record; + nimbusUserId?: string; +} + +const NIMBUS_PREVIEW_PARAM = 'nimbusPreview'; +const SENTRY_TAGS = { + area: 'NimbusProvider', + component: 'NimbusContext' +} as const; + +export interface NimbusContextValue { + experiments: NimbusResult | null; + loading: boolean; + error?: Error; +} + +const NimbusContext = createContext(undefined); + +export function useNimbusContext() { + const context = useContext(NimbusContext); + if (context === undefined) { + // Return default values when no NimbusProvider is present + return { + experiments: null, + loading: false, + error: undefined, + }; + } + return context; +} + +export interface NimbusProviderProps { + children: ReactNode; +} + +export function NimbusProvider({ children }: NimbusProviderProps) { + const { config, uniqueUserId } = useContext(AppContext); + const { currentLocale } = useDynamicLocalization(); + + if (!config) { + throw new Error('NimbusProvider requires AppContext with config'); + } + + const [experiments, setExperiments] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + + useEffect(() => { + if (!config?.nimbus.enabled || !uniqueUserId) { + setExperiments(null); + setLoading(false); + setError(undefined); + return; + } + + let mounted = true; + setLoading(true); + + const fetchNimbusExperiments = async (): Promise => { + try { + const [locale] = parseAcceptLanguage( + currentLocale || navigator.languages.join(', ') + ); + let [language, region] = locale.split('-'); + if (region) { + region = region.toLowerCase(); + } + + const nimbusPreview = config.nimbus.preview + ? config.nimbus.preview + : searchParams(window.location.search)[NIMBUS_PREVIEW_PARAM] === 'true'; + + const nimbusResult = await initializeNimbus( + uniqueUserId, + nimbusPreview, + { + language, + region, + } as NimbusContextT + ); + + if (mounted) { + if (nimbusResult) { + const apiResponse = nimbusResult as NimbusApiResponse; + const features = apiResponse.Features || apiResponse.features; + setExperiments({ + features: features, + nimbusUserId: uniqueUserId, + } as NimbusResult); + } else { + setExperiments(null); + } + setLoading(false); + } + } catch (err) { + Sentry.captureException(err, { + tags: SENTRY_TAGS, + extra: { + uniqueUserId, + nimbusEnabled: config.nimbus.enabled, + previewMode: config.nimbus.preview + }, + }); + + if (mounted) { + setError(err instanceof Error ? err : new Error('Failed to fetch nimbus experiments')); + setLoading(false); + } + } + }; + + fetchNimbusExperiments(); + + return () => { + mounted = false; + }; + }, [config?.nimbus.enabled, config?.nimbus.preview, uniqueUserId, currentLocale]); + + const value: NimbusContextValue = useMemo(() => ({ + experiments, + loading, + error, + }), [experiments, loading, error]); + + return React.createElement(NimbusContext.Provider, { value }, children); +} diff --git a/packages/fxa-settings/src/models/hooks.ts b/packages/fxa-settings/src/models/hooks.ts index f32d6e8d119..81cbae84a33 100644 --- a/packages/fxa-settings/src/models/hooks.ts +++ b/packages/fxa-settings/src/models/hooks.ts @@ -5,6 +5,8 @@ import { useContext, useRef, useEffect, useMemo, useState } from 'react'; import { isHexadecimal, length } from 'class-validator'; import { AppContext } from './contexts/AppContext'; +import { useNimbusContext } from './contexts/NimbusContext'; +import { NimbusResult } from '../lib/nimbus'; import { INITIAL_SETTINGS_QUERY, SettingsContext, @@ -34,7 +36,6 @@ import { RelierSubscriptionInfo, RelierCmsInfo, } from './integrations'; -import { NimbusResult } from '../lib/nimbus'; import * as Sentry from '@sentry/browser'; import { useDynamicLocalization } from '../contexts/DynamicLocalizationContext'; @@ -128,34 +129,12 @@ export function useIntegration() { /** * A hook to provide the Nimbus experiments within components. - * This hook does not perform a network request. + * This hook uses the NimbusContext to get experiment data. * - * @returns the {@link NimbusResult} with experiment information. + * @returns the NimbusResult with experiment information, or null if not available. */ export function useExperiments(): NimbusResult | null { - const { experiments: experimentInfo, uniqueUserId } = useContext(AppContext); - const [experiments, setExperiments] = useState(null); - useEffect(() => { - async function fetchExperiments() { - if (experimentInfo) { - const exp = await experimentInfo; - if (exp) { - // Today, we don't need everything from the response so let's only add them as needed. - // We map out the response from the doc examples here: - // https://github.com/mozilla/experimenter/blob/main/cirrus/README.md - setExperiments({ - features: exp.Features, - // The ID we send and the one receive should be the same. - // There has been a case were a bug in Nimbus sent us different IDs, - // so for now, let us trust our own ID. - // See: https://github.com/mozilla/blurts-server/pull/5509 - nimbusUserId: uniqueUserId, - } as NimbusResult); - } - } - } - fetchExperiments(); - }, [experimentInfo, uniqueUserId]); + const { experiments } = useNimbusContext(); return experiments; }