1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 this.EXPORTED_SYMBOLS = [
12 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
14 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
15 Cu.import("resource://gre/modules/Services.jsm");
16 Cu.import("resource://gre/modules/Task.jsm");
17 Cu.import("resource://gre/modules/Promise.jsm");
18 Cu.import("resource://gre/modules/osfile.jsm");
19 Cu.import("resource://gre/modules/Log.jsm");
20 Cu.import("resource://gre/modules/Preferences.jsm");
21 Cu.import("resource://gre/modules/AsyncShutdown.jsm");
23 XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
24 "resource://gre/modules/UpdateChannel.jsm");
25 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
26 "resource://gre/modules/AddonManager.jsm");
27 XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate",
28 "resource://gre/modules/AddonManager.jsm");
29 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing",
30 "resource://gre/modules/TelemetryPing.jsm");
31 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryLog",
32 "resource://gre/modules/TelemetryLog.jsm");
33 XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
34 "resource://services-common/utils.js");
35 XPCOMUtils.defineLazyModuleGetter(this, "Metrics",
36 "resource://gre/modules/Metrics.jsm");
38 XPCOMUtils.defineLazyServiceGetter(this, "gCrashReporter",
39 "@mozilla.org/xre/app-info;1",
42 const FILE_CACHE = "experiments.json";
43 const EXPERIMENTS_CHANGED_TOPIC = "experiments-changed";
44 const MANIFEST_VERSION = 1;
45 const CACHE_VERSION = 1;
47 const KEEP_HISTORY_N_DAYS = 180;
48 const MIN_EXPERIMENT_ACTIVE_SECONDS = 60;
50 const PREF_BRANCH = "experiments.";
51 const PREF_ENABLED = "enabled"; // experiments.enabled
52 const PREF_ACTIVE_EXPERIMENT = "activeExperiment"; // whether we have an active experiment
53 const PREF_LOGGING = "logging";
54 const PREF_LOGGING_LEVEL = PREF_LOGGING + ".level"; // experiments.logging.level
55 const PREF_LOGGING_DUMP = PREF_LOGGING + ".dump"; // experiments.logging.dump
56 const PREF_MANIFEST_URI = "manifest.uri"; // experiments.logging.manifest.uri
57 const PREF_FORCE_SAMPLE = "force-sample-value"; // experiments.force-sample-value
59 const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled";
61 const PREF_BRANCH_TELEMETRY = "toolkit.telemetry.";
62 const PREF_TELEMETRY_ENABLED = "enabled";
64 const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties";
65 const STRING_TYPE_NAME = "type.%ID%.name";
67 const CACHE_WRITE_RETRY_DELAY_SEC = 60 * 3;
68 const MANIFEST_FETCH_TIMEOUT_MSEC = 60 * 3 * 1000; // 3 minutes
70 const TELEMETRY_LOG = {
71 // log(key, [kind, experimentId, details])
72 ACTIVATION_KEY: "EXPERIMENT_ACTIVATION",
74 // Successfully activated.
75 ACTIVATED: "ACTIVATED",
76 // Failed to install the add-on.
77 INSTALL_FAILURE: "INSTALL_FAILURE",
78 // Experiment does not meet activation requirements. Details will
83 // log(key, [kind, experimentId, optionalDetails...])
84 TERMINATION_KEY: "EXPERIMENT_TERMINATION",
86 // The Experiments service was disabled.
87 SERVICE_DISABLED: "SERVICE_DISABLED",
88 // Add-on uninstalled.
89 ADDON_UNINSTALLED: "ADDON_UNINSTALLED",
90 // The experiment disabled itself.
92 // The experiment expired (e.g. by exceeding the end date).
94 // Disabled after re-evaluating conditions. If this is specified,
95 // details will be provided.
100 const gPrefs = new Preferences(PREF_BRANCH);
101 const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY);
102 let gExperimentsEnabled = false;
103 let gAddonProvider = null;
104 let gExperiments = null;
105 let gLogAppenderDump = null;
106 let gPolicyCounter = 0;
107 let gExperimentsCounter = 0;
108 let gExperimentEntryCounter = 0;
109 let gPreviousProviderCounter = 0;
111 // Tracks active AddonInstall we know about so we can deny external
113 let gActiveInstallURLs = new Set();
115 // Tracks add-on IDs that are being uninstalled by us. This allows us
116 // to differentiate between expected uninstalled and user-driven uninstalls.
117 let gActiveUninstallAddonIDs = new Set();
120 let gLogDumping = false;
122 function configureLogging() {
124 gLogger = Log.repository.getLogger("Browser.Experiments");
125 gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
127 gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, Log.Level.Warn);
129 let logDumping = gPrefs.get(PREF_LOGGING_DUMP, false);
130 if (logDumping != gLogDumping) {
132 gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter());
133 gLogger.addAppender(gLogAppenderDump);
135 gLogger.removeAppender(gLogAppenderDump);
136 gLogAppenderDump = null;
138 gLogDumping = logDumping;
142 // Takes an array of promises and returns a promise that is resolved once all of
143 // them are rejected or resolved.
144 function allResolvedOrRejected(promises) {
145 if (!promises.length) {
146 return Promise.resolve([]);
149 let countdown = promises.length;
150 let deferred = Promise.defer();
152 for (let p of promises) {
154 if (--countdown == 0) {
158 Promise.resolve(p).then(helper, helper);
161 return deferred.promise;
164 // Loads a JSON file using OS.file. file is a string representing the path
165 // of the file to be read, options contains additional options to pass to
167 // Returns a Promise resolved with the json payload or rejected with
168 // OS.File.Error or JSON.parse() errors.
169 function loadJSONAsync(file, options) {
170 return Task.spawn(function() {
171 let rawData = yield OS.File.read(file, options);
172 // Read json file into a string
175 // Obtain a converter to read from a UTF-8 encoded input stream.
176 let converter = new TextDecoder();
177 data = JSON.parse(converter.decode(rawData));
179 gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex);
182 throw new Task.Result(data);
186 function telemetryEnabled() {
187 return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false);
190 // Returns a promise that is resolved with the AddonInstall for that URL.
191 function addonInstallForURL(url, hash) {
192 let deferred = Promise.defer();
193 AddonManager.getInstallForURL(url, install => deferred.resolve(install),
194 "application/x-xpinstall", hash);
195 return deferred.promise;
198 // Returns a promise that is resolved with an Array<Addon> of the installed
199 // experiment addons.
200 function installedExperimentAddons() {
201 let deferred = Promise.defer();
202 AddonManager.getAddonsByTypes(["experiment"], (addons) => {
203 deferred.resolve([a for (a of addons) if (!a.appDisabled)]);
205 return deferred.promise;
208 // Takes an Array<Addon> and returns a promise that is resolved when the
209 // addons are uninstalled.
210 function uninstallAddons(addons) {
211 let ids = new Set([a.id for (a of addons)]);
212 let deferred = Promise.defer();
215 listener.onUninstalled = addon => {
216 if (!ids.has(addon.id)) {
220 ids.delete(addon.id);
222 AddonManager.removeAddonListener(listener);
227 AddonManager.addAddonListener(listener);
229 for (let addon of addons) {
230 // Disabling the add-on before uninstalling is necessary to cause tests to
231 // pass. This might be indicative of a bug in XPIProvider.
232 // TODO follow up in bug 992396.
233 addon.userDisabled = true;
237 return deferred.promise;
241 * The experiments module.
246 * Provides access to the global `Experiments.Experiments` instance.
248 instance: function () {
250 gExperiments = new Experiments.Experiments();
258 * The policy object allows us to inject fake enviroment data from the
259 * outside by monkey-patching.
262 Experiments.Policy = function () {
263 this._log = Log.repository.getLoggerWithMessagePrefix(
264 "Browser.Experiments.Policy",
265 "Policy #" + gPolicyCounter++ + "::");
267 // Set to true to ignore hash verification on downloaded XPIs. This should
268 // not be used outside of testing.
269 this.ignoreHashes = false;
272 Experiments.Policy.prototype = {
277 random: function () {
278 let pref = gPrefs.get(PREF_FORCE_SAMPLE);
279 if (pref !== undefined) {
280 let val = Number.parseFloat(pref);
281 this._log.debug("random sample forced: " + val);
282 if (isNaN(val) || val < 0) {
290 return Math.random();
293 futureDate: function (offset) {
294 return new Date(this.now().getTime() + offset);
297 oneshotTimer: function (callback, timeout, thisObj, name) {
298 return CommonUtils.namedTimer(callback, timeout, thisObj, name);
301 updatechannel: function () {
302 return UpdateChannel.get();
305 locale: function () {
306 let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry);
307 return chrome.getSelectedLocale("global");
311 * @return Promise<> Resolved with the payload data.
313 healthReportPayload: function () {
314 return Task.spawn(function*() {
315 let reporter = Cc["@mozilla.org/datareporting/service;1"]
316 .getService(Ci.nsISupports)
319 yield reporter.onInit();
320 let payload = yield reporter.collectAndObtainJSONPayload();
325 telemetryPayload: function () {
326 return TelemetryPing.getPayload();
330 * For testing a race condition, one of the tests delays the callback of
331 * writing the cache by replacing this policy function.
333 delayCacheWrite: function(promise) {
338 function AlreadyShutdownError(message="already shut down") {
339 Error.call(this, message);
340 let error = new Error();
341 this.name = "AlreadyShutdownError";
342 this.message = message;
343 this.stack = error.stack;
345 AlreadyShutdownError.prototype = Object.create(Error.prototype);
346 AlreadyShutdownError.prototype.constructor = AlreadyShutdownError;
348 function CacheWriteError(message="Error writing cache file") {
349 Error.call(this, message);
350 let error = new Error();
351 this.name = "CacheWriteError";
352 this.message = message;
353 this.stack = error.stack;
355 CacheWriteError.prototype = Object.create(Error.prototype);
356 CacheWriteError.prototype.constructor = CacheWriteError;
359 * Manages the experiments and provides an interface to control them.
362 Experiments.Experiments = function (policy=new Experiments.Policy()) {
363 let log = Log.repository.getLoggerWithMessagePrefix(
364 "Browser.Experiments.Experiments",
365 "Experiments #" + gExperimentsCounter++ + "::");
367 // At the time of this writing, Experiments.jsm has severe
368 // crashes. For forensics purposes, keep the last few log
369 // messages in memory and upload them in case of crash.
370 this._forensicsLogs = [];
371 this._forensicsLogs.length = 20;
372 this._log = Object.create(log);
373 this._log.log = (level, string, params) => {
374 this._forensicsLogs.shift();
375 this._forensicsLogs.push(level + ": " + string);
376 log.log(level, string, params);
379 this._log.trace("constructor");
381 // Capture the latest error, for forensics purposes.
382 this._latestError = null;
385 this._policy = policy;
387 // This is a Map of (string -> ExperimentEntry), keyed with the experiment id.
388 // It holds both the current experiments and history.
389 // Map() preserves insertion order, which means we preserve the manifest order.
390 // This is null until we've successfully completed loading the cache from
391 // disk the first time.
392 this._experiments = null;
393 this._refresh = false;
394 this._terminateReason = null; // or TELEMETRY_LOG.TERMINATION....
397 // Loading the cache happens once asynchronously on startup
398 this._loadTask = null;
400 // The _main task handles all other actions:
401 // * refreshing the manifest off the network (if _refresh)
402 // * disabling/enabling experiments
403 // * saving the cache (if _dirty)
404 this._mainTask = null;
406 // Timer for re-evaluating experiment status.
409 this._shutdown = false;
410 this._networkRequest = null;
412 // We need to tell when we first evaluated the experiments to fire an
413 // experiments-changed notification when we only loaded completed experiments.
414 this._firstEvaluate = true;
419 Experiments.Experiments.prototype = {
420 QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]),
423 this._shutdown = false;
426 gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false);
427 this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled);
429 gPrefs.observe(PREF_LOGGING, configureLogging);
430 gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this);
431 gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this);
433 gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
435 AddonManager.shutdown.addBlocker("Experiments.jsm shutdown",
436 this.uninit.bind(this),
437 this._getState.bind(this)
440 this._registerWithAddonManager();
442 this._loadTask = this._loadFromCache();
444 return this._loadTask.then(
446 this._log.trace("_loadTask finished ok");
447 this._loadTask = null;
451 this._log.error("_loadFromCache caught error: " + e);
452 this._latestError = e;
459 * Uninitialize this instance.
461 * This function is susceptible to race conditions. If it is called multiple
462 * times before the previous uninit() has completed or if it is called while
463 * an init() operation is being performed, the object may get in bad state
464 * and/or deadlock could occur.
467 * The promise is fulfilled when all pending tasks are finished.
469 uninit: Task.async(function* () {
470 this._log.trace("uninit: started");
471 yield this._loadTask;
472 this._log.trace("uninit: finished with _loadTask");
474 if (!this._shutdown) {
475 this._log.trace("uninit: no previous shutdown");
476 this._unregisterWithAddonManager();
478 gPrefs.ignore(PREF_LOGGING, configureLogging);
479 gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this);
480 gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this);
482 gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
489 this._shutdown = true;
490 if (this._mainTask) {
491 if (this._networkRequest) {
493 this._log.trace("Aborting pending network request: " + this._networkRequest);
494 this._networkRequest.abort();
500 this._log.trace("uninit: waiting on _mainTask");
501 yield this._mainTask;
502 } catch (e if e instanceof AlreadyShutdownError) {
503 // We error out of tasks after shutdown via that exception.
505 this._latestError = e;
510 this._log.info("Completed uninitialization.");
513 // Return state information, for debugging purposes.
514 _getState: function() {
516 isShutdown: this._shutdown,
517 isEnabled: gExperimentsEnabled,
518 isRefresh: this._refresh,
519 isDirty: this._dirty,
520 isFirstEvaluate: this._firstEvaluate,
521 hasLoadTask: !!this._loadTask,
522 hasMainTask: !!this._mainTask,
523 hasTimer: !!this._hasTimer,
524 hasAddonProvider: !!gAddonProvider,
525 latestLogs: this._forensicsLogs,
526 experiments: this._experiments ? this._experiments.keys() : null,
527 terminateReason: this._terminateReason,
529 if (this._latestError) {
530 if (typeof this._latestError == "object") {
531 state.latestError = {
532 message: this._latestError.message,
533 stack: this._latestError.stack
536 state.latestError = "" + this._latestError;
542 _registerWithAddonManager: function (previousExperimentsProvider) {
543 this._log.trace("Registering instance with Addon Manager.");
545 AddonManager.addAddonListener(this);
546 AddonManager.addInstallListener(this);
548 if (!gAddonProvider) {
549 // The properties of this AddonType should be kept in sync with the
550 // experiment AddonType registered in XPIProvider.
551 this._log.trace("Registering previous experiment add-on provider.");
552 gAddonProvider = previousExperimentsProvider || new Experiments.PreviousExperimentProvider(this);
553 AddonManagerPrivate.registerProvider(gAddonProvider, [
554 new AddonManagerPrivate.AddonType("experiment",
555 URI_EXTENSION_STRINGS,
557 AddonManager.VIEW_TYPE_LIST,
559 AddonManager.TYPE_UI_HIDE_EMPTY),
565 _unregisterWithAddonManager: function () {
566 this._log.trace("Unregistering instance with Addon Manager.");
568 this._log.trace("Removing install listener from add-on manager.");
569 AddonManager.removeInstallListener(this);
570 this._log.trace("Removing addon listener from add-on manager.");
571 AddonManager.removeAddonListener(this);
572 this._log.trace("Finished unregistering with addon manager.");
574 if (gAddonProvider) {
575 this._log.trace("Unregistering previous experiment add-on provider.");
576 AddonManagerPrivate.unregisterProvider(gAddonProvider);
577 gAddonProvider = null;
582 * Change the PreviousExperimentsProvider that this instance uses.
585 _setPreviousExperimentsProvider: function (provider) {
586 this._unregisterWithAddonManager();
587 this._registerWithAddonManager(provider);
591 * Throws an exception if we've already shut down.
593 _checkForShutdown: function() {
594 if (this._shutdown) {
595 throw new AlreadyShutdownError("uninit() already called");
600 * Whether the experiments feature is enabled.
603 return gExperimentsEnabled;
607 * Toggle whether the experiments feature is enabled or not.
609 set enabled(enabled) {
610 this._log.trace("set enabled(" + enabled + ")");
611 gPrefs.set(PREF_ENABLED, enabled);
614 _toggleExperimentsEnabled: Task.async(function* (enabled) {
615 this._log.trace("_toggleExperimentsEnabled(" + enabled + ")");
616 let wasEnabled = gExperimentsEnabled;
617 gExperimentsEnabled = enabled && telemetryEnabled();
619 if (wasEnabled == gExperimentsEnabled) {
623 if (gExperimentsEnabled) {
624 yield this.updateManifest();
626 yield this.disableExperiment(TELEMETRY_LOG.TERMINATION.SERVICE_DISABLED);
633 _telemetryStatusChanged: function () {
634 this._toggleExperimentsEnabled(gExperimentsEnabled);
638 * Returns a promise that is resolved with an array of `ExperimentInfo` objects,
639 * which provide info on the currently and recently active experiments.
640 * The array is in chronological order.
642 * The experiment info is of the form:
646 * description: <string>,
648 * endDate: <integer>, // epoch ms
649 * detailURL: <string>,
650 * ... // possibly extended later
653 * @return Promise<Array<ExperimentInfo>> Array of experiment info objects.
655 getExperiments: function () {
656 return Task.spawn(function*() {
657 yield this._loadTask;
660 for (let [id, experiment] of this._experiments) {
661 if (!experiment.startDate) {
662 // We only collect experiments that are or were active.
668 name: experiment._name,
669 description: experiment._description,
670 active: experiment.enabled,
671 endDate: experiment.endDate.getTime(),
672 detailURL: experiment._homepageURL,
673 branch: experiment.branch,
677 // Sort chronologically, descending.
678 list.sort((a, b) => b.endDate - a.endDate);
684 * Returns the ExperimentInfo for the active experiment, or null
687 getActiveExperiment: function () {
688 let experiment = this._getActiveExperiment();
695 name: experiment._name,
696 description: experiment._description,
697 active: experiment.enabled,
698 endDate: experiment.endDate.getTime(),
699 detailURL: experiment._homepageURL,
706 * Experiment "branch" support. If an experiment has multiple branches, it
707 * can record the branch with the experiment system and it will
708 * automatically be included in data reporting (FHR/telemetry payloads).
712 * Set the experiment branch for the specified experiment ID.
715 setExperimentBranch: Task.async(function*(id, branchstr) {
716 yield this._loadTask;
717 let e = this._experiments.get(id);
719 throw new Error("Experiment not found");
721 e.branch = String(branchstr);
722 this._log.trace("setExperimentBranch(" + id + ", " + e.branch + ") _dirty=" + this._dirty);
724 Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null);
728 * Get the branch of the specified experiment. If the experiment is unknown,
731 * @param id The ID of the experiment. Pass null for the currently running
733 * @returns Promise<string|null>
734 * @throws Error if the specified experiment ID is unknown, or if there is no
735 * current experiment.
737 getExperimentBranch: Task.async(function*(id=null) {
738 yield this._loadTask;
741 e = this._experiments.get(id);
743 throw new Error("Experiment not found");
746 e = this._getActiveExperiment();
748 throw new Error("No active experiment");
755 * Determine whether another date has the same UTC day as now().
757 _dateIsTodayUTC: function (d) {
758 let now = this._policy.now();
760 return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime();
764 * Obtain the entry of the most recent active experiment that was active
767 * If no experiment was active today, this resolves to nothing.
769 * Assumption: Only a single experiment can be active at a time.
771 * @return Promise<object>
773 lastActiveToday: function () {
774 return Task.spawn(function* getMostRecentActiveExperimentTask() {
775 let experiments = yield this.getExperiments();
777 // Assumption: Ordered chronologically, descending, with active always
779 for (let experiment of experiments) {
780 if (experiment.active) {
784 if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) {
793 this._log.trace("_run");
794 this._checkForShutdown();
795 if (!this._mainTask) {
796 this._mainTask = Task.spawn(function*() {
799 } catch (e if e instanceof CacheWriteError) {
800 // In this case we want to reschedule
802 this._log.error("_main caught error: " + e);
805 this._mainTask = null;
807 this._log.trace("_main finished, scheduling next run");
809 yield this._scheduleNextRun();
810 } catch (ex if ex instanceof AlreadyShutdownError) {
811 // We error out of tasks after shutdown via that exception.
815 return this._mainTask;
820 this._log.trace("_main iteration");
821 yield this._loadTask;
822 if (!gExperimentsEnabled) {
823 this._refresh = false;
827 yield this._loadManifest();
829 yield this._evaluateExperiments();
831 yield this._saveToCache();
833 // If somebody called .updateManifest() or disableExperiment()
834 // while we were running, go again right now.
836 while (this._refresh || this._terminateReason || this._dirty);
839 _loadManifest: function*() {
840 this._log.trace("_loadManifest");
841 let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI);
843 this._checkForShutdown();
845 this._refresh = false;
847 let responseText = yield this._httpGetRequest(uri);
848 this._log.trace("_loadManifest() - responseText=\"" + responseText + "\"");
850 if (this._shutdown) {
854 let data = JSON.parse(responseText);
855 this._updateExperiments(data);
857 this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e);
862 * Fetch an updated list of experiments and trigger experiment updates.
863 * Do only use when experiments are enabled.
866 * The promise is resolved when the manifest and experiment list is updated.
868 updateManifest: function () {
869 this._log.trace("updateManifest()");
871 if (!gExperimentsEnabled) {
872 return Promise.reject(new Error("experiments are disabled"));
875 if (this._shutdown) {
876 return Promise.reject(Error("uninit() alrady called"));
879 this._refresh = true;
883 notify: function (timer) {
884 this._log.trace("notify()");
885 this._checkForShutdown();
889 // START OF ADD-ON LISTENERS
891 onUninstalled: function (addon) {
892 this._log.trace("onUninstalled() - addon id: " + addon.id);
893 if (gActiveUninstallAddonIDs.has(addon.id)) {
894 this._log.trace("matches pending uninstall");
897 let activeExperiment = this._getActiveExperiment();
898 if (!activeExperiment || activeExperiment._addonId != addon.id) {
902 this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED);
905 onInstallStarted: function (install) {
906 if (install.addon.type != "experiment") {
910 this._log.trace("onInstallStarted() - " + install.addon.id);
911 if (install.addon.appDisabled) {
912 // This is a PreviousExperiment
916 // We want to be in control of all experiment add-ons: reject installs
917 // for add-ons that we don't know about.
919 // We have a race condition of sorts to worry about here. We have 2
920 // onInstallStarted listeners. This one (the global one) and the one
921 // created as part of ExperimentEntry._installAddon. Because of the order
922 // they are registered in, this one likely executes first. Unfortunately,
923 // this means that the add-on ID is not yet set on the ExperimentEntry.
924 // So, we can't just look at this._trackedAddonIds because the new experiment
925 // will have its add-on ID set to null. We work around this by storing a
926 // identifying field - the source URL of the install - in a module-level
927 // variable (so multiple Experiments instances doesn't cancel each other
930 if (this._trackedAddonIds.has(install.addon.id)) {
931 this._log.info("onInstallStarted allowing install because add-on ID " +
936 if (gActiveInstallURLs.has(install.sourceURI.spec)) {
937 this._log.info("onInstallStarted allowing install because install " +
942 this._log.warn("onInstallStarted cancelling install of unknown " +
943 "experiment add-on: " + install.addon.id);
947 // END OF ADD-ON LISTENERS.
949 _getExperimentByAddonId: function (addonId) {
950 for (let [, entry] of this._experiments) {
951 if (entry._addonId === addonId) {
960 * Helper function to make HTTP GET requests. Returns a promise that is resolved with
961 * the responseText when the request is complete.
963 _httpGetRequest: function (url) {
964 this._log.trace("httpGetRequest(" + url + ")");
965 let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
967 xhr.open("GET", url);
969 this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e);
970 return Promise.reject(new Error("Experiments - Error opening XHR for " + url));
973 this._networkRequest = xhr;
974 let deferred = Promise.defer();
977 let errorhandler = (evt) => {
978 log.error("httpGetRequest::onError() - Error making request to " + url + ": " + evt.type);
979 deferred.reject(new Error("Experiments - XHR error for " + url + " - " + evt.type));
980 this._networkRequest = null;
982 xhr.onerror = errorhandler;
983 xhr.ontimeout = errorhandler;
984 xhr.onabort = errorhandler;
986 xhr.onload = (event) => {
987 if (xhr.status !== 200 && xhr.state !== 0) {
988 log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status);
989 deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status));
990 this._networkRequest = null;
994 deferred.resolve(xhr.responseText);
995 this._networkRequest = null;
998 if (xhr.channel instanceof Ci.nsISupportsPriority) {
999 xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST;
1002 xhr.timeout = MANIFEST_FETCH_TIMEOUT_MSEC;
1004 return deferred.promise;
1008 * Path of the cache file we use in the profile.
1010 get _cacheFilePath() {
1011 return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE);
1015 * Part of the main task to save the cache to disk, called from _main.
1017 _saveToCache: function* () {
1018 this._log.trace("_saveToCache");
1019 let path = this._cacheFilePath;
1020 this._dirty = false;
1022 let textData = JSON.stringify({
1023 version: CACHE_VERSION,
1024 data: [e[1].toJSON() for (e of this._experiments.entries())],
1027 let encoder = new TextEncoder();
1028 let data = encoder.encode(textData);
1029 let options = { tmpPath: path + ".tmp", compression: "lz4" };
1030 yield this._policy.delayCacheWrite(OS.File.writeAtomic(path, data, options));
1032 // We failed to write the cache, it's still dirty.
1034 this._log.error("_saveToCache failed and caught error: " + e);
1035 throw new CacheWriteError();
1038 this._log.debug("_saveToCache saved to " + path);
1042 * Task function, load the cached experiments manifest file from disk.
1044 _loadFromCache: Task.async(function* () {
1045 this._log.trace("_loadFromCache");
1046 let path = this._cacheFilePath;
1048 let result = yield loadJSONAsync(path, { compression: "lz4" });
1049 this._populateFromCache(result);
1050 } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
1051 // No cached manifest yet.
1052 this._experiments = new Map();
1056 _populateFromCache: function (data) {
1057 this._log.trace("populateFromCache() - data: " + JSON.stringify(data));
1059 // If the user has a newer cache version than we can understand, we fail
1060 // hard; no experiments should be active in this older client.
1061 if (CACHE_VERSION !== data.version) {
1062 throw new Error("Experiments::_populateFromCache() - invalid cache version");
1065 let experiments = new Map();
1066 for (let item of data.data) {
1067 let entry = new Experiments.ExperimentEntry(this._policy);
1068 if (!entry.initFromCacheData(item)) {
1071 experiments.set(entry.id, entry);
1074 this._experiments = experiments;
1078 * Update the experiment entries from the experiments
1079 * array in the manifest
1081 _updateExperiments: function (manifestObject) {
1082 this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject));
1084 if (manifestObject.version !== MANIFEST_VERSION) {
1085 this._log.warning("updateExperiments() - unsupported version " + manifestObject.version);
1088 let experiments = new Map(); // The new experiments map
1090 // Collect new and updated experiments.
1091 for (let data of manifestObject.experiments) {
1092 let entry = this._experiments.get(data.id);
1095 if (!entry.updateFromManifestData(data)) {
1096 this._log.error("updateExperiments() - Invalid manifest data for " + data.id);
1100 entry = new Experiments.ExperimentEntry(this._policy);
1101 if (!entry.initFromManifestData(data)) {
1106 if (entry.shouldDiscard()) {
1110 experiments.set(entry.id, entry);
1113 // Make sure we keep experiments that are or were running.
1114 // We remove them after KEEP_HISTORY_N_DAYS.
1115 for (let [id, entry] of this._experiments) {
1116 if (experiments.has(id)) {
1120 if (!entry.startDate || entry.shouldDiscard()) {
1121 this._log.trace("updateExperiments() - discarding entry for " + id);
1125 experiments.set(id, entry);
1128 this._experiments = experiments;
1132 getActiveExperimentID: function() {
1133 if (!this._experiments) {
1136 let e = this._getActiveExperiment();
1143 getActiveExperimentBranch: function() {
1144 if (!this._experiments) {
1147 let e = this._getActiveExperiment();
1154 _getActiveExperiment: function () {
1155 let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)];
1157 if (enabled.length == 1) {
1161 if (enabled.length > 1) {
1162 this._log.error("getActiveExperimentId() - should not have more than 1 active experiment");
1163 throw new Error("have more than 1 active experiment");
1170 * Disables all active experiments.
1172 * @return Promise<> Promise that will get resolved once the task is done or failed.
1174 disableExperiment: function (reason) {
1176 throw new Error("Must specify a termination reason.");
1179 this._log.trace("disableExperiment()");
1180 this._terminateReason = reason;
1185 * The Set of add-on IDs that we know about from manifests.
1187 get _trackedAddonIds() {
1188 if (!this._experiments) {
1192 return new Set([e._addonId for ([,e] of this._experiments) if (e._addonId)]);
1196 * Task function to check applicability of experiments, disable the active
1197 * experiment if needed and activate the first applicable candidate.
1199 _evaluateExperiments: function*() {
1200 this._log.trace("_evaluateExperiments");
1202 this._checkForShutdown();
1204 // The first thing we do is reconcile our state against what's in the
1205 // Addon Manager. It's possible that the Addon Manager knows of experiment
1206 // add-ons that we don't. This could happen if an experiment gets installed
1207 // when we're not listening or if there is a bug in our synchronization
1210 // We have a few options of what to do with unknown experiment add-ons
1211 // coming from the Addon Manager. Ideally, we'd convert these to
1212 // ExperimentEntry instances and stuff them inside this._experiments.
1213 // However, since ExperimentEntry contain lots of metadata from the
1214 // manifest and trying to make up data could be error prone, it's safer
1215 // to not try. Furthermore, if an experiment really did come from us, we
1216 // should have some record of it. In the end, we decide to discard all
1217 // knowledge for these unknown experiment add-ons.
1218 let installedExperiments = yield installedExperimentAddons();
1219 let expectedAddonIds = this._trackedAddonIds;
1220 let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))];
1221 if (unknownAddons.length) {
1222 this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " +
1223 [a.id for (a of unknownAddons)].join(", "));
1225 yield uninstallAddons(unknownAddons);
1228 let activeExperiment = this._getActiveExperiment();
1229 let activeChanged = false;
1230 let now = this._policy.now();
1232 if (!activeExperiment) {
1233 // Avoid this pref staying out of sync if there were e.g. crashes.
1234 gPrefs.set(PREF_ACTIVE_EXPERIMENT, false);
1237 // Ensure the active experiment is in the proper state. This may install,
1238 // uninstall, upgrade, or enable the experiment add-on. What exactly is
1239 // abstracted away from us by design.
1240 if (activeExperiment) {
1242 let shouldStopResult = yield activeExperiment.shouldStop();
1243 if (shouldStopResult.shouldStop) {
1244 let expireReasons = ["endTime", "maxActiveSeconds"];
1247 if (expireReasons.indexOf(shouldStopResult.reason[0]) != -1) {
1248 kind = TELEMETRY_LOG.TERMINATION.EXPIRED;
1251 kind = TELEMETRY_LOG.TERMINATION.RECHECK;
1252 reason = shouldStopResult.reason;
1254 changes = yield activeExperiment.stop(kind, reason);
1256 else if (this._terminateReason) {
1257 changes = yield activeExperiment.stop(this._terminateReason);
1260 changes = yield activeExperiment.reconcileAddonState();
1265 activeChanged = true;
1268 if (!activeExperiment._enabled) {
1269 activeExperiment = null;
1270 activeChanged = true;
1274 this._terminateReason = null;
1276 if (!activeExperiment && gExperimentsEnabled) {
1277 for (let [id, experiment] of this._experiments) {
1281 applicable = yield experiment.isApplicable();
1288 if (!applicable && reason && reason[0] != "was-active") {
1289 // Report this from here to avoid over-reporting.
1290 let desc = TELEMETRY_LOG.ACTIVATION;
1291 let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id];
1292 data = data.concat(reason);
1293 const key = TELEMETRY_LOG.ACTIVATION_KEY;
1294 TelemetryLog.log(key, data);
1295 this._log.trace("evaluateExperiments() - added " + key + " to TelemetryLog: " + JSON.stringify(data));
1302 this._log.debug("evaluateExperiments() - activating experiment " + id);
1304 yield experiment.start();
1305 activeChanged = true;
1306 activeExperiment = experiment;
1310 // On failure, clean up the best we can and try the next experiment.
1311 this._log.error("evaluateExperiments() - Unable to start experiment: " + e.message);
1312 experiment._enabled = false;
1313 yield experiment.reconcileAddonState();
1318 gPrefs.set(PREF_ACTIVE_EXPERIMENT, activeExperiment != null);
1320 if (activeChanged || this._firstEvaluate) {
1321 Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null);
1322 this._firstEvaluate = false;
1325 if ("@mozilla.org/toolkit/crash-reporter;1" in Cc && activeExperiment) {
1327 gCrashReporter.annotateCrashReport("ActiveExperiment", activeExperiment.id);
1328 gCrashReporter.annotateCrashReport("ActiveExperimentBranch", activeExperiment.branch);
1330 // It's ok if crash reporting is disabled.
1336 * Schedule the soonest re-check of experiment applicability that is needed.
1338 _scheduleNextRun: function () {
1339 this._checkForShutdown();
1342 this._timer.clear();
1345 if (!gExperimentsEnabled || this._experiments.length == 0) {
1350 let now = this._policy.now().getTime();
1352 // If we failed to write the cache, we should try again periodically
1353 time = now + 1000 * CACHE_WRITE_RETRY_DELAY_SEC;
1356 for (let [id, experiment] of this._experiments) {
1357 let scheduleTime = experiment.getScheduleTime();
1358 if (scheduleTime > now) {
1359 if (time !== null) {
1360 time = Math.min(time, scheduleTime);
1362 time = scheduleTime;
1367 if (time === null) {
1368 // No schedule time found.
1372 this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now);
1373 this._policy.oneshotTimer(this.notify, time - now, this, "_timer");
1379 * Represents a single experiment.
1382 Experiments.ExperimentEntry = function (policy) {
1383 this._policy = policy || new Experiments.Policy();
1384 this._log = Log.repository.getLoggerWithMessagePrefix(
1385 "Browser.Experiments.Experiments",
1386 "ExperimentEntry #" + gExperimentEntryCounter++ + "::");
1388 // Is the experiment supposed to be running.
1389 this._enabled = false;
1390 // When this experiment was started, if ever.
1391 this._startDate = null;
1392 // When this experiment was ended, if ever.
1393 this._endDate = null;
1394 // The condition data from the manifest.
1395 this._manifestData = null;
1396 // For an active experiment, signifies whether we need to update the xpi.
1397 this._needsUpdate = false;
1398 // A random sample value for comparison against the manifest conditions.
1399 this._randomValue = null;
1400 // When this entry was last changed for respecting history retention duration.
1401 this._lastChangedDate = null;
1402 // Has this experiment failed to activate before?
1403 this._failedStart = false;
1404 // The experiment branch
1405 this._branch = null;
1407 // We grab these from the addon after download.
1409 this._description = null;
1410 this._homepageURL = null;
1411 this._addonId = null;
1414 Experiments.ExperimentEntry.prototype = {
1415 MANIFEST_REQUIRED_FIELDS: new Set([
1426 MANIFEST_OPTIONAL_FIELDS: new Set([
1442 SERIALIZE_KEYS: new Set([
1457 DATE_KEYS: new Set([
1462 UPGRADE_KEYS: new Map([
1466 ADDON_CHANGE_NONE: 0,
1467 ADDON_CHANGE_INSTALL: 1,
1468 ADDON_CHANGE_UNINSTALL: 2,
1469 ADDON_CHANGE_ENABLE: 4,
1472 * Initialize entry from the manifest.
1473 * @param data The experiment data from the manifest.
1474 * @return boolean Whether initialization succeeded.
1476 initFromManifestData: function (data) {
1477 if (!this._isManifestDataValid(data)) {
1481 this._manifestData = data;
1483 this._randomValue = this._policy.random();
1484 this._lastChangedDate = this._policy.now();
1490 return this._enabled;
1494 return this._manifestData.id;
1498 return this._branch;
1506 return this._startDate;
1510 if (!this._startDate) {
1516 if (!this._enabled) {
1517 return this._endDate;
1520 let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds;
1521 endTime = Math.min(1000 * this._manifestData.endTime,
1522 this._startDate.getTime() + maxActiveMs);
1524 return new Date(endTime);
1528 return this._needsUpdate;
1532 * Initialize entry from the cache.
1533 * @param data The entry data from the cache.
1534 * @return boolean Whether initialization succeeded.
1536 initFromCacheData: function (data) {
1537 for (let [key, dval] of this.UPGRADE_KEYS) {
1538 if (!(key in data)) {
1543 for (let key of this.SERIALIZE_KEYS) {
1544 if (!(key in data) && !this.DATE_KEYS.has(key)) {
1545 this._log.error("initFromCacheData() - missing required key " + key);
1550 if (!this._isManifestDataValid(data._manifestData)) {
1554 // Dates are restored separately from epoch ms, everything else is just
1557 this.SERIALIZE_KEYS.forEach(key => {
1558 if (!this.DATE_KEYS.has(key)) {
1559 this[key] = data[key];
1563 this.DATE_KEYS.forEach(key => {
1565 let date = new Date();
1566 date.setTime(data[key]);
1571 this._lastChangedDate = this._policy.now();
1577 * Returns a JSON representation of this object.
1579 toJSON: function () {
1582 // Dates are serialized separately as epoch ms.
1584 this.SERIALIZE_KEYS.forEach(key => {
1585 if (!this.DATE_KEYS.has(key)) {
1586 obj[key] = this[key];
1590 this.DATE_KEYS.forEach(key => {
1592 obj[key] = this[key].getTime();
1600 * Update from the experiment data from the manifest.
1601 * @param data The experiment data from the manifest.
1602 * @return boolean Whether updating succeeded.
1604 updateFromManifestData: function (data) {
1605 let old = this._manifestData;
1607 if (!this._isManifestDataValid(data)) {
1611 if (this._enabled) {
1612 if (old.xpiHash !== data.xpiHash) {
1613 // A changed hash means we need to update active experiments.
1614 this._needsUpdate = true;
1616 } else if (this._failedStart &&
1617 (old.xpiHash !== data.xpiHash) ||
1618 (old.xpiURL !== data.xpiURL)) {
1619 // Retry installation of previously invalid experiments
1620 // if hash or url changed.
1621 this._failedStart = false;
1624 this._manifestData = data;
1625 this._lastChangedDate = this._policy.now();
1631 * Is this experiment applicable?
1632 * @return Promise<> Resolved if the experiment is applicable.
1633 * If it is not applicable it is rejected with
1634 * a Promise<string> which contains the reason.
1636 isApplicable: function () {
1637 let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"]
1638 .getService(Ci.nsIVersionComparator);
1639 let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
1640 let runtime = Cc["@mozilla.org/xre/app-info;1"]
1641 .getService(Ci.nsIXULRuntime);
1643 let locale = this._policy.locale();
1644 let channel = this._policy.updatechannel();
1645 let data = this._manifestData;
1647 let now = this._policy.now() / 1000; // The manifest times are in seconds.
1648 let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS;
1649 let maxActive = data.maxActiveSeconds || 0;
1650 let startSec = (this.startDate || 0) / 1000;
1652 this._log.trace("isApplicable() - now=" + now
1653 + ", randomValue=" + this._randomValue
1654 + ", data=" + JSON.stringify(this._manifestData));
1656 // Not applicable if it already ran.
1658 if (!this.enabled && this._endDate) {
1659 return Promise.reject(["was-active"]);
1662 // Define and run the condition checks.
1664 let simpleChecks = [
1665 { name: "failedStart",
1666 condition: () => !this._failedStart },
1668 condition: () => !data.disabled },
1670 condition: () => !data.frozen || this._enabled },
1671 { name: "startTime",
1672 condition: () => now >= data.startTime },
1674 condition: () => now < data.endTime },
1675 { name: "maxStartTime",
1676 condition: () => this._startDate || !data.maxStartTime || now <= data.maxStartTime },
1677 { name: "maxActiveSeconds",
1678 condition: () => !this._startDate || now <= (startSec + maxActive) },
1680 condition: () => !data.appName || data.appName.indexOf(app.name) != -1 },
1681 { name: "minBuildID",
1682 condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID },
1683 { name: "maxBuildID",
1684 condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID },
1686 condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 },
1688 condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 },
1690 condition: () => !data.channel || data.channel.indexOf(channel) != -1 },
1692 condition: () => !data.locale || data.locale.indexOf(locale) != -1 },
1694 condition: () => data.sample === undefined || this._randomValue <= data.sample },
1696 condition: () => !data.version || data.version.indexOf(app.version) != -1 },
1697 { name: "minVersion",
1698 condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 },
1699 { name: "maxVersion",
1700 condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 },
1703 for (let check of simpleChecks) {
1704 let result = check.condition();
1706 this._log.debug("isApplicable() - id="
1707 + data.id + " - test '" + check.name + "' failed");
1708 return Promise.reject([check.name]);
1712 if (data.jsfilter) {
1713 return this._runFilterFunction(data.jsfilter);
1716 return Promise.resolve(true);
1720 * Run the jsfilter function from the manifest in a sandbox and return the
1721 * result (forced to boolean).
1723 _runFilterFunction: function (jsfilter) {
1724 this._log.trace("runFilterFunction() - filter: " + jsfilter);
1726 return Task.spawn(function ExperimentEntry_runFilterFunction_task() {
1727 const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal);
1729 sandboxName: "telemetry experiments jsfilter sandbox",
1730 wantComponents: false,
1733 let sandbox = Cu.Sandbox(nullprincipal, options);
1735 Cu.evalInSandbox(jsfilter, sandbox);
1737 this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message);
1738 throw ["jsfilter-evalfailed"];
1741 // You can't insert arbitrarily complex objects into a sandbox, so
1742 // we serialize everything through JSON.
1743 sandbox._hr = yield this._policy.healthReportPayload();
1744 Object.defineProperty(sandbox, "_t",
1745 { get: () => JSON.stringify(this._policy.telemetryPayload()) });
1749 result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox);
1752 this._log.debug("runFilterFunction() - filter function failed: "
1753 + e.message + ", " + e.stack);
1754 throw ["jsfilter-threw", e.message];
1757 Cu.nukeSandbox(sandbox);
1761 throw ["jsfilter-false"];
1764 throw new Task.Result(true);
1769 * Start running the experiment.
1771 * @return Promise<> Resolved when the operation is complete.
1773 start: Task.async(function* () {
1774 this._log.trace("start() for " + this.id);
1776 this._enabled = true;
1777 return yield this.reconcileAddonState();
1780 // Async install of the addon for this experiment, part of the start task above.
1781 _installAddon: Task.async(function* () {
1782 let deferred = Promise.defer();
1784 let hash = this._policy.ignoreHashes ? null : this._manifestData.xpiHash;
1786 let install = yield addonInstallForURL(this._manifestData.xpiURL, hash);
1787 gActiveInstallURLs.add(install.sourceURI.spec);
1789 let failureHandler = (install, handler) => {
1790 let message = "AddonInstall " + handler + " for " + this.id + ", state=" +
1791 (install.state || "?") + ", error=" + install.error;
1792 this._log.error("_installAddon() - " + message);
1793 this._failedStart = true;
1794 gActiveInstallURLs.delete(install.sourceURI.spec);
1796 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
1797 [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]);
1799 deferred.reject(new Error(message));
1805 onDownloadEnded: install => {
1806 this._log.trace("_installAddon() - onDownloadEnded for " + this.id);
1808 if (install.existingAddon) {
1809 this._log.warn("_installAddon() - onDownloadEnded, addon already installed");
1812 if (install.addon.type !== "experiment") {
1813 this._log.error("_installAddon() - onDownloadEnded, wrong addon type");
1818 onInstallStarted: install => {
1819 this._log.trace("_installAddon() - onInstallStarted for " + this.id);
1821 if (install.existingAddon) {
1822 this._log.warn("_installAddon() - onInstallStarted, addon already installed");
1825 if (install.addon.type !== "experiment") {
1826 this._log.error("_installAddon() - onInstallStarted, wrong addon type");
1831 onInstallEnded: install => {
1832 this._log.trace("_installAddon() - install ended for " + this.id);
1833 gActiveInstallURLs.delete(install.sourceURI.spec);
1835 this._lastChangedDate = this._policy.now();
1836 this._startDate = this._policy.now();
1837 this._enabled = true;
1839 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
1840 [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]);
1842 let addon = install.addon;
1843 this._name = addon.name;
1844 this._addonId = addon.id;
1845 this._description = addon.description || "";
1846 this._homepageURL = addon.homepageURL || "";
1848 // Experiment add-ons default to userDisabled=true. Enable if needed.
1849 if (addon.userDisabled) {
1850 this._log.trace("Add-on is disabled. Enabling.");
1851 listener._expectedID = addon.id;
1852 AddonManager.addAddonListener(listener);
1853 addon.userDisabled = false;
1855 this._log.trace("Add-on is enabled. start() completed.");
1860 onEnabled: addon => {
1861 this._log.info("onEnabled() for " + addon.id);
1863 if (addon.id != listener._expectedID) {
1867 AddonManager.removeAddonListener(listener);
1872 ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"]
1874 listener[what] = install => failureHandler(install, what)
1877 install.addListener(listener);
1880 return yield deferred.promise;
1884 * Stop running the experiment if it is active.
1886 * @param terminationKind (optional)
1887 * The termination kind, e.g. ADDON_UNINSTALLED or EXPIRED.
1888 * @param terminationReason (optional)
1889 * The termination reason details for termination kind RECHECK.
1890 * @return Promise<> Resolved when the operation is complete.
1892 stop: Task.async(function* (terminationKind, terminationReason) {
1893 this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind);
1894 if (!this._enabled) {
1895 throw new Error("Must not call stop() on an inactive experiment.");
1898 this._enabled = false;
1899 let now = this._policy.now();
1900 this._lastChangedDate = now;
1901 this._endDate = now;
1903 let changes = yield this.reconcileAddonState();
1904 this._logTermination(terminationKind, terminationReason);
1906 if (terminationKind == TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED) {
1907 changes |= this.ADDON_CHANGE_UNINSTALL;
1914 * Reconcile the state of the add-on against what it's supposed to be.
1916 * If we are active, ensure the add-on is enabled and up to date.
1918 * If we are inactive, ensure the add-on is not installed.
1920 reconcileAddonState: Task.async(function* () {
1921 this._log.trace("reconcileAddonState()");
1923 if (!this._enabled) {
1924 if (!this._addonId) {
1925 this._log.trace("reconcileAddonState() - Experiment is not enabled and " +
1926 "has no add-on. Doing nothing.");
1927 return this.ADDON_CHANGE_NONE;
1930 let addon = yield this._getAddon();
1932 this._log.trace("reconcileAddonState() - Inactive experiment has no " +
1933 "add-on. Doing nothing.");
1934 return this.ADDON_CHANGE_NONE;
1937 this._log.info("reconcileAddonState() - Uninstalling add-on for inactive " +
1938 "experiment: " + addon.id);
1939 gActiveUninstallAddonIDs.add(addon.id);
1940 yield uninstallAddons([addon]);
1941 gActiveUninstallAddonIDs.delete(addon.id);
1942 return this.ADDON_CHANGE_UNINSTALL;
1945 // If we get here, we're supposed to be active.
1949 // That requires an add-on.
1950 let currentAddon = yield this._getAddon();
1952 // If we have an add-on but it isn't up to date, uninstall it
1953 // (to prepare for reinstall).
1954 if (currentAddon && this._needsUpdate) {
1955 this._log.info("reconcileAddonState() - Uninstalling add-on because update " +
1956 "needed: " + currentAddon.id);
1957 gActiveUninstallAddonIDs.add(currentAddon.id);
1958 yield uninstallAddons([currentAddon]);
1959 gActiveUninstallAddonIDs.delete(currentAddon.id);
1960 changes |= this.ADDON_CHANGE_UNINSTALL;
1963 if (!currentAddon || this._needsUpdate) {
1964 this._log.info("reconcileAddonState() - Installing add-on.");
1965 yield this._installAddon();
1966 changes |= this.ADDON_CHANGE_INSTALL;
1969 let addon = yield this._getAddon();
1971 throw new Error("Could not obtain add-on for experiment that should be " +
1975 // If we have the add-on and it is enabled, we are done.
1976 if (!addon.userDisabled) {
1980 let deferred = Promise.defer();
1982 // Else we need to enable it.
1984 onEnabled: enabledAddon => {
1985 if (enabledAddon.id != addon.id) {
1989 AddonManager.removeAddonListener(listener);
1994 this._log.info("Activating add-on: " + addon.id);
1995 AddonManager.addAddonListener(listener);
1996 addon.userDisabled = false;
1997 yield deferred.promise;
1998 changes |= this.ADDON_CHANGE_ENABLE;
2000 this._log.info("Add-on has been enabled: " + addon.id);
2005 * Obtain the underlying Addon from the Addon Manager.
2007 * @return Promise<Addon|null>
2009 _getAddon: function () {
2010 if (!this._addonId) {
2011 return Promise.resolve(null);
2014 let deferred = Promise.defer();
2016 AddonManager.getAddonByID(this._addonId, (addon) => {
2017 if (addon && addon.appDisabled) {
2018 // Don't return PreviousExperiments.
2022 deferred.resolve(addon);
2025 return deferred.promise;
2028 _logTermination: function (terminationKind, terminationReason) {
2029 if (terminationKind === undefined) {
2033 if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) {
2034 this._log.warn("stop() - unknown terminationKind " + terminationKind);
2038 let data = [terminationKind, this.id];
2039 if (terminationReason) {
2040 data = data.concat(terminationReason);
2043 TelemetryLog.log(TELEMETRY_LOG.TERMINATION_KEY, data);
2047 * Determine whether an active experiment should be stopped.
2049 shouldStop: function () {
2050 if (!this._enabled) {
2051 throw new Error("shouldStop must not be called on disabled experiments.");
2054 let data = this._manifestData;
2055 let now = this._policy.now() / 1000; // The manifest times are in seconds.
2056 let maxActiveSec = data.maxActiveSeconds || 0;
2058 let deferred = Promise.defer();
2059 this.isApplicable().then(
2060 () => deferred.resolve({shouldStop: false}),
2061 reason => deferred.resolve({shouldStop: true, reason: reason})
2064 return deferred.promise;
2068 * Should this be discarded from the cache due to age?
2070 shouldDiscard: function () {
2071 let limit = this._policy.now();
2072 limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS);
2073 return (this._lastChangedDate < limit);
2077 * Get next date (in epoch-ms) to schedule a re-evaluation for this.
2078 * Returns 0 if it doesn't need one.
2080 getScheduleTime: function () {
2081 if (this._enabled) {
2082 let now = this._policy.now();
2083 let startTime = this._startDate.getTime();
2084 let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds;
2085 return Math.min(1000 * this._manifestData.endTime, maxActiveTime);
2088 if (this._endDate) {
2089 return this._endDate.getTime();
2092 return 1000 * this._manifestData.startTime;
2096 * Perform sanity checks on the experiment data.
2098 _isManifestDataValid: function (data) {
2099 this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data));
2101 for (let key of this.MANIFEST_REQUIRED_FIELDS) {
2102 if (!(key in data)) {
2103 this._log.error("isManifestDataValid() - missing required key: " + key);
2108 for (let key in data) {
2109 if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) &&
2110 !this.MANIFEST_REQUIRED_FIELDS.has(key)) {
2111 this._log.error("isManifestDataValid() - unknown key: " + key);
2123 * Strip a Date down to its UTC midnight.
2125 * This will return a cloned Date object. The original is unchanged.
2127 let stripDateToMidnight = function (d) {
2128 let m = new Date(d);
2129 m.setUTCHours(0, 0, 0, 0);
2134 function ExperimentsLastActiveMeasurement1() {
2135 Metrics.Measurement.call(this);
2137 function ExperimentsLastActiveMeasurement2() {
2138 Metrics.Measurement.call(this);
2141 const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT};
2143 ExperimentsLastActiveMeasurement1.prototype = Object.freeze({
2144 __proto__: Metrics.Measurement.prototype,
2150 lastActive: FIELD_DAILY_LAST_TEXT,
2153 ExperimentsLastActiveMeasurement2.prototype = Object.freeze({
2154 __proto__: Metrics.Measurement.prototype,
2160 lastActive: FIELD_DAILY_LAST_TEXT,
2161 lastActiveBranch: FIELD_DAILY_LAST_TEXT,
2165 this.ExperimentsProvider = function () {
2166 Metrics.Provider.call(this);
2168 this._experiments = null;
2171 ExperimentsProvider.prototype = Object.freeze({
2172 __proto__: Metrics.Provider.prototype,
2174 name: "org.mozilla.experiments",
2177 ExperimentsLastActiveMeasurement1,
2178 ExperimentsLastActiveMeasurement2,
2182 EXPERIMENTS_CHANGED_TOPIC,
2185 postInit: function () {
2186 for (let o of this._OBSERVERS) {
2187 Services.obs.addObserver(this, o, false);
2190 return Promise.resolve();
2193 onShutdown: function () {
2194 for (let o of this._OBSERVERS) {
2195 Services.obs.removeObserver(this, o);
2198 return Promise.resolve();
2201 observe: function (subject, topic, data) {
2203 case EXPERIMENTS_CHANGED_TOPIC:
2204 this.recordLastActiveExperiment();
2209 collectDailyData: function () {
2210 return this.recordLastActiveExperiment();
2213 recordLastActiveExperiment: function () {
2214 if (!gExperimentsEnabled) {
2215 return Promise.resolve();
2218 if (!this._experiments) {
2219 this._experiments = Experiments.instance();
2222 let m = this.getMeasurement(ExperimentsLastActiveMeasurement2.prototype.name,
2223 ExperimentsLastActiveMeasurement2.prototype.version);
2225 return this.enqueueStorageOperation(() => {
2226 return Task.spawn(function* recordTask() {
2227 let todayActive = yield this._experiments.lastActiveToday();
2229 this._log.info("No active experiment on this day: " +
2230 this._experiments._policy.now());
2234 this._log.info("Recording last active experiment: " + todayActive.id);
2235 yield m.setDailyLastText("lastActive", todayActive.id,
2236 this._experiments._policy.now());
2237 let branch = todayActive.branch;
2239 yield m.setDailyLastText("lastActiveBranch", branch,
2240 this._experiments._policy.now());
2248 * An Add-ons Manager provider that knows about old experiments.
2250 * This provider exposes read-only add-ons corresponding to previously-active
2251 * experiments. The existence of this provider (and the add-ons it knows about)
2252 * facilitates the display of old experiments in the Add-ons Manager UI with
2253 * very little custom code in that component.
2255 this.Experiments.PreviousExperimentProvider = function (experiments) {
2256 this._experiments = experiments;
2257 this._experimentList = [];
2258 this._log = Log.repository.getLoggerWithMessagePrefix(
2259 "Browser.Experiments.Experiments",
2260 "PreviousExperimentProvider #" + gPreviousProviderCounter++ + "::");
2263 this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({
2264 get name() "PreviousExperimentProvider",
2266 startup: function () {
2267 this._log.trace("startup()");
2268 Services.obs.addObserver(this, EXPERIMENTS_CHANGED_TOPIC, false);
2271 shutdown: function () {
2272 this._log.trace("shutdown()");
2274 Services.obs.removeObserver(this, EXPERIMENTS_CHANGED_TOPIC);
2276 // Prevent crash in mochitest-browser3 on Mulet
2280 observe: function (subject, topic, data) {
2282 case EXPERIMENTS_CHANGED_TOPIC:
2283 this._updateExperimentList();
2288 getAddonByID: function (id, cb) {
2289 for (let experiment of this._experimentList) {
2290 if (experiment.id == id) {
2291 cb(new PreviousExperimentAddon(experiment));
2299 getAddonsByTypes: function (types, cb) {
2300 if (types && types.length > 0 && types.indexOf("experiment") == -1) {
2305 cb([new PreviousExperimentAddon(e) for (e of this._experimentList)]);
2308 _updateExperimentList: function () {
2309 return this._experiments.getExperiments().then((experiments) => {
2310 let list = [e for (e of experiments) if (!e.active)];
2312 let newMap = new Map([[e.id, e] for (e of list)]);
2313 let oldMap = new Map([[e.id, e] for (e of this._experimentList)]);
2315 let added = [e.id for (e of list) if (!oldMap.has(e.id))];
2316 let removed = [e.id for (e of this._experimentList) if (!newMap.has(e.id))];
2318 for (let id of added) {
2319 this._log.trace("updateExperimentList() - adding " + id);
2320 let wrapper = new PreviousExperimentAddon(newMap.get(id));
2321 AddonManagerPrivate.callInstallListeners("onExternalInstall", null, wrapper, null, false);
2322 AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false);
2325 for (let id of removed) {
2326 this._log.trace("updateExperimentList() - removing " + id);
2327 let wrapper = new PreviousExperimentAddon(oldMap.get(id));
2328 AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false);
2331 this._experimentList = list;
2333 for (let id of added) {
2334 let wrapper = new PreviousExperimentAddon(newMap.get(id));
2335 AddonManagerPrivate.callAddonListeners("onInstalled", wrapper);
2338 for (let id of removed) {
2339 let wrapper = new PreviousExperimentAddon(oldMap.get(id));
2340 AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
2343 return this._experimentList;
2349 * An add-on that represents a previously-installed experiment.
2351 function PreviousExperimentAddon(experiment) {
2352 this._id = experiment.id;
2353 this._name = experiment.name;
2354 this._endDate = experiment.endDate;
2355 this._description = experiment.description;
2358 PreviousExperimentAddon.prototype = Object.freeze({
2359 // BEGIN REQUIRED ADDON PROPERTIES
2365 get blocklistState() {
2366 Ci.nsIBlocklistService.STATE_NOT_BLOCKED
2370 return new AddonManagerPrivate.AddonAuthor("");
2373 get foreignInstall() {
2385 get isCompatible() {
2389 get isPlatformCompatible() {
2397 get pendingOperations() {
2398 return AddonManager.PENDING_NONE;
2405 get providesUpdatesSecurely() {
2410 return AddonManager.SCOPE_PROFILE;
2414 return "experiment";
2417 get userDisabled() {
2425 // END REQUIRED PROPERTIES
2427 // BEGIN OPTIONAL PROPERTIES
2430 return this._description;
2434 return new Date(this._endDate);
2437 // END OPTIONAL PROPERTIES
2439 // BEGIN REQUIRED METHODS
2441 isCompatibleWith: function (appVersion, platformVersion) {
2445 findUpdates: function (listener, reason, appVersion, platformVersion) {
2446 AddonManagerPrivate.callNoUpdateListeners(this, listener, reason,
2447 appVersion, platformVersion);
2450 // END REQUIRED METHODS
2453 * The end-date of the experiment, required for the Addon Manager UI.
2457 return this._endDate;