Skip to content

Commit 523b39a

Browse files
authored
Merge pull request #20227 from mozilla/fxa-13019
feat(metrics): add Glean metrics for passwordless signin
2 parents fc917b6 + c68eb3d commit 523b39a

22 files changed

Lines changed: 1084 additions & 24 deletions

File tree

packages/functional-tests/lib/fixtures/standard.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ServerTarget, TargetName, create } from '../targets';
1616
import { BaseTarget } from '../targets/base';
1717
import { TestAccountTracker } from '../testAccountTracker';
1818
import { PasskeyPage } from '../../pages/passkey';
19+
import { GleanEventsHelper } from '../glean';
1920
import { existsSync, readFileSync } from 'fs';
2021
import { join, dirname, basename } from 'path';
2122

@@ -30,6 +31,7 @@ export type TestOptions = {
3031
syncBrowserPages: POMS;
3132
syncOAuthBrowserPages: POMS;
3233
testAccountTracker: TestAccountTracker;
34+
gleanEventsHelper: GleanEventsHelper;
3335
};
3436
export type WorkerOptions = { targetName: TargetName; target: ServerTarget };
3537

@@ -88,6 +90,13 @@ export const test = base.extend<TestOptions, WorkerOptions>({
8890
await testAccountTracker.destroyAllAccounts();
8991
},
9092

93+
gleanEventsHelper: async ({ page }, use) => {
94+
const helper = new GleanEventsHelper(page);
95+
await helper.start();
96+
await use(helper);
97+
await helper.stop();
98+
},
99+
91100
storageState: async ({ target }, use, testInfo) => {
92101
// This is to store our session without logging in through the ui
93102
const localStorageItems = [
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Page, Route } from '@playwright/test';
6+
import { gunzipSync } from 'zlib';
7+
8+
export interface GleanPing {
9+
eventName: string;
10+
extras: Record<string, string>;
11+
payload: Record<string, any>;
12+
url: string;
13+
timestamp: number;
14+
}
15+
16+
/**
17+
* Intercepts Glean HTTP pings via page.route() and exposes captured events
18+
* for assertions in functional tests.
19+
*
20+
* Must be started (via start()) BEFORE any page.goto() calls, since
21+
* page.route() only intercepts requests registered before navigation.
22+
*/
23+
export class GleanEventsHelper {
24+
private pings: GleanPing[] = [];
25+
private page: Page;
26+
private started = false;
27+
private readonly ROUTE_PATTERN = '**/submit/accounts*frontend*/**';
28+
29+
constructor(page: Page) {
30+
this.page = page;
31+
}
32+
33+
async start(): Promise<void> {
34+
if (this.started) return;
35+
this.started = true;
36+
37+
// Glean.js uses navigator.sendBeacon() which Playwright's page.route()
38+
// cannot intercept. Monkey-patch sendBeacon to use fetch() instead.
39+
await this.page.addInitScript(() => {
40+
// eslint-disable-next-line no-undef
41+
navigator.sendBeacon = function (url: string, data?: BodyInit | null) {
42+
fetch(url, {
43+
method: 'POST',
44+
body: data,
45+
keepalive: true,
46+
mode: 'no-cors',
47+
}).catch(() => {});
48+
return true;
49+
};
50+
});
51+
52+
await this.page.route(this.ROUTE_PATTERN, async (route: Route) => {
53+
const request = route.request();
54+
55+
if (request.method() !== 'POST') {
56+
await route.fulfill({ status: 200 });
57+
return;
58+
}
59+
60+
try {
61+
const body = this.parseRequestBody(request);
62+
const eventName = body?.metrics?.string?.['event.name'];
63+
64+
if (eventName) {
65+
// FxA stores event metadata as string metrics (event.reason, etc.)
66+
const stringMetrics = body?.metrics?.string ?? {};
67+
const extras: Record<string, string> = {};
68+
for (const [key, value] of Object.entries(stringMetrics)) {
69+
if (key.startsWith('event.') && key !== 'event.name') {
70+
extras[key.replace('event.', '')] = value as string;
71+
}
72+
}
73+
this.pings.push({
74+
eventName,
75+
extras,
76+
payload: body,
77+
url: request.url(),
78+
timestamp: Date.now(),
79+
});
80+
}
81+
} catch {
82+
// Silently ignore parse errors — non-event pings are expected
83+
}
84+
85+
await route.fulfill({ status: 200 });
86+
});
87+
}
88+
89+
async stop(): Promise<void> {
90+
if (!this.started) return;
91+
await this.page.unroute(this.ROUTE_PATTERN);
92+
this.started = false;
93+
}
94+
95+
private parseRequestBody(
96+
request: ReturnType<Route['request']>
97+
): Record<string, any> {
98+
const contentEncoding = request.headers()['content-encoding'];
99+
const rawBody = request.postDataBuffer();
100+
101+
if (!rawBody) return {};
102+
103+
if (contentEncoding === 'gzip') {
104+
const decompressed = gunzipSync(rawBody);
105+
return JSON.parse(decompressed.toString('utf-8'));
106+
}
107+
108+
return JSON.parse(rawBody.toString('utf-8'));
109+
}
110+
111+
getEventNames(): string[] {
112+
return this.pings.map((p) => p.eventName);
113+
}
114+
115+
hasEvent(name: string): boolean {
116+
return this.pings.some((p) => p.eventName === name);
117+
}
118+
119+
getEventsByName(name: string): GleanPing[] {
120+
return this.pings.filter((p) => p.eventName === name);
121+
}
122+
123+
getPings(): GleanPing[] {
124+
return [...this.pings];
125+
}
126+
127+
clear(): void {
128+
this.pings = [];
129+
}
130+
131+
/**
132+
* Polls until an event with the given name appears.
133+
* @param name The event name to wait for.
134+
* @param timeout Maximum wait time in ms (default 5000).
135+
* @param interval Poll interval in ms (default 100).
136+
*/
137+
async waitForEvent(
138+
name: string,
139+
timeout = 5000,
140+
interval = 100
141+
): Promise<GleanPing> {
142+
const start = Date.now();
143+
while (Date.now() - start < timeout) {
144+
const ping = this.pings.find((p) => p.eventName === name);
145+
if (ping) return ping;
146+
await new Promise((resolve) => setTimeout(resolve, interval));
147+
}
148+
throw new Error(
149+
`Timed out waiting for Glean event "${name}" after ${timeout}ms.\n` +
150+
`Captured events: [${this.getEventNames().join(', ')}]`
151+
);
152+
}
153+
154+
/**
155+
* Asserts that the given events appeared in order (not necessarily
156+
* contiguous — other events may appear between them).
157+
*
158+
* Filters captured events to only those in the expected list, then
159+
* checks sequential order.
160+
*
161+
* @param expectedSequence Event names in the expected order.
162+
*/
163+
assertEventOrder(expectedSequence: string[]): void {
164+
const captured = this.getEventNames();
165+
const expectedSet = new Set(expectedSequence);
166+
167+
const relevant = captured.filter((name) => expectedSet.has(name));
168+
169+
let expectedIdx = 0;
170+
for (const eventName of relevant) {
171+
if (
172+
expectedIdx < expectedSequence.length &&
173+
eventName === expectedSequence[expectedIdx]
174+
) {
175+
expectedIdx++;
176+
}
177+
}
178+
179+
if (expectedIdx !== expectedSequence.length) {
180+
throw new Error(
181+
`Glean event order mismatch.\n` +
182+
`Expected sequence: [${expectedSequence.join(', ')}]\n` +
183+
`Relevant captured: [${relevant.join(', ')}]\n` +
184+
`All captured: [${captured.join(', ')}]`
185+
);
186+
}
187+
}
188+
}

packages/functional-tests/tests/passwordless/signinPasswordless.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ test.describe('severity-1 #smoke', () => {
1616
target,
1717
pages: { page, signin, relier, signinPasswordlessCode },
1818
testAccountTracker,
19+
gleanEventsHelper,
1920
}) => {
2021
// Generate email with 'passwordless' prefix for readability
2122
const { email } =
@@ -35,12 +36,21 @@ test.describe('severity-1 #smoke', () => {
3536

3637
// Should complete OAuth and redirect to RP
3738
expect(await relier.isLoggedIn()).toBe(true);
39+
40+
await gleanEventsHelper.waitForEvent('reg_otp_submit_success');
41+
gleanEventsHelper.assertEventOrder([
42+
'email_first_view',
43+
'reg_otp_view',
44+
'reg_otp_submit',
45+
'reg_otp_submit_success',
46+
]);
3847
});
3948

4049
test('passwordless signin - existing passwordless account', async ({
4150
target,
4251
pages: { page, signin, relier, signinPasswordlessCode },
4352
testAccountTracker,
53+
gleanEventsHelper,
4454
}) => {
4555
// Create passwordless account via API first
4656
const { email } = await testAccountTracker.signUpPasswordless();
@@ -62,12 +72,21 @@ test.describe('severity-1 #smoke', () => {
6272
await signinPasswordlessCode.fillOutCodeForm(code);
6373

6474
expect(await relier.isLoggedIn()).toBe(true);
75+
76+
await gleanEventsHelper.waitForEvent('login_otp_submit_success');
77+
gleanEventsHelper.assertEventOrder([
78+
'email_first_view',
79+
'login_otp_view',
80+
'login_otp_submit',
81+
'login_otp_submit_success',
82+
]);
6583
});
6684

6785
test('passwordless code resend', async ({
6886
target,
6987
pages: { page, signin, relier, signinPasswordlessCode },
7088
testAccountTracker,
89+
gleanEventsHelper,
7190
}) => {
7291
const { email } =
7392
testAccountTracker.generatePasswordlessAccountDetails();
@@ -92,6 +111,14 @@ test.describe('severity-1 #smoke', () => {
92111
await signinPasswordlessCode.fillOutCodeForm(code);
93112

94113
expect(await relier.isLoggedIn()).toBe(true);
114+
115+
await gleanEventsHelper.waitForEvent('reg_otp_submit_success');
116+
gleanEventsHelper.assertEventOrder([
117+
'reg_otp_view',
118+
'reg_otp_email_confirmation_resend_code',
119+
'reg_otp_submit',
120+
'reg_otp_submit_success',
121+
]);
95122
});
96123
});
97124

@@ -593,6 +620,7 @@ test.describe('severity-1 #smoke', () => {
593620
target,
594621
pages: { page, signin, relier, signinPasswordlessCode },
595622
testAccountTracker,
623+
gleanEventsHelper,
596624
}) => {
597625
const { email } =
598626
testAccountTracker.generatePasswordlessAccountDetails();
@@ -610,6 +638,19 @@ test.describe('severity-1 #smoke', () => {
610638
await expect(
611639
page.getByTestId('tooltip').or(page.getByText(/invalid|incorrect/i))
612640
).toBeVisible();
641+
642+
await gleanEventsHelper.waitForEvent('reg_otp_submit_frontend_error');
643+
gleanEventsHelper.assertEventOrder([
644+
'reg_otp_view',
645+
'reg_otp_submit',
646+
'reg_otp_submit_frontend_error',
647+
]);
648+
649+
const errorPings = gleanEventsHelper.getEventsByName(
650+
'reg_otp_submit_frontend_error'
651+
);
652+
expect(errorPings.length).toBeGreaterThan(0);
653+
expect(errorPings[0].extras.reason).toBe('invalid');
613654
});
614655

615656
test('passwordless - account with password redirects to password flow', async ({
@@ -632,6 +673,7 @@ test.describe('severity-1 #smoke', () => {
632673
target,
633674
pages: { page, signin, relier, signinPasswordlessCode, signinTotpCode },
634675
testAccountTracker,
676+
gleanEventsHelper,
635677
}) => {
636678
// Passwordless users with 2FA should be able to sign in via OTP,
637679
// then be prompted for their TOTP code (not told to use a password).
@@ -690,6 +732,17 @@ test.describe('severity-1 #smoke', () => {
690732
// Should complete OAuth and redirect to RP
691733
expect(await relier.isLoggedIn()).toBe(true);
692734

735+
await gleanEventsHelper.waitForEvent('login_totp_code_success_view');
736+
gleanEventsHelper.assertEventOrder([
737+
'email_first_view',
738+
'login_otp_view',
739+
'login_otp_submit',
740+
'login_otp_submit_success',
741+
'login_totp_form_view',
742+
'login_totp_code_submit',
743+
'login_totp_code_success_view',
744+
]);
745+
693746
// Cleanup: Set password so testAccountTracker can sign in and destroy
694747
// Re-authenticate to get a fresh session since the old one may be stale
695748
await target.authClient.passwordlessSendCode(email, {

packages/fxa-auth-server/lib/metrics/events.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ module.exports = (log, config, glean) => {
140140
await amplitude('flow.complete', request, data, metricsContext);
141141

142142
if (metricsContext.flowType === 'login') {
143-
glean.login.complete(request, { uid: data?.uid ?? '' });
143+
glean.login.complete(request, { uid: data?.uid ?? '', reason: 'email' });
144144
}
145145

146146
return request.clearMetricsContext();

packages/fxa-auth-server/lib/metrics/glean/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ const createEventFn =
195195
};
196196

197197
const extraKeyReasonCb = (metrics: Record<string, any>) => ({
198-
reason: metrics.reason,
198+
reason: metrics.reason ?? '',
199199
});
200200

201201
export function gleanMetrics(config: ConfigType) {
@@ -218,7 +218,9 @@ export function gleanMetrics(config: ConfigType) {
218218
accountCreated: createEventFn('reg_acc_created'),
219219
confirmationEmailSent: createEventFn('reg_email_sent'),
220220
accountVerified: createEventFn('reg_acc_verified'),
221-
complete: createEventFn('reg_complete'),
221+
complete: createEventFn('reg_complete', {
222+
additionalMetrics: extraKeyReasonCb,
223+
}),
222224
error: createEventFn('reg_submit_error', {
223225
additionalMetrics: extraKeyReasonCb,
224226
}),
@@ -235,7 +237,9 @@ export function gleanMetrics(config: ConfigType) {
235237
recoveryPhoneSuccess: createEventFn('login_recovery_phone_success'),
236238
verifyCodeEmailSent: createEventFn('login_email_confirmation_sent'),
237239
verifyCodeConfirmed: createEventFn('login_email_confirmation_success'),
238-
complete: createEventFn('login_complete'),
240+
complete: createEventFn('login_complete', {
241+
additionalMetrics: extraKeyReasonCb,
242+
}),
239243
},
240244

241245
resetPassword: {

0 commit comments

Comments
 (0)