From da61ea678ea7316821530ec460f3301ae2bcca07 Mon Sep 17 00:00:00 2001 From: Reino Muhl <10620585+StaberindeZA@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:17:33 -0400 Subject: [PATCH] feat(next): add experiments to payments-next Because: - Need to enable experiments in payments-next by adding support for nimbus. This commit: - Initializes the experiments shared library - Updates subplat backend glean metrics with nimbus_user_id - Adds Nimbus client Closes #PAY-3248 --- _scripts/cirrus.sh | 5 +- apps/payments/next/.env | 9 + .../checkout/[cartId]/start/page.tsx | 4 +- .../[locale]/subscriptions/manage/page.tsx | 698 +++++++++--------- apps/payments/next/middleware.ts | 16 + .../events/src/lib/emitter.factories.ts | 12 +- .../events/src/lib/emitter.service.spec.ts | 34 + .../events/src/lib/emitter.service.ts | 23 + libs/payments/events/src/lib/emitter.types.ts | 7 +- .../lib/util/retrieveAdditionalMetricsData.ts | 4 + .../retrieveAdditionalMetricsdata.spec.ts | 5 + libs/payments/experiments/.eslintrc.json | 18 + libs/payments/experiments/.swcrc | 15 + libs/payments/experiments/README.md | 11 + libs/payments/experiments/jest.config.ts | 43 ++ libs/payments/experiments/package.json | 9 + libs/payments/experiments/project.json | 29 + libs/payments/experiments/src/index.ts | 8 + .../experiments/src/lib/nimbus.factories.ts | 30 + .../src/lib/nimbus.manager.config.ts | 25 + .../src/lib/nimbus.manager.spec.ts | 156 ++++ .../experiments/src/lib/nimbus.manager.ts | 52 ++ .../experiments/src/lib/nimbus.types.ts | 17 + libs/payments/experiments/tsconfig.json | 23 + libs/payments/experiments/tsconfig.lib.json | 10 + libs/payments/experiments/tsconfig.spec.json | 15 + .../metrics/src/lib/glean/glean.factory.ts | 10 + .../metrics/src/lib/glean/glean.manager.ts | 12 +- .../metrics/src/lib/glean/glean.types.ts | 12 +- .../src/lib/actions/getExperimentsAction.ts | 31 + libs/payments/ui/src/lib/actions/index.ts | 1 + .../payments/ui/src/lib/nestapp/app.module.ts | 4 + libs/payments/ui/src/lib/nestapp/config.ts | 12 + .../src/lib/nestapp/nextjs-actions.service.ts | 36 +- .../validators/GetExperimentsActionArgs.ts | 21 + .../validators/GetExperimentsActionResult.ts | 19 + .../nestapp/validators/RecordEmitterEvent.ts | 3 + .../src/lib/utils/getAdditionalRequestArgs.ts | 3 + .../src/lib/stripe-event.manager.spec.ts | 12 + .../lib/stripe-webhooks.controller.spec.ts | 12 + .../src/lib/stripe-webhooks.service.spec.ts | 12 + .../lib/subscription-handler.service.spec.ts | 12 + libs/shared/experiments/.eslintrc.json | 18 + libs/shared/experiments/.swcrc | 15 + libs/shared/experiments/README.md | 12 + libs/shared/experiments/jest.config.ts | 43 ++ libs/shared/experiments/package.json | 9 + libs/shared/experiments/project.json | 29 + libs/shared/experiments/src/index.ts | 6 + .../experiments/src/lib/nimbus.client.spec.ts | 124 ++++ .../experiments/src/lib/nimbus.client.ts | 80 ++ .../experiments/src/lib/nimbus.config.ts | 29 + .../experiments/src/lib/nimbus.errors.ts | 46 ++ .../experiments/src/lib/nimbus.factories.ts | 38 + .../experiments/src/lib/nimbus.types.ts | 32 + .../src/lib/utils/generateNimbusId.spec.ts | 21 + .../src/lib/utils/generateNimbusId.ts | 13 + libs/shared/experiments/tsconfig.json | 23 + libs/shared/experiments/tsconfig.lib.json | 10 + libs/shared/experiments/tsconfig.spec.json | 15 + tsconfig.base.json | 2 + 61 files changed, 1701 insertions(+), 354 deletions(-) create mode 100644 libs/payments/experiments/.eslintrc.json create mode 100644 libs/payments/experiments/.swcrc create mode 100644 libs/payments/experiments/README.md create mode 100644 libs/payments/experiments/jest.config.ts create mode 100644 libs/payments/experiments/package.json create mode 100644 libs/payments/experiments/project.json create mode 100644 libs/payments/experiments/src/index.ts create mode 100644 libs/payments/experiments/src/lib/nimbus.factories.ts create mode 100644 libs/payments/experiments/src/lib/nimbus.manager.config.ts create mode 100644 libs/payments/experiments/src/lib/nimbus.manager.spec.ts create mode 100644 libs/payments/experiments/src/lib/nimbus.manager.ts create mode 100644 libs/payments/experiments/src/lib/nimbus.types.ts create mode 100644 libs/payments/experiments/tsconfig.json create mode 100644 libs/payments/experiments/tsconfig.lib.json create mode 100644 libs/payments/experiments/tsconfig.spec.json create mode 100644 libs/payments/ui/src/lib/actions/getExperimentsAction.ts create mode 100644 libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionArgs.ts create mode 100644 libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionResult.ts create mode 100644 libs/shared/experiments/.eslintrc.json create mode 100644 libs/shared/experiments/.swcrc create mode 100644 libs/shared/experiments/README.md create mode 100644 libs/shared/experiments/jest.config.ts create mode 100644 libs/shared/experiments/package.json create mode 100644 libs/shared/experiments/project.json create mode 100644 libs/shared/experiments/src/index.ts create mode 100644 libs/shared/experiments/src/lib/nimbus.client.spec.ts create mode 100644 libs/shared/experiments/src/lib/nimbus.client.ts create mode 100644 libs/shared/experiments/src/lib/nimbus.config.ts create mode 100644 libs/shared/experiments/src/lib/nimbus.errors.ts create mode 100644 libs/shared/experiments/src/lib/nimbus.factories.ts create mode 100644 libs/shared/experiments/src/lib/nimbus.types.ts create mode 100644 libs/shared/experiments/src/lib/utils/generateNimbusId.spec.ts create mode 100644 libs/shared/experiments/src/lib/utils/generateNimbusId.ts create mode 100644 libs/shared/experiments/tsconfig.json create mode 100644 libs/shared/experiments/tsconfig.lib.json create mode 100644 libs/shared/experiments/tsconfig.spec.json diff --git a/_scripts/cirrus.sh b/_scripts/cirrus.sh index c64645a7a7..daa34a397f 100755 --- a/_scripts/cirrus.sh +++ b/_scripts/cirrus.sh @@ -2,7 +2,10 @@ echo -e "Starting cirrus experimenter." +source "$(pwd)/.env" + CHANNEL_FILE="cirrus.env.development" +EXPERIMENTS_FML="${NIMBUS_FML_FILE:-configs/nimbus.yaml}" if [[ "${NIMBUS_CIRRUS_CHANNEL}" == "release" ]]; then CHANNEL_FILE="cirrus.env.release" @@ -13,7 +16,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)/configs/nimbus.yaml,target=/cirrus/feature_manifest/frontend-experiments.fml.yml" \ + --mount type=bind,source="$(pwd)/${EXPERIMENTS_FML},target=/cirrus/feature_manifest/frontend-experiments.fml.yml" \ --memory=1024m \ -p 8001:8001 \ mozilla/cirrus:sha-1275f51cb diff --git a/apps/payments/next/.env b/apps/payments/next/.env index e3f5ac31fd..1c88bec025 100644 --- a/apps/payments/next/.env +++ b/apps/payments/next/.env @@ -145,6 +145,15 @@ STRIPE_EVENTS_CONFIG__FIRESTORE_STRIPE_EVENT_STORE_COLLECTION_NAME=stripeEvents # Feature flags FEATURE_FLAG_SUB_MANAGE=true +# Nimbus Client +NIMBUS_CLIENT__API_URL=http://localhost:8001/v2/features/ +NIMBUS_CLIENT__PREVIEW_ENABLED= +NIMBUS_CLIENT__TIMEOUT_MS=100 + +# Nimbus Manager +NIMBUS_MANAGER__ENABLED=true +NIMBUS_MANAGER__NAMESPACE=e0066f05-3967-4f6e-8492-03933512611a + # Other CONTENT_SERVER_URL=http://localhost:3030 SUPPORT_URL=https://support.mozilla.org diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/start/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/start/page.tsx index 285a03a647..a082fe7540 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/start/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/start/page.tsx @@ -226,7 +226,7 @@ export default async function Checkout({ className={clsx( 'font-semibold text-grey-600 text-lg mt-10 mb-5', !session?.user?.email && - 'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none' + 'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none' )} data-testid="header-prefix" > @@ -251,7 +251,7 @@ export default async function Checkout({ className={clsx( 'font-semibold text-grey-600 text-start', !session?.user?.email && - 'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none' + 'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none' )} > {l10n.getString( diff --git a/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx b/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx index 1e31fb02f2..ec29013a87 100644 --- a/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx +++ b/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx @@ -17,7 +17,7 @@ import { ManageParams, SubscriptionContent, } from '@fxa/payments/ui'; -import { getSubManPageContentAction } from '@fxa/payments/ui/actions'; +import { getExperimentsAction, getSubManPageContentAction } from '@fxa/payments/ui/actions'; import { getApp } from '@fxa/payments/ui/server'; import alertIcon from '@fxa/shared/assets/images/alert-yellow.svg'; import arrowDownIcon from '@fxa/shared/assets/images/arrow-down.svg'; @@ -49,6 +49,11 @@ export default async function Manage({ const userId = session.user.id; + const experiments = await getExperimentsAction({ + fxaUid: userId, + language: locale, + }) + const { accountCreditBalance, defaultPaymentMethod, @@ -77,6 +82,19 @@ export default async function Manage({ className="w-full tablet:px-8 desktop:max-w-[1024px]" aria-labelledby="subscription-management" > + {experiments?.Features['welcome-feature']?.enabled && ( + +
+

+ Welcome +

+ +

+ Welcome to the new and redesigned Subscription Management Page! +

+
+
+ )} {isPaypalBillingAgreementError && (
@@ -148,46 +166,25 @@ export default async function Manage({ {(subscriptions.length > 0 || appleIapSubscriptions.length > 0 || googleIapSubscriptions.length > 0) && ( - + )}
0 || appleIapSubscriptions.length > 0 || googleIapSubscriptions.length > 0) && ( - <> -
    - {subscriptions.map((sub, index: number) => { - return ( -
  • -
    -
    -
    - {sub.productName} -
    -
    -
    -
    -

    - {sub.productName} -

    -

    - {sub.interval && - formatPlanInterval(sub.interval)} -

    -
    - - - {l10n.getString( - 'subscription-management-button-support', - 'Get help' - )} - - -
    - -
    -
    -
    -
  • - ); - })} -
- - {appleIapSubscriptions.length > 0 && ( + <>
    - {appleIapSubscriptions.map((purchase, index: number) => { - let nextBillDate: string | undefined; - if (purchase.expiresDate) { - const dateExpired = new Date(purchase.expiresDate); - nextBillDate = l10n.getLocalizedDateString( - Math.floor(dateExpired.getTime() / 1000), - false, - locale - ); - } + {subscriptions.map((sub, index: number) => { return (
  • {purchase.productName}
    -

    - {purchase.productName} -

    +
    +

    + {sub.productName} +

    +

    + {sub.interval && + formatPlanInterval(sub.interval)} +

    +
    {l10n.getString( @@ -648,77 +593,12 @@ export default async function Manage({
    -
    -
    - -

    - {l10n.getString( - 'subscription-management-apple-in-app-purchase-2', - 'Apple in-app purchase' - )} -

    -
    - {nextBillDate && ( - <> - -
    - -

    - {l10n.getString( - 'subscription-management-iap-sub-expires-on-expiry-date', - { - date: nextBillDate, - }, - `Expires on ${nextBillDate}` - )} -

    -
    - - )} -
    -
    - - - {l10n.getString( - 'subscription-management-button-manage-subscription-1', - 'Manage subscription' - )} - - - -
    +
    @@ -726,98 +606,89 @@ export default async function Manage({ ); })}
- )} - {googleIapSubscriptions.length > 0 && ( -
    - {googleIapSubscriptions.map((purchase, index: number) => { - const nextBillDate = l10n.getLocalizedDateString( - purchase.expiryTimeMillis / 1000, - false, - locale - ); - return ( -
  • -
    -
    -
    - {purchase.productName} -
    -
    -
    -

    - {purchase.productName} -

    - - - {l10n.getString( - 'subscription-management-button-support', - 'Get help' - )} - - + {appleIapSubscriptions.length > 0 && ( +
      + {appleIapSubscriptions.map((purchase, index: number) => { + let nextBillDate: string | undefined; + if (purchase.expiresDate) { + const dateExpired = new Date(purchase.expiresDate); + nextBillDate = l10n.getLocalizedDateString( + Math.floor(dateExpired.getTime() / 1000), + false, + locale + ); + } + return ( +
    • +
      +
      +
      + {purchase.productName}
      -
      -
      - -

      - {l10n.getString( - 'subscription-management-google-in-app-purchase-2', - 'Google in-app purchase' +

      +
      +

      + {purchase.productName} +

      + + data-testid={`link-external-support-${purchase.productName}`} + > + + {l10n.getString( + 'subscription-management-button-support', + 'Get help' + )} + +
      - {!!purchase.expiryTimeMillis && ( - <> -
      +
      +
      - - {purchase.autoRenewing ? ( -

      - {l10n.getFragmentWithSource( - 'subscription-management-iap-sub-next-bill-1', - { - vars: { date: nextBillDate }, - }, -

      Next bill • {nextBillDate}

      - )} -

      - ) : ( + /> +

      + {l10n.getString( + 'subscription-management-apple-in-app-purchase-2', + 'Apple in-app purchase' + )} +

      +
      + {nextBillDate && ( + <> +
      - )} - - )} -
      -
      - )} - > - - {l10n.getString( - 'subscription-management-button-manage-subscription-1', - 'Manage subscription' +
      +
      + - - + > + + {l10n.getString( + 'subscription-management-button-manage-subscription-1', + 'Manage subscription' + )} + + + +
      -
      -
    • - ); - })} -
    - )} - - )} +
  • + ); + })} +
+ )} + + {googleIapSubscriptions.length > 0 && ( +
    + {googleIapSubscriptions.map((purchase, index: number) => { + const nextBillDate = l10n.getLocalizedDateString( + purchase.expiryTimeMillis / 1000, + false, + locale + ); + return ( +
  • +
    +
    +
    + {purchase.productName} +
    +
    +
    +

    + {purchase.productName} +

    + + + {l10n.getString( + 'subscription-management-button-support', + 'Get help' + )} + + +
    +
    +
    + +

    + {l10n.getString( + 'subscription-management-google-in-app-purchase-2', + 'Google in-app purchase' + )} +

    +
    + {!!purchase.expiryTimeMillis && ( + <> + + + {purchase.autoRenewing ? ( +

    + {l10n.getFragmentWithSource( + 'subscription-management-iap-sub-next-bill-1', + { + vars: { date: nextBillDate }, + }, +

    Next bill • {nextBillDate}

    + )} +

    + ) : ( +
    + +

    + {l10n.getString( + 'subscription-management-iap-sub-expires-on-expiry-date', + { + date: nextBillDate, + }, + `Expires on ${nextBillDate}` + )} +

    +
    + )} + + )} +
    +
    + + + {l10n.getString( + 'subscription-management-button-manage-subscription-1', + 'Manage subscription' + )} + + + +
    +
    +
    +
    +
  • + ); + })} +
+ )} + + )}
); diff --git a/apps/payments/next/middleware.ts b/apps/payments/next/middleware.ts index b93a2fa23c..4b351f2e2d 100644 --- a/apps/payments/next/middleware.ts +++ b/apps/payments/next/middleware.ts @@ -69,6 +69,16 @@ export function middleware(request: NextRequest) { contentSecurityPolicyHeaderValue ); + // If the user is not logged in, `getExperimentationId` uses the value we + // set here to determine which experiments to show the user. We read it from + // the `experimentationId` cookie, and initialise that cookie if it's unset. + // (The reason we do this in middleware, is to ensure that every call to + // `getExperimentationId` results in the same ID.) + const existingExperimentationId = request.cookies.get('experimentationId'); + const experimentationId = + existingExperimentationId?.value ?? `guest-${crypto.randomUUID()}`; + requestHeaders.set('x-experimentation-id', experimentationId); + const response = NextResponse.next({ request: { headers: requestHeaders, @@ -79,6 +89,12 @@ export function middleware(request: NextRequest) { contentSecurityPolicyHeaderValue ); + response.cookies.set({ + name: 'experimentationId', + value: experimentationId, + path: '/', + }); + return response; } diff --git a/libs/payments/events/src/lib/emitter.factories.ts b/libs/payments/events/src/lib/emitter.factories.ts index 9dc7649a49..0feccbe238 100644 --- a/libs/payments/events/src/lib/emitter.factories.ts +++ b/libs/payments/events/src/lib/emitter.factories.ts @@ -18,15 +18,21 @@ import { SubplatInterval } from '@fxa/payments/customer'; export const AuthEventsFactory = ( override?: Partial ): AuthEvents => ({ - type: faker.helpers.arrayElement(['signin', 'signout', 'prompt_none_fail', 'error']), - ...override -}) + type: faker.helpers.arrayElement([ + 'signin', + 'signout', + 'prompt_none_fail', + 'error', + ]), + ...override, +}); export const AdditionalMetricsDataFactory = ( override?: AdditionalMetricsData ): AdditionalMetricsData => ({ cmsMetricsData: CmsMetricsDataFactory(), cartMetricsData: CartMetricsFactory(), + locale: faker.helpers.arrayElement(['en-US', 'de', 'es', 'fr-FR']), ...override, }); diff --git a/libs/payments/events/src/lib/emitter.service.spec.ts b/libs/payments/events/src/lib/emitter.service.spec.ts index b9ff80fddc..a6db31a08a 100644 --- a/libs/payments/events/src/lib/emitter.service.spec.ts +++ b/libs/payments/events/src/lib/emitter.service.spec.ts @@ -64,6 +64,16 @@ import { PaypalClientConfig, PaypalCustomerManager, } from '@fxa/payments/paypal'; +import { + MockNimbusClientConfigProvider, + NimbusClient, + NimbusEnrollmentFactory, +} from '@fxa/shared/experiments'; +import { + MockNimbusManagerConfigProvider, + NimbusManager, + SubPlatNimbusResultFactory, +} from '@fxa/payments/experiments'; jest.mock('./util/retrieveAdditionalMetricsData'); const mockedRetrieveAdditionalMetricsData = jest.mocked( @@ -78,6 +88,7 @@ describe('PaymentsEmitterService', () => { let paymentsGleanManager: PaymentsGleanManager; let paymentMethodManager: PaymentMethodManager; let productConfigurationManager: ProductConfigurationManager; + let nimbusManager: NimbusManager; let statsd: StatsD; let logger: Logger; let subscriptionManager: SubscriptionManager; @@ -125,6 +136,10 @@ describe('PaymentsEmitterService', () => { PaymentsEmitterService, PaymentMethodManager, SubscriptionManager, + NimbusClient, + MockNimbusClientConfigProvider, + NimbusManager, + MockNimbusManagerConfigProvider, ], }).compile(); @@ -134,6 +149,7 @@ describe('PaymentsEmitterService', () => { paymentsGleanManager = moduleRef.get(PaymentsGleanManager); paymentMethodManager = moduleRef.get(PaymentMethodManager); productConfigurationManager = moduleRef.get(ProductConfigurationManager); + nimbusManager = moduleRef.get(NimbusManager); cartManager = moduleRef.get(CartManager); statsd = moduleRef.get(StatsDService); logger = moduleRef.get(Logger); @@ -182,10 +198,23 @@ describe('PaymentsEmitterService', () => { }); describe('handleCheckoutView', () => { + const mockEnrollment = NimbusEnrollmentFactory(); + const mockSubPlatExperiments = SubPlatNimbusResultFactory({ + Enrollments: [mockEnrollment], + }); + const nimbusUserId = mockEnrollment.nimbus_user_id; + const generatedNimbusUserId = NimbusEnrollmentFactory().nimbus_user_id; + beforeEach(() => { jest .spyOn(paymentsGleanManager, 'recordFxaPaySetupView') .mockReturnValue(); + jest + .spyOn(nimbusManager, 'fetchExperiments') + .mockResolvedValue(mockSubPlatExperiments); + jest + .spyOn(nimbusManager, 'generateNimbusId') + .mockReturnValue(generatedNimbusUserId); }); it('should call manager record method', async () => { @@ -198,8 +227,11 @@ describe('PaymentsEmitterService', () => { ); expect(paymentsGleanManager.recordFxaPaySetupView).toHaveBeenCalledWith({ commonMetricsData: mockCommonMetricsData, + experimentationData: { nimbusUserId }, ...additionalMetricsData, }); + expect(nimbusManager.fetchExperiments).toHaveBeenCalled(); + expect(nimbusManager.generateNimbusId).toHaveBeenCalled(); }); it('should not record glean event if user opts out', async () => { @@ -215,6 +247,8 @@ describe('PaymentsEmitterService', () => { mockCommonMetricsData.params ); expect(paymentsGleanManager.recordFxaPaySetupView).not.toHaveBeenCalled(); + expect(nimbusManager.fetchExperiments).not.toHaveBeenCalled(); + expect(nimbusManager.generateNimbusId).not.toHaveBeenCalled(); }); }); diff --git a/libs/payments/events/src/lib/emitter.service.ts b/libs/payments/events/src/lib/emitter.service.ts index 8db88b3c65..7d82f0b51e 100644 --- a/libs/payments/events/src/lib/emitter.service.ts +++ b/libs/payments/events/src/lib/emitter.service.ts @@ -27,6 +27,7 @@ import { import * as Sentry from '@sentry/nestjs'; import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd'; import { EmitterServiceHandleAuthError } from './emitter.error'; +import { NimbusManager } from '@fxa/payments/experiments'; @Injectable() export class PaymentsEmitterService { @@ -37,6 +38,7 @@ export class PaymentsEmitterService { private cartManager: CartManager, private customerManager: CustomerManager, private log: Logger, + private nimbusManager: NimbusManager, private paymentsGleanManager: PaymentsGleanManager, private paymentMethodManager: PaymentMethodManager, private productConfigurationManager: ProductConfigurationManager, @@ -83,9 +85,30 @@ export class PaymentsEmitterService { ); if (!metricsOptOut) { + let experiments; + const generatedNimbusUserId = this.nimbusManager.generateNimbusId( + additionalData.cartMetricsData.uid, + eventData.experimentationId + ); + try { + experiments = await this.nimbusManager.fetchExperiments({ + nimbusUserId: generatedNimbusUserId, + language: additionalData.locale, + region: additionalData.cartMetricsData.taxAddress?.countryCode, + }); + } catch (error) { + this.log.error(error); + Sentry.captureException(error); + } + + const nimbusUserId = + experiments?.Enrollments?.at(0)?.nimbus_user_id || + generatedNimbusUserId; + this.paymentsGleanManager.recordFxaPaySetupView({ commonMetricsData: eventData, ...additionalData, + experimentationData: { nimbusUserId }, }); } } diff --git a/libs/payments/events/src/lib/emitter.types.ts b/libs/payments/events/src/lib/emitter.types.ts index 59457b8e45..fc486f4c5e 100644 --- a/libs/payments/events/src/lib/emitter.types.ts +++ b/libs/payments/events/src/lib/emitter.types.ts @@ -10,9 +10,7 @@ import { } from '@fxa/payments/metrics'; import { LocationStatus } from '@fxa/payments/eligibility'; import { TaxChangeAllowedStatus } from '@fxa/payments/cart'; -import { - SubPlatPaymentMethodType, -} from '@fxa/payments/customer'; +import { SubPlatPaymentMethodType } from '@fxa/payments/customer'; export type CheckoutEvents = CommonMetrics; export type CheckoutPaymentEvents = CommonMetrics & { @@ -50,7 +48,7 @@ export type SP3RolloutEvent = { export type AuthEvents = { type: 'signin' | 'signout' | 'prompt_none_fail' | 'error'; errorMessage?: string; -} +}; export type PaymentsEmitterEvents = { checkoutView: CheckoutEvents; @@ -67,4 +65,5 @@ export type PaymentsEmitterEvents = { export type AdditionalMetricsData = { cmsMetricsData: CmsMetricsData; cartMetricsData: CartMetrics; + locale: string; }; diff --git a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts index 9cc154d5ee..2480410e91 100644 --- a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts +++ b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsData.ts @@ -11,6 +11,7 @@ export async function retrieveAdditionalMetricsData( cartManager: CartManager, params: Record ): Promise { + const locale = params['locale'] || 'en'; const offeringId = params['offeringId']; const interval = params['interval']; const cartId = params['cartId']; @@ -54,6 +55,7 @@ export async function retrieveAdditionalMetricsData( couponCode: cartData.value.couponCode, currency: cartData.value.currency, stripeCustomerId: cartData.value.stripeCustomerId, + taxAddress: cartData.value.taxAddress, } : { uid: '', @@ -61,10 +63,12 @@ export async function retrieveAdditionalMetricsData( couponCode: '', currency: '', stripeCustomerId: '', + taxAddress: { countryCode: '', postalCode: '' }, }; return { cmsMetricsData, cartMetricsData, + locale, }; } diff --git a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts index dc1711ad34..c724f7ee27 100644 --- a/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts +++ b/libs/payments/events/src/lib/util/retrieveAdditionalMetricsdata.spec.ts @@ -31,6 +31,7 @@ const expectedCartMetricsData = { couponCode: mockCart.couponCode, currency: mockCart.currency, stripeCustomerId: mockCart.stripeCustomerId, + taxAddress: mockCart.taxAddress, }; const emptyCmsMetricsData = { @@ -43,6 +44,10 @@ const emptyCartMetricsData = { couponCode: '', currency: '', stripeCustomerId: '', + taxAddress: { + countryCode: '', + postalCode: '', + }, }; describe('retrieveAdditionalMetricsData', () => { diff --git a/libs/payments/experiments/.eslintrc.json b/libs/payments/experiments/.eslintrc.json new file mode 100644 index 0000000000..3456be9b90 --- /dev/null +++ b/libs/payments/experiments/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/payments/experiments/.swcrc b/libs/payments/experiments/.swcrc new file mode 100644 index 0000000000..42938ddcef --- /dev/null +++ b/libs/payments/experiments/.swcrc @@ -0,0 +1,15 @@ +{ + "jsc": { + "target": "es2017", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + } + } +} + diff --git a/libs/payments/experiments/README.md b/libs/payments/experiments/README.md new file mode 100644 index 0000000000..6494ff526b --- /dev/null +++ b/libs/payments/experiments/README.md @@ -0,0 +1,11 @@ +# experiments + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build experiments` to build the library. + +## Running unit tests + +Run `nx test-unit experiments` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/payments/experiments/jest.config.ts b/libs/payments/experiments/jest.config.ts new file mode 100644 index 0000000000..8f32661a96 --- /dev/null +++ b/libs/payments/experiments/jest.config.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +import { readFileSync } from 'fs'; +import { Config } from 'jest'; + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse( + readFileSync(`${__dirname}/.swcrc`, 'utf-8') +); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +// Uncomment if using global setup/teardown files being transformed via swc +// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries +// jest needs EsModule Interop to find the default exported setup/teardown functions +// swcJestConfig.module.noInterop = false; + +const config: Config = { + displayName: 'payments-experiments', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/payments/experiments', + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: 'artifacts/tests/payments-experiments', + outputName: 'payments-experiments-jest-unit-results.xml', + }, + ], + ], +}; + +export default config; diff --git a/libs/payments/experiments/package.json b/libs/payments/experiments/package.json new file mode 100644 index 0000000000..5b05d51170 --- /dev/null +++ b/libs/payments/experiments/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fxa/payments/experiments", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./index.cjs", + "types": "./index.d.ts", + "dependencies": {} +} diff --git a/libs/payments/experiments/project.json b/libs/payments/experiments/project.json new file mode 100644 index 0000000000..0608604b4e --- /dev/null +++ b/libs/payments/experiments/project.json @@ -0,0 +1,29 @@ +{ + "name": "payments-experiments", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/payments/experiments/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/payments/experiments", + "main": "libs/payments/experiments/src/index.ts", + "tsConfig": "libs/payments/experiments/tsconfig.lib.json", + "assets": ["libs/payments/experiments/*.md"], + "declaration": true, + "generatePackageJson": true + } + }, + "test-unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/payments/experiments/jest.config.ts", + "testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"] + } + } + } +} diff --git a/libs/payments/experiments/src/index.ts b/libs/payments/experiments/src/index.ts new file mode 100644 index 0000000000..153d1e6a64 --- /dev/null +++ b/libs/payments/experiments/src/index.ts @@ -0,0 +1,8 @@ +/* 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/. */ + +export * from './lib/nimbus.factories'; +export * from './lib/nimbus.manager'; +export * from './lib/nimbus.manager.config'; +export * from './lib/nimbus.types'; diff --git a/libs/payments/experiments/src/lib/nimbus.factories.ts b/libs/payments/experiments/src/lib/nimbus.factories.ts new file mode 100644 index 0000000000..12b8a819b2 --- /dev/null +++ b/libs/payments/experiments/src/lib/nimbus.factories.ts @@ -0,0 +1,30 @@ +/* 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 { faker } from '@faker-js/faker'; +import type { + WelcomeFeature, + Features, + SubPlatNimbusResult, +} from './nimbus.types'; + +export const WelcomeFeatureFactory = ( + override?: Partial +): WelcomeFeature => ({ + enabled: faker.datatype.boolean(), + ...override, +}); + +export const FeaturesFactory = (override?: Partial): Features => ({ + 'welcome-feature': WelcomeFeatureFactory(), + ...override, +}); + +export const SubPlatNimbusResultFactory = ( + override?: Partial +): SubPlatNimbusResult => ({ + Features: FeaturesFactory(), + Enrollments: [], + ...override, +}); diff --git a/libs/payments/experiments/src/lib/nimbus.manager.config.ts b/libs/payments/experiments/src/lib/nimbus.manager.config.ts new file mode 100644 index 0000000000..e2cd29e981 --- /dev/null +++ b/libs/payments/experiments/src/lib/nimbus.manager.config.ts @@ -0,0 +1,25 @@ +/* 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 { faker } from '@faker-js/faker'; +import { Provider } from '@nestjs/common'; +import { IsBoolean, IsString } from 'class-validator'; + +export class NimbusManagerConfig { + @IsBoolean() + public readonly enabled!: boolean; + + @IsString() + public readonly namespace!: string; +} + +export const MockNimbusManagerConfig = { + enabled: faker.datatype.boolean(), + namespace: faker.string.uuid(), +} satisfies NimbusManagerConfig; + +export const MockNimbusManagerConfigProvider = { + provide: NimbusManagerConfig, + useValue: MockNimbusManagerConfig, +} satisfies Provider; diff --git a/libs/payments/experiments/src/lib/nimbus.manager.spec.ts b/libs/payments/experiments/src/lib/nimbus.manager.spec.ts new file mode 100644 index 0000000000..9ab68dbba4 --- /dev/null +++ b/libs/payments/experiments/src/lib/nimbus.manager.spec.ts @@ -0,0 +1,156 @@ +/* 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 { Test } from '@nestjs/testing'; +import { + NimbusClient, + MockNimbusClientConfigProvider, + NimbusEnrollmentFactory, + NimbusContextFactory, + generateNimbusId, +} from '@fxa/shared/experiments'; +import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; +import { NimbusManager } from './nimbus.manager'; +import { SubPlatNimbusResultFactory } from './nimbus.factories'; +import { faker } from '@faker-js/faker/.'; +import { + MockNimbusManagerConfig, + NimbusManagerConfig, +} from './nimbus.manager.config'; +import { Logger } from '@nestjs/common'; + +jest.mock('@fxa/shared/experiments', () => { + const originalModule = jest.requireActual('@fxa/shared/experiments'); + + //Mock the default export and named export 'foo' + return { + __esModule: true, + ...originalModule, + generateNimbusId: jest.fn(), + }; +}); +const mockedGenerateNimbusId = jest.mocked(generateNimbusId); + +describe('NimbusClient', () => { + let nimbusClient: NimbusClient; + let nimbusManager: NimbusManager; + + const mockNimbusManagerConfig = MockNimbusManagerConfig; + + beforeEach(async () => { + global.fetch = jest.fn(); + + const module = await Test.createTestingModule({ + providers: [ + NimbusClient, + MockNimbusClientConfigProvider, + MockStatsDProvider, + { + provide: NimbusManagerConfig, + useValue: mockNimbusManagerConfig, + }, + NimbusManager, + { + provide: Logger, + useValue: { + error: jest.fn(), + log: jest.fn(), + }, + }, + ], + }).compile(); + + nimbusClient = module.get(NimbusClient); + nimbusManager = module.get(NimbusManager); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchExperiments', () => { + const mockContext = NimbusContextFactory(); + const mockEnrollment = NimbusEnrollmentFactory(); + const mockFetchExperimentsResult = SubPlatNimbusResultFactory({ + Enrollments: [mockEnrollment], + }); + const nimbusUserId = mockEnrollment.nimbus_user_id; + const mockFetchExperimentsParams = { + nimbusUserId, + language: mockContext.language || undefined, + region: mockContext.region || undefined, + }; + + beforeEach(() => { + mockNimbusManagerConfig.enabled = true; + jest + .spyOn(nimbusClient, 'fetchExperiments') + .mockResolvedValue(mockFetchExperimentsResult); + }); + + it('successfully returns experiments data', async () => { + const result = await nimbusManager.fetchExperiments( + mockFetchExperimentsParams + ); + expect(result).toEqual(mockFetchExperimentsResult); + expect(nimbusClient.fetchExperiments).toHaveBeenCalledWith({ + clientId: nimbusUserId, + context: mockContext, + }); + }); + + it('returns null if not enabled', async () => { + mockNimbusManagerConfig.enabled = false; + const result = await nimbusManager.fetchExperiments( + mockFetchExperimentsParams + ); + expect(result).toBeNull(); + }); + + it('throws an error', async () => { + const expectedError = new Error('unexpected error'); + jest + .spyOn(nimbusClient, 'fetchExperiments') + .mockRejectedValue(expectedError); + await expect( + nimbusManager.fetchExperiments(mockFetchExperimentsParams) + ).rejects.toThrow(expectedError); + }); + }); + + describe('generateNimbusId', () => { + const mockFxaUid = faker.string.uuid(); + const mockNimbusUserId = faker.string.uuid(); + const mockHeaderExperimentId = faker.string.uuid(); + beforeEach(() => { + mockedGenerateNimbusId.mockReturnValue(mockNimbusUserId); + }); + + it('successfully returns nimbus user id using fxaUid', () => { + const result = nimbusManager.generateNimbusId(mockFxaUid); + expect(result).toEqual(mockNimbusUserId); + expect(mockedGenerateNimbusId).toHaveBeenCalledWith( + mockNimbusManagerConfig.namespace, + mockFxaUid + ); + }); + + it('successfully returns nimbus user id using header experiment id', () => { + const result = nimbusManager.generateNimbusId( + undefined, + mockHeaderExperimentId + ); + expect(result).toEqual(mockHeaderExperimentId); + expect(mockedGenerateNimbusId).not.toHaveBeenCalled(); + }); + + it('successfully returns nimbus user id newly generated', () => { + const result = nimbusManager.generateNimbusId(); + expect(result).toEqual(mockNimbusUserId); + expect(mockedGenerateNimbusId).toHaveBeenCalledWith( + mockNimbusManagerConfig.namespace + ); + }); + }); +}); diff --git a/libs/payments/experiments/src/lib/nimbus.manager.ts b/libs/payments/experiments/src/lib/nimbus.manager.ts new file mode 100644 index 0000000000..3075fcd9a4 --- /dev/null +++ b/libs/payments/experiments/src/lib/nimbus.manager.ts @@ -0,0 +1,52 @@ +/* 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 { Injectable, Logger } from '@nestjs/common'; +import { generateNimbusId, NimbusClient } from '@fxa/shared/experiments'; +import { NimbusManagerConfig } from './nimbus.manager.config'; +import { SubPlatNimbusResult } from './nimbus.types'; + +@Injectable() +export class NimbusManager { + constructor( + private log: Logger, + private nimbusClient: NimbusClient, + private nimbusManagerConfig: NimbusManagerConfig + ) {} + + async fetchExperiments({ + nimbusUserId, + language, + region, + }: { + nimbusUserId: string; + language?: string; + region?: string; + }) { + if (!this.nimbusManagerConfig.enabled) { + return null; + } + + const results = + await this.nimbusClient.fetchExperiments({ + clientId: nimbusUserId, + context: { language: language || null, region: region || null }, + }); + + // Temporarily log results for debugging purposes + this.log.log(JSON.stringify(results)); + + return results; + } + + generateNimbusId(fxaUid?: string, headerExperimentId?: string) { + if (fxaUid) { + return generateNimbusId(this.nimbusManagerConfig.namespace, fxaUid); + } else if (headerExperimentId) { + return headerExperimentId; + } else { + return generateNimbusId(this.nimbusManagerConfig.namespace); + } + } +} diff --git a/libs/payments/experiments/src/lib/nimbus.types.ts b/libs/payments/experiments/src/lib/nimbus.types.ts new file mode 100644 index 0000000000..d2a725e90d --- /dev/null +++ b/libs/payments/experiments/src/lib/nimbus.types.ts @@ -0,0 +1,17 @@ +/* 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 type { NimbusResult } from '@fxa/shared/experiments'; + +export interface WelcomeFeature { + enabled: boolean; +} + +export interface Features { + 'welcome-feature': WelcomeFeature; +} + +export interface SubPlatNimbusResult extends NimbusResult { + Features: Features; +} diff --git a/libs/payments/experiments/tsconfig.json b/libs/payments/experiments/tsconfig.json new file mode 100644 index 0000000000..86622aca55 --- /dev/null +++ b/libs/payments/experiments/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/payments/experiments/tsconfig.lib.json b/libs/payments/experiments/tsconfig.lib.json new file mode 100644 index 0000000000..4befa7f099 --- /dev/null +++ b/libs/payments/experiments/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/payments/experiments/tsconfig.spec.json b/libs/payments/experiments/tsconfig.spec.json new file mode 100644 index 0000000000..ab55b7c7ac --- /dev/null +++ b/libs/payments/experiments/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/payments/metrics/src/lib/glean/glean.factory.ts b/libs/payments/metrics/src/lib/glean/glean.factory.ts index e50caf0470..20d7e76a84 100644 --- a/libs/payments/metrics/src/lib/glean/glean.factory.ts +++ b/libs/payments/metrics/src/lib/glean/glean.factory.ts @@ -8,6 +8,7 @@ import { CmsMetricsData, CommonMetrics, SubscriptionCancellationData, + type ExperimentationData, } from './glean.types'; import { ResultCartFactory } from '@fxa/payments/cart'; import { SubplatInterval } from '@fxa/payments/customer'; @@ -36,6 +37,7 @@ export const CommonMetricsFactory = ( userAgent: faker.internet.userAgent(), params: {}, searchParams: {}, + experimentationId: faker.string.uuid(), ...override, }); @@ -52,6 +54,7 @@ export const CartMetricsFactory = ( couponCode: resultCart.couponCode, currency: faker.finance.currencyCode().toLowerCase(), stripeCustomerId: `cus_${faker.string.alphanumeric({ length: 14 })}`, + taxAddress: resultCart.taxAddress, ...override, }; }; @@ -81,3 +84,10 @@ export const SubscriptionCancellationDataFactory = ( ...override, }; }; + +export const ExperimentationDataFactory = ( + override?: Partial +): ExperimentationData => ({ + nimbusUserId: faker.string.uuid(), + ...override, +}); diff --git a/libs/payments/metrics/src/lib/glean/glean.manager.ts b/libs/payments/metrics/src/lib/glean/glean.manager.ts index 0439a0692e..b57a0f2cd6 100644 --- a/libs/payments/metrics/src/lib/glean/glean.manager.ts +++ b/libs/payments/metrics/src/lib/glean/glean.manager.ts @@ -10,6 +10,7 @@ import { PaymentProvidersType, PaymentsGleanProvider, SubscriptionCancellationData, + type ExperimentationData, } from './glean.types'; import { Inject, Injectable } from '@nestjs/common'; import { type PaymentsGleanServerEventsLogger } from './glean.provider'; @@ -34,6 +35,7 @@ export class PaymentsGleanManager { commonMetricsData: CommonMetrics; cartMetricsData: CartMetrics; cmsMetricsData: CmsMetricsData; + experimentationData: ExperimentationData; }) { if (this.isEnabled) { this.paymentsGleanServerEventsLogger.recordPaySetupView( @@ -131,12 +133,14 @@ export class PaymentsGleanManager { commonMetricsData?: CommonMetrics; cartMetricsData?: CartMetrics; cmsMetricsData?: CmsMetricsData; + experimentationData?: ExperimentationData; subscriptionCancellationData?: SubscriptionCancellationData; }) { const emptyCommonMetricsData: CommonMetrics = { ipAddress: '', deviceType: '', userAgent: '', + experimentationId: '', params: {}, searchParams: {}, }; @@ -146,6 +150,7 @@ export class PaymentsGleanManager { errorReasonId: null, couponCode: '', currency: '', + taxAddress: { countryCode: '', postalCode: '' }, }; const emptyCmsMetricsData: CmsMetricsData = { priceId: '', @@ -157,12 +162,17 @@ export class PaymentsGleanManager { cancellationReason: CancellationReason.Involuntary, providerEventId: '', }; + const emptyExperimentationData: ExperimentationData = { + nimbusUserId: '', + }; const commonMetricsData = metrics.commonMetricsData || emptyCommonMetricsData; const cartMetricsData = metrics.cartMetricsData || emptyCartMetricsData; const cmsMetricsData = metrics.cmsMetricsData || emptyCmsMetricsData; const subscriptionCancellationData = metrics.subscriptionCancellationData || emptySubscriptionCancellationData; + const experimentationData = + metrics.experimentationData || emptyExperimentationData; return { user_agent: commonMetricsData.userAgent, ip_address: commonMetricsData.ipAddress, @@ -178,7 +188,7 @@ export class PaymentsGleanManager { }), ...mapUtm(commonMetricsData.searchParams), ...mapSubscriptionCancellation(subscriptionCancellationData), - nimbus_user_id: '', + nimbus_user_id: experimentationData.nimbusUserId, }; } diff --git a/libs/payments/metrics/src/lib/glean/glean.types.ts b/libs/payments/metrics/src/lib/glean/glean.types.ts index e25c5431aa..55dd925e24 100644 --- a/libs/payments/metrics/src/lib/glean/glean.types.ts +++ b/libs/payments/metrics/src/lib/glean/glean.types.ts @@ -26,15 +26,25 @@ export type CommonMetrics = { ipAddress: string; deviceType: string; userAgent: string; + experimentationId: string; params: Record; searchParams: Record; }; export type CartMetrics = Pick< ResultCart, - 'uid' | 'errorReasonId' | 'couponCode' | 'currency' | 'stripeCustomerId' + | 'uid' + | 'errorReasonId' + | 'couponCode' + | 'currency' + | 'stripeCustomerId' + | 'taxAddress' >; +export type ExperimentationData = { + nimbusUserId: string; +}; + export type CmsMetricsData = { productId: string; priceId: string; diff --git a/libs/payments/ui/src/lib/actions/getExperimentsAction.ts b/libs/payments/ui/src/lib/actions/getExperimentsAction.ts new file mode 100644 index 0000000000..ad34c5a5f8 --- /dev/null +++ b/libs/payments/ui/src/lib/actions/getExperimentsAction.ts @@ -0,0 +1,31 @@ +/* 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/. */ + +'use server'; + +import { getApp } from '../nestapp/app'; +import { getIpAddress } from '../utils/getIpAddress'; +import { headers } from 'next/headers'; + +export const getExperimentsAction = async (args: { + language: string; + fxaUid?: string; +}) => { + const ip = getIpAddress(); + const experimentationId = headers().get('x-experimentation-id') || ''; + + try { + const experiments = await getApp() + .getActionsService() + .getExperiments({ + ...args, + ip, + experimentationId, + }); + + return experiments?.experiments; + } catch (error) { + return undefined; + } +}; diff --git a/libs/payments/ui/src/lib/actions/index.ts b/libs/payments/ui/src/lib/actions/index.ts index 6069ae8a24..84868abe2d 100644 --- a/libs/payments/ui/src/lib/actions/index.ts +++ b/libs/payments/ui/src/lib/actions/index.ts @@ -35,3 +35,4 @@ export { serverLogAction } from './serverLog'; export { getStripeClientSession } from './getStripeClientSession'; export { updateStripePaymentDetails } from './updateStripePaymentDetails'; export { setDefaultStripePaymentDetails } from './setDefaultStripePaymentDetails'; +export { getExperimentsAction } from './getExperimentsAction'; diff --git a/libs/payments/ui/src/lib/nestapp/app.module.ts b/libs/payments/ui/src/lib/nestapp/app.module.ts index ef8f63e76e..8e39cc7094 100644 --- a/libs/payments/ui/src/lib/nestapp/app.module.ts +++ b/libs/payments/ui/src/lib/nestapp/app.module.ts @@ -69,6 +69,8 @@ import { GoogleIapClient, GoogleIapPurchaseManager, } from '@fxa/payments/iap'; +import { NimbusClient } from '@fxa/shared/experiments'; +import { NimbusManager } from '@fxa/payments/experiments'; @Module({ imports: [ @@ -144,6 +146,8 @@ import { SubscriptionEventsService, StripeWebhookService, SubscriptionManagementService, + NimbusClient, + NimbusManager, { provide: LOGGER_PROVIDER, useValue: logger }, ], }) diff --git a/libs/payments/ui/src/lib/nestapp/config.ts b/libs/payments/ui/src/lib/nestapp/config.ts index 3686952ff9..f8616e04fd 100644 --- a/libs/payments/ui/src/lib/nestapp/config.ts +++ b/libs/payments/ui/src/lib/nestapp/config.ts @@ -22,6 +22,8 @@ import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap'; import { TracingConfig } from './tracing.config'; import { StripeEventConfig } from '@fxa/payments/webhooks'; import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config'; +import { NimbusClientConfig } from 'libs/shared/experiments/src/lib/nimbus.config'; +import { NimbusManagerConfig } from '@fxa/payments/experiments'; export class RootConfig { @Type(() => MySQLConfig) @@ -117,4 +119,14 @@ export class RootConfig { @ValidateNested() @IsDefined() location!: LocationConfig; + + @Type(() => NimbusClientConfig) + @ValidateNested() + @IsDefined() + nimbusClient!: NimbusClientConfig; + + @Type(() => NimbusManagerConfig) + @ValidateNested() + @IsDefined() + nimbusManager!: NimbusManagerConfig; } diff --git a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts index c7d7b21c56..7d6170cc9c 100644 --- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts +++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts @@ -18,7 +18,6 @@ import { SubscriptionManagementService } from '@fxa/payments/management'; import { CheckoutTokenManager, PaypalBillingAgreementManager, - PaypalCustomerManager, } from '@fxa/payments/paypal'; import { ProductConfigError, @@ -109,6 +108,9 @@ import { GetPaypalBillingAgreementActiveIdResult } from './validators/GetPaypalB import { CreatePaypalBillingAgreementIdArgs } from './validators/CreatePaypalBillingAgreementIdArgs'; import { DetermineCurrencyForCustomerActionArgs } from './validators/DetermineCurrencyForCustomerActionArgs'; import { DetermineCurrencyForCustomerActionResult } from './validators/DetermineCurrencyForCustomerActionResult'; +import { NimbusManager } from '@fxa/payments/experiments'; +import { GetExperimentsActionArgs } from './validators/GetExperimentsActionArgs'; +import { GetExperimentsActionResult } from './validators/GetExperimentsActionResult'; /** * ANY AND ALL methods exposed via this service should be considered publicly accessible and callable with any arguments. @@ -131,7 +133,7 @@ export class NextJSActionsService { private profileClient: ProfileClient, private subscriptionManagementService: SubscriptionManagementService, private paypalBillingAgreementManager: PaypalBillingAgreementManager, - private paypalCustomerManager: PaypalCustomerManager, + private nimbusManager: NimbusManager, @Inject(StatsDService) public statsd: StatsD, @Inject(Logger) private log: LoggerService ) {} @@ -181,6 +183,36 @@ export class NextJSActionsService { return this.cartService.getCart(args.cartId); } + @SanitizeExceptions() + @NextIOValidator(GetExperimentsActionArgs, GetExperimentsActionResult) + @WithTypeCachableAsyncLocalStorage() + @CaptureTimingWithStatsD() + async getExperiments(args: { + language: string; + ip: string; + fxaUid?: string; + experimentationId?: string; + }) { + const nimbusUserId = this.nimbusManager.generateNimbusId( + args.fxaUid, + args.experimentationId + ); + const location = this.geodbManager.getTaxAddress(args.ip); + const experiments = await this.nimbusManager.fetchExperiments({ + nimbusUserId, + language: args.language, + region: location?.countryCode, + }); + + if (experiments) { + return { + experiments, + }; + } else { + return undefined; + } + } + @SanitizeExceptions({ allowlist: [CartInvalidStateForActionError], }) diff --git a/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionArgs.ts new file mode 100644 index 0000000000..277d5190b4 --- /dev/null +++ b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionArgs.ts @@ -0,0 +1,21 @@ +/* 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 { IsOptional, IsString } from 'class-validator'; + +export class GetExperimentsActionArgs { + @IsString() + language!: string; + + @IsString() + ip!: string; + + @IsString() + @IsOptional() + experimentationId?: string; + + @IsString() + @IsOptional() + fxaUid?: string; +} diff --git a/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionResult.ts b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionResult.ts new file mode 100644 index 0000000000..67d6e0b861 --- /dev/null +++ b/libs/payments/ui/src/lib/nestapp/validators/GetExperimentsActionResult.ts @@ -0,0 +1,19 @@ +/* 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 type { Features } from '@fxa/payments/experiments'; +import type { NimbusEnrollment } from '@fxa/shared/experiments'; +import { IsArray, IsDefined } from 'class-validator'; + +class Experiments { + @IsDefined() + Features!: Features; + + @IsArray() + Enrollments!: Array; +} + +export class GetExperimentsActionResult { + experiments?: Experiments; +} diff --git a/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts b/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts index 7b85a677e9..ecc91ba1c1 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/RecordEmitterEvent.ts @@ -30,6 +30,9 @@ class RequestArgs { @IsString() userAgent!: string; + @IsString() + experimentationId!: string; + @IsObject() params!: Record; diff --git a/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts b/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts index af075fc1e6..9ed3ab611c 100644 --- a/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts +++ b/libs/payments/ui/src/lib/utils/getAdditionalRequestArgs.ts @@ -9,9 +9,12 @@ import { getIpAddress } from './getIpAddress'; export function getAdditionalRequestArgs() { const userAgentString = headers().get('user-agent') || ''; const userAgent = userAgentFromString(userAgentString); + const experimentationId = headers().get('x-experimentation-id') || ''; + return { ipAddress: getIpAddress(), userAgent: userAgentString, deviceType: userAgent.device.type || 'desktop', + experimentationId, }; } diff --git a/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts b/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts index 6413088a94..30d3e9a56b 100644 --- a/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts +++ b/libs/payments/webhooks/src/lib/stripe-event.manager.spec.ts @@ -56,6 +56,14 @@ import { StripeEventStoreEntryAlreadyExistsError, StripeEventStoreEntryNotFoundError, } from './stripe-event-store.error'; +import { + MockNimbusClientConfigProvider, + NimbusClient, +} from '@fxa/shared/experiments'; +import { + MockNimbusManagerConfigProvider, + NimbusManager, +} from '@fxa/payments/experiments'; jest.mock('./stripe-event-store.repository'); const mockedGetStripeEventStoreEntry = jest.mocked(getStripeEventStoreEntry); @@ -115,6 +123,10 @@ describe('StripeEventManager', () => { MockStatsDProvider, PriceManager, MockAccountDatabaseNestFactory, + NimbusClient, + MockNimbusClientConfigProvider, + NimbusManager, + MockNimbusManagerConfigProvider, ], }).compile(); diff --git a/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts b/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts index 7ee64962c1..9282498500 100644 --- a/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts +++ b/libs/payments/webhooks/src/lib/stripe-webhooks.controller.spec.ts @@ -41,6 +41,14 @@ import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { Logger } from '@nestjs/common'; +import { + MockNimbusClientConfigProvider, + NimbusClient, +} from '@fxa/shared/experiments'; +import { + MockNimbusManagerConfigProvider, + NimbusManager, +} from '@fxa/payments/experiments'; describe('StripeWebhooksController', () => { let stripeWebhooksController: StripeWebhooksController; @@ -96,6 +104,10 @@ describe('StripeWebhooksController', () => { StripeWebhookService, StripeEventManager, SubscriptionEventsService, + NimbusClient, + MockNimbusClientConfigProvider, + NimbusManager, + MockNimbusManagerConfigProvider, ], }).compile(); diff --git a/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts b/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts index 6d621619f4..a98258343b 100644 --- a/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts +++ b/libs/payments/webhooks/src/lib/stripe-webhooks.service.spec.ts @@ -43,6 +43,14 @@ import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import * as Sentry from '@sentry/node'; import { Logger } from '@nestjs/common'; import { MockStripeEventConfigProvider } from './stripe-event.config'; +import { + MockNimbusClientConfigProvider, + NimbusClient, +} from '@fxa/shared/experiments'; +import { + MockNimbusManagerConfigProvider, + NimbusManager, +} from '@fxa/payments/experiments'; jest.mock('@sentry/node', () => ({ captureException: jest.fn(), @@ -99,6 +107,10 @@ describe('StripeWebhookService', () => { MockStatsDProvider, PriceManager, MockAccountDatabaseNestFactory, + NimbusClient, + MockNimbusClientConfigProvider, + NimbusManager, + MockNimbusManagerConfigProvider, ], }).compile(); diff --git a/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts b/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts index 41b0c06ddd..3172aedc51 100644 --- a/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts +++ b/libs/payments/webhooks/src/lib/subscription-handler.service.spec.ts @@ -55,6 +55,14 @@ import { } from './util/determineCancellation'; import { Logger } from '@nestjs/common'; import { MockStripeEventConfigProvider } from './stripe-event.config'; +import { + MockNimbusClientConfigProvider, + NimbusClient, +} from '@fxa/shared/experiments'; +import { + MockNimbusManagerConfigProvider, + NimbusManager, +} from '@fxa/payments/experiments'; jest.mock('@fxa/payments/customer'); jest.mock('./util/determineCancellation'); @@ -120,6 +128,10 @@ describe('SubscriptionEventsService', () => { MockStatsDProvider, PriceManager, MockAccountDatabaseNestFactory, + NimbusClient, + MockNimbusClientConfigProvider, + NimbusManager, + MockNimbusManagerConfigProvider, ], }).compile(); diff --git a/libs/shared/experiments/.eslintrc.json b/libs/shared/experiments/.eslintrc.json new file mode 100644 index 0000000000..3456be9b90 --- /dev/null +++ b/libs/shared/experiments/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/shared/experiments/.swcrc b/libs/shared/experiments/.swcrc new file mode 100644 index 0000000000..42938ddcef --- /dev/null +++ b/libs/shared/experiments/.swcrc @@ -0,0 +1,15 @@ +{ + "jsc": { + "target": "es2017", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + } + } +} + diff --git a/libs/shared/experiments/README.md b/libs/shared/experiments/README.md new file mode 100644 index 0000000000..6df81062ee --- /dev/null +++ b/libs/shared/experiments/README.md @@ -0,0 +1,12 @@ +# shared-experiments + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build shared-experiments` to build the library. + +## Running unit tests + +Run `nx run shared-experiments:test-unit` to execute the unit tests via [Jest](https://jestjs.io). + diff --git a/libs/shared/experiments/jest.config.ts b/libs/shared/experiments/jest.config.ts new file mode 100644 index 0000000000..6af61d967a --- /dev/null +++ b/libs/shared/experiments/jest.config.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +import { readFileSync } from 'fs'; +import { Config } from 'jest'; + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse( + readFileSync(`${__dirname}/.swcrc`, 'utf-8') +); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +// Uncomment if using global setup/teardown files being transformed via swc +// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries +// jest needs EsModule Interop to find the default exported setup/teardown functions +// swcJestConfig.module.noInterop = false; + +const config: Config = { + displayName: 'shared-experiments', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/shared/experiments', + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: 'artifacts/tests/shared-experiments', + outputName: 'shared-experiments-jest-unit-results.xml', + }, + ], + ], +}; + +export default config; diff --git a/libs/shared/experiments/package.json b/libs/shared/experiments/package.json new file mode 100644 index 0000000000..320007c223 --- /dev/null +++ b/libs/shared/experiments/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fxa/shared/experiments", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./index.cjs", + "types": "./index.d.ts", + "dependencies": {} +} diff --git a/libs/shared/experiments/project.json b/libs/shared/experiments/project.json new file mode 100644 index 0000000000..9c011479f3 --- /dev/null +++ b/libs/shared/experiments/project.json @@ -0,0 +1,29 @@ +{ + "name": "shared-experiments", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/experiments/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/shared/experiments", + "main": "libs/shared/experiments/src/index.ts", + "tsConfig": "libs/shared/experiments/tsconfig.lib.json", + "assets": ["libs/shared/experiments/*.md"], + "declaration": true, + "generatePackageJson": true + } + }, + "test-unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/shared/experiments/jest.config.ts", + "testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"] + } + } + } +} diff --git a/libs/shared/experiments/src/index.ts b/libs/shared/experiments/src/index.ts new file mode 100644 index 0000000000..159ff2f381 --- /dev/null +++ b/libs/shared/experiments/src/index.ts @@ -0,0 +1,6 @@ +export * from './lib/nimbus.config'; +export * from './lib/nimbus.client'; +export * from './lib/nimbus.errors'; +export * from './lib/nimbus.types'; +export * from './lib/nimbus.factories'; +export * from './lib/utils/generateNimbusId'; diff --git a/libs/shared/experiments/src/lib/nimbus.client.spec.ts b/libs/shared/experiments/src/lib/nimbus.client.spec.ts new file mode 100644 index 0000000000..c918ac6026 --- /dev/null +++ b/libs/shared/experiments/src/lib/nimbus.client.spec.ts @@ -0,0 +1,124 @@ +/* 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 { Test } from '@nestjs/testing'; +import { NimbusClient } from './nimbus.client'; +import { MockNimbusClientConfig, NimbusClientConfig } from './nimbus.config'; +import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; +import { + NimbusContextFactory, + NimbusEnrollmentFactory, + NimbusResultFactory, +} from './nimbus.factories'; +import { + NimbusClientFetchExperimentsHandledError, + NimbusClientFetchExperimentsUnexpectedError, +} from './nimbus.errors'; + +describe('NimbusClient', () => { + let nimbusClient: NimbusClient; + + const mockNimbusClientConfig = MockNimbusClientConfig; + const mockContext = NimbusContextFactory(); + const mockEnrollment = NimbusEnrollmentFactory(); + const mockResult = NimbusResultFactory({ + Enrollments: [mockEnrollment], + }); + const nimbusUserId = mockEnrollment.nimbus_user_id; + const mockFetchExperimentsParams = { + clientId: nimbusUserId, + context: mockContext, + }; + + beforeEach(async () => { + global.fetch = jest.fn(); + + const module = await Test.createTestingModule({ + providers: [ + NimbusClient, + { + provide: NimbusClientConfig, + useValue: mockNimbusClientConfig, + }, + MockStatsDProvider, + ], + }).compile(); + + nimbusClient = module.get(NimbusClient); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchExperiments', () => { + beforeEach(() => { + (fetch as jest.Mock).mockResolvedValue({ + json: () => Promise.resolve(mockResult), + ok: true, + status: 200, + }); + }); + + it('successfully returns experiments from Nimbus', async () => { + const result = await nimbusClient.fetchExperiments( + mockFetchExperimentsParams + ); + expect(result).toEqual(mockResult); + expect(fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: JSON.stringify({ + client_id: nimbusUserId, + context: mockContext, + }), + }) + ); + }); + + it('successfully calls with preview enabled', async () => { + mockNimbusClientConfig.previewEnabled = true; + const expectedUrl = + mockNimbusClientConfig.apiUrl + '?nimbus_preview=true'; + await nimbusClient.fetchExperiments(mockFetchExperimentsParams); + expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.anything()); + }); + + it('successfully calls with preview disabled', async () => { + mockNimbusClientConfig.previewEnabled = false; + const expectedUrl = + mockNimbusClientConfig.apiUrl + '?nimbus_preview=false'; + await nimbusClient.fetchExperiments(mockFetchExperimentsParams); + expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.anything()); + }); + + it('throws a handled error', async () => { + const errorData = { message: 'Handled error' }; + const expectedError = new NimbusClientFetchExperimentsHandledError( + errorData, + mockFetchExperimentsParams + ); + (fetch as jest.Mock).mockResolvedValue({ + json: () => Promise.resolve(errorData), + ok: false, + status: 400, + }); + await expect( + nimbusClient.fetchExperiments(mockFetchExperimentsParams) + ).rejects.toThrow(expectedError); + }); + + it('throws unhandled error', async () => { + const unexpectedError = new Error('unexpected error'); + const expectedError = new NimbusClientFetchExperimentsUnexpectedError( + unexpectedError, + mockFetchExperimentsParams + ); + (fetch as jest.Mock).mockRejectedValue(unexpectedError); + await expect( + nimbusClient.fetchExperiments(mockFetchExperimentsParams) + ).rejects.toThrow(expectedError); + }); + }); +}); diff --git a/libs/shared/experiments/src/lib/nimbus.client.ts b/libs/shared/experiments/src/lib/nimbus.client.ts new file mode 100644 index 0000000000..33f7f88026 --- /dev/null +++ b/libs/shared/experiments/src/lib/nimbus.client.ts @@ -0,0 +1,80 @@ +/* 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 { Inject, Injectable } from '@nestjs/common'; +import { NimbusClientConfig } from './nimbus.config'; +import type { NimbusContext } from './nimbus.types'; +import { + NimbusClientFetchExperimentsUnexpectedError, + NimbusClientFetchExperimentsHandledError, +} from './nimbus.errors'; +import { + CaptureTimingWithStatsD, + StatsD, + StatsDService, +} from '@fxa/shared/metrics/statsd'; + +@Injectable() +export class NimbusClient { + constructor( + private config: NimbusClientConfig, + @Inject(StatsDService) public statsd: StatsD + ) {} + + @CaptureTimingWithStatsD() + async fetchExperiments(params: { + clientId: string; + context: NimbusContext; + }) { + const { clientId, context } = params; + + const body = JSON.stringify({ + client_id: clientId, + context, + }); + + const queryParams = new URLSearchParams({ + nimbus_preview: this.config.previewEnabled === true ? 'true' : 'false', + }); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, this.config.timeoutMs); + + try { + const resp = await fetch( + `${this.config.apiUrl}?${queryParams.toString()}`, + { + method: 'POST', + body, + // A request to cirrus should not be more than 50ms, + // but we give it a large enough padding. + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!resp.ok) { + const errorResponse = await resp.json(); + throw new NimbusClientFetchExperimentsHandledError( + errorResponse, + params + ); + } + + return (await resp.json()) as ResultT; + } catch (err) { + if (err instanceof NimbusClientFetchExperimentsHandledError) { + throw err; + } else { + throw new NimbusClientFetchExperimentsUnexpectedError(err, params); + } + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/libs/shared/experiments/src/lib/nimbus.config.ts b/libs/shared/experiments/src/lib/nimbus.config.ts new file mode 100644 index 0000000000..ad852d6403 --- /dev/null +++ b/libs/shared/experiments/src/lib/nimbus.config.ts @@ -0,0 +1,29 @@ +/* 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 { faker } from '@faker-js/faker'; +import { Provider } from '@nestjs/common'; +import { IsBoolean, IsNumber, IsUrl } from 'class-validator'; + +export class NimbusClientConfig { + @IsUrl({ require_tld: false }) + public readonly apiUrl!: string; + + @IsBoolean() + public readonly previewEnabled!: boolean; + + @IsNumber() + public readonly timeoutMs!: number; +} + +export const MockNimbusClientConfig = { + apiUrl: faker.internet.url(), + previewEnabled: faker.datatype.boolean(), + timeoutMs: faker.number.int({ min: 100, max: 2000 }), +} satisfies NimbusClientConfig; + +export const MockNimbusClientConfigProvider = { + provide: NimbusClientConfig, + useValue: MockNimbusClientConfig, +} satisfies Provider; diff --git a/libs/shared/experiments/src/lib/nimbus.errors.ts b/libs/shared/experiments/src/lib/nimbus.errors.ts new file mode 100644 index 0000000000..5814718eaf --- /dev/null +++ b/libs/shared/experiments/src/lib/nimbus.errors.ts @@ -0,0 +1,46 @@ +/* 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 { BaseError } from '@fxa/shared/error'; +import type { NimbusContext } from './nimbus.types'; + +/** + * Nimbus is not intended for direct use, except for type-checking errors. + * When throwing a new AppleIapError, create a unique extension of the class. + */ +export class NimbusError extends BaseError { + constructor(message: string, info: Record, cause?: Error) { + super(message, { info, cause }); + this.name = 'NimbusError'; + } +} + +export class NimbusClientFetchExperimentsHandledError extends NimbusError { + constructor( + errorResponse: any, + params: { + clientId: string; + context: NimbusContext; + } + ) { + super('Error ocurred while fetching experiments', { + errorResponse, + params, + }); + this.name = 'NimbusClientFetchExperimentsError'; + } +} + +export class NimbusClientFetchExperimentsUnexpectedError extends NimbusError { + constructor( + cause: Error, + params: { + clientId: string; + context: NimbusContext; + } + ) { + super('Error ocurred while fetching experiments', { params }, cause); + this.name = 'NimbusClientFetchExperimentsUnexpectedError'; + } +} diff --git a/libs/shared/experiments/src/lib/nimbus.factories.ts b/libs/shared/experiments/src/lib/nimbus.factories.ts new file mode 100644 index 0000000000..317084ba55 --- /dev/null +++ b/libs/shared/experiments/src/lib/nimbus.factories.ts @@ -0,0 +1,38 @@ +/* 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 { faker } from '@faker-js/faker'; +import type { + NimbusContext, + NimbusEnrollment, + NimbusResult, +} from './nimbus.types'; + +export const NimbusContextFactory = ( + override?: Partial +): NimbusContext => ({ + language: faker.location.language().alpha2, + region: faker.location.countryCode('alpha-2'), + ...override, +}); + +export const NimbusEnrollmentFactory = ( + override?: Partial +): NimbusEnrollment => ({ + nimbus_user_id: faker.string.uuid(), + app_id: faker.string.uuid(), + experiment: faker.lorem.word(), + branch: faker.helpers.arrayElement(['develop', 'stage', 'prod']), + experiment_type: faker.helpers.arrayElement(['feature', 'abtest']), + is_preview: faker.datatype.boolean().toString(), + ...override, +}); + +export const NimbusResultFactory = ( + override?: Partial +): NimbusResult => ({ + Features: {}, + Enrollments: [], + ...override, +}); diff --git a/libs/shared/experiments/src/lib/nimbus.types.ts b/libs/shared/experiments/src/lib/nimbus.types.ts new file mode 100644 index 0000000000..39e57ccd0c --- /dev/null +++ b/libs/shared/experiments/src/lib/nimbus.types.ts @@ -0,0 +1,32 @@ +/* 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/. */ + +/** + * A collection of attributes about the client that will be used for + * targeting an experiment. + */ +export type NimbusContext = { + language: string | null; + region: string | null; +}; + +/** + * The nimbus experiments and enrollment information needed for applying a feature experiment. + */ +export interface NimbusEnrollment { + nimbus_user_id: string; + app_id: string; + experiment: string; + branch: string; + experiment_type: string; + is_preview: string; +} + +/** + * The nimbus experiments and enrollment information needed for applying a feature experiment. + */ +export interface NimbusResult { + Features: Record; + Enrollments: Array; +} diff --git a/libs/shared/experiments/src/lib/utils/generateNimbusId.spec.ts b/libs/shared/experiments/src/lib/utils/generateNimbusId.spec.ts new file mode 100644 index 0000000000..3d4b1a065b --- /dev/null +++ b/libs/shared/experiments/src/lib/utils/generateNimbusId.spec.ts @@ -0,0 +1,21 @@ +/* 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 { generateNimbusId } from './generateNimbusId'; + +describe('generateNimbusId', () => { + const namespace = '2b94b5a8-407f-5ff4-8d6a-e53ada958c9d'; + + it('returns a uuid v5', () => { + const id = 'test-id'; + const expected = 'a9e87f95-efa5-575e-bac2-e297abb1e597'; + + expect(generateNimbusId(namespace, id)).toEqual(expected); + }); + + it('returns a guest id with random uuid', () => { + const result = generateNimbusId(namespace); + expect(result.startsWith('guest-')).toBeTruthy(); + }); +}); diff --git a/libs/shared/experiments/src/lib/utils/generateNimbusId.ts b/libs/shared/experiments/src/lib/utils/generateNimbusId.ts new file mode 100644 index 0000000000..62496bac8a --- /dev/null +++ b/libs/shared/experiments/src/lib/utils/generateNimbusId.ts @@ -0,0 +1,13 @@ +/* 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 { v5 as uuidv5 } from 'uuid'; + +export function generateNimbusId(namespace: string, id?: string) { + if (id) { + return uuidv5(id, namespace); + } else { + return `guest-${crypto.randomUUID()}`; + } +} diff --git a/libs/shared/experiments/tsconfig.json b/libs/shared/experiments/tsconfig.json new file mode 100644 index 0000000000..86622aca55 --- /dev/null +++ b/libs/shared/experiments/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/shared/experiments/tsconfig.lib.json b/libs/shared/experiments/tsconfig.lib.json new file mode 100644 index 0000000000..4befa7f099 --- /dev/null +++ b/libs/shared/experiments/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/shared/experiments/tsconfig.spec.json b/libs/shared/experiments/tsconfig.spec.json new file mode 100644 index 0000000000..ab55b7c7ac --- /dev/null +++ b/libs/shared/experiments/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index e823613d23..07bcb9bcbf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,6 +41,7 @@ "@fxa/payments/customer": ["libs/payments/customer/src/index.ts"], "@fxa/payments/eligibility": ["libs/payments/eligibility/src/index.ts"], "@fxa/payments/events": ["libs/payments/events/src/index.ts"], + "@fxa/payments/experiments": ["libs/payments/experiments/src/index.ts"], "@fxa/payments/iap": ["libs/payments/iap/src/index.ts"], "@fxa/payments/legacy": ["libs/payments/legacy/src/index.ts"], "@fxa/payments/management": ["libs/payments/management/src/index.ts"], @@ -75,6 +76,7 @@ ], "@fxa/shared/error": ["libs/shared/error/src/index.ts"], "@fxa/shared/error/error": ["libs/shared/error/src/lib/error.ts"], + "@fxa/shared/experiments": ["libs/shared/experiments/src/index.ts"], "@fxa/shared/geodb": ["libs/shared/geodb/src/index.ts"], "@fxa/shared/glean": ["libs/shared/metrics/glean/src/index.ts"], "@fxa/shared/guards": ["libs/shared/guards/src/index.ts"],