commit 7360a821339e6f5070fc53c8deb1616ac8707e88
parent 78c57b6fc96cea6e55d1f7133e1309145b052462
Author: Robin Steuber <bytesized@mozilla.com>
Date: Fri, 14 Nov 2025 23:01:35 +0000
Bug 1992809 - Add telemetry for when the user selects a backup file r=cdupuis,jprickett,toolkit-telemetry-reviewers,data-stewards,jhirsch
Also changes `BackupService.getBackupFileInfo` behavior slightly. Prevents it from sending duplicate state update events, since that was causing the UI to reload an additional time, causing issues in testing. And it now clears out previous state unconditionally before it runs, causing its state to be consistently reset after all types of errors.
Differential Revision: https://phabricator.services.mozilla.com/D270908
Diffstat:
7 files changed, 353 insertions(+), 114 deletions(-)
diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs
@@ -780,6 +780,14 @@ export class BackupService extends EventTarget {
supportBaseLink: Services.urlFormatter.formatURLPref("app.support.baseURL"),
recoveryInProgress: false,
recoveryErrorCode: 0,
+ /**
+ * Every file we load successfully is going to get a restore ID which is
+ * basically the identifier for that profile restore event. If we actually
+ * do restore it, this ID will end up being propagated into the restored
+ * file and used to correlate this restore event with the profile that was
+ * restored.
+ */
+ restoreID: null,
};
/**
@@ -4262,6 +4270,13 @@ export class BackupService extends EventTarget {
*/
async getBackupFileInfo(backupFilePath) {
lazy.logConsole.debug(`Getting info from backup file at ${backupFilePath}`);
+
+ this.#_state.restoreID = Services.uuid.generateUUID().toString();
+ this.#_state.backupFileInfo = null;
+ this.#_state.backupFileToRestore = backupFilePath;
+ this.#_state.backupFileCoarseLocation =
+ this.classifyLocationForTelemetry(backupFilePath);
+
try {
let { archiveJSON, isEncrypted } =
await this.sampleArchive(backupFilePath);
@@ -4269,25 +4284,26 @@ export class BackupService extends EventTarget {
isEncrypted,
date: archiveJSON?.meta?.date,
deviceName: archiveJSON?.meta?.deviceName,
+ appName: archiveJSON?.meta?.appName,
+ appVersion: archiveJSON?.meta?.appVersion,
+ buildID: archiveJSON?.meta?.buildID,
+ osName: archiveJSON?.meta?.osName,
+ osVersion: archiveJSON?.meta?.osVersion,
+ healthTelemetryEnabled: archiveJSON?.meta?.healthTelemetryEnabled,
};
- this.#_state.backupFileToRestore = backupFilePath;
- // Clear any existing recovery error from state since we've successfully got our file info
+
+ // Clear any existing recovery error from state since we've successfully
+ // got our file info. Make sure to do this last, since it will cause
+ // state change observers to fire.
this.setRecoveryError(ERRORS.NONE);
} catch (error) {
- this.setRecoveryError(error.cause);
// Nullify the file info when we catch errors that indicate the file is invalid
- switch (error.cause) {
- case ERRORS.FILE_SYSTEM_ERROR:
- case ERRORS.CORRUPTED_ARCHIVE:
- case ERRORS.UNSUPPORTED_BACKUP_VERSION:
- this.#_state.backupFileInfo = null;
- this.#_state.backupFileToRestore = null;
- break;
- default:
- break;
- }
+ this.#_state.backupFileInfo = null;
+ this.#_state.backupFileToRestore = null;
+
+ // Notify observers of the error last, after we have set the state.
+ this.setRecoveryError(error.cause);
}
- this.stateUpdate();
}
/**
diff --git a/browser/components/backup/content/restore-from-backup.mjs b/browser/components/backup/content/restore-from-backup.mjs
@@ -20,6 +20,13 @@ import "chrome://global/content/elements/moz-message-bar.mjs";
*/
export default class RestoreFromBackup extends MozLitElement {
#placeholderFileIconURL = "chrome://global/skin/icons/page-portrait.svg";
+ /**
+ * When the user clicks the button to choose a backup file to restore, we send
+ * a message to the `BackupService` process asking it to read that file.
+ * When we do this, we set this property to be a promise, which we resolve
+ * when the file reading is complete.
+ */
+ #backupFileReadPromise = null;
static properties = {
_fileIconURL: { type: String },
@@ -80,6 +87,7 @@ export default class RestoreFromBackup extends MozLitElement {
this.maybeGetBackupFileInfo();
this.addEventListener("BackupUI:SelectNewFilepickerPath", this);
+ this.addEventListener("BackupUI:StateWasUpdated", this);
// Resize the textarea when the window is resized
if (this.aboutWelcomeEmbedded) {
@@ -138,7 +146,39 @@ export default class RestoreFromBackup extends MozLitElement {
if (event.type == "BackupUI:SelectNewFilepickerPath") {
let { path, iconURL } = event.detail;
this._fileIconURL = iconURL;
+
+ this.#backupFileReadPromise = Promise.withResolvers();
+ this.#backupFileReadPromise.promise.then(() => {
+ const payload = {
+ location: this.backupServiceState?.backupFileCoarseLocation,
+ valid: this.backupServiceState?.recoveryErrorCode == ERRORS.NONE,
+ };
+ if (payload.valid) {
+ payload.backup_timestamp = new Date(
+ this.backupServiceState?.backupFileInfo?.date || 0
+ ).getTime();
+ payload.restore_id = this.backupServiceState?.restoreID;
+ payload.encryption =
+ this.backupServiceState?.backupFileInfo?.isEncrypted;
+ payload.app_name = this.backupServiceState?.backupFileInfo?.appName;
+ payload.version = this.backupServiceState?.backupFileInfo?.appVersion;
+ payload.build_id = this.backupServiceState?.backupFileInfo?.buildID;
+ payload.os_name = this.backupServiceState?.backupFileInfo?.osName;
+ payload.os_version =
+ this.backupServiceState?.backupFileInfo?.osVersion;
+ payload.telemetry_enabled =
+ this.backupServiceState?.backupFileInfo?.healthTelemetryEnabled;
+ }
+ Glean.browserBackup.restoreFileChosen.record(payload);
+ Services.obs.notifyObservers(null, "browser-backup-glean-sent");
+ });
+
this.getBackupFileInfo(path);
+ } else if (event.type == "BackupUI:StateWasUpdated") {
+ if (this.#backupFileReadPromise) {
+ this.#backupFileReadPromise.resolve();
+ this.#backupFileReadPromise = null;
+ }
}
}
diff --git a/browser/components/backup/metrics.yaml b/browser/components/backup/metrics.yaml
@@ -742,3 +742,62 @@ browser.backup:
notification_emails:
- drubino@mozilla.com
expires: never
+
+ restore_file_chosen:
+ type: event
+ description: Dispatched when a file is chosen to be restored.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1992809
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1992809
+ data_sensitivity:
+ - interaction
+ send_in_pings:
+ - profile-restore
+ notification_emails:
+ - drubino@mozilla.com
+ expires: never
+ extra_keys:
+ location:
+ type: string
+ description: >
+ The location of the restore file. This can be either "onedrive",
+ "documents", or "other". "other" will be sent if the file's parent
+ directory doesn't match the default "documents" or "onedrive" save
+ paths.
+ valid:
+ type: boolean
+ description: >
+ If `false`, the selected restore file was invalid and could not be
+ restored. In this case, keys besides this one and `location` will not
+ be specified.
+ backup_timestamp:
+ type: quantity
+ description: >
+ The number of milliseconds between the epoch and the creation of the
+ backup.
+ restore_id:
+ type: string
+ description: The identifier for the potential profile restore event.
+ encryption:
+ type: boolean
+ description: >
+ `true` if the restore file chosen is encrypted.
+ app_name:
+ type: string
+ description: The name of the application that created the backup file.
+ version:
+ type: string
+ description: The application version that created the backup file.
+ build_id:
+ type: string
+ description: The build ID of the application that created the backup.
+ os_name:
+ type: string
+ description: The OS in which the backup was created.
+ os_version:
+ type: string
+ description: The OS version in which the backup was created.
+ telemetry_enabled:
+ type: boolean
+ description: Whether telemetry was enabled when the backup was created.
diff --git a/browser/components/backup/pings.yaml b/browser/components/backup/pings.yaml
@@ -0,0 +1,24 @@
+# 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/.
+
+---
+$schema: moz://mozilla.org/schemas/glean/pings/2-0-0
+
+profile-restore:
+ description: |
+ Only used for telemetry specific to restoring a profile from a backup. This
+ is necessary to ensure that we can unconditionally send it immediately when
+ we complete the restore since, if we don't, that profile may never be
+ launched again and then the telemetry would be lost.
+ Note that this only contains specifically the probes that have this
+ requirement. Any telemetry that is intended to be gathered regularly or
+ telemetry that is intended to be gathered in the new (post-restore) profile
+ will not be in this ping.
+ include_client_id: true
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1992809
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1992809
+ notification_emails:
+ - drubino@mozilla.com
diff --git a/browser/components/backup/tests/browser/browser_settings_restore_from_backup.js b/browser/components/backup/tests/browser/browser_settings_restore_from_backup.js
@@ -9,6 +9,16 @@ const { ERRORS } = ChromeUtils.importESModule(
let TEST_PROFILE_PATH;
+// Helper function for when we need to wait for the backup state to update and
+// the UI to finish reacting to that.
+async function makeStateUpdatedPromise(restoreFromBackupEl) {
+ await BrowserTestUtils.waitForEvent(window, "BackupUI:StateWasUpdated");
+ // Wait for `restoreFromBackupEl` to handle this event.
+ await TestUtils.waitForTick();
+ // Then wait for it to finish updating all the UI.
+ await restoreFromBackupEl.updateComplete;
+}
+
add_setup(async () => {
MockFilePicker.init(window.browsingContext);
TEST_PROFILE_PATH = await IOUtils.createUniqueDirectory(
@@ -32,32 +42,114 @@ add_setup(async () => {
});
/**
+ * Tests for when the user specifies an invalid backup file to restore.
+ */
+add_task(async function test_backup_failure() {
+ await BrowserTestUtils.withNewTab("about:preferences#sync", async browser => {
+ const mockBackupFilePath = await IOUtils.createUniqueFile(
+ TEST_PROFILE_PATH,
+ "backup.html"
+ );
+ const mockBackupFile = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ mockBackupFile.initWithPath(mockBackupFilePath);
+
+ MockFilePicker.showCallback = () => {
+ Assert.ok(true, "Filepicker shown");
+ MockFilePicker.setFiles([mockBackupFile]);
+ };
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ let settings = browser.contentDocument.querySelector("backup-settings");
+
+ await settings.updateComplete;
+
+ Assert.ok(
+ settings.restoreFromBackupButtonEl,
+ "Button to restore backups should be found"
+ );
+
+ settings.restoreFromBackupButtonEl.click();
+ await settings.updateComplete;
+
+ let restoreFromBackup = settings.restoreFromBackupEl;
+ Assert.ok(restoreFromBackup, "restore-from-backup should be found");
+
+ Services.fog.testResetFOG();
+
+ let stateUpdatedPromise = makeStateUpdatedPromise(restoreFromBackup);
+ restoreFromBackup.chooseButtonEl.click();
+ await stateUpdatedPromise;
+
+ const restoreEvents = Glean.browserBackup.restoreFileChosen.testGetValue();
+ Assert.equal(
+ restoreEvents?.length,
+ 1,
+ "Should be 1 restore file chosen telemetry event"
+ );
+ Assert.deepEqual(
+ restoreEvents[0].extra,
+ { location: "other", valid: "false" },
+ "Restore telemetry event should have the right data"
+ );
+ });
+});
+
+/**
* Tests that the a backup file can be restored from the settings page.
*/
add_task(async function test_restore_from_backup() {
await BrowserTestUtils.withNewTab("about:preferences#sync", async browser => {
- let sandbox = sinon.createSandbox();
- let recoverFromBackupArchiveStub = sandbox
- .stub(BackupService.prototype, "recoverFromBackupArchive")
- .resolves();
+ // Info about our mock backup
+ const date = new Date().getTime();
+ const deviceName = "test-device";
+ const isEncrypted = true;
+ const appName = "test-app-name";
+ const appVersion = "test-app-version";
+ const buildID = "test-build-id";
+ const osName = "test-os-name";
+ const osVersion = "test-os-version";
+ const healthTelemetryEnabled = true;
+ const restoreID = Services.uuid.generateUUID().toString();
const mockBackupFilePath = await IOUtils.createUniqueFile(
TEST_PROFILE_PATH,
"backup.html"
);
-
const mockBackupFile = Cc["@mozilla.org/file/local;1"].createInstance(
Ci.nsIFile
);
mockBackupFile.initWithPath(mockBackupFilePath);
- let filePickerShownPromise = new Promise(resolve => {
- MockFilePicker.showCallback = async () => {
- Assert.ok(true, "Filepicker shown");
- MockFilePicker.setFiles([mockBackupFile]);
- resolve();
- };
- });
+ const mockBackupState = {
+ ...BackupService.get().state,
+ backupFileInfo: {
+ date,
+ deviceName,
+ isEncrypted,
+ appName,
+ appVersion,
+ buildID,
+ osName,
+ osVersion,
+ healthTelemetryEnabled,
+ },
+ backupFileToRestore: mockBackupFilePath,
+ restoreID,
+ recoveryErrorCode: ERRORS.NONE,
+ };
+
+ let sandbox = sinon.createSandbox();
+ let recoverFromBackupArchiveStub = sandbox
+ .stub(BackupService.prototype, "recoverFromBackupArchive")
+ .resolves();
+ sandbox.stub(BackupService.prototype, "state").get(() => mockBackupState);
+
+ MockFilePicker.showCallback = () => {
+ Assert.ok(true, "Filepicker shown");
+ MockFilePicker.setFiles([mockBackupFile]);
+ };
MockFilePicker.returnValue = MockFilePicker.returnOK;
let quitObservedPromise = TestUtils.topicObserved(
@@ -86,45 +178,35 @@ add_task(async function test_restore_from_backup() {
Assert.ok(restoreFromBackup, "restore-from-backup should be found");
- let infoPromise = BrowserTestUtils.waitForEvent(
- window,
- "BackupUI:GetBackupFileInfo"
- );
+ Services.fog.testResetFOG();
+ let stateUpdatedPromise = makeStateUpdatedPromise(restoreFromBackup);
restoreFromBackup.chooseButtonEl.click();
+ await stateUpdatedPromise;
- await filePickerShownPromise;
- restoreFromBackup.backupServiceState = {
- ...restoreFromBackup.backupServiceState,
- backupFileToRestore: mockBackupFilePath,
- };
- await restoreFromBackup.updateComplete;
-
- // Dispatch the event that would normally be sent by BackupUIChild
- // after a file is selected
- restoreFromBackup.dispatchEvent(
- new CustomEvent("BackupUI:SelectNewFilepickerPath", {
- bubbles: true,
- composed: true,
- detail: {
- path: mockBackupFilePath,
- filename: mockBackupFile.leafName,
- iconURL: "",
- },
- })
- );
-
- await infoPromise;
- // Set mock file info
- restoreFromBackup.backupServiceState = {
- ...restoreFromBackup.backupServiceState,
- backupFileInfo: {
- date: new Date(),
- deviceName: "test-device",
- isEncrypted: true,
+ const restoreEvents = Glean.browserBackup.restoreFileChosen.testGetValue();
+ Assert.equal(
+ restoreEvents?.length,
+ 1,
+ "Should be 1 restore file chosen telemetry event"
+ );
+ Assert.deepEqual(
+ restoreEvents[0].extra,
+ {
+ location: "other",
+ valid: "true",
+ backup_timestamp: date.toString(),
+ restore_id: restoreID,
+ encryption: isEncrypted.toString(),
+ app_name: appName,
+ version: appVersion,
+ build_id: buildID,
+ os_name: osName,
+ os_version: osVersion,
+ telemetry_enabled: healthTelemetryEnabled.toString(),
},
- };
- await restoreFromBackup.updateComplete;
+ "Restore telemetry event should have the right data"
+ );
// Set password for file
restoreFromBackup.passwordInput.value = "h-*@Vfge3_hGxdpwqr@w";
diff --git a/browser/components/backup/tests/xpcshell/test_BackupService.js b/browser/components/backup/tests/xpcshell/test_BackupService.js
@@ -33,6 +33,17 @@ const BACKUP_RESTORE_ENABLED_PREF_NAME = "browser.backup.restore.enabled";
/** @type {nsIToolkitProfile} */
let currentProfile;
+// Mock backup metadata
+const DATE = "2024-06-25T21:59:11.777Z";
+const IS_ENCRYPTED = true;
+const DEVICE_NAME = "test-device";
+const APP_NAME = "test-app-name";
+const APP_VERSION = "test-app-version";
+const BUILD_ID = "test-build-id";
+const OS_NAME = "test-os-name";
+const OS_VERSION = "test-os-version";
+const TELEMETRY_ENABLED = true;
+
add_setup(function () {
currentProfile = setupProfile();
});
@@ -1033,17 +1044,22 @@ add_task(async function test_checkForPostRecovery() {
add_task(async function test_getBackupFileInfo() {
let sandbox = sinon.createSandbox();
- const DATE = "2024-06-25T21:59:11.777Z";
- const IS_ENCRYPTED = true;
- const DEVICE_NAME = "test-device";
-
let fakeSampleArchiveResult = {
isEncrypted: IS_ENCRYPTED,
startByteOffset: 26985,
contentType: "multipart/mixed",
archiveJSON: {
version: 1,
- meta: { date: DATE, deviceName: DEVICE_NAME },
+ meta: {
+ date: DATE,
+ deviceName: DEVICE_NAME,
+ appName: APP_NAME,
+ appVersion: APP_VERSION,
+ buildID: BUILD_ID,
+ osName: OS_NAME,
+ osVersion: OS_VERSION,
+ healthTelemetryEnabled: TELEMETRY_ENABLED,
+ },
encConfig: {},
},
};
@@ -1063,7 +1079,17 @@ add_task(async function test_getBackupFileInfo() {
Assert.deepEqual(
bs.state.backupFileInfo,
- { isEncrypted: IS_ENCRYPTED, date: DATE, deviceName: DEVICE_NAME },
+ {
+ isEncrypted: IS_ENCRYPTED,
+ date: DATE,
+ deviceName: DEVICE_NAME,
+ appName: APP_NAME,
+ appVersion: APP_VERSION,
+ buildID: BUILD_ID,
+ osName: OS_NAME,
+ osVersion: OS_VERSION,
+ healthTelemetryEnabled: TELEMETRY_ENABLED,
+ },
"State should match a subset from the archive sample."
);
@@ -1099,31 +1125,34 @@ add_task(async function test__deleteLastBackup_file_does_not_exist() {
add_task(async function test_getBackupFileInfo_error_handling() {
let sandbox = sinon.createSandbox();
- const testCases = [
- // Errors that should clear backupFileInfo
- { error: ERRORS.FILE_SYSTEM_ERROR, shouldClear: true },
- { error: ERRORS.CORRUPTED_ARCHIVE, shouldClear: true },
- { error: ERRORS.UNSUPPORTED_BACKUP_VERSION, shouldClear: true },
- // Errors that shouldn't clear backupFileInfo
- { error: ERRORS.INTERNAL_ERROR, shouldClear: false },
- { error: ERRORS.UNINITIALIZED, shouldClear: false },
- { error: ERRORS.INVALID_PASSWORD, shouldClear: false },
+ const errorTypes = [
+ ERRORS.FILE_SYSTEM_ERROR,
+ ERRORS.CORRUPTED_ARCHIVE,
+ ERRORS.UNSUPPORTED_BACKUP_VERSION,
+ ERRORS.INTERNAL_ERROR,
+ ERRORS.UNINITIALIZED,
+ ERRORS.INVALID_PASSWORD,
];
- for (const testCase of testCases) {
+ for (const testError of errorTypes) {
let bs = new BackupService();
- const DATE = "2024-06-25T21:59:11.777Z";
- const IS_ENCRYPTED = true;
- const DEVICE_NAME = "test-device";
-
let fakeSampleArchiveResult = {
isEncrypted: IS_ENCRYPTED,
startByteOffset: 26985,
contentType: "multipart/mixed",
archiveJSON: {
version: 1,
- meta: { date: DATE, deviceName: DEVICE_NAME },
+ meta: {
+ date: DATE,
+ deviceName: DEVICE_NAME,
+ appName: APP_NAME,
+ appVersion: APP_VERSION,
+ buildID: BUILD_ID,
+ osName: OS_NAME,
+ osVersion: OS_VERSION,
+ healthTelemetryEnabled: TELEMETRY_ENABLED,
+ },
encConfig: {},
},
};
@@ -1140,6 +1169,12 @@ add_task(async function test_getBackupFileInfo_error_handling() {
isEncrypted: IS_ENCRYPTED,
date: DATE,
deviceName: DEVICE_NAME,
+ appName: APP_NAME,
+ appVersion: APP_VERSION,
+ buildID: BUILD_ID,
+ osName: OS_NAME,
+ osVersion: OS_VERSION,
+ healthTelemetryEnabled: TELEMETRY_ENABLED,
},
"Initial state should be set correctly"
);
@@ -1153,7 +1188,7 @@ add_task(async function test_getBackupFileInfo_error_handling() {
sandbox.restore();
sandbox
.stub(BackupService.prototype, "sampleArchive")
- .rejects(new Error("Test error", { cause: testCase.error }));
+ .rejects(new Error("Test error", { cause: testError }));
const setRecoveryErrorStub = sandbox.stub(bs, "setRecoveryError");
try {
@@ -1161,43 +1196,25 @@ add_task(async function test_getBackupFileInfo_error_handling() {
} catch (error) {
Assert.ok(
false,
- `Expected getBackupFileInfo to throw for error ${testCase.error}`
+ `Expected getBackupFileInfo to throw for error ${testError}`
);
}
Assert.ok(
- setRecoveryErrorStub.calledOnceWith(testCase.error),
- `setRecoveryError should be called with ${testCase.error}`
+ setRecoveryErrorStub.calledOnceWith(testError),
+ `setRecoveryError should be called with ${testError}`
);
- // backupFileInfo should be either cleared or preserved based on error type
- if (testCase.shouldClear) {
- Assert.strictEqual(
- bs.state.backupFileInfo,
- null,
- `backupFileInfo should be cleared for error ${testCase.error}`
- );
- Assert.strictEqual(
- bs.state.backupFileToRestore,
- null,
- `backupFileToRestore should be cleared for error ${testCase.error}`
- );
- } else {
- Assert.deepEqual(
- bs.state.backupFileInfo,
- {
- isEncrypted: IS_ENCRYPTED,
- date: DATE,
- deviceName: DEVICE_NAME,
- },
- `backupFileInfo should be preserved for error ${testCase.error}`
- );
- Assert.strictEqual(
- bs.state.backupFileToRestore,
- "test-backup.html",
- `backupFileToRestore should be preserved for error ${testCase.error}`
- );
- }
+ Assert.strictEqual(
+ bs.state.backupFileInfo,
+ null,
+ `backupFileInfo should be cleared for error ${testError}`
+ );
+ Assert.strictEqual(
+ bs.state.backupFileToRestore,
+ null,
+ `backupFileToRestore should be cleared for error ${testError}`
+ );
sandbox.restore();
}
diff --git a/toolkit/components/glean/metrics_index.py b/toolkit/components/glean/metrics_index.py
@@ -219,6 +219,7 @@ gecko_pings = [
# Order is lexicographical, enforced by t/c/glean/tests/pytest/test_yaml_indices.py
firefox_desktop_pings = [
"browser/components/asrouter/pings.yaml",
+ "browser/components/backup/pings.yaml",
"browser/components/newtab/pings.yaml",
"browser/components/profiles/pings.yaml",
"browser/components/search/pings.yaml",