-
Notifications
You must be signed in to change notification settings - Fork 229
feat(metrics): add Glean metrics for passwordless signin #20227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| /* 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 { Page, Route } from '@playwright/test'; | ||
| import { gunzipSync } from 'zlib'; | ||
|
|
||
| export interface GleanPing { | ||
| eventName: string; | ||
| extras: Record<string, string>; | ||
| payload: Record<string, any>; | ||
| url: string; | ||
| timestamp: number; | ||
| } | ||
|
|
||
| /** | ||
| * Intercepts Glean HTTP pings via page.route() and exposes captured events | ||
| * for assertions in functional tests. | ||
| * | ||
| * Must be started (via start()) BEFORE any page.goto() calls, since | ||
| * page.route() only intercepts requests registered before navigation. | ||
| */ | ||
| export class GleanEventsHelper { | ||
| private pings: GleanPing[] = []; | ||
| private page: Page; | ||
| private started = false; | ||
| private readonly ROUTE_PATTERN = '**/submit/accounts*frontend*/**'; | ||
|
|
||
| constructor(page: Page) { | ||
| this.page = page; | ||
| } | ||
|
|
||
| async start(): Promise<void> { | ||
| if (this.started) return; | ||
| this.started = true; | ||
|
|
||
| // Glean.js uses navigator.sendBeacon() which Playwright's page.route() | ||
| // cannot intercept. Monkey-patch sendBeacon to use fetch() instead. | ||
| await this.page.addInitScript(() => { | ||
| // eslint-disable-next-line no-undef | ||
| navigator.sendBeacon = function (url: string, data?: BodyInit | null) { | ||
| fetch(url, { | ||
| method: 'POST', | ||
| body: data, | ||
| keepalive: true, | ||
| mode: 'no-cors', | ||
| }).catch(() => {}); | ||
| return true; | ||
| }; | ||
| }); | ||
|
|
||
| await this.page.route(this.ROUTE_PATTERN, async (route: Route) => { | ||
| const request = route.request(); | ||
|
|
||
| if (request.method() !== 'POST') { | ||
| await route.fulfill({ status: 200 }); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const body = this.parseRequestBody(request); | ||
| const eventName = body?.metrics?.string?.['event.name']; | ||
|
|
||
| if (eventName) { | ||
| // FxA stores event metadata as string metrics (event.reason, etc.) | ||
| const stringMetrics = body?.metrics?.string ?? {}; | ||
| const extras: Record<string, string> = {}; | ||
| for (const [key, value] of Object.entries(stringMetrics)) { | ||
| if (key.startsWith('event.') && key !== 'event.name') { | ||
| extras[key.replace('event.', '')] = value as string; | ||
| } | ||
| } | ||
| this.pings.push({ | ||
| eventName, | ||
| extras, | ||
| payload: body, | ||
| url: request.url(), | ||
| timestamp: Date.now(), | ||
| }); | ||
| } | ||
| } catch { | ||
| // Silently ignore parse errors — non-event pings are expected | ||
| } | ||
|
|
||
| await route.fulfill({ status: 200 }); | ||
| }); | ||
| } | ||
|
|
||
| async stop(): Promise<void> { | ||
| if (!this.started) return; | ||
| await this.page.unroute(this.ROUTE_PATTERN); | ||
| this.started = false; | ||
| } | ||
|
|
||
| private parseRequestBody( | ||
| request: ReturnType<Route['request']> | ||
| ): Record<string, any> { | ||
| const contentEncoding = request.headers()['content-encoding']; | ||
| const rawBody = request.postDataBuffer(); | ||
|
|
||
| if (!rawBody) return {}; | ||
|
|
||
| if (contentEncoding === 'gzip') { | ||
| const decompressed = gunzipSync(rawBody); | ||
| return JSON.parse(decompressed.toString('utf-8')); | ||
| } | ||
|
|
||
| return JSON.parse(rawBody.toString('utf-8')); | ||
| } | ||
|
|
||
| getEventNames(): string[] { | ||
| return this.pings.map((p) => p.eventName); | ||
| } | ||
|
|
||
| hasEvent(name: string): boolean { | ||
| return this.pings.some((p) => p.eventName === name); | ||
| } | ||
|
|
||
| getEventsByName(name: string): GleanPing[] { | ||
| return this.pings.filter((p) => p.eventName === name); | ||
| } | ||
|
|
||
| getPings(): GleanPing[] { | ||
| return [...this.pings]; | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.pings = []; | ||
| } | ||
|
|
||
| /** | ||
| * Polls until an event with the given name appears. | ||
| * @param name The event name to wait for. | ||
| * @param timeout Maximum wait time in ms (default 5000). | ||
| * @param interval Poll interval in ms (default 100). | ||
| */ | ||
| async waitForEvent( | ||
| name: string, | ||
| timeout = 5000, | ||
| interval = 100 | ||
| ): Promise<GleanPing> { | ||
| const start = Date.now(); | ||
| while (Date.now() - start < timeout) { | ||
| const ping = this.pings.find((p) => p.eventName === name); | ||
| if (ping) return ping; | ||
| await new Promise((resolve) => setTimeout(resolve, interval)); | ||
| } | ||
| throw new Error( | ||
| `Timed out waiting for Glean event "${name}" after ${timeout}ms.\n` + | ||
| `Captured events: [${this.getEventNames().join(', ')}]` | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Asserts that the given events appeared in order (not necessarily | ||
| * contiguous — other events may appear between them). | ||
| * | ||
| * Filters captured events to only those in the expected list, then | ||
| * checks sequential order. | ||
| * | ||
| * @param expectedSequence Event names in the expected order. | ||
| */ | ||
| assertEventOrder(expectedSequence: string[]): void { | ||
| const captured = this.getEventNames(); | ||
| const expectedSet = new Set(expectedSequence); | ||
|
|
||
| const relevant = captured.filter((name) => expectedSet.has(name)); | ||
|
|
||
| let expectedIdx = 0; | ||
| for (const eventName of relevant) { | ||
| if ( | ||
| expectedIdx < expectedSequence.length && | ||
| eventName === expectedSequence[expectedIdx] | ||
| ) { | ||
| expectedIdx++; | ||
| } | ||
| } | ||
|
|
||
| if (expectedIdx !== expectedSequence.length) { | ||
| throw new Error( | ||
| `Glean event order mismatch.\n` + | ||
| `Expected sequence: [${expectedSequence.join(', ')}]\n` + | ||
| `Relevant captured: [${relevant.join(', ')}]\n` + | ||
| `All captured: [${captured.join(', ')}]` | ||
| ); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ test.describe('severity-1 #smoke', () => { | |
| target, | ||
| pages: { page, signin, relier, signinPasswordlessCode }, | ||
| testAccountTracker, | ||
| gleanEventsHelper, | ||
| }) => { | ||
| // Generate email with 'passwordless' prefix for readability | ||
| const { email } = | ||
|
|
@@ -35,12 +36,21 @@ test.describe('severity-1 #smoke', () => { | |
|
|
||
| // Should complete OAuth and redirect to RP | ||
| expect(await relier.isLoggedIn()).toBe(true); | ||
|
|
||
| await gleanEventsHelper.waitForEvent('reg_otp_submit_success'); | ||
| gleanEventsHelper.assertEventOrder([ | ||
| 'email_first_view', | ||
| 'reg_otp_view', | ||
| 'reg_otp_submit', | ||
| 'reg_otp_submit_success', | ||
| ]); | ||
| }); | ||
|
|
||
| test('passwordless signin - existing passwordless account', async ({ | ||
| target, | ||
| pages: { page, signin, relier, signinPasswordlessCode }, | ||
| testAccountTracker, | ||
| gleanEventsHelper, | ||
| }) => { | ||
| // Create passwordless account via API first | ||
| const { email } = await testAccountTracker.signUpPasswordless(); | ||
|
|
@@ -62,12 +72,21 @@ test.describe('severity-1 #smoke', () => { | |
| await signinPasswordlessCode.fillOutCodeForm(code); | ||
|
|
||
| expect(await relier.isLoggedIn()).toBe(true); | ||
|
|
||
| await gleanEventsHelper.waitForEvent('login_otp_submit_success'); | ||
| gleanEventsHelper.assertEventOrder([ | ||
| 'email_first_view', | ||
| 'login_otp_view', | ||
| 'login_otp_submit', | ||
| 'login_otp_submit_success', | ||
| ]); | ||
| }); | ||
|
|
||
| test('passwordless code resend', async ({ | ||
| target, | ||
| pages: { page, signin, relier, signinPasswordlessCode }, | ||
| testAccountTracker, | ||
| gleanEventsHelper, | ||
| }) => { | ||
| const { email } = | ||
| testAccountTracker.generatePasswordlessAccountDetails(); | ||
|
|
@@ -92,6 +111,14 @@ test.describe('severity-1 #smoke', () => { | |
| await signinPasswordlessCode.fillOutCodeForm(code); | ||
|
|
||
| expect(await relier.isLoggedIn()).toBe(true); | ||
|
|
||
| await gleanEventsHelper.waitForEvent('reg_otp_submit_success'); | ||
| gleanEventsHelper.assertEventOrder([ | ||
| 'reg_otp_view', | ||
| 'reg_otp_email_confirmation_resend_code', | ||
| 'reg_otp_submit', | ||
| 'reg_otp_submit_success', | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
|
|
@@ -593,6 +620,7 @@ test.describe('severity-1 #smoke', () => { | |
| target, | ||
| pages: { page, signin, relier, signinPasswordlessCode }, | ||
| testAccountTracker, | ||
| gleanEventsHelper, | ||
| }) => { | ||
| const { email } = | ||
| testAccountTracker.generatePasswordlessAccountDetails(); | ||
|
|
@@ -610,6 +638,19 @@ test.describe('severity-1 #smoke', () => { | |
| await expect( | ||
| page.getByTestId('tooltip').or(page.getByText(/invalid|incorrect/i)) | ||
| ).toBeVisible(); | ||
|
|
||
| await gleanEventsHelper.waitForEvent('reg_otp_submit_frontend_error'); | ||
| gleanEventsHelper.assertEventOrder([ | ||
| 'reg_otp_view', | ||
| 'reg_otp_submit', | ||
| 'reg_otp_submit_frontend_error', | ||
| ]); | ||
|
|
||
| const errorPings = gleanEventsHelper.getEventsByName( | ||
| 'reg_otp_submit_frontend_error' | ||
| ); | ||
| expect(errorPings.length).toBeGreaterThan(0); | ||
| expect(errorPings[0].extras.reason).toBe('invalid'); | ||
|
Comment on lines
+643
to
+653
|
||
| }); | ||
|
|
||
| test('passwordless - account with password redirects to password flow', async ({ | ||
|
|
@@ -632,6 +673,7 @@ test.describe('severity-1 #smoke', () => { | |
| target, | ||
| pages: { page, signin, relier, signinPasswordlessCode, signinTotpCode }, | ||
| testAccountTracker, | ||
| gleanEventsHelper, | ||
| }) => { | ||
| // Passwordless users with 2FA should be able to sign in via OTP, | ||
| // then be prompted for their TOTP code (not told to use a password). | ||
|
|
@@ -690,6 +732,17 @@ test.describe('severity-1 #smoke', () => { | |
| // Should complete OAuth and redirect to RP | ||
| expect(await relier.isLoggedIn()).toBe(true); | ||
|
|
||
| await gleanEventsHelper.waitForEvent('login_totp_code_success_view'); | ||
| gleanEventsHelper.assertEventOrder([ | ||
| 'email_first_view', | ||
| 'login_otp_view', | ||
| 'login_otp_submit', | ||
| 'login_otp_submit_success', | ||
| 'login_totp_form_view', | ||
| 'login_totp_code_submit', | ||
| 'login_totp_code_success_view', | ||
| ]); | ||
|
|
||
| // Cleanup: Set password so testAccountTracker can sign in and destroy | ||
| // Re-authenticate to get a fresh session since the old one may be stale | ||
| await target.authClient.passwordlessSendCode(email, { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -195,7 +195,7 @@ const createEventFn = | |
| }; | ||
|
|
||
| const extraKeyReasonCb = (metrics: Record<string, any>) => ({ | ||
| reason: metrics.reason, | ||
| reason: metrics.reason ?? '', | ||
| }); | ||
|
|
||
| export function gleanMetrics(config: ConfigType) { | ||
|
|
@@ -218,7 +218,9 @@ export function gleanMetrics(config: ConfigType) { | |
| accountCreated: createEventFn('reg_acc_created'), | ||
| confirmationEmailSent: createEventFn('reg_email_sent'), | ||
| accountVerified: createEventFn('reg_acc_verified'), | ||
| complete: createEventFn('reg_complete'), | ||
| complete: createEventFn('reg_complete', { | ||
| additionalMetrics: extraKeyReasonCb, | ||
| }), | ||
|
Comment on lines
+221
to
+223
|
||
| error: createEventFn('reg_submit_error', { | ||
| additionalMetrics: extraKeyReasonCb, | ||
| }), | ||
|
|
@@ -235,7 +237,9 @@ export function gleanMetrics(config: ConfigType) { | |
| recoveryPhoneSuccess: createEventFn('login_recovery_phone_success'), | ||
| verifyCodeEmailSent: createEventFn('login_email_confirmation_sent'), | ||
| verifyCodeConfirmed: createEventFn('login_email_confirmation_success'), | ||
| complete: createEventFn('login_complete'), | ||
| complete: createEventFn('login_complete', { | ||
| additionalMetrics: extraKeyReasonCb, | ||
| }), | ||
| }, | ||
|
|
||
| resetPassword: { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These assertions can be flaky because Glean pings are sent asynchronously;
assertEventOrderruns immediately afterisLoggedIn()without waiting for the final ping(s) to be captured. Consider awaitinggleanEventsHelper.waitForEvent(...)for the last expected event (e.g.*_submit_success) before asserting order.