commit 651225d029ad278ac1c035acb8b243c69ac1b05b
parent 7f763790d281b0c748588749fdfd15ac98355b8a
Author: Tessa Heidkamp <theidkamp@mozilla.com>
Date: Thu, 2 Oct 2025 08:12:33 +0000
Bug 1977783 - Add Telemetry for JSON to Rust Storage r=dimi,data-stewards
Differential Revision: https://phabricator.services.mozilla.com/D260959
Diffstat:
4 files changed, 633 insertions(+), 29 deletions(-)
diff --git a/toolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs b/toolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs
@@ -7,6 +7,97 @@ ChromeUtils.defineESModuleGetters(lazy, {
LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
});
+/* Check if an url has punicode encoded hostname */
+function isPunycode(origin) {
+ try {
+ return origin && new URL(origin).hostname.startsWith("xn--");
+ } catch (_) {
+ return false;
+ }
+}
+
+function recordIncompatibleFormats(runId, operation, loginInfo) {
+ if (isPunycode(loginInfo.origin)) {
+ Glean.pwmgr.rustIncompatibleLoginFormat.record({
+ run_id: runId,
+ issue: "nonAsciiOrigin",
+ operation,
+ });
+ }
+ if (isPunycode(loginInfo.formActionOrigin)) {
+ Glean.pwmgr.rustIncompatibleLoginFormat.record({
+ run_id: runId,
+ issue: "nonAsciiFormAction",
+ operation,
+ });
+ }
+
+ if (loginInfo.origin === ".") {
+ Glean.pwmgr.rustIncompatibleLoginFormat.record({
+ run_id: runId,
+ issue: "dotOrigin",
+ operation,
+ });
+ }
+
+ if (
+ loginInfo.username?.includes("\n") ||
+ loginInfo.username?.includes("\r")
+ ) {
+ Glean.pwmgr.rustIncompatibleLoginFormat.record({
+ run_id: runId,
+ issue: "usernameLineBreak",
+ operation,
+ });
+ }
+}
+
+function recordMirrorStatus(runId, operation, status, error = null) {
+ const poisoned = Services.prefs.getBoolPref(
+ "signon.rustMirror.poisoned",
+ false
+ );
+
+ let errorMessage = "";
+ if (error) {
+ errorMessage = error.message ?? String(error);
+ }
+
+ Glean.pwmgr.rustMirrorStatus.record({
+ run_id: runId,
+ operation,
+ status,
+ error_message: errorMessage,
+ poisoned,
+ });
+
+ if (status === "failure" && !poisoned) {
+ Services.prefs.setBoolPref("signon.rustMirror.poisoned", true);
+ }
+}
+
+function recordMigrationStatus(
+ runId,
+ duration,
+ numberOfLoginsToMigrate,
+ numberOfLoginsMigrated
+) {
+ Glean.pwmgr.rustMigrationStatus.record({
+ run_id: runId,
+ duration_ms: duration,
+ number_of_logins_to_migrate: numberOfLoginsToMigrate,
+ number_of_logins_migrated: numberOfLoginsMigrated,
+ had_errors: numberOfLoginsMigrated < numberOfLoginsToMigrate,
+ });
+}
+
+function recordMigrationFailure(runId, error) {
+ Glean.pwmgr.rustMigrationFailure.record({
+ run_id: runId,
+ error_message: error.message ?? String(error),
+ });
+}
+
export class LoginManagerRustMirror {
#logger = null;
#jsonStorage = null;
@@ -69,6 +160,7 @@ export class LoginManagerRustMirror {
this.#logger.log("Rust Mirror is enabled.");
} catch (e) {
this.#logger.error("Login migration failed", e);
+ recordMirrorStatus("migration-enable", "failure", e);
}
}
@@ -82,7 +174,7 @@ export class LoginManagerRustMirror {
this.#isEnabled = false;
this.#logger.log("Rust Mirror is disabled.");
- // Since we'll miss updates we'll need to migrate again
+ // Since we'll miss updates we'll need to migrate again once disabled
Services.prefs.setBoolPref("signon.rustMirror.migrationNeeded", true);
}
@@ -100,14 +192,19 @@ export class LoginManagerRustMirror {
return;
}
+ const runId = Services.uuid.generateUUID();
+
switch (eventName) {
case "addLogin":
this.#logger.log(`adding login ${subject.guid}...`);
try {
+ recordIncompatibleFormats(runId, "add", subject);
await this.#rustStorage.addLoginsAsync([subject]);
+ recordMirrorStatus(runId, "add", "success");
this.#logger.log(`added login ${subject.guid}.`);
} catch (e) {
this.#logger.error("mirror-error:", e);
+ recordMirrorStatus(runId, "add", "failure", e);
}
break;
@@ -116,10 +213,13 @@ export class LoginManagerRustMirror {
const newLoginData = subject.queryElementAt(1, Ci.nsILoginInfo);
this.#logger.log(`modifying login ${loginToModify.guid}...`);
try {
+ recordIncompatibleFormats(runId, "modify", newLoginData);
this.#rustStorage.modifyLogin(loginToModify, newLoginData);
+ recordMirrorStatus(runId, "modify", "success");
this.#logger.log(`modified login ${loginToModify.guid}.`);
} catch (e) {
this.#logger.error("error: modifyLogin:", e);
+ recordMirrorStatus(runId, "modify", "failure", e);
}
break;
@@ -127,9 +227,11 @@ export class LoginManagerRustMirror {
this.#logger.log(`removing login ${subject.guid}...`);
try {
this.#rustStorage.removeLogin(subject);
+ recordMirrorStatus(runId, "remove", "success");
this.#logger.log(`removed login ${subject.guid}.`);
} catch (e) {
this.#logger.error("error: removeLogin:", e);
+ recordMirrorStatus(runId, "remove", "failure", e);
}
break;
@@ -137,18 +239,21 @@ export class LoginManagerRustMirror {
this.#logger.log("removing all logins...");
try {
this.#rustStorage.removeAllLogins();
+ recordMirrorStatus(runId, "remove-all", "success");
this.#logger.log("removed all logins.");
} catch (e) {
this.#logger.error("error: removeAllLogins:", e);
+ recordMirrorStatus(runId, "remove-all", "failure", e);
}
break;
case "importLogins":
- this.#logger.log("ignoring importLogins message");
+ // ignoring importLogins event
break;
default:
this.#logger.error(`error: received unhandled event "${eventName}"`);
+ break;
}
}
@@ -176,7 +281,7 @@ export class LoginManagerRustMirror {
this.#logger.log("Migration is needed, migrating...");
- // We ignore events during erolling migration run. Once we switch the
+ // We ignore events during migration run. Once we switch the
// stores over, we will run an initial migration again to ensure
// consistancy.
this.#migrationInProgress = true;
@@ -184,15 +289,33 @@ export class LoginManagerRustMirror {
// wait until loaded
await this.#jsonStorage.initializationPromise;
+ const t0 = Date.now();
+ const runId = Services.uuid.generateUUID();
+ let numberOfLoginsToMigrate = 0;
+ let numberOfLoginsMigrated = 0;
+
try {
this.#rustStorage.removeAllLogins();
this.#logger.log("Cleared existing Rust logins.");
- const logins = await this.#jsonStorage.getAllLogins();
+ Services.prefs.setBoolPref("signon.rustMirror.poisoned", false);
- await this.#rustStorage.addLoginsAsync(logins, true);
+ const logins = await this.#jsonStorage.getAllLogins();
+ numberOfLoginsToMigrate = logins.length;
+
+ const results = await this.#rustStorage.addLoginsAsync(logins, true);
+ for (const { error } of results) {
+ if (error) {
+ this.#logger.error("error during migration:", error.message);
+ recordMigrationFailure(runId, error);
+ } else {
+ numberOfLoginsMigrated += 1;
+ }
+ }
- this.#logger.log(`Successfully migrated ${logins.length} logins.`);
+ this.#logger.log(
+ `Successfully migrated ${numberOfLoginsMigrated}/${numberOfLoginsToMigrate} logins.`
+ );
// Migration complete, don't run again
Services.prefs.setBoolPref("signon.rustMirror.migrationNeeded", false);
@@ -201,6 +324,13 @@ export class LoginManagerRustMirror {
} catch (e) {
this.#logger.error("migration error:", e);
} finally {
+ const duration = Date.now() - t0;
+ recordMigrationStatus(
+ runId,
+ duration,
+ numberOfLoginsToMigrate,
+ numberOfLoginsMigrated
+ );
this.#migrationInProgress = false;
}
}
diff --git a/toolkit/components/passwordmgr/metrics.yaml b/toolkit/components/passwordmgr/metrics.yaml
@@ -838,6 +838,127 @@ pwmgr:
no_lint:
- UNIT_IN_NAME
+ rust_mirror_status:
+ type: event
+ description: >
+ Records the outcome of each operation mirrored into the Rust password store.
+ Each record contains the operation type and whether it
+ succeeded or failed.
+ bugs:
+ - https://bugzil.la/1981812
+ data_reviews:
+ - https://bugzil.la/1981812
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ run_id:
+ type: string
+ description: Unique identifier for the login change event
+ operation:
+ description: >
+ The type of operation that failed (e.g. "add", "modify", "remove", "remove-all", "migration", etc.).
+ type: string
+ status:
+ description: >
+ The result of the operation ("success" or "failure").
+ type: string
+ error_message:
+ description: >
+ The error message or exception string from the failure.
+ type: string
+ poisoned:
+ description: >
+ Whether there was a previous error, meaning that this event must be viewed with caution.
+ type: boolean
+
+ rust_migration_failure:
+ type: event
+ description: >
+ Records errors during a migration run.
+ bugs:
+ - https://bugzil.la/1991676
+ data_reviews:
+ - https://bugzil.la/1991676
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ run_id:
+ type: string
+ description: Unique identifier for the migration run.
+ error_message:
+ type: string
+ description: The error message from the failed login entry.
+
+ rust_incompatible_login_format:
+ type: event
+ description: >
+ Records whenever a login format is incompatible with the Rust password
+ store. Includes which field was affected and during which operation.
+ bugs:
+ - https://bugzil.la/1981814
+ data_reviews:
+ - https://bugzil.la/1981814
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ run_id:
+ type: string
+ description: Unique identifier for the login change event
+ issue:
+ description: >
+ Which kind of incompatible format (dotOrigin, nonAsciiOrigin,
+ nonAsciiFormAction, usernameLineBreak).
+ type: string
+ operation:
+ description: >
+ The operation being performed when the incompatible format was found
+ (e.g. addLogin, modifyLogin, removeLogin).
+ type: string
+
+ rust_migration_status:
+ type: event
+ description: >
+ One record per rolling migration run, recording duration, total number
+ of logins migrated and whether there have been errors.
+ bugs:
+ - https://bugzil.la/1985800
+ data_reviews:
+ - https://bugzil.la/1985800
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - passwords-dev@mozilla.org
+ expires: never
+ extra_keys:
+ run_id:
+ type: string
+ description: Unique identifier for the migration run.
+ number_of_logins_to_migrate:
+ description: >
+ Total number of logins to be migrated during this run.
+ type: quantity
+ number_of_logins_migrated:
+ description: >
+ Number of logins migrated during this run.
+ type: quantity
+ duration_ms:
+ description: >
+ Duration of the migration in milliseconds.
+ type: quantity
+ had_errors:
+ description: >
+ Whether the migration had errors.
+ type: boolean
+
saving_enabled:
type: boolean
lifetime: application
diff --git a/toolkit/components/passwordmgr/storage-rust.sys.mjs b/toolkit/components/passwordmgr/storage-rust.sys.mjs
@@ -134,14 +134,19 @@ class RustLoginsStoreAdapter {
const loginEntriesWithMeta = loginInfos.map(loginInfoToLoginEntryWithMeta);
const results = this.#store.addManyWithMeta(loginEntriesWithMeta);
- // on continuous mode, return result objects, which could be either a login or an error
+ // on continuous mode, return result objects, which could be either a login
+ // or an error containing the error message
if (continueOnDuplicates) {
- return results
- .filter(l => l instanceof BulkResultEntry.Success)
- .map(({ login, message }) => ({
- login: loginToLoginInfo(login),
- error: { message },
- }));
+ return results.map(l => {
+ if (l instanceof BulkResultEntry.Error) {
+ return {
+ error: { message: l.message },
+ };
+ }
+ return {
+ login: loginToLoginInfo(l.login),
+ };
+ });
}
// otherwise throw first error
diff --git a/toolkit/components/passwordmgr/test/browser/browser_rust_mirror.js b/toolkit/components/passwordmgr/test/browser/browser_rust_mirror.js
@@ -36,7 +36,7 @@ add_task(async function test_mirror_addLogin() {
LoginTestUtils.clearData();
rustStorage.removeAllLogins();
- await SpecialPowers.popPrefEnv();
+ await SpecialPowers.flushPrefEnv();
});
/**
@@ -77,7 +77,7 @@ add_task(async function test_mirror_modifyLogin() {
LoginTestUtils.clearData();
rustStorage.removeAllLogins();
- await SpecialPowers.popPrefEnv();
+ await SpecialPowers.flushPrefEnv();
});
/**
@@ -105,7 +105,7 @@ add_task(async function test_mirror_removeLogin() {
LoginTestUtils.clearData();
rustStorage.removeAllLogins();
- await SpecialPowers.popPrefEnv();
+ await SpecialPowers.flushPrefEnv();
});
/**
@@ -139,8 +139,7 @@ add_task(async function test_migration_is_triggered_by_pref_change() {
"migrationNeeded is set to false"
);
- await SpecialPowers.popPrefEnv();
- await SpecialPowers.popPrefEnv();
+ await SpecialPowers.flushPrefEnv();
});
/**
@@ -186,9 +185,7 @@ add_task(async function test_migration_is_idempotent() {
LoginTestUtils.clearData();
rustStorage.removeAllLogins();
- await SpecialPowers.popPrefEnv();
- await SpecialPowers.popPrefEnv();
- await SpecialPowers.popPrefEnv();
+ await SpecialPowers.flushPrefEnv();
});
/**
@@ -196,7 +193,6 @@ add_task(async function test_migration_is_idempotent() {
* - continues when some rows fail (partial failure),
* - still migrates valid logins,
*/
-
add_task(async function test_migration_partial_failure() {
// ensure mirror is off
await SpecialPowers.pushPrefEnv({
@@ -242,9 +238,7 @@ add_task(async function test_migration_partial_failure() {
sinon.restore();
LoginTestUtils.clearData();
rustStorage.removeAllLogins();
- await SpecialPowers.popPrefEnv();
- await SpecialPowers.popPrefEnv();
- await SpecialPowers.popPrefEnv();
+ await SpecialPowers.flushPrefEnv();
});
/**
@@ -286,15 +280,74 @@ add_task(async function test_migration_rejects_when_bulk_add_rejects() {
"signon.rustMirror.migrationNeeded",
false
);
-
Assert.equal(newPrefValue, true, "pref has not been reset");
sinon.restore();
LoginTestUtils.clearData();
rustStorage.removeAllLogins();
- await SpecialPowers.popPrefEnv();
- await SpecialPowers.popPrefEnv();
- await SpecialPowers.popPrefEnv();
+ await SpecialPowers.flushPrefEnv();
+});
+
+/**
+ * Tests that rust_migration_failure events are recorded
+ * when a migration run encounters entry errors.
+ */
+add_task(async function test_rust_migration_failure_event() {
+ // ensure mirror is off first
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", false]],
+ });
+
+ Services.fog.testResetFOG();
+
+ const rustStorage = new LoginManagerRustStorage();
+
+ // Stub addLoginsAsync to simulate a failure for one entry
+ sinon
+ .stub(rustStorage, "addLoginsAsync")
+ .callsFake(async (_logins, _cont) => {
+ return [
+ { login: {}, error: null }, // success
+ { login: null, error: { message: "simulated migration failure" } }, // failure
+ ];
+ });
+
+ // Add two logins to JSON so migration has something to work on
+ const login_ok = LoginTestUtils.testData.formLogin({
+ username: "ok-user",
+ password: "secure-password",
+ });
+ await Services.logins.addLoginAsync(login_ok);
+
+ const login_bad = LoginTestUtils.testData.formLogin({
+ username: "bad-user",
+ password: "secure-password",
+ });
+ await Services.logins.addLoginAsync(login_bad);
+
+ // Trigger migration
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", true]],
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => Glean.pwmgr.rustMigrationFailure.testGetValue()?.length == 1,
+ "event has been emitted"
+ );
+
+ const [evt] = Glean.pwmgr.rustMigrationFailure.testGetValue();
+ Assert.ok(evt.extra?.run_id, "event has a run_id");
+ Assert.equal(
+ evt.extra?.error_message,
+ "simulated migration failure",
+ "event has the expected error message"
+ );
+ Assert.equal(evt.name, "rust_migration_failure", "event has correct name");
+
+ sinon.restore();
+ LoginTestUtils.clearData();
+ rustStorage.removeAllLogins();
+ await SpecialPowers.flushPrefEnv();
});
/**
@@ -336,4 +389,299 @@ add_task(async function test_migration_time_under_threshold() {
LoginTestUtils.clearData();
rustStorage.removeAllLogins();
+ await SpecialPowers.flushPrefEnv();
+});
+
+/*
+ * Tests that an error is logged when adding an invalid login to the Rust store.
+ * The Rust store is stricter than the JSON store and rejects some formats,
+ * such as single-dot origins.
+ */
+add_task(async function test_rust_mirror_addLogin_failure() {
+ // ensure mirror is on, and reset poisoned flag
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["signon.rustMirror.enabled", true],
+ ["signon.rustMirror.poisoned", false],
+ ],
+ });
+ Services.fog.testResetFOG();
+ // This login will be accepted by JSON but rejected by Rust
+ const badLogin = LoginTestUtils.testData.formLogin({
+ origin: ".",
+ passwordField: ".",
+ });
+
+ await Services.logins.addLoginAsync(badLogin);
+ const allLoginsJson = await Services.logins.getAllLogins();
+ Assert.equal(
+ allLoginsJson.length,
+ 1,
+ "single dot origin login saved to JSON"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => Glean.pwmgr.rustMirrorStatus.testGetValue()?.length == 1,
+ "event has been emitted"
+ );
+
+ const rustStorage = new LoginManagerRustStorage();
+
+ const allLogins = await rustStorage.getAllLogins();
+ Assert.equal(
+ allLogins.length,
+ 0,
+ "single dot origin login not saved to Rust"
+ );
+
+ const [evt] = Glean.pwmgr.rustMirrorStatus.testGetValue();
+ Assert.ok(evt, "event has been emitted");
+ Assert.equal(evt.extra?.operation, "add", "event has operation");
+ Assert.equal(evt.extra?.status, "failure", "event has status=failure");
+ Assert.equal(
+ evt.extra?.error_message,
+ "Invalid login: Login has illegal origin",
+ "event has error_message"
+ );
+ Assert.equal(evt.extra?.poisoned, "false", "event is not poisoned");
+ Assert.equal(evt.name, "rust_mirror_status", "event has name");
+
+ // produce another failure
+ const badLogin2 = LoginTestUtils.testData.formLogin({
+ username: "another-bad-login",
+ origin: ".",
+ passwordField: ".",
+ });
+ await Services.logins.addLoginAsync(badLogin2);
+
+ await BrowserTestUtils.waitForCondition(
+ () => Glean.pwmgr.rustMirrorStatus.testGetValue()?.length == 2,
+ "two events have been emitted"
+ );
+
+ // eslint-disable-next-line no-unused-vars
+ const [_, evt2] = Glean.pwmgr.rustMirrorStatus.testGetValue();
+ Assert.equal(evt2.extra?.poisoned, "true", "event is poisoned now");
+
+ LoginTestUtils.clearData();
+ await SpecialPowers.flushPrefEnv();
+});
+
+/*
+ * Tests that we collect telemetry if non-ASCII origins get punycoded.
+ */
+add_task(async function test_punycode_origin_metric() {
+ // ensure mirror is on
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", true]],
+ });
+
+ Services.fog.testResetFOG();
+
+ const punicodeOrigin = "https://münich.example.com";
+ const login = LoginTestUtils.testData.formLogin({
+ origin: punicodeOrigin,
+ formActionOrigin: "https://example.com",
+ username: "user1",
+ password: "pass1",
+ });
+
+ await Services.logins.addLoginAsync(login);
+
+ await BrowserTestUtils.waitForCondition(
+ () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1,
+ "event has been emitted"
+ );
+
+ const rustStorage = new LoginManagerRustStorage();
+
+ const allLogins = await rustStorage.getAllLogins();
+ Assert.equal(allLogins.length, 1, "punicode origin login saved to Rust");
+ const [rustLogin] = allLogins;
+ Assert.equal(
+ rustLogin.origin,
+ "https://xn--mnich-kva.example.com",
+ "origin has been punicoded on the Rust side"
+ );
+
+ const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue();
+ Assert.equal(evt.extra?.issue, "nonAsciiOrigin");
+ Assert.equal(evt.extra?.operation, "add");
+ Assert.ok("run_id" in evt.extra);
+
+ LoginTestUtils.clearData();
+ rustStorage.removeAllLogins();
+ await SpecialPowers.flushPrefEnv();
+});
+
+/*
+ * Tests that we collect telemetry if non-ASCII formorigins get punycoded.
+ */
+add_task(async function test_punycode_formActionOrigin_metric() {
+ // ensure mirror is on
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", true]],
+ });
+
+ Services.fog.testResetFOG();
+
+ const punicodeOrigin = "https://münich.example.com";
+ const login = LoginTestUtils.testData.formLogin({
+ formActionOrigin: punicodeOrigin,
+ origin: "https://example.com",
+ username: "user1",
+ password: "pass1",
+ });
+
+ await Services.logins.addLoginAsync(login);
+
+ await BrowserTestUtils.waitForCondition(
+ () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1,
+ "event has been emitted"
+ );
+
+ const rustStorage = new LoginManagerRustStorage();
+
+ const allLogins = await rustStorage.getAllLogins();
+ Assert.equal(allLogins.length, 1, "punicode origin login saved to Rust");
+ const [rustLogin] = allLogins;
+ Assert.equal(
+ rustLogin.formActionOrigin,
+ "https://xn--mnich-kva.example.com",
+ "origin has been punicoded on the Rust side"
+ );
+
+ const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue();
+ Assert.equal(evt.extra?.issue, "nonAsciiFormAction");
+ Assert.equal(evt.extra?.operation, "add");
+ Assert.ok("run_id" in evt.extra);
+
+ LoginTestUtils.clearData();
+ rustStorage.removeAllLogins();
+ await SpecialPowers.flushPrefEnv();
+});
+
+/*
+ * Tests that we collect telemetry for single dot in origin
+ */
+add_task(async function test_single_dot_in_origin() {
+ // ensure mirror is on
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", true]],
+ });
+
+ Services.fog.testResetFOG();
+
+ const badOrigin = ".";
+ const login = LoginTestUtils.testData.formLogin({
+ origin: badOrigin,
+ formActionOrigin: "https://example.com",
+ username: "user1",
+ password: "pass1",
+ });
+
+ await Services.logins.addLoginAsync(login);
+
+ await BrowserTestUtils.waitForCondition(
+ () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1,
+ "event has been emitted"
+ );
+
+ const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue();
+ Assert.equal(evt.extra?.issue, "dotOrigin");
+ Assert.equal(evt.extra?.operation, "add");
+ Assert.ok("run_id" in evt.extra);
+
+ LoginTestUtils.clearData();
+ await SpecialPowers.flushPrefEnv();
+});
+
+/*
+ * Tests that we collect telemetry if the username contains line breaks.
+ */
+add_task(async function test_username_linebreak_metric() {
+ // ensure mirror is on
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", true]],
+ });
+
+ Services.fog.testResetFOG();
+
+ const login = LoginTestUtils.testData.formLogin({
+ origin: "https://example.com",
+ formActionOrigin: "https://example.com",
+ username: "user\nname",
+ password: "pass1",
+ });
+
+ await Services.logins.addLoginAsync(login);
+
+ await BrowserTestUtils.waitForCondition(
+ () => Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue()?.length == 1,
+ "event has been emitted"
+ );
+
+ const [evt] = Glean.pwmgr.rustIncompatibleLoginFormat.testGetValue();
+ Assert.equal(evt.extra?.issue, "usernameLineBreak");
+ Assert.equal(evt.extra?.operation, "add");
+ Assert.ok("run_id" in evt.extra);
+
+ LoginTestUtils.clearData();
+ const rustStorage = new LoginManagerRustStorage();
+ rustStorage.removeAllLogins();
+ await SpecialPowers.flushPrefEnv();
+});
+
+/**
+ * Tests that a rust_migration_performance event is recorded after migration,
+ * containing both duration and total number of migrated logins.
+ */
+add_task(async function test_migration_performance_probe() {
+ // ensure mirror is off
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", false]],
+ });
+ Services.fog.testResetFOG();
+
+ const login = LoginTestUtils.testData.formLogin({
+ username: "perf-user",
+ password: "perf-password",
+ });
+ await Services.logins.addLoginAsync(login);
+
+ // using the migrationNeeded pref change as an indicator that the migration did run
+ const prefChangePromise = TestUtils.waitForPrefChange(
+ "signon.rustMirror.migrationNeeded"
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.rustMirror.enabled", true]],
+ });
+ await prefChangePromise;
+
+ const [evt] = Glean.pwmgr.rustMigrationStatus.testGetValue();
+ Assert.ok(evt, "rustMigrationStatus event should have been emitted");
+ Assert.equal(
+ evt.extra?.number_of_logins_to_migrate,
+ 1,
+ "event should record number of logins to migrate"
+ );
+ Assert.equal(
+ evt.extra?.number_of_logins_migrated,
+ 1,
+ "event should record number of logins migrated"
+ );
+ Assert.equal(
+ evt.extra?.had_errors,
+ "false",
+ "event should record a boolean indicating migration errors"
+ );
+ Assert.greaterOrEqual(
+ parseInt(evt.extra?.duration_ms, 10),
+ 0,
+ "event should record non-negative duration in ms"
+ );
+
+ sinon.restore();
+ LoginTestUtils.clearData();
+ await SpecialPowers.flushPrefEnv();
});