diff --git a/.circleci/config.yml b/.circleci/config.yml index 82ee07f3baa..77bd4a02717 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -302,6 +302,25 @@ commands: type: string default: run-many steps: + - run: + name: Ensure Python venv has pip (for glean lint) + command: | + set -euxo pipefail + + echo "Python version:" + python3 --version + + rm -rf .venv + + python3 -m venv .venv + . .venv/bin/activate + + python -m ensurepip --upgrade || true + python -m pip install --upgrade pip setuptools wheel + + echo "pip version:" + python -m pip --version + - run: name: Linting command: npx nx << parameters.nx_run >> --parallel=1 -t lint diff --git a/_dev/docker/ci/Dockerfile b/_dev/docker/ci/Dockerfile index cafff597b4e..44009facc76 100644 --- a/_dev/docker/ci/Dockerfile +++ b/_dev/docker/ci/Dockerfile @@ -6,7 +6,8 @@ # workspace will be restored into the project folder. FROM cimg/node:22.15.1 AS test-runner RUN sudo apt-get update && sudo apt-get install -y \ - python3-venv + python3-venv \ + python3-pip WORKDIR /home/circleci COPY --chown=circleci:circleci project project WORKDIR /home/circleci/project @@ -39,7 +40,8 @@ RUN npx playwright install --with-deps firefox chromium webkit; # workspace will be restored into the project folder. FROM cimg/node:22.15.1-browsers AS functional-test-runner RUN sudo apt-get update && sudo apt-get install -y \ - python3-venv + python3-venv \ + python3-pip WORKDIR /home/circleci COPY --chown=circleci:circleci --from=playwright-install /home/circleci/.cache/ms-playwright .cache/ms-playwright/ COPY --chown=circleci:circleci project project diff --git a/apps/payments/api/.env b/apps/payments/api/.env index 8fc4c6ecc6d..c718e413642 100644 --- a/apps/payments/api/.env +++ b/apps/payments/api/.env @@ -57,3 +57,9 @@ GLEAN_CONFIG__APPLICATION_ID= GLEAN_CONFIG__VERSION=0.0.0 GLEAN_CONFIG__CHANNEL='development' GLEAN_CONFIG__LOGGER_APP_NAME='fxa-payments-next' + +# Glean Client Config +GLEAN_CLIENT_CONFIG__ENABLED=true +GLEAN_CLIENT_CONFIG__APPLICATION_ID= +GLEAN_CLIENT_CONFIG__VERSION=0.0.0 +GLEAN_CLIENT_CONFIG__CHANNEL='development' diff --git a/apps/payments/next/.env b/apps/payments/next/.env index 7708ee5c29c..872de851e37 100644 --- a/apps/payments/next/.env +++ b/apps/payments/next/.env @@ -110,6 +110,12 @@ GLEAN_CONFIG__APPLICATION_ID= GLEAN_CONFIG__CHANNEL='development' GLEAN_CONFIG__LOGGER_APP_NAME='fxa-payments-next' +# Glean Client Config +GLEAN_CLIENT_CONFIG__ENABLED=true +GLEAN_CLIENT_CONFIG__APPLICATION_ID= +# GLEAN_CLIENT_CONFIG__VERSION= # Set in next.config.js +GLEAN_CLIENT_CONFIG__CHANNEL='development' + # CSP Config CSP__ACCOUNTS_STATIC_CDN=https://cdn.accounts.firefox.com CSP__PAYPAL_API='https://www.sandbox.paypal.com' diff --git a/apps/payments/next/next.config.js b/apps/payments/next/next.config.js index 5320f239dc0..f021905c5ee 100644 --- a/apps/payments/next/next.config.js +++ b/apps/payments/next/next.config.js @@ -21,6 +21,7 @@ const nextConfig = { env: { version, GLEAN_CONFIG__VERSION: version, + GLEAN_CLIENT_CONFIG__VERSION: version, }, distDir: 'build', experimental: { @@ -96,11 +97,11 @@ const nextConfig = { headers: [ { key: 'Strict-Transport-Security', - value: 'max-age=63072000; includeSubDomains; preload' - } + value: 'max-age=63072000; includeSubDomains; preload', + }, ], }, - ] + ]; }, }; diff --git a/apps/payments/next/project.json b/apps/payments/next/project.json index 49b57cd0835..c1c311a7e36 100644 --- a/apps/payments/next/project.json +++ b/apps/payments/next/project.json @@ -5,7 +5,7 @@ "projectType": "application", "targets": { "build": { - "dependsOn": ["l10n-bundle", "glean-generate"] + "dependsOn": ["l10n-bundle", "glean-generate", "glean-generate-frontend"] }, "dev": { "options": { @@ -39,7 +39,7 @@ }, "start": { "command": "pm2 start apps/payments/next/pm2.config.js && yarn check:url localhost:3035/__heartbeat__", - "dependsOn": ["l10n-bundle", "glean-generate"] + "dependsOn": ["l10n-bundle", "glean-generate", "glean-generate-frontend"] }, "stop": { "command": "pm2 stop apps/payments/next/pm2.config.js" @@ -71,6 +71,13 @@ }, "glean-lint": { "command": "yarn glean glinter libs/shared/metrics/glean/src/registry/subplat-backend-metrics.yaml" + }, + "glean-generate-frontend": { + "dependsOn": ["glean-lint-frontend"], + "command": "yarn glean translate libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml -f typescript -o libs/payments/metrics/src/lib/glean/__generated__" + }, + "glean-lint-frontend": { + "command": "yarn glean glinter libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml" } }, "tags": ["app", "payments", "type:sp3"] diff --git a/libs/payments/metrics/jest.config.ts b/libs/payments/metrics/jest.config.ts index f59f6bc0b4f..e4cb5d7c205 100644 --- a/libs/payments/metrics/jest.config.ts +++ b/libs/payments/metrics/jest.config.ts @@ -28,6 +28,7 @@ const config: Config = { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../../coverage/libs/payments/metrics', + setupFilesAfterEnv: ['/test/jest.setup.ts'], reporters: [ 'default', [ diff --git a/libs/payments/metrics/project.json b/libs/payments/metrics/project.json index e4a8a73ab97..229178fcce4 100644 --- a/libs/payments/metrics/project.json +++ b/libs/payments/metrics/project.json @@ -15,11 +15,11 @@ "assets": ["libs/payments/metrics/*.md"], "declaration": true }, - "dependsOn": ["glean-generate"] + "dependsOn": ["glean-generate", "glean-generate-frontend"] }, "compile": { "command": "tsc -p libs/payments/metrics/tsconfig.json --noEmit", - "dependsOn": ["glean-generate"] + "dependsOn": ["glean-generate", "glean-generate-frontend"] }, "glean-generate": { "dependsOn": ["glean-lint"], @@ -28,6 +28,13 @@ "glean-lint": { "command": "yarn glean glinter libs/shared/metrics/glean/src/registry/subplat-backend-metrics.yaml" }, + "glean-generate-frontend": { + "dependsOn": ["glean-lint-frontend"], + "command": "yarn glean translate libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml -f typescript -o libs/payments/metrics/src/lib/glean/__generated__" + }, + "glean-lint-frontend": { + "command": "yarn glean glinter libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml" + }, "test-unit": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], diff --git a/libs/payments/metrics/src/index.ts b/libs/payments/metrics/src/index.ts index c9d59b85cf1..c4b7701c4e1 100644 --- a/libs/payments/metrics/src/index.ts +++ b/libs/payments/metrics/src/index.ts @@ -1,9 +1,11 @@ /* 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/glean/glean.types'; export * from './lib/glean/glean.manager'; export * from './lib/glean/glean.service'; export * from './lib/glean/glean.config'; export * from './lib/glean/glean.factory'; export * from './lib/glean/glean.test-provider'; +export * from './lib/glean/glean-client.manager'; diff --git a/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts b/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts new file mode 100644 index 00000000000..74e4899a9d7 --- /dev/null +++ b/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts @@ -0,0 +1,370 @@ +/* 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 { PaymentsGleanClientManager } from './glean-client.manager'; +import { PaymentsGleanClientConfig } from './glean.config'; +import { + PageViewEventFactory, + RetentionFlowEventFactory, +} from './glean.factory'; + +const mockGlean = jest.requireMock('@mozilla/glean/web').default; +const mockSubscriptions = jest.requireMock('./__generated__/subscriptions'); + +describe('PaymentsGleanClientManager', () => { + const originalEnv = process.env; + let paymentsGleanClientManager: PaymentsGleanClientManager; + + const mockConfigValue: PaymentsGleanClientConfig = { + enabled: true, + applicationId: 'test.app', + version: '0.0.0-test', + channel: 'development', + }; + + beforeEach(async () => { + jest.resetModules(); + jest.clearAllMocks(); + process.env = { ...originalEnv, CI: '' }; + (global as any).window = {}; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: PaymentsGleanClientConfig, useValue: mockConfigValue }, + PaymentsGleanClientManager, + ], + }).compile(); + + paymentsGleanClientManager = moduleRef.get(PaymentsGleanClientManager); + }); + + afterEach(() => { + delete (global as any).window; + process.env = originalEnv; + }); + + it('should be defined', () => { + expect(paymentsGleanClientManager).toBeDefined(); + }); + + describe('initialize', () => { + it('does not initialize Glean in non-browser environment', () => { + delete (global as any).window; + + paymentsGleanClientManager.initialize(); + + expect(mockGlean.initialize).not.toHaveBeenCalled(); + }); + + it('initializes Glean with correct config', async () => { + paymentsGleanClientManager.initialize(); + + expect(mockGlean.initialize).toHaveBeenCalledWith( + mockConfigValue.applicationId, + true, + { + appDisplayVersion: mockConfigValue.version, + channel: mockConfigValue.channel, + } + ); + }); + + it('does not initialize Glean when disabled', async () => { + const disabledConfigValue = { + ...mockConfigValue, + enabled: false, + }; + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: PaymentsGleanClientConfig, useValue: disabledConfigValue }, + PaymentsGleanClientManager, + ], + }).compile(); + const manager = moduleRef.get(PaymentsGleanClientManager); + + manager.initialize(); + + expect(mockGlean.initialize).not.toHaveBeenCalled(); + }); + }); + + describe('recordPageView', () => { + it('initializes and records page view', async () => { + paymentsGleanClientManager.recordPageView( + PageViewEventFactory({ pageName: 'management' }) + ); + + expect(mockGlean.initialize).toHaveBeenCalledWith( + mockConfigValue.applicationId, + true, + { + appDisplayVersion: mockConfigValue.version, + channel: mockConfigValue.channel, + } + ); + expect(mockSubscriptions.pageView.record).toHaveBeenCalledWith( + expect.objectContaining({ + page_name: 'management', + }) + ); + expect(mockSubscriptions.pageView.record).toHaveBeenCalledTimes(1); + }); + + it('initializes Glean only once', () => { + paymentsGleanClientManager.recordPageView( + PageViewEventFactory({ pageName: 'management' }) + ); + paymentsGleanClientManager.recordPageView( + PageViewEventFactory({ + pageName: 'management', + }) + ); + + expect(mockGlean.initialize).toHaveBeenCalledTimes(1); + }); + + it('does not record page_view when config.enabled=false', async () => { + const disabledConfig = { ...mockConfigValue, enabled: false }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: PaymentsGleanClientConfig, useValue: disabledConfig }, + PaymentsGleanClientManager, + ], + }).compile(); + + const manager = moduleRef.get(PaymentsGleanClientManager); + + manager.recordPageView(PageViewEventFactory({ pageName: 'management' })); + + expect(mockSubscriptions.pageView.record).not.toHaveBeenCalled(); + }); + + it('does not record page_view when CI=true', () => { + process.env['CI'] = 'true'; + + paymentsGleanClientManager.recordPageView( + PageViewEventFactory({ pageName: 'management' }) + ); + + expect(mockSubscriptions.pageView.record).not.toHaveBeenCalled(); + }); + + it('does not throw if window is undefined', () => { + delete (global as any).window; + + expect(() => + paymentsGleanClientManager.recordPageView( + PageViewEventFactory({ pageName: 'management' }) + ) + ).not.toThrow(); + }); + }); + + describe('recordRetentionFlow', () => { + it('initializes and records retention flow when enabled', () => { + paymentsGleanClientManager.recordRetentionFlow( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + + expect(mockSubscriptions.retentionFlow.record).toHaveBeenCalledWith( + expect.objectContaining({ + flow_type: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + expect(mockGlean.initialize).toHaveBeenCalledTimes(1); + expect(mockSubscriptions.retentionFlow.record).toHaveBeenCalledTimes(1); + }); + + it('does not recordRetentionFlow when disabled', async () => { + const disabledConfigValue = { ...mockConfigValue, enabled: false }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: PaymentsGleanClientConfig, useValue: disabledConfigValue }, + PaymentsGleanClientManager, + ], + }).compile(); + + const manager = moduleRef.get(PaymentsGleanClientManager); + + manager.recordRetentionFlow( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + + expect(mockSubscriptions.retentionFlow.record).not.toHaveBeenCalled(); + }); + + it('does not recordRetentionFlow in non-browser environment', () => { + delete (global as any).window; + + paymentsGleanClientManager.recordRetentionFlow( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + + expect(mockSubscriptions.retentionFlow.record).not.toHaveBeenCalled(); + }); + + it('does not throw if recordRetentionFlow throws', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockSubscriptions.retentionFlow.record.mockImplementation(() => { + throw new Error('boom'); + }); + + expect(() => + paymentsGleanClientManager.recordRetentionFlow( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ) + ).not.toThrow(); + + expect(console.warn).toHaveBeenCalledWith( + 'Glean client metric record failed', + expect.any(Error) + ); + }); + + it('initializes only once when recording retention flow multiple times', () => { + paymentsGleanClientManager.recordRetentionFlow( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + paymentsGleanClientManager.recordRetentionFlow( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'submit', + outcome: 'success', + }) + ); + + expect(mockGlean.initialize).toHaveBeenCalledTimes(1); + expect(mockSubscriptions.retentionFlow.record).toHaveBeenCalledTimes(2); + }); + }); + + describe('recordInterstitialOffer', () => { + it('initializes and records interstitial offer', () => { + paymentsGleanClientManager.recordInterstitialOffer( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + + expect(mockGlean.initialize).toHaveBeenCalledWith( + mockConfigValue.applicationId, + true, + { + appDisplayVersion: mockConfigValue.version, + channel: mockConfigValue.channel, + } + ); + + expect(mockSubscriptions.interstitialOffer.record).toHaveBeenCalledWith( + expect.objectContaining({ + flow_type: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + }); + + it('does not record interstitial offer in non-browser environment', () => { + delete (global as any).window; + + paymentsGleanClientManager.recordInterstitialOffer( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + + expect(mockSubscriptions.interstitialOffer.record).not.toHaveBeenCalled(); + expect(mockGlean.initialize).not.toHaveBeenCalled(); + }); + + it('does not record interstitial offer when CI=true', () => { + process.env = { ...process.env, CI: 'true' }; + + paymentsGleanClientManager.recordInterstitialOffer( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + + expect(mockSubscriptions.interstitialOffer.record).not.toHaveBeenCalled(); + }); + + it('does not throw if interstitialOffer.record throws', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + mockSubscriptions.interstitialOffer.record.mockImplementation(() => { + throw new Error('boom'); + }); + + expect(() => + paymentsGleanClientManager.recordInterstitialOffer( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ) + ).not.toThrow(); + + expect(console.warn).toHaveBeenCalledWith( + 'Glean client metric record failed', + expect.any(Error) + ); + }); + + it('initializes Glean only once when recording interstitial offer multiple times', () => { + paymentsGleanClientManager.recordInterstitialOffer( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'engage', + outcome: 'success', + }) + ); + + paymentsGleanClientManager.recordInterstitialOffer( + RetentionFlowEventFactory({ + flowType: 'cancel', + step: 'result', + outcome: 'success', + }) + ); + + expect(mockGlean.initialize).toHaveBeenCalledTimes(1); + expect(mockSubscriptions.interstitialOffer.record).toHaveBeenCalledTimes( + 2 + ); + }); + }); +}); diff --git a/libs/payments/metrics/src/lib/glean/glean-client.manager.ts b/libs/payments/metrics/src/lib/glean/glean-client.manager.ts new file mode 100644 index 00000000000..6abf85b617f --- /dev/null +++ b/libs/payments/metrics/src/lib/glean/glean-client.manager.ts @@ -0,0 +1,107 @@ +/* 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 Glean from '@mozilla/glean/web'; +import { Injectable } from '@nestjs/common'; +import * as subscriptions from './__generated__/subscriptions'; +import { PaymentsGleanClientConfig } from './glean.config'; +import type { + PageMetricsData, + RetentionFlowEventMetricsData, +} from './glean.types'; + +@Injectable() +export class PaymentsGleanClientManager { + private initialized = false; + + constructor(private paymentsGleanClientConfig: PaymentsGleanClientConfig) {} + + initialize() { + if (this.initialized) return; + if (typeof window === 'undefined') return; + if (!this.isEnabled) return; + + try { + Glean.initialize( + this.paymentsGleanClientConfig.applicationId, + this.isEnabled, + { + appDisplayVersion: this.paymentsGleanClientConfig.version, + channel: this.paymentsGleanClientConfig.channel, + } + ); + + this.initialized = true; + } catch (err) { + console.warn('Payments Glean client initialization failed', err); + } + } + + recordPageView(args: PageMetricsData) { + this.recordWithGlean(() => + subscriptions.pageView.record(this.mapPageViewToGlean(args)) + ); + } + + recordRetentionFlow(args: RetentionFlowEventMetricsData) { + this.recordWithGlean(() => + subscriptions.retentionFlow.record(this.mapRetentionFlowToGlean(args)) + ); + } + + recordInterstitialOffer(args: RetentionFlowEventMetricsData) { + this.recordWithGlean(() => + subscriptions.interstitialOffer.record(this.mapRetentionFlowToGlean(args)) + ); + } + + private get isEnabled() { + return ( + this.paymentsGleanClientConfig.enabled && process.env['CI'] !== 'true' + ); + } + + private recordWithGlean(fn: () => void) { + if (typeof window === 'undefined') return; + if (!this.initialized) { + this.initialize(); + } + if (!this.isEnabled) return; + + try { + fn(); + } catch (err) { + console.warn('Glean client metric record failed', err); + } + } + + private mapPageViewToGlean(pageMetrics: PageMetricsData) { + return { + page_name: pageMetrics.pageName, + page_variant: pageMetrics.pageVariant ?? '', + source: pageMetrics.source ?? '', + offering_id: pageMetrics.offeringId ?? '', + interval: pageMetrics.interval ?? '', + }; + } + + private mapRetentionFlowToGlean( + retentionFlowMetrics: RetentionFlowEventMetricsData + ) { + return { + flow_type: retentionFlowMetrics.flowType, + step: retentionFlowMetrics.step, + outcome: retentionFlowMetrics.outcome, + error_reason: retentionFlowMetrics.errorReason ?? '', + offering_id: retentionFlowMetrics.offeringId ?? '', + interval: retentionFlowMetrics.interval ?? '', + eligibility_status: retentionFlowMetrics.eligibilityStatus ?? '', + entrypoint: retentionFlowMetrics.entrypoint ?? '', + utm_source: retentionFlowMetrics.utmSource ?? '', + utm_medium: retentionFlowMetrics.utmMedium ?? '', + utm_campaign: retentionFlowMetrics.utmCampaign ?? '', + nimbus_user_id: retentionFlowMetrics.nimbusUserId ?? '', + }; + } +} diff --git a/libs/payments/metrics/src/lib/glean/glean.config.ts b/libs/payments/metrics/src/lib/glean/glean.config.ts index a4eb0ef206b..14800954d88 100644 --- a/libs/payments/metrics/src/lib/glean/glean.config.ts +++ b/libs/payments/metrics/src/lib/glean/glean.config.ts @@ -42,3 +42,30 @@ export const MockPaymentsGleanConfigProvider = { provide: PaymentsGleanConfig, useValue: MockPaymentsGleanConfig, } satisfies Provider; + +export class PaymentsGleanClientConfig { + @Type(() => Boolean) + @IsBoolean() + enabled!: boolean; + + @IsString() + applicationId!: string; + + @IsString() + version!: string; + + @IsEnum(GleanChannel) + channel!: string; +} + +export const MockPaymentsGleanClientConfig = { + enabled: true, + applicationId: faker.string.uuid(), + version: '0.0.1', + channel: GleanChannel.Development, +} satisfies PaymentsGleanClientConfig; + +export const MockPaymentsGleanClientConfigProvider = { + provide: PaymentsGleanClientConfig, + useValue: MockPaymentsGleanClientConfig, +} satisfies Provider; diff --git a/libs/payments/metrics/src/lib/glean/glean.factory.ts b/libs/payments/metrics/src/lib/glean/glean.factory.ts index 709dcc22db5..503e8e9c525 100644 --- a/libs/payments/metrics/src/lib/glean/glean.factory.ts +++ b/libs/payments/metrics/src/lib/glean/glean.factory.ts @@ -1,17 +1,26 @@ /* 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 { CancellationReason, CartMetrics, CmsMetricsData, CommonMetrics, + EligibilityStatus, + FlowType, + Outcome, + PageName, + Source, + Step, SubscriptionCancellationData, type AccountsMetricsData, type ExperimentationData, type GenericGleanSubManageEvent, type GleanMetricsData, + type PageMetricsData, + type RetentionFlowEventMetricsData, type SessionMetricsData, type StripeMetricsData, type SubPlatCmsMetricsData, @@ -158,3 +167,41 @@ export const GleanMetricsDataFactory = ( experimentation: ExperimentationDataFactory(), ...override, }); + +export const PageViewEventFactory = (override?: Partial) => ({ + pageName: faker.helpers.arrayElement([ + 'management', + 'stay_standard', + 'cancel_standard', + ]), + source: faker.helpers.arrayElement([ + 'email', + 'internal_nav', + 'deep_link', + ]), + offeringId: faker.string.alphanumeric(8), + interval: faker.helpers.enumValue(SubplatInterval), + ...override, +}); + +export const RetentionFlowEventFactory = ( + override?: Partial +) => ({ + flowType: faker.helpers.arrayElement(['cancel', 'stay']), + eligibilityStatus: faker.helpers.arrayElement([ + 'cancel', + 'stay', + 'offer', + 'none', + ]), + step: faker.helpers.arrayElement([ + 'view', + 'engage', + 'submit', + 'result', + ]), + outcome: faker.helpers.arrayElement(['success', 'error']), + offeringId: faker.string.alphanumeric(8), + interval: faker.helpers.enumValue(SubplatInterval), + ...override, +}); diff --git a/libs/payments/metrics/src/lib/glean/glean.types.ts b/libs/payments/metrics/src/lib/glean/glean.types.ts index 8d7296fc934..b841fa9128f 100644 --- a/libs/payments/metrics/src/lib/glean/glean.types.ts +++ b/libs/payments/metrics/src/lib/glean/glean.types.ts @@ -1,7 +1,9 @@ /* 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 { ResultCart } from '@fxa/payments/cart'; +import { SubplatInterval } from '@fxa/payments/customer'; export const CheckoutTypes = [ 'new_account', @@ -112,3 +114,70 @@ export type PaymentsGleanServerEventsLoggerTester = { recordPaySetupFail: (data: any) => void; recordSubscriptionEnded: (data: any) => void; }; + +export type PageName = + | 'management' + | 'stay_standard' + | 'stay_retention' + | 'cancel_standard' + | 'cancel_retention' + | 'interstitial_offer'; + +export type PageVariant = + | 'stay_standard_success' + | 'stay_retention_success' + | 'cancel_standard_success' + | 'cancel_retention_success' + | 'interstitial_offer_success'; + +export type Source = 'email' | 'internal_nav' | 'deep_link'; +export type EligibilityStatus = 'cancel' | 'stay' | 'offer' | 'none'; +export type FlowType = 'cancel' | 'stay'; +export type Step = 'view' | 'engage' | 'submit' | 'result'; +export type Outcome = 'success' | 'error'; + +export type ErrorReason = + | 'customer_mismatch' + | 'discount_already_applied' + | 'general_error' + | 'no_churn_intervention_found' + | 'redemption_limit_exceeded' + | 'subscription_not_active' + | 'subscription_not_found' + | 'subscription_still_active'; + +export type Interval = SubplatInterval; + +export type PageMetricsData = { + pageName: PageName; + pageVariant?: PageVariant; + source?: Source; + offeringId?: string; + interval?: Interval; +}; + +export type RetentionFlowEventMetricsData = { + flowType: FlowType; + step: Step; + outcome: Outcome; + errorReason?: ErrorReason; + offeringId?: string; + interval?: Interval; + eligibilityStatus?: EligibilityStatus; + entrypoint?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + nimbusUserId?: string; +}; + +export type FlowStatus = + | 'eligible_for_stay' + | 'eligible_for_cancel' + | 'eligible_for_offer'; + +export type RetentionEligibilityMetricsData = { + product: string; + interval: 'monthly' | 'yearly'; + eligibilityStatus: FlowStatus; +}; diff --git a/libs/payments/metrics/test/jest.setup.ts b/libs/payments/metrics/test/jest.setup.ts new file mode 100644 index 00000000000..44d46d9fa95 --- /dev/null +++ b/libs/payments/metrics/test/jest.setup.ts @@ -0,0 +1,17 @@ +import { jest } from '@jest/globals'; + +jest.mock('@mozilla/glean/web', () => { + const mockGlean = { + initialize: jest.fn(), + setUploadEnabled: jest.fn(), + shutdown: jest.fn(), + }; + return { __esModule: true, default: mockGlean, Glean: mockGlean }; +}); + +jest.mock('../src/lib/glean/__generated__/subscriptions', () => ({ + __esModule: true, + interstitialOffer: { record: jest.fn() }, + retentionFlow: { record: jest.fn() }, + pageView: { record: jest.fn() }, +})); diff --git a/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml b/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml new file mode 100644 index 00000000000..2b0251df7c8 --- /dev/null +++ b/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml @@ -0,0 +1,182 @@ +--- +# Schema +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 + +subscriptions: + page_view: + type: event + description: | + View of pages (management, standard, retention, interstitial offer) + send_in_pings: + - events + notification_emails: + - lchan@mozilla.com + - subplat-team@mozilla.com + bugs: + - https://mozilla-hub.atlassian.net/browse/PAY-3472 + data_reviews: + - FILL IN + expires: never + data_sensitivity: + - interaction + extra_keys: + page_name: + description: | + The page reached by the user. Allowed values: + - management + - stay_standard + - stay_retention + - cancel_standard + - cancel_retention + - interstitial_offer + type: string + page_variant: + description: | + UI state of page. Allowed values: + - stay_standard_success + - stay_retention_success + - cancel_standard_success + - cancel_retention_success + - interstitial_offer_success + type: string + source: + description: | + Source of traffic. Allowed values: email, internal_nav, deep_link. + type: string + offering_id: + description: The API id of the specific subscription offering. + type: string + interval: + description: | + Billing interval for the subscription (daily | weekly | monthly | halfyearly | yearly). + type: string + + retention_flow: + type: event + description: | + Tracks the lifecycle of a retention flow decision. + send_in_pings: + - events + notification_emails: + - lchan@mozilla.com + - subplat-team@mozilla.com + bugs: + - https://mozilla-hub.atlassian.net/browse/PAY-3472 + data_reviews: + - FILL IN + expires: never + data_sensitivity: + - interaction + extra_keys: + entrypoint: + description: | + Where the flow was entered from (e.g., email | internal_nav | deep_link). + type: string + flow_type: + description: | + Type of retention flow. Allowed values: cancel, stay. + type: string + eligibility_status: + description: | + Eligibility status for the subscription. Allowed values: stay, cancel, offer, none. + type: string + step: + description: | + Stage in the user journey lifecycle. Allowed values: view, engage, submit, result. + type: string + outcome: + description: | + The category of the result. Allowed values: success, error. + type: string + error_reason: + description: | + Specific reason for failure. Allowed values: + - customer_mismatch + - discount_already_applied + - general_error + - no_churn_intervention_found + - redemption_limit_exceeded + - subscription_not_active + - subscription_not_found + - subscription_still_active + type: string + offering_id: + description: The API id of the specific subscription offering. + type: string + interval: + description: Billing interval for the subscription (daily | weekly | monthly | halfyearly | yearly) + type: string + utm_source: + description: UTM source (if present). + type: string + utm_medium: + description: UTM medium (if present). + type: string + utm_campaign: + description: UTM campaign (if present). + type: string + nimbus_user_id: + description: Nimbus user ID + type: string + + interstitial_offer: + type: event + description: Tracks engagement with non-retention flow. + send_in_pings: + - events + notification_emails: + - lchan@mozilla.com + - subplat-team@mozilla.com + bugs: + - https://mozilla-hub.atlassian.net/browse/PAY-3472 + data_reviews: + - FILL IN + expires: never + data_sensitivity: + - interaction + extra_keys: + flow_type: + description: | + Type of retention flow. Allowed values: cancel, stay. + type: string + step: + description: | + Stage in the user journey lifecycle. Allowed values: view, engage, submit, result. + type: string + outcome: + description: | + The category of the result. Allowed values: success, error. + type: string + error_reason: + description: | + Specific reason for failure. Allowed values: + - subscription_not_found + - customer_mismatch + - subscription_not_active + - stripe_price_id_not_found + - already_canceling_at_period_end + - current_interval_not_found + - offering_id_not_found + - no_cancel_interstitial_offer_found + - no_upgrade_plan_found + - not_eligible_for_upgrade_interval + - general_error + type: string + offering_id: + description: The API id of the specific subscription offering. + type: string + interval: + description: Billing interval for the subscription (daily | weekly | monthly | halfyearly | yearly) + type: string + utm_source: + type: string + description: UTM source (if present). + utm_medium: + type: string + description: UTM medium (if present). + utm_campaign: + type: string + description: UTM campaign (if present). + nimbus_user_id: + description: Nimbus user ID + type: string