Repository: mozilla/fxa
Pull request #18554: feat(settings): Add useExperiments hook and test experiment
Author: login: jonalmeida
Created: 2025-03-17T17:26:41Z | Merged: 2025-03-19T04:16:35Z

## Because

- We want to run an A/A experiment.

## This pull request

- Removes some duplication: there were two nimbus experiment files and one was not being referenced.
- Adds `nimbus_user_id` as an extra key that can add to metrics.
- Adds a new hook `useExperiments` that lets any user access all the experiments from anywhere in React.
- Adds an experiment to the `BentoView` by showing various smiling emojis next to the 'Made by Mozilla'.

## Issue that this pull request solves

Closes: FXA-11216

## Checklist

_Put an `x` in the boxes that apply_

- [x] My commit is GPG signed.
- [x] If applicable, I have modified or added tests which pass locally.
- [x] I have added necessary documentation (if appropriate).
- [x] I have verified that my changes render correctly in RTL (if appropriate).

## Screenshots (Optional)

<img width="399" alt="Screenshot 2025-03-17 at 1 24 26 PM" src="https://github.com/user-attachments/assets/203beacb-b132-4f1b-9b43-27235e96ea62" />

## Other information (Optional)

- I'd like to land these two commits separately so that the BentoMenu one could act as a reference patch on how to access an experiment and it's required glean changes.
- The glean metric today will not fetch the `nimbus_client_id` until that part is setup from the Nimbus side.


Diff:
diff --git a/_scripts/cirrus.sh b/_scripts/cirrus.sh
index 6e8a8e7a244..c64645a7a73 100755
--- a/_scripts/cirrus.sh
+++ b/_scripts/cirrus.sh
@@ -13,7 +13,7 @@ fi
 docker run --rm --name cirrus \
   --net fxa \
   --mount type=bind,source="$(pwd)/_scripts/configs/${CHANNEL_FILE},target=/cirrus/.env" \
-  --mount type=bind,source="$(pwd)/_scripts/configs/cirrus.fml.yml,target=/cirrus/feature_manifest/frontend-experiments.fml.yml" \
+  --mount type=bind,source="$(pwd)/configs/nimbus.yaml,target=/cirrus/feature_manifest/frontend-experiments.fml.yml" \
   --memory=1024m \
   -p 8001:8001 \
   mozilla/cirrus:sha-1275f51cb
diff --git a/_scripts/configs/cirrus.env.release b/_scripts/configs/cirrus.env.release
index 4fa2094dd96..23f6b5d17b1 100644
--- a/_scripts/configs/cirrus.env.release
+++ b/_scripts/configs/cirrus.env.release
@@ -7,5 +7,5 @@ CIRRUS_CHANNEL=release
 CIRRUS_FML_PATH=/cirrus/feature_manifest/frontend-experiments.fml.yml
 CIRRUS_SENTRY_DSN=
 CIRRUS_INSTANCE_NAME=cirrus_pod_app_v1
-CIRRUS_ENV_NAME=fxa_release
+CIRRUS_ENV_NAME=fxa_prod
 CIRRUS_GLEAN_MAX_EVENTS_BUFFER=10
diff --git a/_scripts/configs/cirrus.env.stage b/_scripts/configs/cirrus.env.stage
index f8ee16ac6ec..03711ed0780 100644
--- a/_scripts/configs/cirrus.env.stage
+++ b/_scripts/configs/cirrus.env.stage
@@ -1,5 +1,5 @@
-CIRRUS_REMOTE_SETTING_URL=https://firefox.settings.services.allizom.org/v1/buckets/main/collections/nimbus-web-experiments/records
-CIRRUS_REMOTE_SETTING_PREVIEW_URL=https://firefox.settings.services.allizom.org/v1/buckets/main/collections/nimbus-web-preview/records
+CIRRUS_REMOTE_SETTING_URL=https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/nimbus-web-experiments/records
+CIRRUS_REMOTE_SETTING_PREVIEW_URL=https://firefox.settings.services.mozilla.org/v1/buckets/main/collections/nimbus-web-preview/records
 CIRRUS_REMOTE_SETTING_REFRESH_RATE_IN_SECONDS=10
 CIRRUS_APP_ID=accounts.cirrus
 CIRRUS_APP_NAME=accounts_cirrus
diff --git a/_scripts/configs/cirrus.fml.yml b/_scripts/configs/cirrus.fml.yml
deleted file mode 100644
index 95025905a41..00000000000
--- a/_scripts/configs/cirrus.fml.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-about:
-  description: Nimbus Feature Manifest for FxA Frontend Experimentation
-channels:
-  - development
-  - stage
-  - release
-features:
-  example-feature:
-    description: An example feature
-    variables:
-      enabled:
-        description: If the feature is enabled
-        type: Boolean
-        default: false
-      something:
-        description: Another variable
-        type: Option<String>
-        default: null
-    defaults:
-      - channel: development
-        value: { "enabled": false }
-      - channel: stage
-        value: { "enabled": true }
-      - channel: release
-        value: { "something": "nice" }
-
-types:
-  objects: {}
-  enums: {}
diff --git a/configs/nimbus.yaml b/configs/nimbus.yaml
index 2bb37784172..18b18201dcc 100644
--- a/configs/nimbus.yaml
+++ b/configs/nimbus.yaml
@@ -1,22 +1,28 @@
 about:
   description: Nimbus Feature Manifest for Accounts Web testing
 channels:
+  - developer
+  - staging
   - production
 features:
   example-feature:
-    description: An example feature
+    description: An example feature.
     variables:
       enabled:
-        description: If the feature is enabled
+        description: If the feature is enabled.
         type: Boolean
         default: false
-      something:
-        description: Another variable
+      emoji:
+        description: An emoji to show for this treatment.
         type: Option<String>
         default: null
     defaults:
+      - channel: developer
+        value: { "enabled": true, "emoji": ":)" }
+      - channel: staging
+        value: { "enabled": false, "emoji": ";)" }
       - channel: production
-        value: { "something": "gnihtemos" }
+        value: { "enabled": false, "emoji": "❤️" }
 types:
   objects: {}
   enums: {}
diff --git a/packages/fxa-settings/src/components/Settings/BentoMenu/index.tsx b/packages/fxa-settings/src/components/Settings/BentoMenu/index.tsx
index 8daa38a71e2..021fcc7a273 100644
--- a/packages/fxa-settings/src/components/Settings/BentoMenu/index.tsx
+++ b/packages/fxa-settings/src/components/Settings/BentoMenu/index.tsx
@@ -17,7 +17,11 @@ import vpnIcon from './vpn-logo.svg';
 import { ReactComponent as BentoIcon } from './bento.svg';
 import { ReactComponent as CloseIcon } from '@fxa/shared/assets/images/close.svg';
 import { FtlMsg } from 'fxa-react/lib/utils';
-import { useConfig, useFtlMsgResolver } from '../../../models/hooks';
+import {
+  useConfig,
+  useExperiments,
+  useFtlMsgResolver,
+} from '../../../models/hooks';
 import { LINK } from '../../../constants';
 import { constructHrefWithUtm } from '../../../lib/utilities';
 import GleanMetrics from '../../../lib/glean';
@@ -33,18 +37,28 @@ export const BentoMenu = () => {
 
   const { env } = useConfig();
   const ftlMsgResolver = useFtlMsgResolver();
+  const experiments = useExperiments();
+  const nimbusUserId = experiments?.nimbusUserId;
+  const exampleFeature = experiments?.features?.['example-feature'];
+  const emojiExperiment = exampleFeature?.['emoji'] || '';
 
   useEffect(() => {
     if (isRevealed) {
-      GleanMetrics.accountPref.bentoView();
+      GleanMetrics.accountPref.bentoView({
+        event: { nimbusUserId },
+      });
     }
-  }, [isRevealed]);
+  }, [isRevealed, nimbusUserId]);
 
   const bentoMenuTitle = ftlMsgResolver.getMsg(
     'bento-menu-title-3',
     'Mozilla products'
   );
 
+  const madeByMozilla =
+    ftlMsgResolver.getMsg('bento-menu-made-by-mozilla', 'Made by Mozilla') +
+    ` ${emojiExperiment}`;
+
   const desktopLink = constructHrefWithUtm(
     LINK.FX_DESKTOP,
     'mozilla-websites',
@@ -218,15 +232,13 @@ export const BentoMenu = () => {
                   </li>
                 </ul>
               </div>
-              <FtlMsg id="bento-menu-made-by-mozilla">
-                <LinkExternal
-                  data-testid="mozilla-link"
-                  className="link-blue text-xs underline-offset-4 w-full text-center p-2 block hover:bg-grey-100"
-                  href="https://www.mozilla.org/"
-                >
-                  Made by Mozilla
-                </LinkExternal>
-              </FtlMsg>
+              <LinkExternal
+                data-testid="mozilla-link"
+                className="link-blue text-xs underline-offset-4 w-full text-center p-2 block hover:bg-grey-100"
+                href="https://www.mozilla.org/"
+              >
+                {madeByMozilla}
+              </LinkExternal>
             </div>
           </div>
         </div>
diff --git a/packages/fxa-settings/src/lib/glean/index.ts b/packages/fxa-settings/src/lib/glean/index.ts
index 1a94d762e27..6d498935cd0 100644
--- a/packages/fxa-settings/src/lib/glean/index.ts
+++ b/packages/fxa-settings/src/lib/glean/index.ts
@@ -482,7 +482,9 @@ const recordEventMetric = (
       accountPref.help.record();
       break;
     case 'account_pref_bento_view':
-      accountPref.bentoView.record();
+      accountPref.bentoView.record({
+        nimbus_user_id: gleanPingMetrics?.event?.['nimbusUserId'] || '',
+      });
       break;
     case 'account_pref_bento_firefox_desktop':
       accountPref.bentoFirefoxDesktop.record();
diff --git a/packages/fxa-settings/src/lib/nimbus/index.ts b/packages/fxa-settings/src/lib/nimbus/index.ts
index 18637f2576a..0df7d564a56 100644
--- a/packages/fxa-settings/src/lib/nimbus/index.ts
+++ b/packages/fxa-settings/src/lib/nimbus/index.ts
@@ -13,6 +13,14 @@ export type NimbusContextT = {
   region: string | null;
 };
 
+/**
+ * The nimbus experiments and enrollment information needed for applying a feature experiment.
+ */
+export interface NimbusResult {
+  features: Record<string, any>;
+  nimbusUserId: string;
+}
+
 /**
  * Initializes Nimbus in the React app. This should be done before the first render
  * so that experiments can be applied during first initialization.
@@ -24,7 +32,7 @@ export type NimbusContextT = {
 export async function initializeNimbus(
   clientId: string,
   context: NimbusContextT
-) {
+): Promise<any> {
   const MAX_TIMEOUT_MS = 1000;
   const body = JSON.stringify({
     client_id: clientId,
@@ -49,12 +57,12 @@ export async function initializeNimbus(
       return;
     }
 
-    experiments = await resp.json();
+    experiments = resp.json();
   } catch (err) {
     Sentry.withScope(() => {
       let errorMsg = 'Experiment fetch error';
       if (err.name === 'TimeoutError') {
-        errorMsg = `Timeout: It took more than ${MAX_TIMEOUT_MS} milliseconds to get the result - this is render blocking!`;
+        errorMsg = `Timeout: It took more than ${MAX_TIMEOUT_MS} milliseconds to get the result!`;
       }
       Sentry.captureMessage(errorMsg, 'error');
     });
diff --git a/packages/fxa-settings/src/models/contexts/AppContext.ts b/packages/fxa-settings/src/models/contexts/AppContext.ts
index 81b38e57b84..4004959a871 100644
--- a/packages/fxa-settings/src/models/contexts/AppContext.ts
+++ b/packages/fxa-settings/src/models/contexts/AppContext.ts
@@ -28,7 +28,7 @@ export interface AppContextValue {
   account?: Account;
   session?: Session;
   uniqueUserId?: string; // used for experiments
-  experiments?: any; // TODO: add types for experiments
+  experiments?: Promise<any>; // external response; not adding types
 }
 
 export interface SettingsContextValue {
@@ -36,7 +36,15 @@ export interface SettingsContextValue {
   navigatorLanguages?: readonly string[];
 }
 
-function fetchNimbusExperiments(uniqueUserId: 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<any> {
   // 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
diff --git a/packages/fxa-settings/src/models/hooks.ts b/packages/fxa-settings/src/models/hooks.ts
index 8e4cbdb61fb..0121420c07e 100644
--- a/packages/fxa-settings/src/models/hooks.ts
+++ b/packages/fxa-settings/src/models/hooks.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 { useContext, useRef, useEffect, useMemo } from 'react';
+import { useContext, useRef, useEffect, useMemo, useState } from 'react';
 import { isHexadecimal, length } from 'class-validator';
 import { AppContext } from './contexts/AppContext';
 import {
@@ -30,6 +30,7 @@ import {
   SignedInAccountStatus,
 } from '../components/App/interfaces';
 import { RelierClientInfo, RelierSubscriptionInfo } from './integrations';
+import { NimbusResult } from '../lib/nimbus';
 
 export function useAccount() {
   const { account } = useContext(AppContext);
@@ -104,13 +105,35 @@ export function useIntegration() {
   }, [clientInfoState, productInfoState]);
 }
 
-export function useExperiments() {
-  const { experiments } = useContext(AppContext);
-  const { Features, Enrollments } = experiments;
-  if (!Features || !Enrollments) {
-    return {};
-  }
-  return { features: Features, enrollments: Enrollments };
+/**
+ * A hook to provide the Nimbus experiments within components.
+ * This hook does not perform a network request.
+ *
+ * @returns the {@link NimbusResult} with experiment information.
+ */
+export function useExperiments(): NimbusResult | null {
+  const { experiments: experimentInfo, uniqueUserId } = useContext(AppContext);
+  const [experiments, setExperiments] = useState<null | NimbusResult>(null);
+  useEffect(() => {
+    async function fetchExperiments() {
+      if (experimentInfo) {
+        const exp = await experimentInfo;
+        // 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]);
+  return experiments;
 }
 
 export function useSession() {
diff --git a/packages/fxa-settings/src/models/mocks.tsx b/packages/fxa-settings/src/models/mocks.tsx
index 23579e868d9..2e32b56cc08 100644
--- a/packages/fxa-settings/src/models/mocks.tsx
+++ b/packages/fxa-settings/src/models/mocks.tsx
@@ -165,6 +165,22 @@ export const MOCK_PROFILE_INFO: ProfileInfo = {
   emails: MOCK_ACCOUNT.emails,
 };
 
+function mockExperiment() {
+  return Promise.resolve({
+    Features: {
+      'example-feature': {
+        enabled: true,
+        emoji: ':)',
+      },
+    },
+    Enrollments: [
+      {
+        nimbus_user_id: '4a9512ac-3110-43df-aa8a-958A3d210b9c3',
+      },
+    ],
+  });
+}
+
 export function mockAppContext(context?: AppContextValue) {
   return Object.assign(
     {
@@ -173,6 +189,7 @@ export function mockAppContext(context?: AppContextValue) {
       config: getDefault(),
       sensitiveDataClient: mockSensitiveDataClient(),
       uniqueUserId: '4a9512ac-3110-43df-aa8a-958A3d210b9c3',
+      experiments: mockExperiment(),
     },
     context
   ) as AppContextValue;
diff --git a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml
index f0127178cde..81aea29e70e 100644
--- a/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml
+++ b/packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml
@@ -57,6 +57,25 @@ event:
     data_sensitivity:
       - interaction
 
+  nimbus_user_id:
+    type: string
+    description: |
+      The name of the client ID for Nimbus experiments.
+    lifetime: ping
+    send_in_pings:
+      - accounts-events
+    notification_emails:
+      - vzare@mozilla.com
+      - fxa-staff@mozilla.com
+    bugs:
+      - https://mozilla-hub.atlassian.net/browse/FXA-11216
+    data_reviews:
+      - https://bugzilla.mozilla.org/show_bug.cgi?id=1830504
+      - https://bugzilla.mozilla.org/show_bug.cgi?id=1844121
+    expires: never
+    data_sensitivity:
+      - interaction
+
 account:
   user_id:
     type: string
@@ -2498,6 +2517,10 @@ account_pref:
     expires: never
     data_sensitivity:
       - interaction
+    extra_keys:
+      nimbus_user_id:
+        description: Nimbus user ID
+        type: string
   bento_firefox_desktop:
     type: event
     description: |
diff --git a/packages/fxa-shared/metrics/glean/web/accountPref.ts b/packages/fxa-shared/metrics/glean/web/accountPref.ts
index 8dce907d093..665347256c1 100644
--- a/packages/fxa-shared/metrics/glean/web/accountPref.ts
+++ b/packages/fxa-shared/metrics/glean/web/accountPref.ts
@@ -145,7 +145,9 @@ export const bentoRelay = new EventMetricType(
  *
  * Generated from `account_pref.bento_view`.
  */
-export const bentoView = new EventMetricType(
+export const bentoView = new EventMetricType<{
+  nimbus_user_id?: string;
+}>(
   {
     category: 'account_pref',
     name: 'bento_view',
@@ -153,7 +155,7 @@ export const bentoView = new EventMetricType(
     lifetime: 'ping',
     disabled: false,
   },
-  []
+  ['nimbus_user_id']
 );
 
 /**
diff --git a/packages/fxa-shared/metrics/glean/web/event.ts b/packages/fxa-shared/metrics/glean/web/event.ts
index e0bbcb809de..e4a670ea6d1 100644
--- a/packages/fxa-shared/metrics/glean/web/event.ts
+++ b/packages/fxa-shared/metrics/glean/web/event.ts
@@ -20,6 +20,19 @@ export const name = new StringMetricType({
   disabled: false,
 });
 
+/**
+ * The name of the client ID for Nimbus experiments.
+ *
+ * Generated from `event.nimbus_user_id`.
+ */
+export const nimbusUserId = new StringMetricType({
+  category: 'event',
+  name: 'nimbus_user_id',
+  sendInPings: ['accounts-events'],
+  lifetime: 'ping',
+  disabled: false,
+});
+
 /**
  * additional context-dependent (on event.name) info, e.g. the cause of an error
  *
diff --git a/packages/fxa-shared/metrics/glean/web/index.ts b/packages/fxa-shared/metrics/glean/web/index.ts
index 29dc613d21f..a125261310d 100644
--- a/packages/fxa-shared/metrics/glean/web/index.ts
+++ b/packages/fxa-shared/metrics/glean/web/index.ts
@@ -21,7 +21,7 @@ export type GleanMetricsConfig = {
 };
 
 export const booleanEventPropertyNames = ['thirdPartyLinks'] as const;
-export const stringEventPropertyNames = ['reason'] as const;
+export const stringEventPropertyNames = ['reason', 'nimbusUserId'] as const;
 
 export type PropertyNameStringT = typeof stringEventPropertyNames;
 export type PropertyNameString = PropertyNameStringT[number];


Reviews:
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T17:28:08Z
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T18:29:38Z
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T01:01:52Z
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T01:08:51Z
user: login: LZoog
body: Works as expected 👍 
<img width="243" alt="image" src="https://github.com/user-attachments/assets/a00fbd60-6268-47b2-aad3-d09198d943c4" />

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T18:19:20Z
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T02:53:44Z
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T02:55:16Z
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T03:11:29Z
user: login: jonalmeida
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T03:16:18Z

Review comments:
diff_hunk: @@ -33,18 +37,28 @@ export const BentoMenu = () => {
 
   const { env } = useConfig();
   const ftlMsgResolver = useFtlMsgResolver();
+  const experiments = useExperiments();
+  const nimbusUserId = experiments?.nimbusUserId;
+  const exampleFeature = experiments?.features?.['example-feature'];
+  const emojiExperiment = exampleFeature && exampleFeature?.['emoji'];
 
   useEffect(() => {
     if (isRevealed) {
-      GleanMetrics.accountPref.bentoView();
+      GleanMetrics.accountPref.bentoView({
+        event: { nimbusUserId },
+      });
path: packages/fxa-settings/src/components/Settings/BentoMenu/index.tsx
user: login: jonalmeida
body: Related to the second additional notes comment, I assigned a test `nimbusUserId` within the `useExperiments` hook so that I could verify the extra was added:

<img width="353" alt="Screenshot 2025-03-17 at 1 20 18 PM" src="https://github.com/user-attachments/assets/cbf835a3-4d82-4fcb-8912-2bace13bfd5a" />

created_at: 2025-03-17T17:28:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,13 +105,29 @@ export function useIntegration() {
   }, [clientInfoState, productInfoState]);
 }
 
-export function useExperiments() {
-  const { experiments } = useContext(AppContext);
-  const { Features, Enrollments } = experiments;
-  if (!Features || !Enrollments) {
-    return {};
-  }
-  return { features: Features, enrollments: Enrollments };
+/**
+ * A hook to provide the Nimbus experiments within components.
+ * This hook does not perform a network request.
+ *
+ * @returns the {@link NimbusResult} with experiement information.
+ */
+export function useExperiments(): NimbusResult | null {
+  const { experiments: experimentInfo } = useContext(AppContext) as {
+    experiments: Promise<any>;
+  };
+  const [experiments, setExperiments] = useState<null | NimbusResult>(null);
+  useEffect(() => {
+    experimentInfo.then((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,
+        nimbusUserId: exp.Enrollments.nimbus_user_id || '',
+      } as NimbusResult);
path: packages/fxa-settings/src/models/hooks.ts
user: login: jonalmeida
body: This mapping is also to remove the confusing capitalization.

Context: https://github.com/mozilla/fxa/pull/18524#discussion_r1990523832
created_at: 2025-03-17T18:29:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,15 +28,15 @@ export interface AppContextValue {
   account?: Account;
   session?: Session;
   uniqueUserId?: string; // used for experiments
-  experiments?: any; // TODO: add types for experiments
+  experiments?: Promise<any>; // TODO: add types for experiments
path: packages/fxa-settings/src/models/contexts/AppContext.ts
user: login: jonalmeida
body: I don't really want to spend the effort of typing this response since it's this external response that feels like it should come with it's own published types. Since we're mapping it out into `NimbusResult` in our hook, I think that is worth more time.
created_at: 2025-03-18T01:01:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,6 +57,25 @@ event:
     data_sensitivity:
       - interaction
 
+  nimbus_user_id:
path: packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml
user: login: jonalmeida
body: Not part of this change, but this event along with the other three (name/reason/third_party_links) should not have been created. They are meant to be extra keys in metric events, but the way our metrics are setup (see: `stringEventPropertyNames` and `booleanEventPropertyNames`) means that we have to generate these events even though we will never send them (!).
created_at: 2025-03-18T01:08:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,22 +1,28 @@
 about:
   description: Nimbus Feature Manifest for Accounts Web testing
 channels:
-  - production
+  - development
+  - stage
+  - release
 features:
   example-feature:
-    description: An example feature
+    description: An example feature.
     variables:
       enabled:
-        description: If the feature is enabled
+        description: If the feature is enabled.
         type: Boolean
         default: false
-      something:
-        description: Another variable
+      emoji:
+        description: An emoji to show for this treatment.
         type: Option<String>
         default: null
     defaults:
-      - channel: production
-        value: { "something": "gnihtemos" }
+      - channel: development
+        value: { "enabled": true, "emoji": ":)" }
+      - channel: stage
+        value: { "enabled": false, "emoji": ";)" }
+      - channel: release
+        value: { "enabled": false, "emoji": "❤️" }
path: configs/nimbus.yaml
user: login: LZoog
body: The best experiment choice 😆

I guess this means if we ever need to turn an experiment off, it's a code change. Is it possible or are there plans to set these to feature flags which I mostly wonder about since a full redeploy takes so long?
created_at: 2025-03-18T16:58:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,18 +37,28 @@ export const BentoMenu = () => {
 
   const { env } = useConfig();
   const ftlMsgResolver = useFtlMsgResolver();
+  const experiments = useExperiments();
+  const nimbusUserId = experiments?.nimbusUserId;
+  const exampleFeature = experiments?.features?.['example-feature'];
+  const emojiExperiment = exampleFeature?.['emoji'] || '';
 
   useEffect(() => {
     if (isRevealed) {
-      GleanMetrics.accountPref.bentoView();
+      GleanMetrics.accountPref.bentoView({
+        event: { nimbusUserId },
+      });
     }
-  }, [isRevealed]);
+  }, [isRevealed, nimbusUserId]);
 
   const bentoMenuTitle = ftlMsgResolver.getMsg(
     'bento-menu-title-3',
     'Mozilla products'
   );
 
+  const madeByMozilla =
+    ftlMsgResolver.getMsg('bento-menu-made-by-mozilla', 'Made by Mozilla') +
path: packages/fxa-settings/src/components/Settings/BentoMenu/index.tsx
user: login: LZoog
body: If we conditionally display some copy from the experiment that needs to be localized, what's the game plan there? Will we always know what copy or options there may be ahead of time? 🤔
created_at: 2025-03-18T17:10:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,15 +28,15 @@ export interface AppContextValue {
   account?: Account;
   session?: Session;
   uniqueUserId?: string; // used for experiments
-  experiments?: any; // TODO: add types for experiments
+  experiments?: Promise<any>; // TODO: add types for experiments
path: packages/fxa-settings/src/models/contexts/AppContext.ts
user: login: LZoog
body: Might be worthwhile to slap a similar comment above the other places we use `Promise<any>`, I agree it makes sense for us to use a type they provide. I thought maybe `initializeNimbus` could get type `Promise<undefined | NimbusResult>` but I see `NimbusResult` comes after we call `experimentInfo`.
created_at: 2025-03-18T17:12:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,13 +105,31 @@ export function useIntegration() {
   }, [clientInfoState, productInfoState]);
 }
 
-export function useExperiments() {
-  const { experiments } = useContext(AppContext);
-  const { Features, Enrollments } = experiments;
-  if (!Features || !Enrollments) {
-    return {};
-  }
-  return { features: Features, enrollments: Enrollments };
+/**
+ * A hook to provide the Nimbus experiments within components.
+ * This hook does not perform a network request.
+ *
+ * @returns the {@link NimbusResult} with experiment information.
+ */
+export function useExperiments(): NimbusResult | null {
+  const { experiments: experimentInfo, uniqueUserId } = useContext(AppContext);
+  const [experiments, setExperiments] = useState<null | NimbusResult>(null);
+  useEffect(() => {
+    experimentInfo?.then((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);
+    });
+  }, [experimentInfo, uniqueUserId]);
path: packages/fxa-settings/src/models/hooks.ts
user: login: LZoog
body: Just a nit for async/await preference:
```
  useEffect(() => {
    async function fetchExperiments() {
      if (experimentInfo) {
        const exp = await experimentInfo;
        setExperiments({
          features: exp.Features,
          nimbusUserId: uniqueUserId,
        } as NimbusResult);
      }
    }
    fetchExperiments();
  }, [experimentInfo, uniqueUserId]);

```
created_at: 2025-03-18T17:29:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,6 +57,25 @@ event:
     data_sensitivity:
       - interaction
 
+  nimbus_user_id:
path: packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml
user: login: LZoog
body: Thanks for the call out, I think this is expected under the `event` umbrella for anything "extra" we need since top level the options are `name`, `reason` (which we've used for all needed extra info strings), and then I'd added `third_party_links` copying what `reason` had when we wanted a boolean. I don't think it's technically an "event" this way but 🤷‍♀️  It is interesting/confusing.
![image](https://github.com/user-attachments/assets/f51c2253-f99f-4b26-b6bf-c4aa0d8c9724)
created_at: 2025-03-18T17:39:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,6 +165,22 @@ export const MOCK_PROFILE_INFO: ProfileInfo = {
   emails: MOCK_ACCOUNT.emails,
 };
 
+function mockExperiment() {
path: packages/fxa-settings/src/models/mocks.tsx
user: login: LZoog
body: I think we are blocked on Storybook 8 upgrade, but we should be able to use these in Storybook too. It might make sense (not in your PR) for us to have a new high level section called "Experiments" so we can show known variations. Currently unsure how useful that upkeep will be, maybe it depends on the experiment.
created_at: 2025-03-18T18:18:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,22 +1,28 @@
 about:
   description: Nimbus Feature Manifest for Accounts Web testing
 channels:
-  - production
+  - development
+  - stage
+  - release
 features:
   example-feature:
-    description: An example feature
+    description: An example feature.
     variables:
       enabled:
-        description: If the feature is enabled
+        description: If the feature is enabled.
         type: Boolean
         default: false
-      something:
-        description: Another variable
+      emoji:
+        description: An emoji to show for this treatment.
         type: Option<String>
         default: null
     defaults:
-      - channel: production
-        value: { "something": "gnihtemos" }
+      - channel: development
+        value: { "enabled": true, "emoji": ":)" }
+      - channel: stage
+        value: { "enabled": false, "emoji": ";)" }
+      - channel: release
+        value: { "enabled": false, "emoji": "❤️" }
path: configs/nimbus.yaml
user: login: jonalmeida
body: No, if you need to turn an experiment off, we would then be able to do it in [Experimenter](https://experimenter.services.mozilla.com/) that would change this value via the cirrus container.

You can also have defaults like the Monitor server [has done here](https://github.com/mozilla/blurts-server/blob/84b8f606c916b9cd92dab3048de7d573e60bbca1/config/nimbus.yaml#L19-L25) too, ending the experiment via Experimenter would then trigger the default.
created_at: 2025-03-19T02:53:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,18 +37,28 @@ export const BentoMenu = () => {
 
   const { env } = useConfig();
   const ftlMsgResolver = useFtlMsgResolver();
+  const experiments = useExperiments();
+  const nimbusUserId = experiments?.nimbusUserId;
+  const exampleFeature = experiments?.features?.['example-feature'];
+  const emojiExperiment = exampleFeature?.['emoji'] || '';
 
   useEffect(() => {
     if (isRevealed) {
-      GleanMetrics.accountPref.bentoView();
+      GleanMetrics.accountPref.bentoView({
+        event: { nimbusUserId },
+      });
     }
-  }, [isRevealed]);
+  }, [isRevealed, nimbusUserId]);
 
   const bentoMenuTitle = ftlMsgResolver.getMsg(
     'bento-menu-title-3',
     'Mozilla products'
   );
 
+  const madeByMozilla =
+    ftlMsgResolver.getMsg('bento-menu-made-by-mozilla', 'Made by Mozilla') +
path: packages/fxa-settings/src/components/Settings/BentoMenu/index.tsx
user: login: jonalmeida
body: Good question! I hear that only Desktop has support for localization in Experiments, so we would not be able to localize experiment text.

Typically, what I have seen is that you ship both the strings in your codebase so that they can be localized, and the experiment will then switch between the two.
created_at: 2025-03-19T02:55:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,6 +57,25 @@ event:
     data_sensitivity:
       - interaction
 
+  nimbus_user_id:
path: packages/fxa-shared/metrics/glean/fxa-ui-metrics.yaml
user: login: jonalmeida
body: I don't believe this is true, but I could be wrong. 'event' is not a special category of metrics AFAIK. 

If I were to pick out a random metric on another dictionary.m.o app like this one, there are a ton of extras there like `addon_id` but they do not show up as individual metric events at the top-level of that metric file.

Here is the app-level metrics file that has an event category with events that have extras, but really it could be any name instead of event: https://searchfox.org/mozilla-central/source/mobile/android/fenix/app/metrics.yaml
created_at: 2025-03-19T03:11:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,6 +165,22 @@ export const MOCK_PROFILE_INFO: ProfileInfo = {
   emails: MOCK_ACCOUNT.emails,
 };
 
+function mockExperiment() {
path: packages/fxa-settings/src/models/mocks.tsx
user: login: jonalmeida
body: Yeah, that sounds like a lot of upkeep on initial thought. I'll file a bug to consider this though.
created_at: 2025-03-19T03:16:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: jonalmeida
created_at: 2025-03-17T21:36:56Z
body: ~~Ah, I need to fix the mocks for the integration tests.~~

Fixed: Missing types on the `AppContext` but added a `mockExperiment` as well for future testing.
author_association: CONTRIBUTOR
user: login: jonalmeida
created_at: 2025-03-19T03:42:29Z
body: Thanks for the questions, as part of writing docs for this, I'm going to include your questions in them too.

I added two changes that you haven't reviewed, but should be safe to land:
- The channel names have to match what is in [experimenter here](https://github.com/mozilla/experimenter/blob/ff8f2da57d989591ee18b8f1c8937deb998fa7e6/experimenter/experimenter/experiments/constants.py#L25-L27). Although it's not clear why blurts-server choose to do something different with "local".
- The Nimbus engineers advised using the production nimbus servers for both stage and prod FxA environments.
author_association: CONTRIBUTOR