Skip to content

Commit a80ff65

Browse files
Merge pull request #20381 from mozilla/PAY-3438
feat(payments-api): Delete glean/nimbus data on account deletion
2 parents 1b191f2 + 941c4b6 commit a80ff65

12 files changed

Lines changed: 194 additions & 3 deletions

File tree

apps/payments/api/.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ GLEAN_CONFIG__VERSION=0.0.0
5858
GLEAN_CONFIG__CHANNEL='development'
5959
GLEAN_CONFIG__LOGGER_APP_NAME='fxa-payments-next'
6060

61+
# Nimbus Manager Config
62+
NIMBUS_MANAGER__ENABLED=false
63+
NIMBUS_MANAGER__NAMESPACE=
64+
NIMBUS_MANAGER__DELETION_NAMESPACES=
65+
6166
# FXA Webhook Config
6267
FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_ISSUER=https://accounts.firefox.com/
6368
FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_JWKS_URI=https://oauth.accounts.firefox.com/v1/jwks/

apps/payments/next/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ NIMBUS_CLIENT__TIMEOUT_MS=100
170170
# Nimbus Manager
171171
NIMBUS_MANAGER__ENABLED=true
172172
NIMBUS_MANAGER__NAMESPACE=e0066f05-3967-4f6e-8492-03933512611a
173+
NIMBUS_MANAGER__DELETION_NAMESPACES=e0066f05-3967-4f6e-8492-03933512611a
173174

174175
# Other
175176
CONTENT_SERVER_URL=http://localhost:3030

libs/payments/experiments/src/lib/nimbus.manager.config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,35 @@
44

55
import { faker } from '@faker-js/faker';
66
import { Provider } from '@nestjs/common';
7-
import { IsBoolean, IsString } from 'class-validator';
7+
import { Transform } from 'class-transformer';
8+
import { IsBoolean, IsOptional, IsString } from 'class-validator';
89

910
export class NimbusManagerConfig {
1011
@IsBoolean()
1112
public readonly enabled!: boolean;
1213

1314
@IsString()
1415
public readonly namespace!: string;
16+
17+
@IsOptional()
18+
@IsString({ each: true })
19+
@Transform(({ value }) => {
20+
if (typeof value !== 'string') {
21+
return value;
22+
}
23+
const deletionNamespaces = value
24+
.split(',')
25+
.map((namespace) => namespace.trim())
26+
.filter(Boolean);
27+
return deletionNamespaces.length > 0 ? deletionNamespaces : undefined;
28+
})
29+
public readonly deletionNamespaces?: string[];
1530
}
1631

1732
export const MockNimbusManagerConfig = {
1833
enabled: faker.datatype.boolean(),
1934
namespace: faker.string.uuid(),
35+
deletionNamespaces: [faker.string.uuid(), faker.string.uuid()],
2036
} satisfies NimbusManagerConfig;
2137

2238
export const MockNimbusManagerConfigProvider = {

libs/payments/experiments/src/lib/nimbus.manager.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ describe('NimbusClient', () => {
8080
nimbusUserId,
8181
language: mockContext.language || undefined,
8282
region: mockContext.region || undefined,
83+
preview: false,
8384
};
8485

8586
beforeEach(() => {
@@ -97,6 +98,7 @@ describe('NimbusClient', () => {
9798
expect(nimbusClient.fetchExperiments).toHaveBeenCalledWith({
9899
clientId: nimbusUserId,
99100
context: mockContext,
101+
preview: false,
100102
});
101103
});
102104

@@ -153,4 +155,47 @@ describe('NimbusClient', () => {
153155
);
154156
});
155157
});
158+
159+
describe('generateAllNimbusIdsForDeletion', () => {
160+
const mockFxaUid = faker.string.uuid();
161+
const mockNimbusUserId = faker.string.uuid();
162+
163+
beforeEach(() => {
164+
mockedGenerateNimbusId.mockReturnValue(mockNimbusUserId);
165+
});
166+
167+
it('returns a nimbus id for the primary namespace and each deletion namespace', () => {
168+
const result = nimbusManager.generateAllNimbusIdsForDeletion(mockFxaUid);
169+
170+
expect(result).toHaveLength(
171+
mockNimbusManagerConfig.deletionNamespaces.length + 1
172+
);
173+
expect(mockedGenerateNimbusId).toHaveBeenCalledWith(
174+
mockNimbusManagerConfig.namespace,
175+
mockFxaUid
176+
);
177+
for (const ns of mockNimbusManagerConfig.deletionNamespaces) {
178+
expect(mockedGenerateNimbusId).toHaveBeenCalledWith(ns, mockFxaUid);
179+
}
180+
});
181+
182+
it('returns only the primary namespace id when deletionNamespaces is undefined', () => {
183+
const originalDeletionNamespaces =
184+
mockNimbusManagerConfig.deletionNamespaces;
185+
Object.defineProperty(mockNimbusManagerConfig, 'deletionNamespaces', {
186+
value: undefined,
187+
writable: true,
188+
});
189+
190+
const result = nimbusManager.generateAllNimbusIdsForDeletion(mockFxaUid);
191+
192+
expect(result).toHaveLength(1);
193+
expect(mockedGenerateNimbusId).toHaveBeenCalledWith(
194+
mockNimbusManagerConfig.namespace,
195+
mockFxaUid
196+
);
197+
198+
mockNimbusManagerConfig.deletionNamespaces = originalDeletionNamespaces;
199+
});
200+
});
156201
});

libs/payments/experiments/src/lib/nimbus.manager.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,13 @@ export class NimbusManager {
6060
return generateNimbusId(this.nimbusManagerConfig.namespace);
6161
}
6262
}
63+
64+
// Returns a list of unique Nimbus IDs for a given fxaUid to be used for deletion
65+
generateAllNimbusIdsForDeletion(fxaUid: string): string[] {
66+
const namespaceSet = new Set([
67+
this.nimbusManagerConfig.namespace,
68+
...(this.nimbusManagerConfig.deletionNamespaces ?? []),
69+
]);
70+
return Array.from(namespaceSet).map((ns) => generateNimbusId(ns, fxaUid));
71+
}
6372
}

libs/payments/metrics/src/lib/glean/glean.service.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { PaymentsGleanService } from './glean.service';
88
import { MockPaymentsGleanFactory } from './glean.test-provider';
99
import {
1010
MockPaymentsGleanConfigProvider,
11+
PaymentsGleanConfig,
1112
} from './glean.config';
1213
import {
1314
AccountsMetricsDataFactory,
@@ -64,6 +65,7 @@ describe('PaymentsGleanService', () => {
6465
let accountManager: AccountManager;
6566
let customerManager: CustomerManager;
6667
let nimbusManager: NimbusManager;
68+
let gleanConfig: PaymentsGleanConfig;
6769
let logger: Logger;
6870

6971
beforeEach(async () => {
@@ -102,9 +104,70 @@ describe('PaymentsGleanService', () => {
102104
accountManager = moduleRef.get(AccountManager);
103105
customerManager = moduleRef.get(CustomerManager);
104106
nimbusManager = moduleRef.get(NimbusManager);
107+
gleanConfig = moduleRef.get(PaymentsGleanConfig);
105108
logger = moduleRef.get(Logger);
106109
});
107110

111+
describe('handleUserDelete', () => {
112+
const mockUid = 'test-uid-123';
113+
const mockNimbusIds = ['nimbus-id-1', 'nimbus-id-2'];
114+
115+
beforeEach(() => {
116+
jest
117+
.spyOn(nimbusManager, 'generateAllNimbusIdsForDeletion')
118+
.mockReturnValue(mockNimbusIds);
119+
jest.spyOn(logger, 'log').mockImplementation(() => {});
120+
});
121+
122+
it('logs one entry per nimbus user id', () => {
123+
paymentsGleanService.handleUserDelete(mockUid);
124+
125+
expect(
126+
nimbusManager.generateAllNimbusIdsForDeletion
127+
).toHaveBeenCalledWith(mockUid);
128+
expect(logger.log).toHaveBeenCalledTimes(mockNimbusIds.length);
129+
for (const nimbusUserId of mockNimbusIds) {
130+
expect(logger.log).toHaveBeenCalledWith('glean.user.delete', {
131+
uid: mockUid,
132+
nimbus_user_id: nimbusUserId,
133+
});
134+
}
135+
});
136+
137+
it('still logs deletion even when glean is disabled', () => {
138+
gleanConfig.enabled = false;
139+
140+
paymentsGleanService.handleUserDelete(mockUid);
141+
142+
expect(
143+
nimbusManager.generateAllNimbusIdsForDeletion
144+
).toHaveBeenCalledWith(mockUid);
145+
expect(logger.log).toHaveBeenCalledTimes(mockNimbusIds.length);
146+
for (const nimbusUserId of mockNimbusIds) {
147+
expect(logger.log).toHaveBeenCalledWith('glean.user.delete', {
148+
uid: mockUid,
149+
nimbus_user_id: nimbusUserId,
150+
});
151+
}
152+
153+
gleanConfig.enabled = true;
154+
});
155+
156+
it('logs a single entry when one namespace is configured', () => {
157+
jest
158+
.spyOn(nimbusManager, 'generateAllNimbusIdsForDeletion')
159+
.mockReturnValue(['single-nimbus-id']);
160+
161+
paymentsGleanService.handleUserDelete(mockUid);
162+
163+
expect(logger.log).toHaveBeenCalledTimes(1);
164+
expect(logger.log).toHaveBeenCalledWith('glean.user.delete', {
165+
uid: mockUid,
166+
nimbus_user_id: 'single-nimbus-id',
167+
});
168+
});
169+
});
170+
108171
describe('recordGenericSubManageEvent', () => {
109172
const mockEventData = GenericGleanSubManageEventFactory();
110173

libs/payments/metrics/src/lib/glean/glean.service.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,26 @@ export class PaymentsGleanService {
201201
};
202202
}
203203

204+
handleUserDelete(uid: string): void {
205+
try {
206+
const nimbusUserIds =
207+
this.nimbusManager.generateAllNimbusIdsForDeletion(uid);
208+
for (const nimbusUserId of nimbusUserIds) {
209+
// PAY-3438: Log format consumed by BigQuery ETL for Glean Shredder.
210+
// Do not change jsonPayload.type or field names without updating the ETL query.
211+
this.log.log('glean.user.delete', {
212+
uid,
213+
nimbus_user_id: nimbusUserId,
214+
});
215+
}
216+
} catch (error) {
217+
this.log.error(
218+
`Failed to generate Nimbus deletion ids for uid ${uid}`,
219+
error instanceof Error ? error.stack : undefined
220+
);
221+
}
222+
}
223+
204224
async recordGenericSubManageEvent({
205225
eventName,
206226
uid,

libs/payments/metrics/src/lib/glean/glean.test-provider.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { PaymentsGleanService } from './glean.service';
12
import { PaymentsGleanProvider } from './glean.types';
23

34
/**
@@ -18,3 +19,18 @@ export const MockPaymentsGleanFactory = {
1819
recordSubscriptionTrialConverted: () => {},
1920
}) as any,
2021
};
22+
23+
export const MockPaymentsGleanServiceFactory = {
24+
provide: PaymentsGleanService,
25+
useFactory: () =>
26+
({
27+
handleUserDelete: () => {},
28+
recordGenericSubManageEvent: () => {},
29+
retrieveSubManageMetricsData: () => {},
30+
mapStripeMetricsData: () => {},
31+
mapAccountsMetricsData: () => {},
32+
mapSubPlatCmsMetricsData: () => {},
33+
mapSessionMetricsData: () => {},
34+
mapExperimentationMetricsData: () => {},
35+
}) as any,
36+
};

libs/payments/webhooks/src/lib/fxa-webhooks.controller.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { Test } from '@nestjs/testing';
66
import { Logger } from '@nestjs/common';
7+
import { MockPaymentsGleanServiceFactory } from '@fxa/payments/metrics';
78
import { FxaWebhooksController } from './fxa-webhooks.controller';
89
import { FxaWebhookService } from './fxa-webhooks.service';
910
import { MockFxaWebhookConfigProvider } from './fxa-webhooks.config';
@@ -21,6 +22,7 @@ describe('FxaWebhooksController', () => {
2122
FxaWebhookService,
2223
MockFxaWebhookConfigProvider,
2324
MockStatsDProvider,
25+
MockPaymentsGleanServiceFactory,
2426
],
2527
}).compile();
2628

libs/payments/webhooks/src/lib/fxa-webhooks.service.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Sentry from '@sentry/nestjs';
88
import { JWTool, PrivateJWK } from '@fxa/vendored/jwtool';
99
import { StatsDService } from '@fxa/shared/metrics/statsd';
1010
import type { StatsD } from 'hot-shots';
11+
import { PaymentsGleanService } from '@fxa/payments/metrics';
1112
import { FxaWebhookService } from './fxa-webhooks.service';
1213
import { FxaWebhookConfig } from './fxa-webhooks.config';
1314
import {
@@ -43,6 +44,7 @@ jest.mock('@type-cacheable/core', () => {
4344
descriptor;
4445

4546
const Cacheable = jest.fn(() => noopDecorator);
47+
const CacheClear = jest.fn(() => noopDecorator);
4648

4749
const defaultExport = {
4850
setOptions: jest.fn(),
@@ -52,6 +54,7 @@ jest.mock('@type-cacheable/core', () => {
5254
__esModule: true,
5355
default: defaultExport,
5456
Cacheable,
57+
CacheClear,
5558
};
5659
});
5760

@@ -121,6 +124,7 @@ describe('FxaWebhookService', () => {
121124
let service: FxaWebhookService;
122125
let statsd: { increment: jest.Mock; timing: jest.Mock };
123126
let logger: { error: jest.Mock; log: jest.Mock };
127+
let paymentsGleanService: { handleUserDelete: jest.Mock };
124128
let originalFetch: typeof global.fetch;
125129

126130
beforeEach(async () => {
@@ -129,6 +133,7 @@ describe('FxaWebhookService', () => {
129133

130134
logger = { error: jest.fn(), log: jest.fn() };
131135
statsd = { increment: jest.fn(), timing: jest.fn() };
136+
paymentsGleanService = { handleUserDelete: jest.fn() };
132137

133138
const module = await Test.createTestingModule({
134139
providers: [
@@ -143,6 +148,10 @@ describe('FxaWebhookService', () => {
143148
} satisfies FxaWebhookConfig,
144149
},
145150
{ provide: StatsDService, useValue: statsd as unknown as StatsD },
151+
{
152+
provide: PaymentsGleanService,
153+
useValue: paymentsGleanService,
154+
},
146155
],
147156
}).compile();
148157

@@ -224,6 +233,9 @@ describe('FxaWebhookService', () => {
224233
'handleDeleteUser',
225234
expect.objectContaining({ sub: TEST_UID })
226235
);
236+
expect(paymentsGleanService.handleUserDelete).toHaveBeenCalledWith(
237+
TEST_UID
238+
);
227239
});
228240

229241
it('handles metrics-opt-out event', async () => {

0 commit comments

Comments
 (0)