commit 626cbe672edf7b0323fbd49c38ac0a599774a811
parent 8f71edffd04897bc2a673d04e7c48b7c74f32633
Author: Beth Rennie <beth@brennie.ca>
Date: Fri, 31 Oct 2025 03:39:49 +0000
Bug 1972647 - Make ExperimentAPI a singleton instance of a class r=nimbus-reviewers,relud
We have been accruing internal state inside `ExperimentAPI.sys.mjs` that
we do not want to expose as mutable to outside of the module. That state
is now bundled into the `ExperimentAPI` object itself as private
elements.
Differential Revision: https://phabricator.services.mozilla.com/D269919
Diffstat:
1 file changed, 154 insertions(+), 90 deletions(-)
diff --git a/toolkit/components/nimbus/ExperimentAPI.sys.mjs b/toolkit/components/nimbus/ExperimentAPI.sys.mjs
@@ -2,6 +2,11 @@
* 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 { ExperimentManager } from "./lib/ExperimentManager.sys.mjs"
+ * @import { RemoteSettingsExperimentLoader } from "./lib/RemoteSettingsExperimentLoader.sys.mjs"
+ */
+
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
@@ -74,26 +79,6 @@ const experimentBranchAccessor = {
const NIMBUS_PROFILE_ID_PREF = "nimbus.profileId";
-let cachedProfileId = null;
-
-/**
- * Ensure the Nimbus profile ID exists.
- *
- * @returns {string} The profile ID.
- */
-function ensureNimbusProfileId() {
- if (!cachedProfileId) {
- if (Services.prefs.prefHasUserValue(NIMBUS_PROFILE_ID_PREF)) {
- cachedProfileId = Services.prefs.getStringPref(NIMBUS_PROFILE_ID_PREF);
- } else {
- cachedProfileId = Services.uuid.generateUUID().toString().slice(1, -1);
- Services.prefs.setStringPref(NIMBUS_PROFILE_ID_PREF, cachedProfileId);
- }
- }
-
- return cachedProfileId;
-}
-
/**
* Metadata about an enrollment.
*
@@ -131,11 +116,98 @@ export const EnrollmentType = Object.freeze({
ROLLOUT: "rollout",
});
-let initialized = false;
-let experimentManager = null;
-let experimentLoader = null;
+export const ExperimentAPI = new (class {
+ /**
+ * Whether or not the ExperimentAPI has been initialized.
+ */
+ #initialized = false;
+
+ /**
+ * The current ExperimentManager.
+ *
+ * @type {ExperimentManager | null}
+ */
+ #experimentManager = null;
+
+ /**
+ * The current RemoteSettingsExperimentLoader.
+ *
+ * @type {RemoteSettingsExperimentLoader | null}
+ */
+ #experimentLoader = null;
+
+ /**
+ * The unique ID of this Profile.
+ *
+ * @type {string | null}
+ */
+ #cachedProfileId = null;
+
+ /**
+ * Cached pref values.
+ */
+ #prefValues = {
+ /**
+ * Whether or not opt-out studies are enabled.
+ *
+ * @see {@link STUDIES_OPT_OUT_PREF}
+ */
+ studiesEnabled: false,
+
+ /**
+ * Whether or not telemetry is enabled.
+ *
+ * @see {@link UPLOAD_ENABLED_PREF}
+ */
+ telemetryEnabled: false,
+ };
+
+ constructor() {
+ if (IS_MAIN_PROCESS) {
+ // Ensure that the profile ID is cached in a pref.
+ if (Services.prefs.prefHasUserValue(NIMBUS_PROFILE_ID_PREF)) {
+ this.#cachedProfileId = Services.prefs.getStringPref(
+ NIMBUS_PROFILE_ID_PREF
+ );
+ } else {
+ this.#cachedProfileId = Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1);
+ Services.prefs.setStringPref(
+ NIMBUS_PROFILE_ID_PREF,
+ this.#cachedProfileId
+ );
+ }
+ }
+
+ const onStudiesEnabledChanged = this.#onStudiesEnabledChanged.bind(this);
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this.#prefValues,
+ "studiesEnabled",
+ STUDIES_OPT_OUT_PREF,
+ false,
+ onStudiesEnabledChanged
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this.#prefValues,
+ "telemetryEnabled",
+ UPLOAD_ENABLED_PREF,
+ false,
+ onStudiesEnabledChanged
+ );
+
+ this._annotateCrashReport = this._annotateCrashReport.bind(this);
+ this._removeCrashReportAnnotator =
+ this._removeCrashReportAnnotator.bind(this);
+
+ ChromeUtils.defineLazyGetter(this, "_remoteSettingsClient", function () {
+ return lazy.RemoteSettings(lazy.COLLECTION_ID);
+ });
+ }
-export const ExperimentAPI = {
/**
* The topic that is notified when either the studies enabled pref or the
* telemetry enabled pref changes.
@@ -145,7 +217,7 @@ export const ExperimentAPI = {
*/
get STUDIES_ENABLED_CHANGED() {
return "nimbus:studies-enabled-changed";
- },
+ }
/**
* Initialize the ExperimentAPI.
@@ -166,13 +238,11 @@ export const ExperimentAPI = {
* Whether or not the ExperimentAPI was initialized.
*/
async init({ extraContext, forceSync = false } = {}) {
- if (initialized) {
+ if (this.#initialized) {
return false;
}
- ensureNimbusProfileId();
-
- initialized = true;
+ this.#initialized = true;
const studiesEnabled = this.studiesEnabled;
@@ -240,94 +310,99 @@ export const ExperimentAPI = {
);
}
- Services.prefs.addObserver(
- UPLOAD_ENABLED_PREF,
- this._onStudiesEnabledChanged
- );
- Services.prefs.addObserver(
- STUDIES_OPT_OUT_PREF,
- this._onStudiesEnabledChanged
- );
-
// If Nimbus was disabled between the start of this function and registering
// the pref observers we have not handled it yet.
if (studiesEnabled !== this.studiesEnabled) {
- await this._onStudiesEnabledChanged();
+ await this.#onStudiesEnabledChanged();
}
return true;
- },
+ }
/**
* Return the global ExperimentManager.
*
* The ExperimentManager will be lazily created upon first access to this
* property.
+ *
+ * @type {ExperimentManager}
*/
get manager() {
- if (experimentManager === null) {
- experimentManager = new lazy.ExperimentManager();
+ if (this.#experimentManager === null) {
+ this.#experimentManager = new lazy.ExperimentManager();
}
- return experimentManager;
- },
+ return this.#experimentManager;
+ }
/**
* Return the global ExperimentManager.
*
* @deprecated Use ExperimentAPI.Manager instead of this property.
+ *
+ * @type {ExperimentManager}
*/
get _manager() {
return this.manager;
- },
+ }
/**
* Return the global RemoteSettingsExperimentLoader.
+ *
+ * @type {RemoteSettingsExperimentLoader}
*/
get _rsLoader() {
- if (experimentLoader === null) {
- experimentLoader = new lazy.RemoteSettingsExperimentLoader(this.manager);
+ if (this.#experimentLoader === null) {
+ this.#experimentLoader = new lazy.RemoteSettingsExperimentLoader(
+ this.manager
+ );
}
- return experimentLoader;
- },
+ return this.#experimentLoader;
+ }
_resetForTests() {
- experimentLoader?.disable();
- experimentLoader = null;
+ this.#experimentLoader?.disable();
+ this.#experimentLoader = null;
- lazy.CleanupManager.removeCleanupHandler(
- ExperimentAPI._removeCrashReportAnnotator
- );
- experimentManager?.store.off("update", this._annotateCrashReport);
- experimentManager = null;
+ lazy.CleanupManager.removeCleanupHandler(this._removeCrashReportAnnotator);
+ this.#experimentManager?.store.off("update", this._annotateCrashReport);
+ this.#experimentManager = null;
- initialized = false;
- },
+ this.#initialized = false;
+ }
get studiesEnabled() {
return (
- Services.prefs.getBoolPref(UPLOAD_ENABLED_PREF, false) &&
- Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF, false) &&
+ this.#prefValues.studiesEnabled &&
+ this.#prefValues.telemetryEnabled &&
Services.policies.isAllowed("Shield")
);
- },
+ }
/**
* Return the profile ID.
*
* This is used to distinguish different profiles in a shared profile group
* apart. Each profile has a persistent and stable profile ID. It is stored as
- * a user branch pref but is locked to prevent tampering.
+ * a user branch pref.
*
* This is still susceptible to user.js editing, but there's nothing we can do
* about that.
*
+ * @throws {Error} If accessed outside the main process.
+ *
* @returns {string} The profile ID.
*/
get profileId() {
- return ensureNimbusProfileId();
- },
+ if (!IS_MAIN_PROCESS) {
+ throw new Error(
+ "ExperimentAPI.profileId is not available outside the main process"
+ );
+ }
+
+ return this.#cachedProfileId;
+ }
/**
* Wait for the ExperimentAPI to become ready.
@@ -342,7 +417,7 @@ export const ExperimentAPI = {
*/
async ready() {
return this.manager.store.ready();
- },
+ }
/**
* Annotate the current crash report with current enrollments.
@@ -362,15 +437,19 @@ export const ExperimentAPI = {
"NimbusEnrollments",
activeEnrollments
);
- },
+ }
_removeCrashReportAnnotator() {
- if (initialized) {
- experimentManager?.store.off("update", this._annotateCrashReport);
+ if (this.#initialized) {
+ this.#experimentManager?.store.off("update", this._annotateCrashReport);
+ }
+ }
+
+ async #onStudiesEnabledChanged() {
+ if (!this.#initialized) {
+ return;
}
- },
- async _onStudiesEnabledChanged() {
if (!this.studiesEnabled) {
await this.manager._handleStudiesOptOut();
}
@@ -378,7 +457,7 @@ export const ExperimentAPI = {
await this._rsLoader.onEnabledPrefChange();
Services.obs.notifyObservers(null, this.STUDIES_ENABLED_CHANGED);
- },
+ }
/**
* Returns the recipe for a given experiment slug
@@ -415,7 +494,7 @@ export const ExperimentAPI = {
}
return recipe;
- },
+ }
/**
* Returns all the branches for a given experiment slug
@@ -437,7 +516,7 @@ export const ExperimentAPI = {
return recipe?.branches.map(
branch => new Proxy(branch, experimentBranchAccessor)
);
- },
+ }
/**
* Opt-in to the given experiment on the given branch.
@@ -465,8 +544,8 @@ export const ExperimentAPI = {
*/
async optInToExperiment(options) {
return this._rsLoader._optInToExperiment(options);
- },
-};
+ }
+})();
/**
* Singleton that holds lazy references to _ExperimentFeature instances
@@ -933,21 +1012,6 @@ export class _ExperimentFeature {
}
}
-ExperimentAPI._annotateCrashReport =
- ExperimentAPI._annotateCrashReport.bind(ExperimentAPI);
-ExperimentAPI._onStudiesEnabledChanged =
- ExperimentAPI._onStudiesEnabledChanged.bind(ExperimentAPI);
-ExperimentAPI._removeCrashReportAnnotator =
- ExperimentAPI._removeCrashReportAnnotator.bind(ExperimentAPI);
-
-ChromeUtils.defineLazyGetter(
- ExperimentAPI,
- "_remoteSettingsClient",
- function () {
- return lazy.RemoteSettings(lazy.COLLECTION_ID);
- }
-);
-
class ExperimentLocalizationError extends Error {
constructor(reason, locale) {
super(`Localized experiment error (${reason})`);