Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/functional-tests/lib/fixtures/standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ServerTarget, TargetName, create } from '../targets';
import { BaseTarget } from '../targets/base';
import { TestAccountTracker } from '../testAccountTracker';
import { PasskeyPage } from '../../pages/passkey';
import { GleanEventsHelper } from '../glean';
import { existsSync, readFileSync } from 'fs';
import { join, dirname, basename } from 'path';

Expand All @@ -30,6 +31,7 @@ export type TestOptions = {
syncBrowserPages: POMS;
syncOAuthBrowserPages: POMS;
testAccountTracker: TestAccountTracker;
gleanEventsHelper: GleanEventsHelper;
};
export type WorkerOptions = { targetName: TargetName; target: ServerTarget };

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

gleanEventsHelper: async ({ page }, use) => {
const helper = new GleanEventsHelper(page);
await helper.start();
await use(helper);
await helper.stop();
},

storageState: async ({ target }, use, testInfo) => {
// This is to store our session without logging in through the ui
const localStorageItems = [
Expand Down
188 changes: 188 additions & 0 deletions packages/functional-tests/lib/glean.ts
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
Expand Up @@ -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 } =
Expand All @@ -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',
]);
Comment on lines +41 to +46
Copy link

Copilot AI Mar 20, 2026

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; assertEventOrder runs immediately after isLoggedIn() without waiting for the final ping(s) to be captured. Consider awaiting gleanEventsHelper.waitForEvent(...) for the last expected event (e.g. *_submit_success) before asserting order.

Copilot uses AI. Check for mistakes.
});

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();
Expand All @@ -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();
Expand All @@ -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',
]);
});
});

Expand Down Expand Up @@ -593,6 +620,7 @@ test.describe('severity-1 #smoke', () => {
target,
pages: { page, signin, relier, signinPasswordlessCode },
testAccountTracker,
gleanEventsHelper,
}) => {
const { email } =
testAccountTracker.generatePasswordlessAccountDetails();
Expand All @@ -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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same timing issue here: assertEventOrder() and subsequent getEventsByName()/extras assertions may run before the error ping is captured, leading to intermittent failures. Await gleanEventsHelper.waitForEvent('reg_otp_submit_frontend_error') (or similar) before asserting order and inspecting extras.reason.

Copilot uses AI. Check for mistakes.
});

test('passwordless - account with password redirects to password flow', async ({
Expand All @@ -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).
Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion packages/fxa-auth-server/lib/metrics/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ module.exports = (log, config, glean) => {
await amplitude('flow.complete', request, data, metricsContext);

if (metricsContext.flowType === 'login') {
glean.login.complete(request, { uid: data?.uid ?? '' });
glean.login.complete(request, { uid: data?.uid ?? '', reason: 'email' });
}

return request.clearMetricsContext();
Expand Down
10 changes: 7 additions & 3 deletions packages/fxa-auth-server/lib/metrics/glean/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ const createEventFn =
};

const extraKeyReasonCb = (metrics: Record<string, any>) => ({
reason: metrics.reason,
reason: metrics.reason ?? '',
});

export function gleanMetrics(config: ConfigType) {
Expand All @@ -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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reg_complete/login_complete now forward reason via additionalMetrics: extraKeyReasonCb, but extraKeyReasonCb currently returns reason: metrics.reason which can be undefined when callers omit it. That ends up as String(undefined) in server_events.ts ("undefined" in telemetry). Recommend defaulting to '' in extraKeyReasonCb (or otherwise ensuring reason is always set) so these events never record an undefined reason.

Copilot uses AI. Check for mistakes.
error: createEventFn('reg_submit_error', {
additionalMetrics: extraKeyReasonCb,
}),
Expand All @@ -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: {
Expand Down
Loading
Loading