From 2a315bbf0c20a579bda85d28c5aaae9576a7004d Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Mon, 23 Feb 2026 16:52:41 -0500 Subject: [PATCH 1/5] WIP --- apps/payments/api/.env | 6 + apps/payments/next/.env | 6 + apps/payments/next/next.config.js | 7 +- libs/payments/metrics/project.json | 9 +- .../lib/glean/glean-client.manager.spec.ts | 511 ++++++++++++++++++ .../src/lib/glean/glean-client.manager.ts | 133 +++++ .../metrics/src/lib/glean/glean.config.ts | 27 + .../metrics/src/lib/glean/glean.factory.ts | 57 ++ .../metrics/src/lib/glean/glean.types.ts | 64 +++ .../registry/subplat-frontend-metrics.yaml | 197 +++++++ 10 files changed, 1013 insertions(+), 4 deletions(-) create mode 100644 libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts create mode 100644 libs/payments/metrics/src/lib/glean/glean-client.manager.ts create mode 100644 libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml 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/libs/payments/metrics/project.json b/libs/payments/metrics/project.json index e4a8a73ab97..f599f3f67d1 100644 --- a/libs/payments/metrics/project.json +++ b/libs/payments/metrics/project.json @@ -15,7 +15,7 @@ "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", @@ -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/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..ff8fd787c68 --- /dev/null +++ b/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts @@ -0,0 +1,511 @@ +// /* 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, + RetentionEligibilityFactory, + RetentionFlowEventFactory, +} from './glean.factory'; + +jest.mock('@mozilla/glean/web', () => { + return { + __esModule: true, + default: { + initialize: jest.fn(), + }, + }; +}); + +jest.mock('./__generated__/subscriptions', () => ({ + pageView: { record: jest.fn() }, + retentionFlow: { record: jest.fn() }, + interstitialOffer: { record: jest.fn() }, + retentionEligibility: { + 'vpn.monthly.eligible_for_offer': { add: jest.fn() }, + }, +})); + +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('recordRetentionEligibility', () => { + it('records retention eligibility', () => { + paymentsGleanClientManager.recordRetentionEligibility( + RetentionEligibilityFactory({ + product: 'vpn', + interval: 'monthly', + eligibilityStatus: 'eligible_for_offer', + }) + ); + + expect( + mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] + .add + ).toHaveBeenCalled(); + expect( + mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] + .add + ).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the label is not present', () => { + paymentsGleanClientManager.recordRetentionEligibility( + RetentionEligibilityFactory({ + product: 'vpn', + interval: 'monthly', + eligibilityStatus: 'eligible_for_stay', + }) + ); + + expect( + mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] + .add + ).not.toHaveBeenCalled(); + }); + + it('does not record when CI=true', () => { + process.env['CI'] = 'true'; + + paymentsGleanClientManager.recordRetentionEligibility( + RetentionEligibilityFactory({ + product: 'vpn', + interval: 'monthly', + eligibilityStatus: 'eligible_for_offer', + }) + ); + + expect( + mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] + .add + ).not.toHaveBeenCalled(); + }); + + it('does not record when config.enabled=false', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: PaymentsGleanClientConfig, + useValue: { ...mockConfigValue, enabled: false }, + }, + PaymentsGleanClientManager, + ], + }).compile(); + + const manager = moduleRef.get(PaymentsGleanClientManager); + + manager.recordRetentionEligibility( + RetentionEligibilityFactory({ + product: 'vpn', + interval: 'monthly', + eligibilityStatus: 'eligible_for_offer', + }) + ); + + expect( + mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] + .add + ).not.toHaveBeenCalled(); + }); + + it('does not throw if the labeled counter add() throws', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const addSpy = + mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] + .add; + + addSpy.mockImplementation(() => { + throw new Error('boom'); + }); + + expect(() => + paymentsGleanClientManager.recordRetentionEligibility( + RetentionEligibilityFactory({ + product: 'vpn', + interval: 'monthly', + eligibilityStatus: 'eligible_for_offer', + }) + ) + ).not.toThrow(); + + expect(console.warn).toHaveBeenCalledWith( + 'Glean client metric record failed', + expect.any(Error) + ); + }); + + it('computes the label as `${product}.${interval}.${eligibility_status}`', () => { + paymentsGleanClientManager.recordRetentionEligibility( + RetentionEligibilityFactory({ + product: 'vpn', + interval: 'monthly', + eligibilityStatus: 'eligible_for_offer', + }) + ); + + expect( + mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] + .add + ).toHaveBeenCalled(); + }); + }); + + 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..38724784b75 --- /dev/null +++ b/libs/payments/metrics/src/lib/glean/glean-client.manager.ts @@ -0,0 +1,133 @@ +/* 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, + RetentionEligibilityMetricsData, + RetentionFlowEventMetricsData, +} from './glean.types'; + +type RetentionEligibilityLabel = + keyof typeof subscriptions.retentionEligibility; + +@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)) + ); + } + + recordRetentionEligibility(args: RetentionEligibilityMetricsData) { + this.recordWithGlean(() => { + const label = this.mapRetentionEligibilityLabel(args); + if (!label) return; + + const metric = subscriptions.retentionEligibility[label]; + if (!metric || typeof metric.add !== 'function') { + return; + } + + metric.add(); + }); + } + + 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 mapRetentionEligibilityLabel( + args: RetentionEligibilityMetricsData + ): RetentionEligibilityLabel | null { + const label = + `${args.product}.${args.interval}.${args.eligibilityStatus}` as const; + + if (!(label in subscriptions.retentionEligibility)) { + return null; + } + + return label as RetentionEligibilityLabel; + } + + 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 ?? '', + 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..090837152e1 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, + FlowType, + Outcome, + PageName, + Source, + Step, SubscriptionCancellationData, type AccountsMetricsData, type ExperimentationData, type GenericGleanSubManageEvent, type GleanMetricsData, + type PageMetricsData, + type RetentionFlowEventMetricsData, + type RetentionEligibilityMetricsData, type SessionMetricsData, type StripeMetricsData, type SubPlatCmsMetricsData, @@ -158,3 +167,51 @@ 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']), + step: faker.helpers.arrayElement(['engage', 'submit', 'result']), + outcome: faker.helpers.arrayElement(['success', 'error']), + offeringId: faker.string.alphanumeric(8), + interval: faker.helpers.enumValue(SubplatInterval), + ...override, +}); + +export const RetentionEligibilityFactory = ( + override?: Partial +) => ({ + product: faker.helpers.arrayElement([ + 'vpn', + 'relaypremiumphone', + 'relaypremium', + 'mdnplus5m', + 'mdnplus10m', + 'mdnplus5y', + 'mdnplus10y', + ]), + interval: faker.helpers.arrayElement(['monthly', 'yearly']), + eligibilityStatus: faker.helpers.arrayElement([ + 'eligible_for_stay', + 'eligible_for_cancel', + 'eligible_for_offer', + ]), + ...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..4c82de746b1 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,65 @@ 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 FlowType = 'cancel' | 'stay'; +export type Step = '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; + 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/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..e00a24d5c78 --- /dev/null +++ b/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml @@ -0,0 +1,197 @@ +--- +# 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_eligibility: + type: labeled_counter + description: | + Counts occurrences of retention eligibility outcomes by product, billing interval, and eligibility type. + notification_emails: + - lchan@mozilla.com + - subplat-team@mozilla.com + bugs: + - https://mozilla-hub.atlassian.net/browse/PAY-3472 + data_reviews: + - FILL IN + expires: never + labels: + - vpn.monthly.eligible_for_stay + - vpn.monthly.eligible_for_cancel + - vpn.monthly.eligible_for_offer + - vpn.yearly.eligible_for_stay + - vpn.yearly.eligible_for_cancel + - vpn.yearly.eligible_for_offer + - relaypremiumphone.monthly.eligible_for_stay + - relaypremiumphone.monthly.eligible_for_cancel + - relaypremiumphone.monthly.eligible_for_offer + - relaypremiumphone.yearly.eligible_for_stay + - relaypremiumphone.yearly.eligible_for_cancel + - relaypremiumphone.yearly.eligible_for_offer + - relaypremium.monthly.eligible_for_stay + - relaypremium.monthly.eligible_for_cancel + - relaypremium.monthly.eligible_for_offer + - relaypremium.yearly.eligible_for_stay + - relaypremium.yearly.eligible_for_cancel + - relaypremium.yearly.eligible_for_offer + - mdnplus5m.monthly.eligible_for_stay + - mdnplus5m.monthly.eligible_for_cancel + - mdnplus5m.monthly.eligible_for_offer + - mdnplus10m.monthly.eligible_for_stay + - mdnplus10m.monthly.eligible_for_cancel + - mdnplus10m.monthly.eligible_for_offer + - mdnplus5y.yearly.eligible_for_stay + - mdnplus5y.yearly.eligible_for_cancel + - mdnplus5y.yearly.eligible_for_offer + - mdnplus10y.yearly.eligible_for_stay + - mdnplus10y.yearly.eligible_for_cancel + - mdnplus10y.yearly.eligible_for_offer + + 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: + flow_type: + description: | + Type of retention flow. Allowed values: cancel, stay. + type: string + step: + description: | + Stage in the user journey lifecycle. Allowed values: 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 + 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: 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 + nimbus_user_id: + description: Nimbus user ID + type: string From 80ffebd6e75b13709702caa67d068ca68c1d376f Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Tue, 24 Feb 2026 09:33:36 -0500 Subject: [PATCH 2/5] Fix - No module named pip - payments-metrics:glean-lint --- _dev/docker/ci/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 From cc618797233d7e411e9b0b2989cc4f330c67dc4f Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Tue, 24 Feb 2026 13:33:46 -0500 Subject: [PATCH 3/5] Remove labeled_counter --- libs/payments/metrics/src/index.ts | 2 + .../lib/glean/glean-client.manager.spec.ts | 132 +----------------- .../src/lib/glean/glean-client.manager.ts | 36 +---- .../metrics/src/lib/glean/glean.factory.ts | 36 ++--- .../metrics/src/lib/glean/glean.types.ts | 9 +- .../registry/subplat-frontend-metrics.yaml | 89 +++++------- 6 files changed, 67 insertions(+), 237 deletions(-) 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 index ff8fd787c68..15597d5ca59 100644 --- a/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts +++ b/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts @@ -1,13 +1,12 @@ -// /* 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/. */ +/* 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, - RetentionEligibilityFactory, RetentionFlowEventFactory, } from './glean.factory'; @@ -24,9 +23,6 @@ jest.mock('./__generated__/subscriptions', () => ({ pageView: { record: jest.fn() }, retentionFlow: { record: jest.fn() }, interstitialOffer: { record: jest.fn() }, - retentionEligibility: { - 'vpn.monthly.eligible_for_offer': { add: jest.fn() }, - }, })); const mockGlean = jest.requireMock('@mozilla/glean/web').default; @@ -182,128 +178,6 @@ describe('PaymentsGleanClientManager', () => { }); }); - describe('recordRetentionEligibility', () => { - it('records retention eligibility', () => { - paymentsGleanClientManager.recordRetentionEligibility( - RetentionEligibilityFactory({ - product: 'vpn', - interval: 'monthly', - eligibilityStatus: 'eligible_for_offer', - }) - ); - - expect( - mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] - .add - ).toHaveBeenCalled(); - expect( - mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] - .add - ).toHaveBeenCalledTimes(1); - }); - - it('does nothing when the label is not present', () => { - paymentsGleanClientManager.recordRetentionEligibility( - RetentionEligibilityFactory({ - product: 'vpn', - interval: 'monthly', - eligibilityStatus: 'eligible_for_stay', - }) - ); - - expect( - mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] - .add - ).not.toHaveBeenCalled(); - }); - - it('does not record when CI=true', () => { - process.env['CI'] = 'true'; - - paymentsGleanClientManager.recordRetentionEligibility( - RetentionEligibilityFactory({ - product: 'vpn', - interval: 'monthly', - eligibilityStatus: 'eligible_for_offer', - }) - ); - - expect( - mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] - .add - ).not.toHaveBeenCalled(); - }); - - it('does not record when config.enabled=false', async () => { - const moduleRef = await Test.createTestingModule({ - providers: [ - { - provide: PaymentsGleanClientConfig, - useValue: { ...mockConfigValue, enabled: false }, - }, - PaymentsGleanClientManager, - ], - }).compile(); - - const manager = moduleRef.get(PaymentsGleanClientManager); - - manager.recordRetentionEligibility( - RetentionEligibilityFactory({ - product: 'vpn', - interval: 'monthly', - eligibilityStatus: 'eligible_for_offer', - }) - ); - - expect( - mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] - .add - ).not.toHaveBeenCalled(); - }); - - it('does not throw if the labeled counter add() throws', () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - - const addSpy = - mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] - .add; - - addSpy.mockImplementation(() => { - throw new Error('boom'); - }); - - expect(() => - paymentsGleanClientManager.recordRetentionEligibility( - RetentionEligibilityFactory({ - product: 'vpn', - interval: 'monthly', - eligibilityStatus: 'eligible_for_offer', - }) - ) - ).not.toThrow(); - - expect(console.warn).toHaveBeenCalledWith( - 'Glean client metric record failed', - expect.any(Error) - ); - }); - - it('computes the label as `${product}.${interval}.${eligibility_status}`', () => { - paymentsGleanClientManager.recordRetentionEligibility( - RetentionEligibilityFactory({ - product: 'vpn', - interval: 'monthly', - eligibilityStatus: 'eligible_for_offer', - }) - ); - - expect( - mockSubscriptions.retentionEligibility['vpn.monthly.eligible_for_offer'] - .add - ).toHaveBeenCalled(); - }); - }); - describe('recordRetentionFlow', () => { it('initializes and records retention flow when enabled', () => { paymentsGleanClientManager.recordRetentionFlow( diff --git a/libs/payments/metrics/src/lib/glean/glean-client.manager.ts b/libs/payments/metrics/src/lib/glean/glean-client.manager.ts index 38724784b75..6abf85b617f 100644 --- a/libs/payments/metrics/src/lib/glean/glean-client.manager.ts +++ b/libs/payments/metrics/src/lib/glean/glean-client.manager.ts @@ -8,13 +8,9 @@ import * as subscriptions from './__generated__/subscriptions'; import { PaymentsGleanClientConfig } from './glean.config'; import type { PageMetricsData, - RetentionEligibilityMetricsData, RetentionFlowEventMetricsData, } from './glean.types'; -type RetentionEligibilityLabel = - keyof typeof subscriptions.retentionEligibility; - @Injectable() export class PaymentsGleanClientManager { private initialized = false; @@ -48,20 +44,6 @@ export class PaymentsGleanClientManager { ); } - recordRetentionEligibility(args: RetentionEligibilityMetricsData) { - this.recordWithGlean(() => { - const label = this.mapRetentionEligibilityLabel(args); - if (!label) return; - - const metric = subscriptions.retentionEligibility[label]; - if (!metric || typeof metric.add !== 'function') { - return; - } - - metric.add(); - }); - } - recordRetentionFlow(args: RetentionFlowEventMetricsData) { this.recordWithGlean(() => subscriptions.retentionFlow.record(this.mapRetentionFlowToGlean(args)) @@ -104,19 +86,6 @@ export class PaymentsGleanClientManager { }; } - private mapRetentionEligibilityLabel( - args: RetentionEligibilityMetricsData - ): RetentionEligibilityLabel | null { - const label = - `${args.product}.${args.interval}.${args.eligibilityStatus}` as const; - - if (!(label in subscriptions.retentionEligibility)) { - return null; - } - - return label as RetentionEligibilityLabel; - } - private mapRetentionFlowToGlean( retentionFlowMetrics: RetentionFlowEventMetricsData ) { @@ -127,6 +96,11 @@ export class PaymentsGleanClientManager { 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.factory.ts b/libs/payments/metrics/src/lib/glean/glean.factory.ts index 090837152e1..503e8e9c525 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 { CartMetrics, CmsMetricsData, CommonMetrics, + EligibilityStatus, FlowType, Outcome, PageName, @@ -20,7 +21,6 @@ import { type GleanMetricsData, type PageMetricsData, type RetentionFlowEventMetricsData, - type RetentionEligibilityMetricsData, type SessionMetricsData, type StripeMetricsData, type SubPlatCmsMetricsData, @@ -188,30 +188,20 @@ export const RetentionFlowEventFactory = ( override?: Partial ) => ({ flowType: faker.helpers.arrayElement(['cancel', 'stay']), - step: faker.helpers.arrayElement(['engage', 'submit', 'result']), + 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, }); - -export const RetentionEligibilityFactory = ( - override?: Partial -) => ({ - product: faker.helpers.arrayElement([ - 'vpn', - 'relaypremiumphone', - 'relaypremium', - 'mdnplus5m', - 'mdnplus10m', - 'mdnplus5y', - 'mdnplus10y', - ]), - interval: faker.helpers.arrayElement(['monthly', 'yearly']), - eligibilityStatus: faker.helpers.arrayElement([ - 'eligible_for_stay', - 'eligible_for_cancel', - 'eligible_for_offer', - ]), - ...override, -}); diff --git a/libs/payments/metrics/src/lib/glean/glean.types.ts b/libs/payments/metrics/src/lib/glean/glean.types.ts index 4c82de746b1..b841fa9128f 100644 --- a/libs/payments/metrics/src/lib/glean/glean.types.ts +++ b/libs/payments/metrics/src/lib/glean/glean.types.ts @@ -131,9 +131,9 @@ export type PageVariant = | '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 = 'engage' | 'submit' | 'result'; +export type Step = 'view' | 'engage' | 'submit' | 'result'; export type Outcome = 'success' | 'error'; export type ErrorReason = @@ -163,6 +163,11 @@ export type RetentionFlowEventMetricsData = { errorReason?: ErrorReason; offeringId?: string; interval?: Interval; + eligibilityStatus?: EligibilityStatus; + entrypoint?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; nimbusUserId?: string; }; diff --git a/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml b/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml index e00a24d5c78..2b0251df7c8 100644 --- a/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml +++ b/libs/shared/metrics/glean/src/registry/subplat-frontend-metrics.yaml @@ -51,50 +51,6 @@ subscriptions: Billing interval for the subscription (daily | weekly | monthly | halfyearly | yearly). type: string - retention_eligibility: - type: labeled_counter - description: | - Counts occurrences of retention eligibility outcomes by product, billing interval, and eligibility type. - notification_emails: - - lchan@mozilla.com - - subplat-team@mozilla.com - bugs: - - https://mozilla-hub.atlassian.net/browse/PAY-3472 - data_reviews: - - FILL IN - expires: never - labels: - - vpn.monthly.eligible_for_stay - - vpn.monthly.eligible_for_cancel - - vpn.monthly.eligible_for_offer - - vpn.yearly.eligible_for_stay - - vpn.yearly.eligible_for_cancel - - vpn.yearly.eligible_for_offer - - relaypremiumphone.monthly.eligible_for_stay - - relaypremiumphone.monthly.eligible_for_cancel - - relaypremiumphone.monthly.eligible_for_offer - - relaypremiumphone.yearly.eligible_for_stay - - relaypremiumphone.yearly.eligible_for_cancel - - relaypremiumphone.yearly.eligible_for_offer - - relaypremium.monthly.eligible_for_stay - - relaypremium.monthly.eligible_for_cancel - - relaypremium.monthly.eligible_for_offer - - relaypremium.yearly.eligible_for_stay - - relaypremium.yearly.eligible_for_cancel - - relaypremium.yearly.eligible_for_offer - - mdnplus5m.monthly.eligible_for_stay - - mdnplus5m.monthly.eligible_for_cancel - - mdnplus5m.monthly.eligible_for_offer - - mdnplus10m.monthly.eligible_for_stay - - mdnplus10m.monthly.eligible_for_cancel - - mdnplus10m.monthly.eligible_for_offer - - mdnplus5y.yearly.eligible_for_stay - - mdnplus5y.yearly.eligible_for_cancel - - mdnplus5y.yearly.eligible_for_offer - - mdnplus10y.yearly.eligible_for_stay - - mdnplus10y.yearly.eligible_for_cancel - - mdnplus10y.yearly.eligible_for_offer - retention_flow: type: event description: | @@ -112,13 +68,21 @@ subscriptions: 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: engage, submit, result. + Stage in the user journey lifecycle. Allowed values: view, engage, submit, result. type: string outcome: description: | @@ -142,6 +106,15 @@ subscriptions: 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 @@ -168,7 +141,7 @@ subscriptions: type: string step: description: | - Stage in the user journey lifecycle. Allowed values: engage, submit, result. + Stage in the user journey lifecycle. Allowed values: view, engage, submit, result. type: string outcome: description: | @@ -177,14 +150,17 @@ subscriptions: error_reason: description: | Specific reason for failure. Allowed values: + - subscription_not_found - customer_mismatch - - discount_already_applied - - general_error - - no_churn_intervention_found - - redemption_limit_exceeded - subscription_not_active - - subscription_not_found - - subscription_still_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. @@ -192,6 +168,15 @@ subscriptions: 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 From 6eefaca3d07b95fc5b219178472d274eda764a11 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Tue, 24 Feb 2026 15:52:38 -0500 Subject: [PATCH 4/5] fix tests? --- apps/payments/next/project.json | 11 +++++++++-- libs/payments/metrics/jest.config.ts | 1 + libs/payments/metrics/project.json | 2 +- .../src/lib/glean/glean-client.manager.spec.ts | 15 --------------- libs/payments/metrics/test/jest.setup.ts | 17 +++++++++++++++++ 5 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 libs/payments/metrics/test/jest.setup.ts 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 f599f3f67d1..229178fcce4 100644 --- a/libs/payments/metrics/project.json +++ b/libs/payments/metrics/project.json @@ -19,7 +19,7 @@ }, "compile": { "command": "tsc -p libs/payments/metrics/tsconfig.json --noEmit", - "dependsOn": ["glean-generate"] + "dependsOn": ["glean-generate", "glean-generate-frontend"] }, "glean-generate": { "dependsOn": ["glean-lint"], 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 index 15597d5ca59..74e4899a9d7 100644 --- a/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts +++ b/libs/payments/metrics/src/lib/glean/glean-client.manager.spec.ts @@ -10,21 +10,6 @@ import { RetentionFlowEventFactory, } from './glean.factory'; -jest.mock('@mozilla/glean/web', () => { - return { - __esModule: true, - default: { - initialize: jest.fn(), - }, - }; -}); - -jest.mock('./__generated__/subscriptions', () => ({ - pageView: { record: jest.fn() }, - retentionFlow: { record: jest.fn() }, - interstitialOffer: { record: jest.fn() }, -})); - const mockGlean = jest.requireMock('@mozilla/glean/web').default; const mockSubscriptions = jest.requireMock('./__generated__/subscriptions'); 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() }, +})); From 259abac7813126132b607789e08e13e1cd3beeac Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Tue, 24 Feb 2026 16:10:25 -0500 Subject: [PATCH 5/5] Fix CI? --- .circleci/config.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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