michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = [ michael@0: "Experiments", michael@0: "ExperimentsProvider", michael@0: ]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/Task.jsm"); michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: Cu.import("resource://gre/modules/osfile.jsm"); michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://gre/modules/Preferences.jsm"); michael@0: Cu.import("resource://gre/modules/AsyncShutdown.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel", michael@0: "resource://gre/modules/UpdateChannel.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", michael@0: "resource://gre/modules/AddonManager.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate", michael@0: "resource://gre/modules/AddonManager.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing", michael@0: "resource://gre/modules/TelemetryPing.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "TelemetryLog", michael@0: "resource://gre/modules/TelemetryLog.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", michael@0: "resource://services-common/utils.js"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Metrics", michael@0: "resource://gre/modules/Metrics.jsm"); michael@0: michael@0: // CertUtils.jsm doesn't expose a single "CertUtils" object like a normal .jsm michael@0: // would. michael@0: XPCOMUtils.defineLazyGetter(this, "CertUtils", michael@0: function() { michael@0: var mod = {}; michael@0: Cu.import("resource://gre/modules/CertUtils.jsm", mod); michael@0: return mod; michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyServiceGetter(this, "gCrashReporter", michael@0: "@mozilla.org/xre/app-info;1", michael@0: "nsICrashReporter"); michael@0: michael@0: const FILE_CACHE = "experiments.json"; michael@0: const EXPERIMENTS_CHANGED_TOPIC = "experiments-changed"; michael@0: const MANIFEST_VERSION = 1; michael@0: const CACHE_VERSION = 1; michael@0: michael@0: const KEEP_HISTORY_N_DAYS = 180; michael@0: const MIN_EXPERIMENT_ACTIVE_SECONDS = 60; michael@0: michael@0: const PREF_BRANCH = "experiments."; michael@0: const PREF_ENABLED = "enabled"; // experiments.enabled michael@0: const PREF_ACTIVE_EXPERIMENT = "activeExperiment"; // whether we have an active experiment michael@0: const PREF_LOGGING = "logging"; michael@0: const PREF_LOGGING_LEVEL = PREF_LOGGING + ".level"; // experiments.logging.level michael@0: const PREF_LOGGING_DUMP = PREF_LOGGING + ".dump"; // experiments.logging.dump michael@0: const PREF_MANIFEST_URI = "manifest.uri"; // experiments.logging.manifest.uri michael@0: const PREF_MANIFEST_CHECKCERT = "manifest.cert.checkAttributes"; // experiments.manifest.cert.checkAttributes michael@0: const PREF_MANIFEST_REQUIREBUILTIN = "manifest.cert.requireBuiltin"; // experiments.manifest.cert.requireBuiltin michael@0: const PREF_FORCE_SAMPLE = "force-sample-value"; // experiments.force-sample-value michael@0: michael@0: const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled"; michael@0: michael@0: const PREF_BRANCH_TELEMETRY = "toolkit.telemetry."; michael@0: const PREF_TELEMETRY_ENABLED = "enabled"; michael@0: michael@0: const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties"; michael@0: const STRING_TYPE_NAME = "type.%ID%.name"; michael@0: michael@0: const TELEMETRY_LOG = { michael@0: // log(key, [kind, experimentId, details]) michael@0: ACTIVATION_KEY: "EXPERIMENT_ACTIVATION", michael@0: ACTIVATION: { michael@0: // Successfully activated. michael@0: ACTIVATED: "ACTIVATED", michael@0: // Failed to install the add-on. michael@0: INSTALL_FAILURE: "INSTALL_FAILURE", michael@0: // Experiment does not meet activation requirements. Details will michael@0: // be provided. michael@0: REJECTED: "REJECTED", michael@0: }, michael@0: michael@0: // log(key, [kind, experimentId, optionalDetails...]) michael@0: TERMINATION_KEY: "EXPERIMENT_TERMINATION", michael@0: TERMINATION: { michael@0: // The Experiments service was disabled. michael@0: SERVICE_DISABLED: "SERVICE_DISABLED", michael@0: // Add-on uninstalled. michael@0: ADDON_UNINSTALLED: "ADDON_UNINSTALLED", michael@0: // The experiment disabled itself. michael@0: FROM_API: "FROM_API", michael@0: // The experiment expired (e.g. by exceeding the end date). michael@0: EXPIRED: "EXPIRED", michael@0: // Disabled after re-evaluating conditions. If this is specified, michael@0: // details will be provided. michael@0: RECHECK: "RECHECK", michael@0: }, michael@0: }; michael@0: michael@0: const gPrefs = new Preferences(PREF_BRANCH); michael@0: const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY); michael@0: let gExperimentsEnabled = false; michael@0: let gAddonProvider = null; michael@0: let gExperiments = null; michael@0: let gLogAppenderDump = null; michael@0: let gPolicyCounter = 0; michael@0: let gExperimentsCounter = 0; michael@0: let gExperimentEntryCounter = 0; michael@0: let gPreviousProviderCounter = 0; michael@0: michael@0: // Tracks active AddonInstall we know about so we can deny external michael@0: // installs. michael@0: let gActiveInstallURLs = new Set(); michael@0: michael@0: // Tracks add-on IDs that are being uninstalled by us. This allows us michael@0: // to differentiate between expected uninstalled and user-driven uninstalls. michael@0: let gActiveUninstallAddonIDs = new Set(); michael@0: michael@0: let gLogger; michael@0: let gLogDumping = false; michael@0: michael@0: function configureLogging() { michael@0: if (!gLogger) { michael@0: gLogger = Log.repository.getLogger("Browser.Experiments"); michael@0: gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); michael@0: } michael@0: gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, Log.Level.Warn); michael@0: michael@0: let logDumping = gPrefs.get(PREF_LOGGING_DUMP, false); michael@0: if (logDumping != gLogDumping) { michael@0: if (logDumping) { michael@0: gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter()); michael@0: gLogger.addAppender(gLogAppenderDump); michael@0: } else { michael@0: gLogger.removeAppender(gLogAppenderDump); michael@0: gLogAppenderDump = null; michael@0: } michael@0: gLogDumping = logDumping; michael@0: } michael@0: } michael@0: michael@0: // Takes an array of promises and returns a promise that is resolved once all of michael@0: // them are rejected or resolved. michael@0: function allResolvedOrRejected(promises) { michael@0: if (!promises.length) { michael@0: return Promise.resolve([]); michael@0: } michael@0: michael@0: let countdown = promises.length; michael@0: let deferred = Promise.defer(); michael@0: michael@0: for (let p of promises) { michael@0: let helper = () => { michael@0: if (--countdown == 0) { michael@0: deferred.resolve(); michael@0: } michael@0: }; michael@0: Promise.resolve(p).then(helper, helper); michael@0: } michael@0: michael@0: return deferred.promise; michael@0: } michael@0: michael@0: // Loads a JSON file using OS.file. file is a string representing the path michael@0: // of the file to be read, options contains additional options to pass to michael@0: // OS.File.read. michael@0: // Returns a Promise resolved with the json payload or rejected with michael@0: // OS.File.Error or JSON.parse() errors. michael@0: function loadJSONAsync(file, options) { michael@0: return Task.spawn(function() { michael@0: let rawData = yield OS.File.read(file, options); michael@0: // Read json file into a string michael@0: let data; michael@0: try { michael@0: // Obtain a converter to read from a UTF-8 encoded input stream. michael@0: let converter = new TextDecoder(); michael@0: data = JSON.parse(converter.decode(rawData)); michael@0: } catch (ex) { michael@0: gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex); michael@0: throw ex; michael@0: } michael@0: throw new Task.Result(data); michael@0: }); michael@0: } michael@0: michael@0: function telemetryEnabled() { michael@0: return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false); michael@0: } michael@0: michael@0: // Returns a promise that is resolved with the AddonInstall for that URL. michael@0: function addonInstallForURL(url, hash) { michael@0: let deferred = Promise.defer(); michael@0: AddonManager.getInstallForURL(url, install => deferred.resolve(install), michael@0: "application/x-xpinstall", hash); michael@0: return deferred.promise; michael@0: } michael@0: michael@0: // Returns a promise that is resolved with an Array of the installed michael@0: // experiment addons. michael@0: function installedExperimentAddons() { michael@0: let deferred = Promise.defer(); michael@0: AddonManager.getAddonsByTypes(["experiment"], (addons) => { michael@0: deferred.resolve([a for (a of addons) if (!a.appDisabled)]); michael@0: }); michael@0: return deferred.promise; michael@0: } michael@0: michael@0: // Takes an Array and returns a promise that is resolved when the michael@0: // addons are uninstalled. michael@0: function uninstallAddons(addons) { michael@0: let ids = new Set([a.id for (a of addons)]); michael@0: let deferred = Promise.defer(); michael@0: michael@0: let listener = {}; michael@0: listener.onUninstalled = addon => { michael@0: if (!ids.has(addon.id)) { michael@0: return; michael@0: } michael@0: michael@0: ids.delete(addon.id); michael@0: if (ids.size == 0) { michael@0: AddonManager.removeAddonListener(listener); michael@0: deferred.resolve(); michael@0: } michael@0: }; michael@0: michael@0: AddonManager.addAddonListener(listener); michael@0: michael@0: for (let addon of addons) { michael@0: // Disabling the add-on before uninstalling is necessary to cause tests to michael@0: // pass. This might be indicative of a bug in XPIProvider. michael@0: // TODO follow up in bug 992396. michael@0: addon.userDisabled = true; michael@0: addon.uninstall(); michael@0: } michael@0: michael@0: return deferred.promise; michael@0: } michael@0: michael@0: /** michael@0: * The experiments module. michael@0: */ michael@0: michael@0: let Experiments = { michael@0: /** michael@0: * Provides access to the global `Experiments.Experiments` instance. michael@0: */ michael@0: instance: function () { michael@0: if (!gExperiments) { michael@0: gExperiments = new Experiments.Experiments(); michael@0: } michael@0: michael@0: return gExperiments; michael@0: }, michael@0: }; michael@0: michael@0: /* michael@0: * The policy object allows us to inject fake enviroment data from the michael@0: * outside by monkey-patching. michael@0: */ michael@0: michael@0: Experiments.Policy = function () { michael@0: this._log = Log.repository.getLoggerWithMessagePrefix( michael@0: "Browser.Experiments.Policy", michael@0: "Policy #" + gPolicyCounter++ + "::"); michael@0: michael@0: // Set to true to ignore hash verification on downloaded XPIs. This should michael@0: // not be used outside of testing. michael@0: this.ignoreHashes = false; michael@0: }; michael@0: michael@0: Experiments.Policy.prototype = { michael@0: now: function () { michael@0: return new Date(); michael@0: }, michael@0: michael@0: random: function () { michael@0: let pref = gPrefs.get(PREF_FORCE_SAMPLE); michael@0: if (pref !== undefined) { michael@0: let val = Number.parseFloat(pref); michael@0: this._log.debug("random sample forced: " + val); michael@0: if (isNaN(val) || val < 0) { michael@0: return 0; michael@0: } michael@0: if (val > 1) { michael@0: return 1; michael@0: } michael@0: return val; michael@0: } michael@0: return Math.random(); michael@0: }, michael@0: michael@0: futureDate: function (offset) { michael@0: return new Date(this.now().getTime() + offset); michael@0: }, michael@0: michael@0: oneshotTimer: function (callback, timeout, thisObj, name) { michael@0: return CommonUtils.namedTimer(callback, timeout, thisObj, name); michael@0: }, michael@0: michael@0: updatechannel: function () { michael@0: return UpdateChannel.get(); michael@0: }, michael@0: michael@0: locale: function () { michael@0: let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry); michael@0: return chrome.getSelectedLocale("global"); michael@0: }, michael@0: michael@0: /* michael@0: * @return Promise<> Resolved with the payload data. michael@0: */ michael@0: healthReportPayload: function () { michael@0: return Task.spawn(function*() { michael@0: let reporter = Cc["@mozilla.org/datareporting/service;1"] michael@0: .getService(Ci.nsISupports) michael@0: .wrappedJSObject michael@0: .healthReporter; michael@0: yield reporter.onInit(); michael@0: let payload = yield reporter.collectAndObtainJSONPayload(); michael@0: throw new Task.Result(payload); michael@0: }); michael@0: }, michael@0: michael@0: telemetryPayload: function () { michael@0: return TelemetryPing.getPayload(); michael@0: }, michael@0: }; michael@0: michael@0: function AlreadyShutdownError(message="already shut down") { michael@0: this.name = "AlreadyShutdownError"; michael@0: this.message = message; michael@0: } michael@0: michael@0: AlreadyShutdownError.prototype = new Error(); michael@0: AlreadyShutdownError.prototype.constructor = AlreadyShutdownError; michael@0: michael@0: /** michael@0: * Manages the experiments and provides an interface to control them. michael@0: */ michael@0: michael@0: Experiments.Experiments = function (policy=new Experiments.Policy()) { michael@0: this._log = Log.repository.getLoggerWithMessagePrefix( michael@0: "Browser.Experiments.Experiments", michael@0: "Experiments #" + gExperimentsCounter++ + "::"); michael@0: this._log.trace("constructor"); michael@0: michael@0: this._policy = policy; michael@0: michael@0: // This is a Map of (string -> ExperimentEntry), keyed with the experiment id. michael@0: // It holds both the current experiments and history. michael@0: // Map() preserves insertion order, which means we preserve the manifest order. michael@0: // This is null until we've successfully completed loading the cache from michael@0: // disk the first time. michael@0: this._experiments = null; michael@0: this._refresh = false; michael@0: this._terminateReason = null; // or TELEMETRY_LOG.TERMINATION.... michael@0: this._dirty = false; michael@0: michael@0: // Loading the cache happens once asynchronously on startup michael@0: this._loadTask = null; michael@0: michael@0: // The _main task handles all other actions: michael@0: // * refreshing the manifest off the network (if _refresh) michael@0: // * disabling/enabling experiments michael@0: // * saving the cache (if _dirty) michael@0: this._mainTask = null; michael@0: michael@0: // Timer for re-evaluating experiment status. michael@0: this._timer = null; michael@0: michael@0: this._shutdown = false; michael@0: michael@0: // We need to tell when we first evaluated the experiments to fire an michael@0: // experiments-changed notification when we only loaded completed experiments. michael@0: this._firstEvaluate = true; michael@0: michael@0: this.init(); michael@0: }; michael@0: michael@0: Experiments.Experiments.prototype = { michael@0: QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]), michael@0: michael@0: init: function () { michael@0: this._shutdown = false; michael@0: configureLogging(); michael@0: michael@0: gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false); michael@0: this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled); michael@0: michael@0: gPrefs.observe(PREF_LOGGING, configureLogging); michael@0: gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this); michael@0: gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this); michael@0: michael@0: gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); michael@0: michael@0: AsyncShutdown.profileBeforeChange.addBlocker("Experiments.jsm shutdown", michael@0: this.uninit.bind(this)); michael@0: michael@0: this._registerWithAddonManager(); michael@0: michael@0: let deferred = Promise.defer(); michael@0: michael@0: this._loadTask = this._loadFromCache(); michael@0: this._loadTask.then( michael@0: () => { michael@0: this._log.trace("_loadTask finished ok"); michael@0: this._loadTask = null; michael@0: this._run().then(deferred.resolve, deferred.reject); michael@0: }, michael@0: (e) => { michael@0: this._log.error("_loadFromCache caught error: " + e); michael@0: deferred.reject(e); michael@0: } michael@0: ); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Uninitialize this instance. michael@0: * michael@0: * This function is susceptible to race conditions. If it is called multiple michael@0: * times before the previous uninit() has completed or if it is called while michael@0: * an init() operation is being performed, the object may get in bad state michael@0: * and/or deadlock could occur. michael@0: * michael@0: * @return Promise<> michael@0: * The promise is fulfilled when all pending tasks are finished. michael@0: */ michael@0: uninit: Task.async(function* () { michael@0: this._log.trace("uninit: started"); michael@0: yield this._loadTask; michael@0: this._log.trace("uninit: finished with _loadTask"); michael@0: michael@0: if (!this._shutdown) { michael@0: this._log.trace("uninit: no previous shutdown"); michael@0: this._unregisterWithAddonManager(); michael@0: michael@0: gPrefs.ignore(PREF_LOGGING, configureLogging); michael@0: gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this); michael@0: gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this); michael@0: michael@0: gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); michael@0: michael@0: if (this._timer) { michael@0: this._timer.clear(); michael@0: } michael@0: } michael@0: michael@0: this._shutdown = true; michael@0: if (this._mainTask) { michael@0: try { michael@0: this._log.trace("uninit: waiting on _mainTask"); michael@0: yield this._mainTask; michael@0: } catch (e if e instanceof AlreadyShutdownError) { michael@0: // We error out of tasks after shutdown via that exception. michael@0: } michael@0: } michael@0: michael@0: this._log.info("Completed uninitialization."); michael@0: }), michael@0: michael@0: _registerWithAddonManager: function (previousExperimentsProvider) { michael@0: this._log.trace("Registering instance with Addon Manager."); michael@0: michael@0: AddonManager.addAddonListener(this); michael@0: AddonManager.addInstallListener(this); michael@0: michael@0: if (!gAddonProvider) { michael@0: // The properties of this AddonType should be kept in sync with the michael@0: // experiment AddonType registered in XPIProvider. michael@0: this._log.trace("Registering previous experiment add-on provider."); michael@0: gAddonProvider = previousExperimentsProvider || new Experiments.PreviousExperimentProvider(this); michael@0: AddonManagerPrivate.registerProvider(gAddonProvider, [ michael@0: new AddonManagerPrivate.AddonType("experiment", michael@0: URI_EXTENSION_STRINGS, michael@0: STRING_TYPE_NAME, michael@0: AddonManager.VIEW_TYPE_LIST, michael@0: 11000, michael@0: AddonManager.TYPE_UI_HIDE_EMPTY), michael@0: ]); michael@0: } michael@0: michael@0: }, michael@0: michael@0: _unregisterWithAddonManager: function () { michael@0: this._log.trace("Unregistering instance with Addon Manager."); michael@0: michael@0: if (gAddonProvider) { michael@0: this._log.trace("Unregistering previous experiment add-on provider."); michael@0: AddonManagerPrivate.unregisterProvider(gAddonProvider); michael@0: gAddonProvider = null; michael@0: } michael@0: michael@0: AddonManager.removeInstallListener(this); michael@0: AddonManager.removeAddonListener(this); michael@0: }, michael@0: michael@0: /* michael@0: * Change the PreviousExperimentsProvider that this instance uses. michael@0: * For testing only. michael@0: */ michael@0: _setPreviousExperimentsProvider: function (provider) { michael@0: this._unregisterWithAddonManager(); michael@0: this._registerWithAddonManager(provider); michael@0: }, michael@0: michael@0: /** michael@0: * Throws an exception if we've already shut down. michael@0: */ michael@0: _checkForShutdown: function() { michael@0: if (this._shutdown) { michael@0: throw new AlreadyShutdownError("uninit() already called"); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Whether the experiments feature is enabled. michael@0: */ michael@0: get enabled() { michael@0: return gExperimentsEnabled; michael@0: }, michael@0: michael@0: /** michael@0: * Toggle whether the experiments feature is enabled or not. michael@0: */ michael@0: set enabled(enabled) { michael@0: this._log.trace("set enabled(" + enabled + ")"); michael@0: gPrefs.set(PREF_ENABLED, enabled); michael@0: }, michael@0: michael@0: _toggleExperimentsEnabled: Task.async(function* (enabled) { michael@0: this._log.trace("_toggleExperimentsEnabled(" + enabled + ")"); michael@0: let wasEnabled = gExperimentsEnabled; michael@0: gExperimentsEnabled = enabled && telemetryEnabled(); michael@0: michael@0: if (wasEnabled == gExperimentsEnabled) { michael@0: return; michael@0: } michael@0: michael@0: if (gExperimentsEnabled) { michael@0: yield this.updateManifest(); michael@0: } else { michael@0: yield this.disableExperiment(TELEMETRY_LOG.TERMINATION.SERVICE_DISABLED); michael@0: if (this._timer) { michael@0: this._timer.clear(); michael@0: } michael@0: } michael@0: }), michael@0: michael@0: _telemetryStatusChanged: function () { michael@0: this._toggleExperimentsEnabled(gExperimentsEnabled); michael@0: }, michael@0: michael@0: /** michael@0: * Returns a promise that is resolved with an array of `ExperimentInfo` objects, michael@0: * which provide info on the currently and recently active experiments. michael@0: * The array is in chronological order. michael@0: * michael@0: * The experiment info is of the form: michael@0: * { michael@0: * id: , michael@0: * name: , michael@0: * description: , michael@0: * active: , michael@0: * endDate: , // epoch ms michael@0: * detailURL: , michael@0: * ... // possibly extended later michael@0: * } michael@0: * michael@0: * @return Promise> Array of experiment info objects. michael@0: */ michael@0: getExperiments: function () { michael@0: return Task.spawn(function*() { michael@0: yield this._loadTask; michael@0: let list = []; michael@0: michael@0: for (let [id, experiment] of this._experiments) { michael@0: if (!experiment.startDate) { michael@0: // We only collect experiments that are or were active. michael@0: continue; michael@0: } michael@0: michael@0: list.push({ michael@0: id: id, michael@0: name: experiment._name, michael@0: description: experiment._description, michael@0: active: experiment.enabled, michael@0: endDate: experiment.endDate.getTime(), michael@0: detailURL: experiment._homepageURL, michael@0: branch: experiment.branch, michael@0: }); michael@0: } michael@0: michael@0: // Sort chronologically, descending. michael@0: list.sort((a, b) => b.endDate - a.endDate); michael@0: return list; michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: /** michael@0: * Returns the ExperimentInfo for the active experiment, or null michael@0: * if there is none. michael@0: */ michael@0: getActiveExperiment: function () { michael@0: let experiment = this._getActiveExperiment(); michael@0: if (!experiment) { michael@0: return null; michael@0: } michael@0: michael@0: let info = { michael@0: id: experiment.id, michael@0: name: experiment._name, michael@0: description: experiment._description, michael@0: active: experiment.enabled, michael@0: endDate: experiment.endDate.getTime(), michael@0: detailURL: experiment._homepageURL, michael@0: }; michael@0: michael@0: return info; michael@0: }, michael@0: michael@0: /** michael@0: * Experiment "branch" support. If an experiment has multiple branches, it michael@0: * can record the branch with the experiment system and it will michael@0: * automatically be included in data reporting (FHR/telemetry payloads). michael@0: */ michael@0: michael@0: /** michael@0: * Set the experiment branch for the specified experiment ID. michael@0: * @returns Promise<> michael@0: */ michael@0: setExperimentBranch: Task.async(function*(id, branchstr) { michael@0: yield this._loadTask; michael@0: let e = this._experiments.get(id); michael@0: if (!e) { michael@0: throw new Error("Experiment not found"); michael@0: } michael@0: e.branch = String(branchstr); michael@0: this._dirty = true; michael@0: Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); michael@0: yield this._run(); michael@0: }), michael@0: /** michael@0: * Get the branch of the specified experiment. If the experiment is unknown, michael@0: * throws an error. michael@0: * michael@0: * @param id The ID of the experiment. Pass null for the currently running michael@0: * experiment. michael@0: * @returns Promise michael@0: * @throws Error if the specified experiment ID is unknown, or if there is no michael@0: * current experiment. michael@0: */ michael@0: getExperimentBranch: Task.async(function*(id=null) { michael@0: yield this._loadTask; michael@0: let e; michael@0: if (id) { michael@0: e = this._experiments.get(id); michael@0: if (!e) { michael@0: throw new Error("Experiment not found"); michael@0: } michael@0: } else { michael@0: e = this._getActiveExperiment(); michael@0: if (e === null) { michael@0: throw new Error("No active experiment"); michael@0: } michael@0: } michael@0: return e.branch; michael@0: }), michael@0: michael@0: /** michael@0: * Determine whether another date has the same UTC day as now(). michael@0: */ michael@0: _dateIsTodayUTC: function (d) { michael@0: let now = this._policy.now(); michael@0: michael@0: return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime(); michael@0: }, michael@0: michael@0: /** michael@0: * Obtain the entry of the most recent active experiment that was active michael@0: * today. michael@0: * michael@0: * If no experiment was active today, this resolves to nothing. michael@0: * michael@0: * Assumption: Only a single experiment can be active at a time. michael@0: * michael@0: * @return Promise michael@0: */ michael@0: lastActiveToday: function () { michael@0: return Task.spawn(function* getMostRecentActiveExperimentTask() { michael@0: let experiments = yield this.getExperiments(); michael@0: michael@0: // Assumption: Ordered chronologically, descending, with active always michael@0: // first. michael@0: for (let experiment of experiments) { michael@0: if (experiment.active) { michael@0: return experiment; michael@0: } michael@0: michael@0: if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) { michael@0: return experiment; michael@0: } michael@0: } michael@0: return null; michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: _run: function() { michael@0: this._log.trace("_run"); michael@0: this._checkForShutdown(); michael@0: if (!this._mainTask) { michael@0: this._mainTask = Task.spawn(this._main.bind(this)); michael@0: this._mainTask.then( michael@0: () => { michael@0: this._log.trace("_main finished, scheduling next run"); michael@0: this._mainTask = null; michael@0: this._scheduleNextRun(); michael@0: }, michael@0: (e) => { michael@0: this._log.error("_main caught error: " + e); michael@0: this._mainTask = null; michael@0: } michael@0: ); michael@0: } michael@0: return this._mainTask; michael@0: }, michael@0: michael@0: _main: function*() { michael@0: do { michael@0: this._log.trace("_main iteration"); michael@0: yield this._loadTask; michael@0: if (!gExperimentsEnabled) { michael@0: this._refresh = false; michael@0: } michael@0: michael@0: if (this._refresh) { michael@0: yield this._loadManifest(); michael@0: } michael@0: yield this._evaluateExperiments(); michael@0: if (this._dirty) { michael@0: yield this._saveToCache(); michael@0: } michael@0: // If somebody called .updateManifest() or disableExperiment() michael@0: // while we were running, go again right now. michael@0: } michael@0: while (this._refresh || this._terminateReason); michael@0: }, michael@0: michael@0: _loadManifest: function*() { michael@0: this._log.trace("_loadManifest"); michael@0: let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI); michael@0: michael@0: this._checkForShutdown(); michael@0: michael@0: this._refresh = false; michael@0: try { michael@0: let responseText = yield this._httpGetRequest(uri); michael@0: this._log.trace("_loadManifest() - responseText=\"" + responseText + "\""); michael@0: michael@0: if (this._shutdown) { michael@0: return; michael@0: } michael@0: michael@0: let data = JSON.parse(responseText); michael@0: this._updateExperiments(data); michael@0: } catch (e) { michael@0: this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Fetch an updated list of experiments and trigger experiment updates. michael@0: * Do only use when experiments are enabled. michael@0: * michael@0: * @return Promise<> michael@0: * The promise is resolved when the manifest and experiment list is updated. michael@0: */ michael@0: updateManifest: function () { michael@0: this._log.trace("updateManifest()"); michael@0: michael@0: if (!gExperimentsEnabled) { michael@0: return Promise.reject(new Error("experiments are disabled")); michael@0: } michael@0: michael@0: if (this._shutdown) { michael@0: return Promise.reject(Error("uninit() alrady called")); michael@0: } michael@0: michael@0: this._refresh = true; michael@0: return this._run(); michael@0: }, michael@0: michael@0: notify: function (timer) { michael@0: this._log.trace("notify()"); michael@0: this._checkForShutdown(); michael@0: return this._run(); michael@0: }, michael@0: michael@0: // START OF ADD-ON LISTENERS michael@0: michael@0: onUninstalled: function (addon) { michael@0: this._log.trace("onUninstalled() - addon id: " + addon.id); michael@0: if (gActiveUninstallAddonIDs.has(addon.id)) { michael@0: this._log.trace("matches pending uninstall"); michael@0: return; michael@0: } michael@0: let activeExperiment = this._getActiveExperiment(); michael@0: if (!activeExperiment || activeExperiment._addonId != addon.id) { michael@0: return; michael@0: } michael@0: michael@0: this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED); michael@0: }, michael@0: michael@0: onInstallStarted: function (install) { michael@0: if (install.addon.type != "experiment") { michael@0: return; michael@0: } michael@0: michael@0: this._log.trace("onInstallStarted() - " + install.addon.id); michael@0: if (install.addon.appDisabled) { michael@0: // This is a PreviousExperiment michael@0: return; michael@0: } michael@0: michael@0: // We want to be in control of all experiment add-ons: reject installs michael@0: // for add-ons that we don't know about. michael@0: michael@0: // We have a race condition of sorts to worry about here. We have 2 michael@0: // onInstallStarted listeners. This one (the global one) and the one michael@0: // created as part of ExperimentEntry._installAddon. Because of the order michael@0: // they are registered in, this one likely executes first. Unfortunately, michael@0: // this means that the add-on ID is not yet set on the ExperimentEntry. michael@0: // So, we can't just look at this._trackedAddonIds because the new experiment michael@0: // will have its add-on ID set to null. We work around this by storing a michael@0: // identifying field - the source URL of the install - in a module-level michael@0: // variable (so multiple Experiments instances doesn't cancel each other michael@0: // out). michael@0: michael@0: if (this._trackedAddonIds.has(install.addon.id)) { michael@0: this._log.info("onInstallStarted allowing install because add-on ID " + michael@0: "tracked by us."); michael@0: return; michael@0: } michael@0: michael@0: if (gActiveInstallURLs.has(install.sourceURI.spec)) { michael@0: this._log.info("onInstallStarted allowing install because install " + michael@0: "tracked by us."); michael@0: return; michael@0: } michael@0: michael@0: this._log.warn("onInstallStarted cancelling install of unknown " + michael@0: "experiment add-on: " + install.addon.id); michael@0: return false; michael@0: }, michael@0: michael@0: // END OF ADD-ON LISTENERS. michael@0: michael@0: _getExperimentByAddonId: function (addonId) { michael@0: for (let [, entry] of this._experiments) { michael@0: if (entry._addonId === addonId) { michael@0: return entry; michael@0: } michael@0: } michael@0: michael@0: return null; michael@0: }, michael@0: michael@0: /* michael@0: * Helper function to make HTTP GET requests. Returns a promise that is resolved with michael@0: * the responseText when the request is complete. michael@0: */ michael@0: _httpGetRequest: function (url) { michael@0: this._log.trace("httpGetRequest(" + url + ")"); michael@0: let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); michael@0: try { michael@0: xhr.open("GET", url); michael@0: } catch (e) { michael@0: this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e); michael@0: return Promise.reject(new Error("Experiments - Error opening XHR for " + url)); michael@0: } michael@0: michael@0: let deferred = Promise.defer(); michael@0: michael@0: let log = this._log; michael@0: xhr.onerror = function (e) { michael@0: log.error("httpGetRequest::onError() - Error making request to " + url + ": " + e.error); michael@0: deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error)); michael@0: }; michael@0: michael@0: xhr.onload = function (event) { michael@0: if (xhr.status !== 200 && xhr.state !== 0) { michael@0: log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status); michael@0: deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status)); michael@0: return; michael@0: } michael@0: michael@0: let certs = null; michael@0: if (gPrefs.get(PREF_MANIFEST_CHECKCERT, true)) { michael@0: certs = CertUtils.readCertPrefs(PREF_BRANCH + "manifest.certs."); michael@0: } michael@0: try { michael@0: let allowNonBuiltin = !gPrefs.get(PREF_MANIFEST_REQUIREBUILTIN, true); michael@0: CertUtils.checkCert(xhr.channel, allowNonBuiltin, certs); michael@0: } michael@0: catch (e) { michael@0: log.error("manifest fetch failed certificate checks", [e]); michael@0: deferred.reject(new Error("Experiments - manifest fetch failed certificate checks: " + e)); michael@0: return; michael@0: } michael@0: michael@0: deferred.resolve(xhr.responseText); michael@0: }; michael@0: michael@0: if (xhr.channel instanceof Ci.nsISupportsPriority) { michael@0: xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST; michael@0: } michael@0: michael@0: xhr.send(null); michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: /* michael@0: * Path of the cache file we use in the profile. michael@0: */ michael@0: get _cacheFilePath() { michael@0: return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE); michael@0: }, michael@0: michael@0: /* michael@0: * Part of the main task to save the cache to disk, called from _main. michael@0: */ michael@0: _saveToCache: function* () { michael@0: this._log.trace("_saveToCache"); michael@0: let path = this._cacheFilePath; michael@0: let textData = JSON.stringify({ michael@0: version: CACHE_VERSION, michael@0: data: [e[1].toJSON() for (e of this._experiments.entries())], michael@0: }); michael@0: michael@0: let encoder = new TextEncoder(); michael@0: let data = encoder.encode(textData); michael@0: let options = { tmpPath: path + ".tmp", compression: "lz4" }; michael@0: yield OS.File.writeAtomic(path, data, options); michael@0: this._dirty = false; michael@0: this._log.debug("_saveToCache saved to " + path); michael@0: }, michael@0: michael@0: /* michael@0: * Task function, load the cached experiments manifest file from disk. michael@0: */ michael@0: _loadFromCache: Task.async(function* () { michael@0: this._log.trace("_loadFromCache"); michael@0: let path = this._cacheFilePath; michael@0: try { michael@0: let result = yield loadJSONAsync(path, { compression: "lz4" }); michael@0: this._populateFromCache(result); michael@0: } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) { michael@0: // No cached manifest yet. michael@0: this._experiments = new Map(); michael@0: } michael@0: }), michael@0: michael@0: _populateFromCache: function (data) { michael@0: this._log.trace("populateFromCache() - data: " + JSON.stringify(data)); michael@0: michael@0: // If the user has a newer cache version than we can understand, we fail michael@0: // hard; no experiments should be active in this older client. michael@0: if (CACHE_VERSION !== data.version) { michael@0: throw new Error("Experiments::_populateFromCache() - invalid cache version"); michael@0: } michael@0: michael@0: let experiments = new Map(); michael@0: for (let item of data.data) { michael@0: let entry = new Experiments.ExperimentEntry(this._policy); michael@0: if (!entry.initFromCacheData(item)) { michael@0: continue; michael@0: } michael@0: experiments.set(entry.id, entry); michael@0: } michael@0: michael@0: this._experiments = experiments; michael@0: }, michael@0: michael@0: /* michael@0: * Update the experiment entries from the experiments michael@0: * array in the manifest michael@0: */ michael@0: _updateExperiments: function (manifestObject) { michael@0: this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject)); michael@0: michael@0: if (manifestObject.version !== MANIFEST_VERSION) { michael@0: this._log.warning("updateExperiments() - unsupported version " + manifestObject.version); michael@0: } michael@0: michael@0: let experiments = new Map(); // The new experiments map michael@0: michael@0: // Collect new and updated experiments. michael@0: for (let data of manifestObject.experiments) { michael@0: let entry = this._experiments.get(data.id); michael@0: michael@0: if (entry) { michael@0: if (!entry.updateFromManifestData(data)) { michael@0: this._log.error("updateExperiments() - Invalid manifest data for " + data.id); michael@0: continue; michael@0: } michael@0: } else { michael@0: entry = new Experiments.ExperimentEntry(this._policy); michael@0: if (!entry.initFromManifestData(data)) { michael@0: continue; michael@0: } michael@0: } michael@0: michael@0: if (entry.shouldDiscard()) { michael@0: continue; michael@0: } michael@0: michael@0: experiments.set(entry.id, entry); michael@0: } michael@0: michael@0: // Make sure we keep experiments that are or were running. michael@0: // We remove them after KEEP_HISTORY_N_DAYS. michael@0: for (let [id, entry] of this._experiments) { michael@0: if (experiments.has(id)) { michael@0: continue; michael@0: } michael@0: michael@0: if (!entry.startDate || entry.shouldDiscard()) { michael@0: this._log.trace("updateExperiments() - discarding entry for " + id); michael@0: continue; michael@0: } michael@0: michael@0: experiments.set(id, entry); michael@0: } michael@0: michael@0: this._experiments = experiments; michael@0: this._dirty = true; michael@0: }, michael@0: michael@0: getActiveExperimentID: function() { michael@0: if (!this._experiments) { michael@0: return null; michael@0: } michael@0: let e = this._getActiveExperiment(); michael@0: if (!e) { michael@0: return null; michael@0: } michael@0: return e.id; michael@0: }, michael@0: michael@0: getActiveExperimentBranch: function() { michael@0: if (!this._experiments) { michael@0: return null; michael@0: } michael@0: let e = this._getActiveExperiment(); michael@0: if (!e) { michael@0: return null; michael@0: } michael@0: return e.branch; michael@0: }, michael@0: michael@0: _getActiveExperiment: function () { michael@0: let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)]; michael@0: michael@0: if (enabled.length == 1) { michael@0: return enabled[0]; michael@0: } michael@0: michael@0: if (enabled.length > 1) { michael@0: this._log.error("getActiveExperimentId() - should not have more than 1 active experiment"); michael@0: throw new Error("have more than 1 active experiment"); michael@0: } michael@0: michael@0: return null; michael@0: }, michael@0: michael@0: /** michael@0: * Disables all active experiments. michael@0: * michael@0: * @return Promise<> Promise that will get resolved once the task is done or failed. michael@0: */ michael@0: disableExperiment: function (reason) { michael@0: if (!reason) { michael@0: throw new Error("Must specify a termination reason."); michael@0: } michael@0: michael@0: this._log.trace("disableExperiment()"); michael@0: this._terminateReason = reason; michael@0: return this._run(); michael@0: }, michael@0: michael@0: /** michael@0: * The Set of add-on IDs that we know about from manifests. michael@0: */ michael@0: get _trackedAddonIds() { michael@0: if (!this._experiments) { michael@0: return new Set(); michael@0: } michael@0: michael@0: return new Set([e._addonId for ([,e] of this._experiments) if (e._addonId)]); michael@0: }, michael@0: michael@0: /* michael@0: * Task function to check applicability of experiments, disable the active michael@0: * experiment if needed and activate the first applicable candidate. michael@0: */ michael@0: _evaluateExperiments: function*() { michael@0: this._log.trace("_evaluateExperiments"); michael@0: michael@0: this._checkForShutdown(); michael@0: michael@0: // The first thing we do is reconcile our state against what's in the michael@0: // Addon Manager. It's possible that the Addon Manager knows of experiment michael@0: // add-ons that we don't. This could happen if an experiment gets installed michael@0: // when we're not listening or if there is a bug in our synchronization michael@0: // code. michael@0: // michael@0: // We have a few options of what to do with unknown experiment add-ons michael@0: // coming from the Addon Manager. Ideally, we'd convert these to michael@0: // ExperimentEntry instances and stuff them inside this._experiments. michael@0: // However, since ExperimentEntry contain lots of metadata from the michael@0: // manifest and trying to make up data could be error prone, it's safer michael@0: // to not try. Furthermore, if an experiment really did come from us, we michael@0: // should have some record of it. In the end, we decide to discard all michael@0: // knowledge for these unknown experiment add-ons. michael@0: let installedExperiments = yield installedExperimentAddons(); michael@0: let expectedAddonIds = this._trackedAddonIds; michael@0: let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))]; michael@0: if (unknownAddons.length) { michael@0: this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " + michael@0: [a.id for (a of unknownAddons)].join(", ")); michael@0: michael@0: yield uninstallAddons(unknownAddons); michael@0: } michael@0: michael@0: let activeExperiment = this._getActiveExperiment(); michael@0: let activeChanged = false; michael@0: let now = this._policy.now(); michael@0: michael@0: if (!activeExperiment) { michael@0: // Avoid this pref staying out of sync if there were e.g. crashes. michael@0: gPrefs.set(PREF_ACTIVE_EXPERIMENT, false); michael@0: } michael@0: michael@0: // Ensure the active experiment is in the proper state. This may install, michael@0: // uninstall, upgrade, or enable the experiment add-on. What exactly is michael@0: // abstracted away from us by design. michael@0: if (activeExperiment) { michael@0: let changes; michael@0: let shouldStopResult = yield activeExperiment.shouldStop(); michael@0: if (shouldStopResult.shouldStop) { michael@0: let expireReasons = ["endTime", "maxActiveSeconds"]; michael@0: let kind, reason; michael@0: michael@0: if (expireReasons.indexOf(shouldStopResult.reason[0]) != -1) { michael@0: kind = TELEMETRY_LOG.TERMINATION.EXPIRED; michael@0: reason = null; michael@0: } else { michael@0: kind = TELEMETRY_LOG.TERMINATION.RECHECK; michael@0: reason = shouldStopResult.reason; michael@0: } michael@0: changes = yield activeExperiment.stop(kind, reason); michael@0: } michael@0: else if (this._terminateReason) { michael@0: changes = yield activeExperiment.stop(this._terminateReason); michael@0: } michael@0: else { michael@0: changes = yield activeExperiment.reconcileAddonState(); michael@0: } michael@0: michael@0: if (changes) { michael@0: this._dirty = true; michael@0: activeChanged = true; michael@0: } michael@0: michael@0: if (!activeExperiment._enabled) { michael@0: activeExperiment = null; michael@0: activeChanged = true; michael@0: } michael@0: } michael@0: michael@0: this._terminateReason = null; michael@0: michael@0: if (!activeExperiment && gExperimentsEnabled) { michael@0: for (let [id, experiment] of this._experiments) { michael@0: let applicable; michael@0: let reason = null; michael@0: try { michael@0: applicable = yield experiment.isApplicable(); michael@0: } michael@0: catch (e) { michael@0: applicable = false; michael@0: reason = e; michael@0: } michael@0: michael@0: if (!applicable && reason && reason[0] != "was-active") { michael@0: // Report this from here to avoid over-reporting. michael@0: let desc = TELEMETRY_LOG.ACTIVATION; michael@0: let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id]; michael@0: data = data.concat(reason); michael@0: TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, data); michael@0: } michael@0: michael@0: if (!applicable) { michael@0: continue; michael@0: } michael@0: michael@0: this._log.debug("evaluateExperiments() - activating experiment " + id); michael@0: try { michael@0: yield experiment.start(); michael@0: activeChanged = true; michael@0: activeExperiment = experiment; michael@0: this._dirty = true; michael@0: break; michael@0: } catch (e) { michael@0: // On failure, clean up the best we can and try the next experiment. michael@0: this._log.error("evaluateExperiments() - Unable to start experiment: " + e.message); michael@0: experiment._enabled = false; michael@0: yield experiment.reconcileAddonState(); michael@0: } michael@0: } michael@0: } michael@0: michael@0: gPrefs.set(PREF_ACTIVE_EXPERIMENT, activeExperiment != null); michael@0: michael@0: if (activeChanged || this._firstEvaluate) { michael@0: Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); michael@0: this._firstEvaluate = false; michael@0: } michael@0: michael@0: if ("@mozilla.org/toolkit/crash-reporter;1" in Cc && activeExperiment) { michael@0: try { michael@0: gCrashReporter.annotateCrashReport("ActiveExperiment", activeExperiment.id); michael@0: } catch (e) { michael@0: // It's ok if crash reporting is disabled. michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /* michael@0: * Schedule the soonest re-check of experiment applicability that is needed. michael@0: */ michael@0: _scheduleNextRun: function () { michael@0: this._checkForShutdown(); michael@0: michael@0: if (this._timer) { michael@0: this._timer.clear(); michael@0: } michael@0: michael@0: if (!gExperimentsEnabled || this._experiments.length == 0) { michael@0: return; michael@0: } michael@0: michael@0: let time = null; michael@0: let now = this._policy.now().getTime(); michael@0: michael@0: for (let [id, experiment] of this._experiments) { michael@0: let scheduleTime = experiment.getScheduleTime(); michael@0: if (scheduleTime > now) { michael@0: if (time !== null) { michael@0: time = Math.min(time, scheduleTime); michael@0: } else { michael@0: time = scheduleTime; michael@0: } michael@0: } michael@0: } michael@0: michael@0: if (time === null) { michael@0: // No schedule time found. michael@0: return; michael@0: } michael@0: michael@0: this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now); michael@0: this._policy.oneshotTimer(this.notify, time - now, this, "_timer"); michael@0: }, michael@0: }; michael@0: michael@0: michael@0: /* michael@0: * Represents a single experiment. michael@0: */ michael@0: michael@0: Experiments.ExperimentEntry = function (policy) { michael@0: this._policy = policy || new Experiments.Policy(); michael@0: this._log = Log.repository.getLoggerWithMessagePrefix( michael@0: "Browser.Experiments.Experiments", michael@0: "ExperimentEntry #" + gExperimentEntryCounter++ + "::"); michael@0: michael@0: // Is the experiment supposed to be running. michael@0: this._enabled = false; michael@0: // When this experiment was started, if ever. michael@0: this._startDate = null; michael@0: // When this experiment was ended, if ever. michael@0: this._endDate = null; michael@0: // The condition data from the manifest. michael@0: this._manifestData = null; michael@0: // For an active experiment, signifies whether we need to update the xpi. michael@0: this._needsUpdate = false; michael@0: // A random sample value for comparison against the manifest conditions. michael@0: this._randomValue = null; michael@0: // When this entry was last changed for respecting history retention duration. michael@0: this._lastChangedDate = null; michael@0: // Has this experiment failed to activate before? michael@0: this._failedStart = false; michael@0: // The experiment branch michael@0: this._branch = null; michael@0: michael@0: // We grab these from the addon after download. michael@0: this._name = null; michael@0: this._description = null; michael@0: this._homepageURL = null; michael@0: this._addonId = null; michael@0: }; michael@0: michael@0: Experiments.ExperimentEntry.prototype = { michael@0: MANIFEST_REQUIRED_FIELDS: new Set([ michael@0: "id", michael@0: "xpiURL", michael@0: "xpiHash", michael@0: "startTime", michael@0: "endTime", michael@0: "maxActiveSeconds", michael@0: "appName", michael@0: "channel", michael@0: ]), michael@0: michael@0: MANIFEST_OPTIONAL_FIELDS: new Set([ michael@0: "maxStartTime", michael@0: "minVersion", michael@0: "maxVersion", michael@0: "version", michael@0: "minBuildID", michael@0: "maxBuildID", michael@0: "buildIDs", michael@0: "os", michael@0: "locale", michael@0: "sample", michael@0: "disabled", michael@0: "frozen", michael@0: "jsfilter", michael@0: ]), michael@0: michael@0: SERIALIZE_KEYS: new Set([ michael@0: "_enabled", michael@0: "_manifestData", michael@0: "_needsUpdate", michael@0: "_randomValue", michael@0: "_failedStart", michael@0: "_name", michael@0: "_description", michael@0: "_homepageURL", michael@0: "_addonId", michael@0: "_startDate", michael@0: "_endDate", michael@0: "_branch", michael@0: ]), michael@0: michael@0: DATE_KEYS: new Set([ michael@0: "_startDate", michael@0: "_endDate", michael@0: ]), michael@0: michael@0: UPGRADE_KEYS: new Map([ michael@0: ["_branch", null], michael@0: ]), michael@0: michael@0: ADDON_CHANGE_NONE: 0, michael@0: ADDON_CHANGE_INSTALL: 1, michael@0: ADDON_CHANGE_UNINSTALL: 2, michael@0: ADDON_CHANGE_ENABLE: 4, michael@0: michael@0: /* michael@0: * Initialize entry from the manifest. michael@0: * @param data The experiment data from the manifest. michael@0: * @return boolean Whether initialization succeeded. michael@0: */ michael@0: initFromManifestData: function (data) { michael@0: if (!this._isManifestDataValid(data)) { michael@0: return false; michael@0: } michael@0: michael@0: this._manifestData = data; michael@0: michael@0: this._randomValue = this._policy.random(); michael@0: this._lastChangedDate = this._policy.now(); michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: get enabled() { michael@0: return this._enabled; michael@0: }, michael@0: michael@0: get id() { michael@0: return this._manifestData.id; michael@0: }, michael@0: michael@0: get branch() { michael@0: return this._branch; michael@0: }, michael@0: michael@0: set branch(v) { michael@0: this._branch = v; michael@0: }, michael@0: michael@0: get startDate() { michael@0: return this._startDate; michael@0: }, michael@0: michael@0: get endDate() { michael@0: if (!this._startDate) { michael@0: return null; michael@0: } michael@0: michael@0: let endTime = 0; michael@0: michael@0: if (!this._enabled) { michael@0: return this._endDate; michael@0: } michael@0: michael@0: let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds; michael@0: endTime = Math.min(1000 * this._manifestData.endTime, michael@0: this._startDate.getTime() + maxActiveMs); michael@0: michael@0: return new Date(endTime); michael@0: }, michael@0: michael@0: get needsUpdate() { michael@0: return this._needsUpdate; michael@0: }, michael@0: michael@0: /* michael@0: * Initialize entry from the cache. michael@0: * @param data The entry data from the cache. michael@0: * @return boolean Whether initialization succeeded. michael@0: */ michael@0: initFromCacheData: function (data) { michael@0: for (let [key, dval] of this.UPGRADE_KEYS) { michael@0: if (!(key in data)) { michael@0: data[key] = dval; michael@0: } michael@0: } michael@0: michael@0: for (let key of this.SERIALIZE_KEYS) { michael@0: if (!(key in data) && !this.DATE_KEYS.has(key)) { michael@0: this._log.error("initFromCacheData() - missing required key " + key); michael@0: return false; michael@0: } michael@0: }; michael@0: michael@0: if (!this._isManifestDataValid(data._manifestData)) { michael@0: return false; michael@0: } michael@0: michael@0: // Dates are restored separately from epoch ms, everything else is just michael@0: // copied in. michael@0: michael@0: this.SERIALIZE_KEYS.forEach(key => { michael@0: if (!this.DATE_KEYS.has(key)) { michael@0: this[key] = data[key]; michael@0: } michael@0: }); michael@0: michael@0: this.DATE_KEYS.forEach(key => { michael@0: if (key in data) { michael@0: let date = new Date(); michael@0: date.setTime(data[key]); michael@0: this[key] = date; michael@0: } michael@0: }); michael@0: michael@0: this._lastChangedDate = this._policy.now(); michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: /* michael@0: * Returns a JSON representation of this object. michael@0: */ michael@0: toJSON: function () { michael@0: let obj = {}; michael@0: michael@0: // Dates are serialized separately as epoch ms. michael@0: michael@0: this.SERIALIZE_KEYS.forEach(key => { michael@0: if (!this.DATE_KEYS.has(key)) { michael@0: obj[key] = this[key]; michael@0: } michael@0: }); michael@0: michael@0: this.DATE_KEYS.forEach(key => { michael@0: if (this[key]) { michael@0: obj[key] = this[key].getTime(); michael@0: } michael@0: }); michael@0: michael@0: return obj; michael@0: }, michael@0: michael@0: /* michael@0: * Update from the experiment data from the manifest. michael@0: * @param data The experiment data from the manifest. michael@0: * @return boolean Whether updating succeeded. michael@0: */ michael@0: updateFromManifestData: function (data) { michael@0: let old = this._manifestData; michael@0: michael@0: if (!this._isManifestDataValid(data)) { michael@0: return false; michael@0: } michael@0: michael@0: if (this._enabled) { michael@0: if (old.xpiHash !== data.xpiHash) { michael@0: // A changed hash means we need to update active experiments. michael@0: this._needsUpdate = true; michael@0: } michael@0: } else if (this._failedStart && michael@0: (old.xpiHash !== data.xpiHash) || michael@0: (old.xpiURL !== data.xpiURL)) { michael@0: // Retry installation of previously invalid experiments michael@0: // if hash or url changed. michael@0: this._failedStart = false; michael@0: } michael@0: michael@0: this._manifestData = data; michael@0: this._lastChangedDate = this._policy.now(); michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: /* michael@0: * Is this experiment applicable? michael@0: * @return Promise<> Resolved if the experiment is applicable. michael@0: * If it is not applicable it is rejected with michael@0: * a Promise which contains the reason. michael@0: */ michael@0: isApplicable: function () { michael@0: let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"] michael@0: .getService(Ci.nsIVersionComparator); michael@0: let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); michael@0: let runtime = Cc["@mozilla.org/xre/app-info;1"] michael@0: .getService(Ci.nsIXULRuntime); michael@0: michael@0: let locale = this._policy.locale(); michael@0: let channel = this._policy.updatechannel(); michael@0: let data = this._manifestData; michael@0: michael@0: let now = this._policy.now() / 1000; // The manifest times are in seconds. michael@0: let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS; michael@0: let maxActive = data.maxActiveSeconds || 0; michael@0: let startSec = (this.startDate || 0) / 1000; michael@0: michael@0: this._log.trace("isApplicable() - now=" + now michael@0: + ", randomValue=" + this._randomValue michael@0: + ", data=" + JSON.stringify(this._manifestData)); michael@0: michael@0: // Not applicable if it already ran. michael@0: michael@0: if (!this.enabled && this._endDate) { michael@0: return Promise.reject(["was-active"]); michael@0: } michael@0: michael@0: // Define and run the condition checks. michael@0: michael@0: let simpleChecks = [ michael@0: { name: "failedStart", michael@0: condition: () => !this._failedStart }, michael@0: { name: "disabled", michael@0: condition: () => !data.disabled }, michael@0: { name: "frozen", michael@0: condition: () => !data.frozen || this._enabled }, michael@0: { name: "startTime", michael@0: condition: () => now >= data.startTime }, michael@0: { name: "endTime", michael@0: condition: () => now < data.endTime }, michael@0: { name: "maxStartTime", michael@0: condition: () => !data.maxStartTime || now <= data.maxStartTime }, michael@0: { name: "maxActiveSeconds", michael@0: condition: () => !this._startDate || now <= (startSec + maxActive) }, michael@0: { name: "appName", michael@0: condition: () => !data.appName || data.appName.indexOf(app.name) != -1 }, michael@0: { name: "minBuildID", michael@0: condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID }, michael@0: { name: "maxBuildID", michael@0: condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID }, michael@0: { name: "buildIDs", michael@0: condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 }, michael@0: { name: "os", michael@0: condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 }, michael@0: { name: "channel", michael@0: condition: () => !data.channel || data.channel.indexOf(channel) != -1 }, michael@0: { name: "locale", michael@0: condition: () => !data.locale || data.locale.indexOf(locale) != -1 }, michael@0: { name: "sample", michael@0: condition: () => data.sample === undefined || this._randomValue <= data.sample }, michael@0: { name: "version", michael@0: condition: () => !data.version || data.version.indexOf(app.version) != -1 }, michael@0: { name: "minVersion", michael@0: condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 }, michael@0: { name: "maxVersion", michael@0: condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 }, michael@0: ]; michael@0: michael@0: for (let check of simpleChecks) { michael@0: let result = check.condition(); michael@0: if (!result) { michael@0: this._log.debug("isApplicable() - id=" michael@0: + data.id + " - test '" + check.name + "' failed"); michael@0: return Promise.reject([check.name]); michael@0: } michael@0: } michael@0: michael@0: if (data.jsfilter) { michael@0: return this._runFilterFunction(data.jsfilter); michael@0: } michael@0: michael@0: return Promise.resolve(true); michael@0: }, michael@0: michael@0: /* michael@0: * Run the jsfilter function from the manifest in a sandbox and return the michael@0: * result (forced to boolean). michael@0: */ michael@0: _runFilterFunction: function (jsfilter) { michael@0: this._log.trace("runFilterFunction() - filter: " + jsfilter); michael@0: michael@0: return Task.spawn(function ExperimentEntry_runFilterFunction_task() { michael@0: const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal); michael@0: let options = { michael@0: sandboxName: "telemetry experiments jsfilter sandbox", michael@0: wantComponents: false, michael@0: }; michael@0: michael@0: let sandbox = Cu.Sandbox(nullprincipal); michael@0: let context = {}; michael@0: context.healthReportPayload = yield this._policy.healthReportPayload(); michael@0: context.telemetryPayload = yield this._policy.telemetryPayload(); michael@0: michael@0: try { michael@0: Cu.evalInSandbox(jsfilter, sandbox); michael@0: } catch (e) { michael@0: this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message); michael@0: throw ["jsfilter-evalfailed"]; michael@0: } michael@0: michael@0: // You can't insert arbitrarily complex objects into a sandbox, so michael@0: // we serialize everything through JSON. michael@0: sandbox._hr = JSON.stringify(yield this._policy.healthReportPayload()); michael@0: Object.defineProperty(sandbox, "_t", michael@0: { get: () => JSON.stringify(this._policy.telemetryPayload()) }); michael@0: michael@0: let result = false; michael@0: try { michael@0: result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox); michael@0: } michael@0: catch (e) { michael@0: this._log.debug("runFilterFunction() - filter function failed: " michael@0: + e.message + ", " + e.stack); michael@0: throw ["jsfilter-threw", e.message]; michael@0: } michael@0: finally { michael@0: Cu.nukeSandbox(sandbox); michael@0: } michael@0: michael@0: if (!result) { michael@0: throw ["jsfilter-false"]; michael@0: } michael@0: michael@0: throw new Task.Result(true); michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: /* michael@0: * Start running the experiment. michael@0: * michael@0: * @return Promise<> Resolved when the operation is complete. michael@0: */ michael@0: start: Task.async(function* () { michael@0: this._log.trace("start() for " + this.id); michael@0: michael@0: this._enabled = true; michael@0: return yield this.reconcileAddonState(); michael@0: }), michael@0: michael@0: // Async install of the addon for this experiment, part of the start task above. michael@0: _installAddon: Task.async(function* () { michael@0: let deferred = Promise.defer(); michael@0: michael@0: let hash = this._policy.ignoreHashes ? null : this._manifestData.xpiHash; michael@0: michael@0: let install = yield addonInstallForURL(this._manifestData.xpiURL, hash); michael@0: gActiveInstallURLs.add(install.sourceURI.spec); michael@0: michael@0: let failureHandler = (install, handler) => { michael@0: let message = "AddonInstall " + handler + " for " + this.id + ", state=" + michael@0: (install.state || "?") + ", error=" + install.error; michael@0: this._log.error("_installAddon() - " + message); michael@0: this._failedStart = true; michael@0: gActiveInstallURLs.delete(install.sourceURI.spec); michael@0: michael@0: TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, michael@0: [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]); michael@0: michael@0: deferred.reject(new Error(message)); michael@0: }; michael@0: michael@0: let listener = { michael@0: _expectedID: null, michael@0: michael@0: onDownloadEnded: install => { michael@0: this._log.trace("_installAddon() - onDownloadEnded for " + this.id); michael@0: michael@0: if (install.existingAddon) { michael@0: this._log.warn("_installAddon() - onDownloadEnded, addon already installed"); michael@0: } michael@0: michael@0: if (install.addon.type !== "experiment") { michael@0: this._log.error("_installAddon() - onDownloadEnded, wrong addon type"); michael@0: install.cancel(); michael@0: } michael@0: }, michael@0: michael@0: onInstallStarted: install => { michael@0: this._log.trace("_installAddon() - onInstallStarted for " + this.id); michael@0: michael@0: if (install.existingAddon) { michael@0: this._log.warn("_installAddon() - onInstallStarted, addon already installed"); michael@0: } michael@0: michael@0: if (install.addon.type !== "experiment") { michael@0: this._log.error("_installAddon() - onInstallStarted, wrong addon type"); michael@0: return false; michael@0: } michael@0: }, michael@0: michael@0: onInstallEnded: install => { michael@0: this._log.trace("_installAddon() - install ended for " + this.id); michael@0: gActiveInstallURLs.delete(install.sourceURI.spec); michael@0: michael@0: this._lastChangedDate = this._policy.now(); michael@0: this._startDate = this._policy.now(); michael@0: this._enabled = true; michael@0: michael@0: TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, michael@0: [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]); michael@0: michael@0: let addon = install.addon; michael@0: this._name = addon.name; michael@0: this._addonId = addon.id; michael@0: this._description = addon.description || ""; michael@0: this._homepageURL = addon.homepageURL || ""; michael@0: michael@0: // Experiment add-ons default to userDisabled=true. Enable if needed. michael@0: if (addon.userDisabled) { michael@0: this._log.trace("Add-on is disabled. Enabling."); michael@0: listener._expectedID = addon.id; michael@0: AddonManager.addAddonListener(listener); michael@0: addon.userDisabled = false; michael@0: } else { michael@0: this._log.trace("Add-on is enabled. start() completed."); michael@0: deferred.resolve(); michael@0: } michael@0: }, michael@0: michael@0: onEnabled: addon => { michael@0: this._log.info("onEnabled() for " + addon.id); michael@0: michael@0: if (addon.id != listener._expectedID) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: deferred.resolve(); michael@0: }, michael@0: }; michael@0: michael@0: ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"] michael@0: .forEach(what => { michael@0: listener[what] = install => failureHandler(install, what) michael@0: }); michael@0: michael@0: install.addListener(listener); michael@0: install.install(); michael@0: michael@0: return yield deferred.promise; michael@0: }), michael@0: michael@0: /** michael@0: * Stop running the experiment if it is active. michael@0: * michael@0: * @param terminationKind (optional) michael@0: * The termination kind, e.g. ADDON_UNINSTALLED or EXPIRED. michael@0: * @param terminationReason (optional) michael@0: * The termination reason details for termination kind RECHECK. michael@0: * @return Promise<> Resolved when the operation is complete. michael@0: */ michael@0: stop: Task.async(function* (terminationKind, terminationReason) { michael@0: this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind); michael@0: if (!this._enabled) { michael@0: throw new Error("Must not call stop() on an inactive experiment."); michael@0: } michael@0: michael@0: this._enabled = false; michael@0: let now = this._policy.now(); michael@0: this._lastChangedDate = now; michael@0: this._endDate = now; michael@0: michael@0: let changes = yield this.reconcileAddonState(); michael@0: this._logTermination(terminationKind, terminationReason); michael@0: michael@0: if (terminationKind == TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED) { michael@0: changes |= this.ADDON_CHANGE_UNINSTALL; michael@0: } michael@0: michael@0: return changes; michael@0: }), michael@0: michael@0: /** michael@0: * Reconcile the state of the add-on against what it's supposed to be. michael@0: * michael@0: * If we are active, ensure the add-on is enabled and up to date. michael@0: * michael@0: * If we are inactive, ensure the add-on is not installed. michael@0: */ michael@0: reconcileAddonState: Task.async(function* () { michael@0: this._log.trace("reconcileAddonState()"); michael@0: michael@0: if (!this._enabled) { michael@0: if (!this._addonId) { michael@0: this._log.trace("reconcileAddonState() - Experiment is not enabled and " + michael@0: "has no add-on. Doing nothing."); michael@0: return this.ADDON_CHANGE_NONE; michael@0: } michael@0: michael@0: let addon = yield this._getAddon(); michael@0: if (!addon) { michael@0: this._log.trace("reconcileAddonState() - Inactive experiment has no " + michael@0: "add-on. Doing nothing."); michael@0: return this.ADDON_CHANGE_NONE; michael@0: } michael@0: michael@0: this._log.info("reconcileAddonState() - Uninstalling add-on for inactive " + michael@0: "experiment: " + addon.id); michael@0: gActiveUninstallAddonIDs.add(addon.id); michael@0: yield uninstallAddons([addon]); michael@0: gActiveUninstallAddonIDs.delete(addon.id); michael@0: return this.ADDON_CHANGE_UNINSTALL; michael@0: } michael@0: michael@0: // If we get here, we're supposed to be active. michael@0: michael@0: let changes = 0; michael@0: michael@0: // That requires an add-on. michael@0: let currentAddon = yield this._getAddon(); michael@0: michael@0: // If we have an add-on but it isn't up to date, uninstall it michael@0: // (to prepare for reinstall). michael@0: if (currentAddon && this._needsUpdate) { michael@0: this._log.info("reconcileAddonState() - Uninstalling add-on because update " + michael@0: "needed: " + currentAddon.id); michael@0: gActiveUninstallAddonIDs.add(currentAddon.id); michael@0: yield uninstallAddons([currentAddon]); michael@0: gActiveUninstallAddonIDs.delete(currentAddon.id); michael@0: changes |= this.ADDON_CHANGE_UNINSTALL; michael@0: } michael@0: michael@0: if (!currentAddon || this._needsUpdate) { michael@0: this._log.info("reconcileAddonState() - Installing add-on."); michael@0: yield this._installAddon(); michael@0: changes |= this.ADDON_CHANGE_INSTALL; michael@0: } michael@0: michael@0: let addon = yield this._getAddon(); michael@0: if (!addon) { michael@0: throw new Error("Could not obtain add-on for experiment that should be " + michael@0: "enabled."); michael@0: } michael@0: michael@0: // If we have the add-on and it is enabled, we are done. michael@0: if (!addon.userDisabled) { michael@0: return changes; michael@0: } michael@0: michael@0: let deferred = Promise.defer(); michael@0: michael@0: // Else we need to enable it. michael@0: let listener = { michael@0: onEnabled: enabledAddon => { michael@0: if (enabledAddon.id != addon.id) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: deferred.resolve(); michael@0: }, michael@0: }; michael@0: michael@0: this._log.info("Activating add-on: " + addon.id); michael@0: AddonManager.addAddonListener(listener); michael@0: addon.userDisabled = false; michael@0: yield deferred.promise; michael@0: changes |= this.ADDON_CHANGE_ENABLE; michael@0: michael@0: this._log.info("Add-on has been enabled: " + addon.id); michael@0: return changes; michael@0: }), michael@0: michael@0: /** michael@0: * Obtain the underlying Addon from the Addon Manager. michael@0: * michael@0: * @return Promise michael@0: */ michael@0: _getAddon: function () { michael@0: if (!this._addonId) { michael@0: return Promise.resolve(null); michael@0: } michael@0: michael@0: let deferred = Promise.defer(); michael@0: michael@0: AddonManager.getAddonByID(this._addonId, (addon) => { michael@0: if (addon && addon.appDisabled) { michael@0: // Don't return PreviousExperiments. michael@0: addon = null; michael@0: } michael@0: michael@0: deferred.resolve(addon); michael@0: }); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _logTermination: function (terminationKind, terminationReason) { michael@0: if (terminationKind === undefined) { michael@0: return; michael@0: } michael@0: michael@0: if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) { michael@0: this._log.warn("stop() - unknown terminationKind " + terminationKind); michael@0: return; michael@0: } michael@0: michael@0: let data = [terminationKind, this.id]; michael@0: if (terminationReason) { michael@0: data = data.concat(terminationReason); michael@0: } michael@0: michael@0: TelemetryLog.log(TELEMETRY_LOG.TERMINATION_KEY, data); michael@0: }, michael@0: michael@0: /** michael@0: * Determine whether an active experiment should be stopped. michael@0: */ michael@0: shouldStop: function () { michael@0: if (!this._enabled) { michael@0: throw new Error("shouldStop must not be called on disabled experiments."); michael@0: } michael@0: michael@0: let data = this._manifestData; michael@0: let now = this._policy.now() / 1000; // The manifest times are in seconds. michael@0: let maxActiveSec = data.maxActiveSeconds || 0; michael@0: michael@0: let deferred = Promise.defer(); michael@0: this.isApplicable().then( michael@0: () => deferred.resolve({shouldStop: false}), michael@0: reason => deferred.resolve({shouldStop: true, reason: reason}) michael@0: ); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: /* michael@0: * Should this be discarded from the cache due to age? michael@0: */ michael@0: shouldDiscard: function () { michael@0: let limit = this._policy.now(); michael@0: limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS); michael@0: return (this._lastChangedDate < limit); michael@0: }, michael@0: michael@0: /* michael@0: * Get next date (in epoch-ms) to schedule a re-evaluation for this. michael@0: * Returns 0 if it doesn't need one. michael@0: */ michael@0: getScheduleTime: function () { michael@0: if (this._enabled) { michael@0: let now = this._policy.now(); michael@0: let startTime = this._startDate.getTime(); michael@0: let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds; michael@0: return Math.min(1000 * this._manifestData.endTime, maxActiveTime); michael@0: } michael@0: michael@0: if (this._endDate) { michael@0: return this._endDate.getTime(); michael@0: } michael@0: michael@0: return 1000 * this._manifestData.startTime; michael@0: }, michael@0: michael@0: /* michael@0: * Perform sanity checks on the experiment data. michael@0: */ michael@0: _isManifestDataValid: function (data) { michael@0: this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data)); michael@0: michael@0: for (let key of this.MANIFEST_REQUIRED_FIELDS) { michael@0: if (!(key in data)) { michael@0: this._log.error("isManifestDataValid() - missing required key: " + key); michael@0: return false; michael@0: } michael@0: } michael@0: michael@0: for (let key in data) { michael@0: if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) && michael@0: !this.MANIFEST_REQUIRED_FIELDS.has(key)) { michael@0: this._log.error("isManifestDataValid() - unknown key: " + key); michael@0: return false; michael@0: } michael@0: } michael@0: michael@0: return true; michael@0: }, michael@0: }; michael@0: michael@0: michael@0: michael@0: /** michael@0: * Strip a Date down to its UTC midnight. michael@0: * michael@0: * This will return a cloned Date object. The original is unchanged. michael@0: */ michael@0: let stripDateToMidnight = function (d) { michael@0: let m = new Date(d); michael@0: m.setUTCHours(0, 0, 0, 0); michael@0: michael@0: return m; michael@0: }; michael@0: michael@0: function ExperimentsLastActiveMeasurement1() { michael@0: Metrics.Measurement.call(this); michael@0: } michael@0: function ExperimentsLastActiveMeasurement2() { michael@0: Metrics.Measurement.call(this); michael@0: } michael@0: michael@0: const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT}; michael@0: michael@0: ExperimentsLastActiveMeasurement1.prototype = Object.freeze({ michael@0: __proto__: Metrics.Measurement.prototype, michael@0: michael@0: name: "info", michael@0: version: 1, michael@0: michael@0: fields: { michael@0: lastActive: FIELD_DAILY_LAST_TEXT, michael@0: } michael@0: }); michael@0: ExperimentsLastActiveMeasurement2.prototype = Object.freeze({ michael@0: __proto__: Metrics.Measurement.prototype, michael@0: michael@0: name: "info", michael@0: version: 2, michael@0: michael@0: fields: { michael@0: lastActive: FIELD_DAILY_LAST_TEXT, michael@0: lastActiveBranch: FIELD_DAILY_LAST_TEXT, michael@0: } michael@0: }); michael@0: michael@0: this.ExperimentsProvider = function () { michael@0: Metrics.Provider.call(this); michael@0: michael@0: this._experiments = null; michael@0: }; michael@0: michael@0: ExperimentsProvider.prototype = Object.freeze({ michael@0: __proto__: Metrics.Provider.prototype, michael@0: michael@0: name: "org.mozilla.experiments", michael@0: michael@0: measurementTypes: [ michael@0: ExperimentsLastActiveMeasurement1, michael@0: ExperimentsLastActiveMeasurement2, michael@0: ], michael@0: michael@0: _OBSERVERS: [ michael@0: EXPERIMENTS_CHANGED_TOPIC, michael@0: ], michael@0: michael@0: postInit: function () { michael@0: for (let o of this._OBSERVERS) { michael@0: Services.obs.addObserver(this, o, false); michael@0: } michael@0: michael@0: return Promise.resolve(); michael@0: }, michael@0: michael@0: onShutdown: function () { michael@0: for (let o of this._OBSERVERS) { michael@0: Services.obs.removeObserver(this, o); michael@0: } michael@0: michael@0: return Promise.resolve(); michael@0: }, michael@0: michael@0: observe: function (subject, topic, data) { michael@0: switch (topic) { michael@0: case EXPERIMENTS_CHANGED_TOPIC: michael@0: this.recordLastActiveExperiment(); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: collectDailyData: function () { michael@0: return this.recordLastActiveExperiment(); michael@0: }, michael@0: michael@0: recordLastActiveExperiment: function () { michael@0: if (!gExperimentsEnabled) { michael@0: return Promise.resolve(); michael@0: } michael@0: michael@0: if (!this._experiments) { michael@0: this._experiments = Experiments.instance(); michael@0: } michael@0: michael@0: let m = this.getMeasurement(ExperimentsLastActiveMeasurement2.prototype.name, michael@0: ExperimentsLastActiveMeasurement2.prototype.version); michael@0: michael@0: return this.enqueueStorageOperation(() => { michael@0: return Task.spawn(function* recordTask() { michael@0: let todayActive = yield this._experiments.lastActiveToday(); michael@0: if (!todayActive) { michael@0: this._log.info("No active experiment on this day: " + michael@0: this._experiments._policy.now()); michael@0: return; michael@0: } michael@0: michael@0: this._log.info("Recording last active experiment: " + todayActive.id); michael@0: yield m.setDailyLastText("lastActive", todayActive.id, michael@0: this._experiments._policy.now()); michael@0: let branch = todayActive.branch; michael@0: if (branch) { michael@0: yield m.setDailyLastText("lastActiveBranch", branch, michael@0: this._experiments._policy.now()); michael@0: } michael@0: }.bind(this)); michael@0: }); michael@0: }, michael@0: }); michael@0: michael@0: /** michael@0: * An Add-ons Manager provider that knows about old experiments. michael@0: * michael@0: * This provider exposes read-only add-ons corresponding to previously-active michael@0: * experiments. The existence of this provider (and the add-ons it knows about) michael@0: * facilitates the display of old experiments in the Add-ons Manager UI with michael@0: * very little custom code in that component. michael@0: */ michael@0: this.Experiments.PreviousExperimentProvider = function (experiments) { michael@0: this._experiments = experiments; michael@0: this._experimentList = []; michael@0: this._log = Log.repository.getLoggerWithMessagePrefix( michael@0: "Browser.Experiments.Experiments", michael@0: "PreviousExperimentProvider #" + gPreviousProviderCounter++ + "::"); michael@0: } michael@0: michael@0: this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({ michael@0: startup: function () { michael@0: this._log.trace("startup()"); michael@0: Services.obs.addObserver(this, EXPERIMENTS_CHANGED_TOPIC, false); michael@0: }, michael@0: michael@0: shutdown: function () { michael@0: this._log.trace("shutdown()"); michael@0: Services.obs.removeObserver(this, EXPERIMENTS_CHANGED_TOPIC); michael@0: }, michael@0: michael@0: observe: function (subject, topic, data) { michael@0: switch (topic) { michael@0: case EXPERIMENTS_CHANGED_TOPIC: michael@0: this._updateExperimentList(); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: getAddonByID: function (id, cb) { michael@0: for (let experiment of this._experimentList) { michael@0: if (experiment.id == id) { michael@0: cb(new PreviousExperimentAddon(experiment)); michael@0: return; michael@0: } michael@0: } michael@0: michael@0: cb(null); michael@0: }, michael@0: michael@0: getAddonsByTypes: function (types, cb) { michael@0: if (types && types.length > 0 && types.indexOf("experiment") == -1) { michael@0: cb([]); michael@0: return; michael@0: } michael@0: michael@0: cb([new PreviousExperimentAddon(e) for (e of this._experimentList)]); michael@0: }, michael@0: michael@0: _updateExperimentList: function () { michael@0: return this._experiments.getExperiments().then((experiments) => { michael@0: let list = [e for (e of experiments) if (!e.active)]; michael@0: michael@0: let newMap = new Map([[e.id, e] for (e of list)]); michael@0: let oldMap = new Map([[e.id, e] for (e of this._experimentList)]); michael@0: michael@0: let added = [e.id for (e of list) if (!oldMap.has(e.id))]; michael@0: let removed = [e.id for (e of this._experimentList) if (!newMap.has(e.id))]; michael@0: michael@0: for (let id of added) { michael@0: this._log.trace("updateExperimentList() - adding " + id); michael@0: let wrapper = new PreviousExperimentAddon(newMap.get(id)); michael@0: AddonManagerPrivate.callInstallListeners("onExternalInstall", null, wrapper, null, false); michael@0: AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false); michael@0: } michael@0: michael@0: for (let id of removed) { michael@0: this._log.trace("updateExperimentList() - removing " + id); michael@0: let wrapper = new PreviousExperimentAddon(oldMap.get(id)); michael@0: AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false); michael@0: } michael@0: michael@0: this._experimentList = list; michael@0: michael@0: for (let id of added) { michael@0: let wrapper = new PreviousExperimentAddon(newMap.get(id)); michael@0: AddonManagerPrivate.callAddonListeners("onInstalled", wrapper); michael@0: } michael@0: michael@0: for (let id of removed) { michael@0: let wrapper = new PreviousExperimentAddon(oldMap.get(id)); michael@0: AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper); michael@0: } michael@0: michael@0: return this._experimentList; michael@0: }); michael@0: }, michael@0: }); michael@0: michael@0: /** michael@0: * An add-on that represents a previously-installed experiment. michael@0: */ michael@0: function PreviousExperimentAddon(experiment) { michael@0: this._id = experiment.id; michael@0: this._name = experiment.name; michael@0: this._endDate = experiment.endDate; michael@0: this._description = experiment.description; michael@0: } michael@0: michael@0: PreviousExperimentAddon.prototype = Object.freeze({ michael@0: // BEGIN REQUIRED ADDON PROPERTIES michael@0: michael@0: get appDisabled() { michael@0: return true; michael@0: }, michael@0: michael@0: get blocklistState() { michael@0: Ci.nsIBlocklistService.STATE_NOT_BLOCKED michael@0: }, michael@0: michael@0: get creator() { michael@0: return new AddonManagerPrivate.AddonAuthor(""); michael@0: }, michael@0: michael@0: get foreignInstall() { michael@0: return false; michael@0: }, michael@0: michael@0: get id() { michael@0: return this._id; michael@0: }, michael@0: michael@0: get isActive() { michael@0: return false; michael@0: }, michael@0: michael@0: get isCompatible() { michael@0: return true; michael@0: }, michael@0: michael@0: get isPlatformCompatible() { michael@0: return true; michael@0: }, michael@0: michael@0: get name() { michael@0: return this._name; michael@0: }, michael@0: michael@0: get pendingOperations() { michael@0: return AddonManager.PENDING_NONE; michael@0: }, michael@0: michael@0: get permissions() { michael@0: return 0; michael@0: }, michael@0: michael@0: get providesUpdatesSecurely() { michael@0: return true; michael@0: }, michael@0: michael@0: get scope() { michael@0: return AddonManager.SCOPE_PROFILE; michael@0: }, michael@0: michael@0: get type() { michael@0: return "experiment"; michael@0: }, michael@0: michael@0: get userDisabled() { michael@0: return true; michael@0: }, michael@0: michael@0: get version() { michael@0: return null; michael@0: }, michael@0: michael@0: // END REQUIRED PROPERTIES michael@0: michael@0: // BEGIN OPTIONAL PROPERTIES michael@0: michael@0: get description() { michael@0: return this._description; michael@0: }, michael@0: michael@0: get updateDate() { michael@0: return new Date(this._endDate); michael@0: }, michael@0: michael@0: // END OPTIONAL PROPERTIES michael@0: michael@0: // BEGIN REQUIRED METHODS michael@0: michael@0: isCompatibleWith: function (appVersion, platformVersion) { michael@0: return true; michael@0: }, michael@0: michael@0: findUpdates: function (listener, reason, appVersion, platformVersion) { michael@0: AddonManagerPrivate.callNoUpdateListeners(this, listener, reason, michael@0: appVersion, platformVersion); michael@0: }, michael@0: michael@0: // END REQUIRED METHODS michael@0: michael@0: /** michael@0: * The end-date of the experiment, required for the Addon Manager UI. michael@0: */ michael@0: michael@0: get endDate() { michael@0: return this._endDate; michael@0: }, michael@0: michael@0: });