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