|
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/. */ |
|
4 |
|
5 "use strict"; |
|
6 |
|
7 this.EXPORTED_SYMBOLS = [ |
|
8 "Experiments", |
|
9 "ExperimentsProvider", |
|
10 ]; |
|
11 |
|
12 const {classes: Cc, interfaces: Ci, utils: Cu} = Components; |
|
13 |
|
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"); |
|
22 |
|
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"); |
|
37 |
|
38 // CertUtils.jsm doesn't expose a single "CertUtils" object like a normal .jsm |
|
39 // would. |
|
40 XPCOMUtils.defineLazyGetter(this, "CertUtils", |
|
41 function() { |
|
42 var mod = {}; |
|
43 Cu.import("resource://gre/modules/CertUtils.jsm", mod); |
|
44 return mod; |
|
45 }); |
|
46 |
|
47 XPCOMUtils.defineLazyServiceGetter(this, "gCrashReporter", |
|
48 "@mozilla.org/xre/app-info;1", |
|
49 "nsICrashReporter"); |
|
50 |
|
51 const FILE_CACHE = "experiments.json"; |
|
52 const EXPERIMENTS_CHANGED_TOPIC = "experiments-changed"; |
|
53 const MANIFEST_VERSION = 1; |
|
54 const CACHE_VERSION = 1; |
|
55 |
|
56 const KEEP_HISTORY_N_DAYS = 180; |
|
57 const MIN_EXPERIMENT_ACTIVE_SECONDS = 60; |
|
58 |
|
59 const PREF_BRANCH = "experiments."; |
|
60 const PREF_ENABLED = "enabled"; // experiments.enabled |
|
61 const PREF_ACTIVE_EXPERIMENT = "activeExperiment"; // whether we have an active experiment |
|
62 const PREF_LOGGING = "logging"; |
|
63 const PREF_LOGGING_LEVEL = PREF_LOGGING + ".level"; // experiments.logging.level |
|
64 const PREF_LOGGING_DUMP = PREF_LOGGING + ".dump"; // experiments.logging.dump |
|
65 const PREF_MANIFEST_URI = "manifest.uri"; // experiments.logging.manifest.uri |
|
66 const PREF_MANIFEST_CHECKCERT = "manifest.cert.checkAttributes"; // experiments.manifest.cert.checkAttributes |
|
67 const PREF_MANIFEST_REQUIREBUILTIN = "manifest.cert.requireBuiltin"; // experiments.manifest.cert.requireBuiltin |
|
68 const PREF_FORCE_SAMPLE = "force-sample-value"; // experiments.force-sample-value |
|
69 |
|
70 const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled"; |
|
71 |
|
72 const PREF_BRANCH_TELEMETRY = "toolkit.telemetry."; |
|
73 const PREF_TELEMETRY_ENABLED = "enabled"; |
|
74 |
|
75 const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties"; |
|
76 const STRING_TYPE_NAME = "type.%ID%.name"; |
|
77 |
|
78 const TELEMETRY_LOG = { |
|
79 // log(key, [kind, experimentId, details]) |
|
80 ACTIVATION_KEY: "EXPERIMENT_ACTIVATION", |
|
81 ACTIVATION: { |
|
82 // Successfully activated. |
|
83 ACTIVATED: "ACTIVATED", |
|
84 // Failed to install the add-on. |
|
85 INSTALL_FAILURE: "INSTALL_FAILURE", |
|
86 // Experiment does not meet activation requirements. Details will |
|
87 // be provided. |
|
88 REJECTED: "REJECTED", |
|
89 }, |
|
90 |
|
91 // log(key, [kind, experimentId, optionalDetails...]) |
|
92 TERMINATION_KEY: "EXPERIMENT_TERMINATION", |
|
93 TERMINATION: { |
|
94 // The Experiments service was disabled. |
|
95 SERVICE_DISABLED: "SERVICE_DISABLED", |
|
96 // Add-on uninstalled. |
|
97 ADDON_UNINSTALLED: "ADDON_UNINSTALLED", |
|
98 // The experiment disabled itself. |
|
99 FROM_API: "FROM_API", |
|
100 // The experiment expired (e.g. by exceeding the end date). |
|
101 EXPIRED: "EXPIRED", |
|
102 // Disabled after re-evaluating conditions. If this is specified, |
|
103 // details will be provided. |
|
104 RECHECK: "RECHECK", |
|
105 }, |
|
106 }; |
|
107 |
|
108 const gPrefs = new Preferences(PREF_BRANCH); |
|
109 const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY); |
|
110 let gExperimentsEnabled = false; |
|
111 let gAddonProvider = null; |
|
112 let gExperiments = null; |
|
113 let gLogAppenderDump = null; |
|
114 let gPolicyCounter = 0; |
|
115 let gExperimentsCounter = 0; |
|
116 let gExperimentEntryCounter = 0; |
|
117 let gPreviousProviderCounter = 0; |
|
118 |
|
119 // Tracks active AddonInstall we know about so we can deny external |
|
120 // installs. |
|
121 let gActiveInstallURLs = new Set(); |
|
122 |
|
123 // Tracks add-on IDs that are being uninstalled by us. This allows us |
|
124 // to differentiate between expected uninstalled and user-driven uninstalls. |
|
125 let gActiveUninstallAddonIDs = new Set(); |
|
126 |
|
127 let gLogger; |
|
128 let gLogDumping = false; |
|
129 |
|
130 function configureLogging() { |
|
131 if (!gLogger) { |
|
132 gLogger = Log.repository.getLogger("Browser.Experiments"); |
|
133 gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); |
|
134 } |
|
135 gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, Log.Level.Warn); |
|
136 |
|
137 let logDumping = gPrefs.get(PREF_LOGGING_DUMP, false); |
|
138 if (logDumping != gLogDumping) { |
|
139 if (logDumping) { |
|
140 gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter()); |
|
141 gLogger.addAppender(gLogAppenderDump); |
|
142 } else { |
|
143 gLogger.removeAppender(gLogAppenderDump); |
|
144 gLogAppenderDump = null; |
|
145 } |
|
146 gLogDumping = logDumping; |
|
147 } |
|
148 } |
|
149 |
|
150 // Takes an array of promises and returns a promise that is resolved once all of |
|
151 // them are rejected or resolved. |
|
152 function allResolvedOrRejected(promises) { |
|
153 if (!promises.length) { |
|
154 return Promise.resolve([]); |
|
155 } |
|
156 |
|
157 let countdown = promises.length; |
|
158 let deferred = Promise.defer(); |
|
159 |
|
160 for (let p of promises) { |
|
161 let helper = () => { |
|
162 if (--countdown == 0) { |
|
163 deferred.resolve(); |
|
164 } |
|
165 }; |
|
166 Promise.resolve(p).then(helper, helper); |
|
167 } |
|
168 |
|
169 return deferred.promise; |
|
170 } |
|
171 |
|
172 // Loads a JSON file using OS.file. file is a string representing the path |
|
173 // of the file to be read, options contains additional options to pass to |
|
174 // OS.File.read. |
|
175 // Returns a Promise resolved with the json payload or rejected with |
|
176 // OS.File.Error or JSON.parse() errors. |
|
177 function loadJSONAsync(file, options) { |
|
178 return Task.spawn(function() { |
|
179 let rawData = yield OS.File.read(file, options); |
|
180 // Read json file into a string |
|
181 let data; |
|
182 try { |
|
183 // Obtain a converter to read from a UTF-8 encoded input stream. |
|
184 let converter = new TextDecoder(); |
|
185 data = JSON.parse(converter.decode(rawData)); |
|
186 } catch (ex) { |
|
187 gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex); |
|
188 throw ex; |
|
189 } |
|
190 throw new Task.Result(data); |
|
191 }); |
|
192 } |
|
193 |
|
194 function telemetryEnabled() { |
|
195 return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false); |
|
196 } |
|
197 |
|
198 // Returns a promise that is resolved with the AddonInstall for that URL. |
|
199 function addonInstallForURL(url, hash) { |
|
200 let deferred = Promise.defer(); |
|
201 AddonManager.getInstallForURL(url, install => deferred.resolve(install), |
|
202 "application/x-xpinstall", hash); |
|
203 return deferred.promise; |
|
204 } |
|
205 |
|
206 // Returns a promise that is resolved with an Array<Addon> of the installed |
|
207 // experiment addons. |
|
208 function installedExperimentAddons() { |
|
209 let deferred = Promise.defer(); |
|
210 AddonManager.getAddonsByTypes(["experiment"], (addons) => { |
|
211 deferred.resolve([a for (a of addons) if (!a.appDisabled)]); |
|
212 }); |
|
213 return deferred.promise; |
|
214 } |
|
215 |
|
216 // Takes an Array<Addon> and returns a promise that is resolved when the |
|
217 // addons are uninstalled. |
|
218 function uninstallAddons(addons) { |
|
219 let ids = new Set([a.id for (a of addons)]); |
|
220 let deferred = Promise.defer(); |
|
221 |
|
222 let listener = {}; |
|
223 listener.onUninstalled = addon => { |
|
224 if (!ids.has(addon.id)) { |
|
225 return; |
|
226 } |
|
227 |
|
228 ids.delete(addon.id); |
|
229 if (ids.size == 0) { |
|
230 AddonManager.removeAddonListener(listener); |
|
231 deferred.resolve(); |
|
232 } |
|
233 }; |
|
234 |
|
235 AddonManager.addAddonListener(listener); |
|
236 |
|
237 for (let addon of addons) { |
|
238 // Disabling the add-on before uninstalling is necessary to cause tests to |
|
239 // pass. This might be indicative of a bug in XPIProvider. |
|
240 // TODO follow up in bug 992396. |
|
241 addon.userDisabled = true; |
|
242 addon.uninstall(); |
|
243 } |
|
244 |
|
245 return deferred.promise; |
|
246 } |
|
247 |
|
248 /** |
|
249 * The experiments module. |
|
250 */ |
|
251 |
|
252 let Experiments = { |
|
253 /** |
|
254 * Provides access to the global `Experiments.Experiments` instance. |
|
255 */ |
|
256 instance: function () { |
|
257 if (!gExperiments) { |
|
258 gExperiments = new Experiments.Experiments(); |
|
259 } |
|
260 |
|
261 return gExperiments; |
|
262 }, |
|
263 }; |
|
264 |
|
265 /* |
|
266 * The policy object allows us to inject fake enviroment data from the |
|
267 * outside by monkey-patching. |
|
268 */ |
|
269 |
|
270 Experiments.Policy = function () { |
|
271 this._log = Log.repository.getLoggerWithMessagePrefix( |
|
272 "Browser.Experiments.Policy", |
|
273 "Policy #" + gPolicyCounter++ + "::"); |
|
274 |
|
275 // Set to true to ignore hash verification on downloaded XPIs. This should |
|
276 // not be used outside of testing. |
|
277 this.ignoreHashes = false; |
|
278 }; |
|
279 |
|
280 Experiments.Policy.prototype = { |
|
281 now: function () { |
|
282 return new Date(); |
|
283 }, |
|
284 |
|
285 random: function () { |
|
286 let pref = gPrefs.get(PREF_FORCE_SAMPLE); |
|
287 if (pref !== undefined) { |
|
288 let val = Number.parseFloat(pref); |
|
289 this._log.debug("random sample forced: " + val); |
|
290 if (isNaN(val) || val < 0) { |
|
291 return 0; |
|
292 } |
|
293 if (val > 1) { |
|
294 return 1; |
|
295 } |
|
296 return val; |
|
297 } |
|
298 return Math.random(); |
|
299 }, |
|
300 |
|
301 futureDate: function (offset) { |
|
302 return new Date(this.now().getTime() + offset); |
|
303 }, |
|
304 |
|
305 oneshotTimer: function (callback, timeout, thisObj, name) { |
|
306 return CommonUtils.namedTimer(callback, timeout, thisObj, name); |
|
307 }, |
|
308 |
|
309 updatechannel: function () { |
|
310 return UpdateChannel.get(); |
|
311 }, |
|
312 |
|
313 locale: function () { |
|
314 let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry); |
|
315 return chrome.getSelectedLocale("global"); |
|
316 }, |
|
317 |
|
318 /* |
|
319 * @return Promise<> Resolved with the payload data. |
|
320 */ |
|
321 healthReportPayload: function () { |
|
322 return Task.spawn(function*() { |
|
323 let reporter = Cc["@mozilla.org/datareporting/service;1"] |
|
324 .getService(Ci.nsISupports) |
|
325 .wrappedJSObject |
|
326 .healthReporter; |
|
327 yield reporter.onInit(); |
|
328 let payload = yield reporter.collectAndObtainJSONPayload(); |
|
329 throw new Task.Result(payload); |
|
330 }); |
|
331 }, |
|
332 |
|
333 telemetryPayload: function () { |
|
334 return TelemetryPing.getPayload(); |
|
335 }, |
|
336 }; |
|
337 |
|
338 function AlreadyShutdownError(message="already shut down") { |
|
339 this.name = "AlreadyShutdownError"; |
|
340 this.message = message; |
|
341 } |
|
342 |
|
343 AlreadyShutdownError.prototype = new Error(); |
|
344 AlreadyShutdownError.prototype.constructor = AlreadyShutdownError; |
|
345 |
|
346 /** |
|
347 * Manages the experiments and provides an interface to control them. |
|
348 */ |
|
349 |
|
350 Experiments.Experiments = function (policy=new Experiments.Policy()) { |
|
351 this._log = Log.repository.getLoggerWithMessagePrefix( |
|
352 "Browser.Experiments.Experiments", |
|
353 "Experiments #" + gExperimentsCounter++ + "::"); |
|
354 this._log.trace("constructor"); |
|
355 |
|
356 this._policy = policy; |
|
357 |
|
358 // This is a Map of (string -> ExperimentEntry), keyed with the experiment id. |
|
359 // It holds both the current experiments and history. |
|
360 // Map() preserves insertion order, which means we preserve the manifest order. |
|
361 // This is null until we've successfully completed loading the cache from |
|
362 // disk the first time. |
|
363 this._experiments = null; |
|
364 this._refresh = false; |
|
365 this._terminateReason = null; // or TELEMETRY_LOG.TERMINATION.... |
|
366 this._dirty = false; |
|
367 |
|
368 // Loading the cache happens once asynchronously on startup |
|
369 this._loadTask = null; |
|
370 |
|
371 // The _main task handles all other actions: |
|
372 // * refreshing the manifest off the network (if _refresh) |
|
373 // * disabling/enabling experiments |
|
374 // * saving the cache (if _dirty) |
|
375 this._mainTask = null; |
|
376 |
|
377 // Timer for re-evaluating experiment status. |
|
378 this._timer = null; |
|
379 |
|
380 this._shutdown = false; |
|
381 |
|
382 // We need to tell when we first evaluated the experiments to fire an |
|
383 // experiments-changed notification when we only loaded completed experiments. |
|
384 this._firstEvaluate = true; |
|
385 |
|
386 this.init(); |
|
387 }; |
|
388 |
|
389 Experiments.Experiments.prototype = { |
|
390 QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]), |
|
391 |
|
392 init: function () { |
|
393 this._shutdown = false; |
|
394 configureLogging(); |
|
395 |
|
396 gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false); |
|
397 this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled); |
|
398 |
|
399 gPrefs.observe(PREF_LOGGING, configureLogging); |
|
400 gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this); |
|
401 gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this); |
|
402 |
|
403 gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); |
|
404 |
|
405 AsyncShutdown.profileBeforeChange.addBlocker("Experiments.jsm shutdown", |
|
406 this.uninit.bind(this)); |
|
407 |
|
408 this._registerWithAddonManager(); |
|
409 |
|
410 let deferred = Promise.defer(); |
|
411 |
|
412 this._loadTask = this._loadFromCache(); |
|
413 this._loadTask.then( |
|
414 () => { |
|
415 this._log.trace("_loadTask finished ok"); |
|
416 this._loadTask = null; |
|
417 this._run().then(deferred.resolve, deferred.reject); |
|
418 }, |
|
419 (e) => { |
|
420 this._log.error("_loadFromCache caught error: " + e); |
|
421 deferred.reject(e); |
|
422 } |
|
423 ); |
|
424 |
|
425 return deferred.promise; |
|
426 }, |
|
427 |
|
428 /** |
|
429 * Uninitialize this instance. |
|
430 * |
|
431 * This function is susceptible to race conditions. If it is called multiple |
|
432 * times before the previous uninit() has completed or if it is called while |
|
433 * an init() operation is being performed, the object may get in bad state |
|
434 * and/or deadlock could occur. |
|
435 * |
|
436 * @return Promise<> |
|
437 * The promise is fulfilled when all pending tasks are finished. |
|
438 */ |
|
439 uninit: Task.async(function* () { |
|
440 this._log.trace("uninit: started"); |
|
441 yield this._loadTask; |
|
442 this._log.trace("uninit: finished with _loadTask"); |
|
443 |
|
444 if (!this._shutdown) { |
|
445 this._log.trace("uninit: no previous shutdown"); |
|
446 this._unregisterWithAddonManager(); |
|
447 |
|
448 gPrefs.ignore(PREF_LOGGING, configureLogging); |
|
449 gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this); |
|
450 gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this); |
|
451 |
|
452 gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); |
|
453 |
|
454 if (this._timer) { |
|
455 this._timer.clear(); |
|
456 } |
|
457 } |
|
458 |
|
459 this._shutdown = true; |
|
460 if (this._mainTask) { |
|
461 try { |
|
462 this._log.trace("uninit: waiting on _mainTask"); |
|
463 yield this._mainTask; |
|
464 } catch (e if e instanceof AlreadyShutdownError) { |
|
465 // We error out of tasks after shutdown via that exception. |
|
466 } |
|
467 } |
|
468 |
|
469 this._log.info("Completed uninitialization."); |
|
470 }), |
|
471 |
|
472 _registerWithAddonManager: function (previousExperimentsProvider) { |
|
473 this._log.trace("Registering instance with Addon Manager."); |
|
474 |
|
475 AddonManager.addAddonListener(this); |
|
476 AddonManager.addInstallListener(this); |
|
477 |
|
478 if (!gAddonProvider) { |
|
479 // The properties of this AddonType should be kept in sync with the |
|
480 // experiment AddonType registered in XPIProvider. |
|
481 this._log.trace("Registering previous experiment add-on provider."); |
|
482 gAddonProvider = previousExperimentsProvider || new Experiments.PreviousExperimentProvider(this); |
|
483 AddonManagerPrivate.registerProvider(gAddonProvider, [ |
|
484 new AddonManagerPrivate.AddonType("experiment", |
|
485 URI_EXTENSION_STRINGS, |
|
486 STRING_TYPE_NAME, |
|
487 AddonManager.VIEW_TYPE_LIST, |
|
488 11000, |
|
489 AddonManager.TYPE_UI_HIDE_EMPTY), |
|
490 ]); |
|
491 } |
|
492 |
|
493 }, |
|
494 |
|
495 _unregisterWithAddonManager: function () { |
|
496 this._log.trace("Unregistering instance with Addon Manager."); |
|
497 |
|
498 if (gAddonProvider) { |
|
499 this._log.trace("Unregistering previous experiment add-on provider."); |
|
500 AddonManagerPrivate.unregisterProvider(gAddonProvider); |
|
501 gAddonProvider = null; |
|
502 } |
|
503 |
|
504 AddonManager.removeInstallListener(this); |
|
505 AddonManager.removeAddonListener(this); |
|
506 }, |
|
507 |
|
508 /* |
|
509 * Change the PreviousExperimentsProvider that this instance uses. |
|
510 * For testing only. |
|
511 */ |
|
512 _setPreviousExperimentsProvider: function (provider) { |
|
513 this._unregisterWithAddonManager(); |
|
514 this._registerWithAddonManager(provider); |
|
515 }, |
|
516 |
|
517 /** |
|
518 * Throws an exception if we've already shut down. |
|
519 */ |
|
520 _checkForShutdown: function() { |
|
521 if (this._shutdown) { |
|
522 throw new AlreadyShutdownError("uninit() already called"); |
|
523 } |
|
524 }, |
|
525 |
|
526 /** |
|
527 * Whether the experiments feature is enabled. |
|
528 */ |
|
529 get enabled() { |
|
530 return gExperimentsEnabled; |
|
531 }, |
|
532 |
|
533 /** |
|
534 * Toggle whether the experiments feature is enabled or not. |
|
535 */ |
|
536 set enabled(enabled) { |
|
537 this._log.trace("set enabled(" + enabled + ")"); |
|
538 gPrefs.set(PREF_ENABLED, enabled); |
|
539 }, |
|
540 |
|
541 _toggleExperimentsEnabled: Task.async(function* (enabled) { |
|
542 this._log.trace("_toggleExperimentsEnabled(" + enabled + ")"); |
|
543 let wasEnabled = gExperimentsEnabled; |
|
544 gExperimentsEnabled = enabled && telemetryEnabled(); |
|
545 |
|
546 if (wasEnabled == gExperimentsEnabled) { |
|
547 return; |
|
548 } |
|
549 |
|
550 if (gExperimentsEnabled) { |
|
551 yield this.updateManifest(); |
|
552 } else { |
|
553 yield this.disableExperiment(TELEMETRY_LOG.TERMINATION.SERVICE_DISABLED); |
|
554 if (this._timer) { |
|
555 this._timer.clear(); |
|
556 } |
|
557 } |
|
558 }), |
|
559 |
|
560 _telemetryStatusChanged: function () { |
|
561 this._toggleExperimentsEnabled(gExperimentsEnabled); |
|
562 }, |
|
563 |
|
564 /** |
|
565 * Returns a promise that is resolved with an array of `ExperimentInfo` objects, |
|
566 * which provide info on the currently and recently active experiments. |
|
567 * The array is in chronological order. |
|
568 * |
|
569 * The experiment info is of the form: |
|
570 * { |
|
571 * id: <string>, |
|
572 * name: <string>, |
|
573 * description: <string>, |
|
574 * active: <boolean>, |
|
575 * endDate: <integer>, // epoch ms |
|
576 * detailURL: <string>, |
|
577 * ... // possibly extended later |
|
578 * } |
|
579 * |
|
580 * @return Promise<Array<ExperimentInfo>> Array of experiment info objects. |
|
581 */ |
|
582 getExperiments: function () { |
|
583 return Task.spawn(function*() { |
|
584 yield this._loadTask; |
|
585 let list = []; |
|
586 |
|
587 for (let [id, experiment] of this._experiments) { |
|
588 if (!experiment.startDate) { |
|
589 // We only collect experiments that are or were active. |
|
590 continue; |
|
591 } |
|
592 |
|
593 list.push({ |
|
594 id: id, |
|
595 name: experiment._name, |
|
596 description: experiment._description, |
|
597 active: experiment.enabled, |
|
598 endDate: experiment.endDate.getTime(), |
|
599 detailURL: experiment._homepageURL, |
|
600 branch: experiment.branch, |
|
601 }); |
|
602 } |
|
603 |
|
604 // Sort chronologically, descending. |
|
605 list.sort((a, b) => b.endDate - a.endDate); |
|
606 return list; |
|
607 }.bind(this)); |
|
608 }, |
|
609 |
|
610 /** |
|
611 * Returns the ExperimentInfo for the active experiment, or null |
|
612 * if there is none. |
|
613 */ |
|
614 getActiveExperiment: function () { |
|
615 let experiment = this._getActiveExperiment(); |
|
616 if (!experiment) { |
|
617 return null; |
|
618 } |
|
619 |
|
620 let info = { |
|
621 id: experiment.id, |
|
622 name: experiment._name, |
|
623 description: experiment._description, |
|
624 active: experiment.enabled, |
|
625 endDate: experiment.endDate.getTime(), |
|
626 detailURL: experiment._homepageURL, |
|
627 }; |
|
628 |
|
629 return info; |
|
630 }, |
|
631 |
|
632 /** |
|
633 * Experiment "branch" support. If an experiment has multiple branches, it |
|
634 * can record the branch with the experiment system and it will |
|
635 * automatically be included in data reporting (FHR/telemetry payloads). |
|
636 */ |
|
637 |
|
638 /** |
|
639 * Set the experiment branch for the specified experiment ID. |
|
640 * @returns Promise<> |
|
641 */ |
|
642 setExperimentBranch: Task.async(function*(id, branchstr) { |
|
643 yield this._loadTask; |
|
644 let e = this._experiments.get(id); |
|
645 if (!e) { |
|
646 throw new Error("Experiment not found"); |
|
647 } |
|
648 e.branch = String(branchstr); |
|
649 this._dirty = true; |
|
650 Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); |
|
651 yield this._run(); |
|
652 }), |
|
653 /** |
|
654 * Get the branch of the specified experiment. If the experiment is unknown, |
|
655 * throws an error. |
|
656 * |
|
657 * @param id The ID of the experiment. Pass null for the currently running |
|
658 * experiment. |
|
659 * @returns Promise<string|null> |
|
660 * @throws Error if the specified experiment ID is unknown, or if there is no |
|
661 * current experiment. |
|
662 */ |
|
663 getExperimentBranch: Task.async(function*(id=null) { |
|
664 yield this._loadTask; |
|
665 let e; |
|
666 if (id) { |
|
667 e = this._experiments.get(id); |
|
668 if (!e) { |
|
669 throw new Error("Experiment not found"); |
|
670 } |
|
671 } else { |
|
672 e = this._getActiveExperiment(); |
|
673 if (e === null) { |
|
674 throw new Error("No active experiment"); |
|
675 } |
|
676 } |
|
677 return e.branch; |
|
678 }), |
|
679 |
|
680 /** |
|
681 * Determine whether another date has the same UTC day as now(). |
|
682 */ |
|
683 _dateIsTodayUTC: function (d) { |
|
684 let now = this._policy.now(); |
|
685 |
|
686 return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime(); |
|
687 }, |
|
688 |
|
689 /** |
|
690 * Obtain the entry of the most recent active experiment that was active |
|
691 * today. |
|
692 * |
|
693 * If no experiment was active today, this resolves to nothing. |
|
694 * |
|
695 * Assumption: Only a single experiment can be active at a time. |
|
696 * |
|
697 * @return Promise<object> |
|
698 */ |
|
699 lastActiveToday: function () { |
|
700 return Task.spawn(function* getMostRecentActiveExperimentTask() { |
|
701 let experiments = yield this.getExperiments(); |
|
702 |
|
703 // Assumption: Ordered chronologically, descending, with active always |
|
704 // first. |
|
705 for (let experiment of experiments) { |
|
706 if (experiment.active) { |
|
707 return experiment; |
|
708 } |
|
709 |
|
710 if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) { |
|
711 return experiment; |
|
712 } |
|
713 } |
|
714 return null; |
|
715 }.bind(this)); |
|
716 }, |
|
717 |
|
718 _run: function() { |
|
719 this._log.trace("_run"); |
|
720 this._checkForShutdown(); |
|
721 if (!this._mainTask) { |
|
722 this._mainTask = Task.spawn(this._main.bind(this)); |
|
723 this._mainTask.then( |
|
724 () => { |
|
725 this._log.trace("_main finished, scheduling next run"); |
|
726 this._mainTask = null; |
|
727 this._scheduleNextRun(); |
|
728 }, |
|
729 (e) => { |
|
730 this._log.error("_main caught error: " + e); |
|
731 this._mainTask = null; |
|
732 } |
|
733 ); |
|
734 } |
|
735 return this._mainTask; |
|
736 }, |
|
737 |
|
738 _main: function*() { |
|
739 do { |
|
740 this._log.trace("_main iteration"); |
|
741 yield this._loadTask; |
|
742 if (!gExperimentsEnabled) { |
|
743 this._refresh = false; |
|
744 } |
|
745 |
|
746 if (this._refresh) { |
|
747 yield this._loadManifest(); |
|
748 } |
|
749 yield this._evaluateExperiments(); |
|
750 if (this._dirty) { |
|
751 yield this._saveToCache(); |
|
752 } |
|
753 // If somebody called .updateManifest() or disableExperiment() |
|
754 // while we were running, go again right now. |
|
755 } |
|
756 while (this._refresh || this._terminateReason); |
|
757 }, |
|
758 |
|
759 _loadManifest: function*() { |
|
760 this._log.trace("_loadManifest"); |
|
761 let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI); |
|
762 |
|
763 this._checkForShutdown(); |
|
764 |
|
765 this._refresh = false; |
|
766 try { |
|
767 let responseText = yield this._httpGetRequest(uri); |
|
768 this._log.trace("_loadManifest() - responseText=\"" + responseText + "\""); |
|
769 |
|
770 if (this._shutdown) { |
|
771 return; |
|
772 } |
|
773 |
|
774 let data = JSON.parse(responseText); |
|
775 this._updateExperiments(data); |
|
776 } catch (e) { |
|
777 this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e); |
|
778 } |
|
779 }, |
|
780 |
|
781 /** |
|
782 * Fetch an updated list of experiments and trigger experiment updates. |
|
783 * Do only use when experiments are enabled. |
|
784 * |
|
785 * @return Promise<> |
|
786 * The promise is resolved when the manifest and experiment list is updated. |
|
787 */ |
|
788 updateManifest: function () { |
|
789 this._log.trace("updateManifest()"); |
|
790 |
|
791 if (!gExperimentsEnabled) { |
|
792 return Promise.reject(new Error("experiments are disabled")); |
|
793 } |
|
794 |
|
795 if (this._shutdown) { |
|
796 return Promise.reject(Error("uninit() alrady called")); |
|
797 } |
|
798 |
|
799 this._refresh = true; |
|
800 return this._run(); |
|
801 }, |
|
802 |
|
803 notify: function (timer) { |
|
804 this._log.trace("notify()"); |
|
805 this._checkForShutdown(); |
|
806 return this._run(); |
|
807 }, |
|
808 |
|
809 // START OF ADD-ON LISTENERS |
|
810 |
|
811 onUninstalled: function (addon) { |
|
812 this._log.trace("onUninstalled() - addon id: " + addon.id); |
|
813 if (gActiveUninstallAddonIDs.has(addon.id)) { |
|
814 this._log.trace("matches pending uninstall"); |
|
815 return; |
|
816 } |
|
817 let activeExperiment = this._getActiveExperiment(); |
|
818 if (!activeExperiment || activeExperiment._addonId != addon.id) { |
|
819 return; |
|
820 } |
|
821 |
|
822 this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED); |
|
823 }, |
|
824 |
|
825 onInstallStarted: function (install) { |
|
826 if (install.addon.type != "experiment") { |
|
827 return; |
|
828 } |
|
829 |
|
830 this._log.trace("onInstallStarted() - " + install.addon.id); |
|
831 if (install.addon.appDisabled) { |
|
832 // This is a PreviousExperiment |
|
833 return; |
|
834 } |
|
835 |
|
836 // We want to be in control of all experiment add-ons: reject installs |
|
837 // for add-ons that we don't know about. |
|
838 |
|
839 // We have a race condition of sorts to worry about here. We have 2 |
|
840 // onInstallStarted listeners. This one (the global one) and the one |
|
841 // created as part of ExperimentEntry._installAddon. Because of the order |
|
842 // they are registered in, this one likely executes first. Unfortunately, |
|
843 // this means that the add-on ID is not yet set on the ExperimentEntry. |
|
844 // So, we can't just look at this._trackedAddonIds because the new experiment |
|
845 // will have its add-on ID set to null. We work around this by storing a |
|
846 // identifying field - the source URL of the install - in a module-level |
|
847 // variable (so multiple Experiments instances doesn't cancel each other |
|
848 // out). |
|
849 |
|
850 if (this._trackedAddonIds.has(install.addon.id)) { |
|
851 this._log.info("onInstallStarted allowing install because add-on ID " + |
|
852 "tracked by us."); |
|
853 return; |
|
854 } |
|
855 |
|
856 if (gActiveInstallURLs.has(install.sourceURI.spec)) { |
|
857 this._log.info("onInstallStarted allowing install because install " + |
|
858 "tracked by us."); |
|
859 return; |
|
860 } |
|
861 |
|
862 this._log.warn("onInstallStarted cancelling install of unknown " + |
|
863 "experiment add-on: " + install.addon.id); |
|
864 return false; |
|
865 }, |
|
866 |
|
867 // END OF ADD-ON LISTENERS. |
|
868 |
|
869 _getExperimentByAddonId: function (addonId) { |
|
870 for (let [, entry] of this._experiments) { |
|
871 if (entry._addonId === addonId) { |
|
872 return entry; |
|
873 } |
|
874 } |
|
875 |
|
876 return null; |
|
877 }, |
|
878 |
|
879 /* |
|
880 * Helper function to make HTTP GET requests. Returns a promise that is resolved with |
|
881 * the responseText when the request is complete. |
|
882 */ |
|
883 _httpGetRequest: function (url) { |
|
884 this._log.trace("httpGetRequest(" + url + ")"); |
|
885 let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); |
|
886 try { |
|
887 xhr.open("GET", url); |
|
888 } catch (e) { |
|
889 this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e); |
|
890 return Promise.reject(new Error("Experiments - Error opening XHR for " + url)); |
|
891 } |
|
892 |
|
893 let deferred = Promise.defer(); |
|
894 |
|
895 let log = this._log; |
|
896 xhr.onerror = function (e) { |
|
897 log.error("httpGetRequest::onError() - Error making request to " + url + ": " + e.error); |
|
898 deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error)); |
|
899 }; |
|
900 |
|
901 xhr.onload = function (event) { |
|
902 if (xhr.status !== 200 && xhr.state !== 0) { |
|
903 log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status); |
|
904 deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status)); |
|
905 return; |
|
906 } |
|
907 |
|
908 let certs = null; |
|
909 if (gPrefs.get(PREF_MANIFEST_CHECKCERT, true)) { |
|
910 certs = CertUtils.readCertPrefs(PREF_BRANCH + "manifest.certs."); |
|
911 } |
|
912 try { |
|
913 let allowNonBuiltin = !gPrefs.get(PREF_MANIFEST_REQUIREBUILTIN, true); |
|
914 CertUtils.checkCert(xhr.channel, allowNonBuiltin, certs); |
|
915 } |
|
916 catch (e) { |
|
917 log.error("manifest fetch failed certificate checks", [e]); |
|
918 deferred.reject(new Error("Experiments - manifest fetch failed certificate checks: " + e)); |
|
919 return; |
|
920 } |
|
921 |
|
922 deferred.resolve(xhr.responseText); |
|
923 }; |
|
924 |
|
925 if (xhr.channel instanceof Ci.nsISupportsPriority) { |
|
926 xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST; |
|
927 } |
|
928 |
|
929 xhr.send(null); |
|
930 return deferred.promise; |
|
931 }, |
|
932 |
|
933 /* |
|
934 * Path of the cache file we use in the profile. |
|
935 */ |
|
936 get _cacheFilePath() { |
|
937 return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE); |
|
938 }, |
|
939 |
|
940 /* |
|
941 * Part of the main task to save the cache to disk, called from _main. |
|
942 */ |
|
943 _saveToCache: function* () { |
|
944 this._log.trace("_saveToCache"); |
|
945 let path = this._cacheFilePath; |
|
946 let textData = JSON.stringify({ |
|
947 version: CACHE_VERSION, |
|
948 data: [e[1].toJSON() for (e of this._experiments.entries())], |
|
949 }); |
|
950 |
|
951 let encoder = new TextEncoder(); |
|
952 let data = encoder.encode(textData); |
|
953 let options = { tmpPath: path + ".tmp", compression: "lz4" }; |
|
954 yield OS.File.writeAtomic(path, data, options); |
|
955 this._dirty = false; |
|
956 this._log.debug("_saveToCache saved to " + path); |
|
957 }, |
|
958 |
|
959 /* |
|
960 * Task function, load the cached experiments manifest file from disk. |
|
961 */ |
|
962 _loadFromCache: Task.async(function* () { |
|
963 this._log.trace("_loadFromCache"); |
|
964 let path = this._cacheFilePath; |
|
965 try { |
|
966 let result = yield loadJSONAsync(path, { compression: "lz4" }); |
|
967 this._populateFromCache(result); |
|
968 } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) { |
|
969 // No cached manifest yet. |
|
970 this._experiments = new Map(); |
|
971 } |
|
972 }), |
|
973 |
|
974 _populateFromCache: function (data) { |
|
975 this._log.trace("populateFromCache() - data: " + JSON.stringify(data)); |
|
976 |
|
977 // If the user has a newer cache version than we can understand, we fail |
|
978 // hard; no experiments should be active in this older client. |
|
979 if (CACHE_VERSION !== data.version) { |
|
980 throw new Error("Experiments::_populateFromCache() - invalid cache version"); |
|
981 } |
|
982 |
|
983 let experiments = new Map(); |
|
984 for (let item of data.data) { |
|
985 let entry = new Experiments.ExperimentEntry(this._policy); |
|
986 if (!entry.initFromCacheData(item)) { |
|
987 continue; |
|
988 } |
|
989 experiments.set(entry.id, entry); |
|
990 } |
|
991 |
|
992 this._experiments = experiments; |
|
993 }, |
|
994 |
|
995 /* |
|
996 * Update the experiment entries from the experiments |
|
997 * array in the manifest |
|
998 */ |
|
999 _updateExperiments: function (manifestObject) { |
|
1000 this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject)); |
|
1001 |
|
1002 if (manifestObject.version !== MANIFEST_VERSION) { |
|
1003 this._log.warning("updateExperiments() - unsupported version " + manifestObject.version); |
|
1004 } |
|
1005 |
|
1006 let experiments = new Map(); // The new experiments map |
|
1007 |
|
1008 // Collect new and updated experiments. |
|
1009 for (let data of manifestObject.experiments) { |
|
1010 let entry = this._experiments.get(data.id); |
|
1011 |
|
1012 if (entry) { |
|
1013 if (!entry.updateFromManifestData(data)) { |
|
1014 this._log.error("updateExperiments() - Invalid manifest data for " + data.id); |
|
1015 continue; |
|
1016 } |
|
1017 } else { |
|
1018 entry = new Experiments.ExperimentEntry(this._policy); |
|
1019 if (!entry.initFromManifestData(data)) { |
|
1020 continue; |
|
1021 } |
|
1022 } |
|
1023 |
|
1024 if (entry.shouldDiscard()) { |
|
1025 continue; |
|
1026 } |
|
1027 |
|
1028 experiments.set(entry.id, entry); |
|
1029 } |
|
1030 |
|
1031 // Make sure we keep experiments that are or were running. |
|
1032 // We remove them after KEEP_HISTORY_N_DAYS. |
|
1033 for (let [id, entry] of this._experiments) { |
|
1034 if (experiments.has(id)) { |
|
1035 continue; |
|
1036 } |
|
1037 |
|
1038 if (!entry.startDate || entry.shouldDiscard()) { |
|
1039 this._log.trace("updateExperiments() - discarding entry for " + id); |
|
1040 continue; |
|
1041 } |
|
1042 |
|
1043 experiments.set(id, entry); |
|
1044 } |
|
1045 |
|
1046 this._experiments = experiments; |
|
1047 this._dirty = true; |
|
1048 }, |
|
1049 |
|
1050 getActiveExperimentID: function() { |
|
1051 if (!this._experiments) { |
|
1052 return null; |
|
1053 } |
|
1054 let e = this._getActiveExperiment(); |
|
1055 if (!e) { |
|
1056 return null; |
|
1057 } |
|
1058 return e.id; |
|
1059 }, |
|
1060 |
|
1061 getActiveExperimentBranch: function() { |
|
1062 if (!this._experiments) { |
|
1063 return null; |
|
1064 } |
|
1065 let e = this._getActiveExperiment(); |
|
1066 if (!e) { |
|
1067 return null; |
|
1068 } |
|
1069 return e.branch; |
|
1070 }, |
|
1071 |
|
1072 _getActiveExperiment: function () { |
|
1073 let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)]; |
|
1074 |
|
1075 if (enabled.length == 1) { |
|
1076 return enabled[0]; |
|
1077 } |
|
1078 |
|
1079 if (enabled.length > 1) { |
|
1080 this._log.error("getActiveExperimentId() - should not have more than 1 active experiment"); |
|
1081 throw new Error("have more than 1 active experiment"); |
|
1082 } |
|
1083 |
|
1084 return null; |
|
1085 }, |
|
1086 |
|
1087 /** |
|
1088 * Disables all active experiments. |
|
1089 * |
|
1090 * @return Promise<> Promise that will get resolved once the task is done or failed. |
|
1091 */ |
|
1092 disableExperiment: function (reason) { |
|
1093 if (!reason) { |
|
1094 throw new Error("Must specify a termination reason."); |
|
1095 } |
|
1096 |
|
1097 this._log.trace("disableExperiment()"); |
|
1098 this._terminateReason = reason; |
|
1099 return this._run(); |
|
1100 }, |
|
1101 |
|
1102 /** |
|
1103 * The Set of add-on IDs that we know about from manifests. |
|
1104 */ |
|
1105 get _trackedAddonIds() { |
|
1106 if (!this._experiments) { |
|
1107 return new Set(); |
|
1108 } |
|
1109 |
|
1110 return new Set([e._addonId for ([,e] of this._experiments) if (e._addonId)]); |
|
1111 }, |
|
1112 |
|
1113 /* |
|
1114 * Task function to check applicability of experiments, disable the active |
|
1115 * experiment if needed and activate the first applicable candidate. |
|
1116 */ |
|
1117 _evaluateExperiments: function*() { |
|
1118 this._log.trace("_evaluateExperiments"); |
|
1119 |
|
1120 this._checkForShutdown(); |
|
1121 |
|
1122 // The first thing we do is reconcile our state against what's in the |
|
1123 // Addon Manager. It's possible that the Addon Manager knows of experiment |
|
1124 // add-ons that we don't. This could happen if an experiment gets installed |
|
1125 // when we're not listening or if there is a bug in our synchronization |
|
1126 // code. |
|
1127 // |
|
1128 // We have a few options of what to do with unknown experiment add-ons |
|
1129 // coming from the Addon Manager. Ideally, we'd convert these to |
|
1130 // ExperimentEntry instances and stuff them inside this._experiments. |
|
1131 // However, since ExperimentEntry contain lots of metadata from the |
|
1132 // manifest and trying to make up data could be error prone, it's safer |
|
1133 // to not try. Furthermore, if an experiment really did come from us, we |
|
1134 // should have some record of it. In the end, we decide to discard all |
|
1135 // knowledge for these unknown experiment add-ons. |
|
1136 let installedExperiments = yield installedExperimentAddons(); |
|
1137 let expectedAddonIds = this._trackedAddonIds; |
|
1138 let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))]; |
|
1139 if (unknownAddons.length) { |
|
1140 this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " + |
|
1141 [a.id for (a of unknownAddons)].join(", ")); |
|
1142 |
|
1143 yield uninstallAddons(unknownAddons); |
|
1144 } |
|
1145 |
|
1146 let activeExperiment = this._getActiveExperiment(); |
|
1147 let activeChanged = false; |
|
1148 let now = this._policy.now(); |
|
1149 |
|
1150 if (!activeExperiment) { |
|
1151 // Avoid this pref staying out of sync if there were e.g. crashes. |
|
1152 gPrefs.set(PREF_ACTIVE_EXPERIMENT, false); |
|
1153 } |
|
1154 |
|
1155 // Ensure the active experiment is in the proper state. This may install, |
|
1156 // uninstall, upgrade, or enable the experiment add-on. What exactly is |
|
1157 // abstracted away from us by design. |
|
1158 if (activeExperiment) { |
|
1159 let changes; |
|
1160 let shouldStopResult = yield activeExperiment.shouldStop(); |
|
1161 if (shouldStopResult.shouldStop) { |
|
1162 let expireReasons = ["endTime", "maxActiveSeconds"]; |
|
1163 let kind, reason; |
|
1164 |
|
1165 if (expireReasons.indexOf(shouldStopResult.reason[0]) != -1) { |
|
1166 kind = TELEMETRY_LOG.TERMINATION.EXPIRED; |
|
1167 reason = null; |
|
1168 } else { |
|
1169 kind = TELEMETRY_LOG.TERMINATION.RECHECK; |
|
1170 reason = shouldStopResult.reason; |
|
1171 } |
|
1172 changes = yield activeExperiment.stop(kind, reason); |
|
1173 } |
|
1174 else if (this._terminateReason) { |
|
1175 changes = yield activeExperiment.stop(this._terminateReason); |
|
1176 } |
|
1177 else { |
|
1178 changes = yield activeExperiment.reconcileAddonState(); |
|
1179 } |
|
1180 |
|
1181 if (changes) { |
|
1182 this._dirty = true; |
|
1183 activeChanged = true; |
|
1184 } |
|
1185 |
|
1186 if (!activeExperiment._enabled) { |
|
1187 activeExperiment = null; |
|
1188 activeChanged = true; |
|
1189 } |
|
1190 } |
|
1191 |
|
1192 this._terminateReason = null; |
|
1193 |
|
1194 if (!activeExperiment && gExperimentsEnabled) { |
|
1195 for (let [id, experiment] of this._experiments) { |
|
1196 let applicable; |
|
1197 let reason = null; |
|
1198 try { |
|
1199 applicable = yield experiment.isApplicable(); |
|
1200 } |
|
1201 catch (e) { |
|
1202 applicable = false; |
|
1203 reason = e; |
|
1204 } |
|
1205 |
|
1206 if (!applicable && reason && reason[0] != "was-active") { |
|
1207 // Report this from here to avoid over-reporting. |
|
1208 let desc = TELEMETRY_LOG.ACTIVATION; |
|
1209 let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id]; |
|
1210 data = data.concat(reason); |
|
1211 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, data); |
|
1212 } |
|
1213 |
|
1214 if (!applicable) { |
|
1215 continue; |
|
1216 } |
|
1217 |
|
1218 this._log.debug("evaluateExperiments() - activating experiment " + id); |
|
1219 try { |
|
1220 yield experiment.start(); |
|
1221 activeChanged = true; |
|
1222 activeExperiment = experiment; |
|
1223 this._dirty = true; |
|
1224 break; |
|
1225 } catch (e) { |
|
1226 // On failure, clean up the best we can and try the next experiment. |
|
1227 this._log.error("evaluateExperiments() - Unable to start experiment: " + e.message); |
|
1228 experiment._enabled = false; |
|
1229 yield experiment.reconcileAddonState(); |
|
1230 } |
|
1231 } |
|
1232 } |
|
1233 |
|
1234 gPrefs.set(PREF_ACTIVE_EXPERIMENT, activeExperiment != null); |
|
1235 |
|
1236 if (activeChanged || this._firstEvaluate) { |
|
1237 Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); |
|
1238 this._firstEvaluate = false; |
|
1239 } |
|
1240 |
|
1241 if ("@mozilla.org/toolkit/crash-reporter;1" in Cc && activeExperiment) { |
|
1242 try { |
|
1243 gCrashReporter.annotateCrashReport("ActiveExperiment", activeExperiment.id); |
|
1244 } catch (e) { |
|
1245 // It's ok if crash reporting is disabled. |
|
1246 } |
|
1247 } |
|
1248 }, |
|
1249 |
|
1250 /* |
|
1251 * Schedule the soonest re-check of experiment applicability that is needed. |
|
1252 */ |
|
1253 _scheduleNextRun: function () { |
|
1254 this._checkForShutdown(); |
|
1255 |
|
1256 if (this._timer) { |
|
1257 this._timer.clear(); |
|
1258 } |
|
1259 |
|
1260 if (!gExperimentsEnabled || this._experiments.length == 0) { |
|
1261 return; |
|
1262 } |
|
1263 |
|
1264 let time = null; |
|
1265 let now = this._policy.now().getTime(); |
|
1266 |
|
1267 for (let [id, experiment] of this._experiments) { |
|
1268 let scheduleTime = experiment.getScheduleTime(); |
|
1269 if (scheduleTime > now) { |
|
1270 if (time !== null) { |
|
1271 time = Math.min(time, scheduleTime); |
|
1272 } else { |
|
1273 time = scheduleTime; |
|
1274 } |
|
1275 } |
|
1276 } |
|
1277 |
|
1278 if (time === null) { |
|
1279 // No schedule time found. |
|
1280 return; |
|
1281 } |
|
1282 |
|
1283 this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now); |
|
1284 this._policy.oneshotTimer(this.notify, time - now, this, "_timer"); |
|
1285 }, |
|
1286 }; |
|
1287 |
|
1288 |
|
1289 /* |
|
1290 * Represents a single experiment. |
|
1291 */ |
|
1292 |
|
1293 Experiments.ExperimentEntry = function (policy) { |
|
1294 this._policy = policy || new Experiments.Policy(); |
|
1295 this._log = Log.repository.getLoggerWithMessagePrefix( |
|
1296 "Browser.Experiments.Experiments", |
|
1297 "ExperimentEntry #" + gExperimentEntryCounter++ + "::"); |
|
1298 |
|
1299 // Is the experiment supposed to be running. |
|
1300 this._enabled = false; |
|
1301 // When this experiment was started, if ever. |
|
1302 this._startDate = null; |
|
1303 // When this experiment was ended, if ever. |
|
1304 this._endDate = null; |
|
1305 // The condition data from the manifest. |
|
1306 this._manifestData = null; |
|
1307 // For an active experiment, signifies whether we need to update the xpi. |
|
1308 this._needsUpdate = false; |
|
1309 // A random sample value for comparison against the manifest conditions. |
|
1310 this._randomValue = null; |
|
1311 // When this entry was last changed for respecting history retention duration. |
|
1312 this._lastChangedDate = null; |
|
1313 // Has this experiment failed to activate before? |
|
1314 this._failedStart = false; |
|
1315 // The experiment branch |
|
1316 this._branch = null; |
|
1317 |
|
1318 // We grab these from the addon after download. |
|
1319 this._name = null; |
|
1320 this._description = null; |
|
1321 this._homepageURL = null; |
|
1322 this._addonId = null; |
|
1323 }; |
|
1324 |
|
1325 Experiments.ExperimentEntry.prototype = { |
|
1326 MANIFEST_REQUIRED_FIELDS: new Set([ |
|
1327 "id", |
|
1328 "xpiURL", |
|
1329 "xpiHash", |
|
1330 "startTime", |
|
1331 "endTime", |
|
1332 "maxActiveSeconds", |
|
1333 "appName", |
|
1334 "channel", |
|
1335 ]), |
|
1336 |
|
1337 MANIFEST_OPTIONAL_FIELDS: new Set([ |
|
1338 "maxStartTime", |
|
1339 "minVersion", |
|
1340 "maxVersion", |
|
1341 "version", |
|
1342 "minBuildID", |
|
1343 "maxBuildID", |
|
1344 "buildIDs", |
|
1345 "os", |
|
1346 "locale", |
|
1347 "sample", |
|
1348 "disabled", |
|
1349 "frozen", |
|
1350 "jsfilter", |
|
1351 ]), |
|
1352 |
|
1353 SERIALIZE_KEYS: new Set([ |
|
1354 "_enabled", |
|
1355 "_manifestData", |
|
1356 "_needsUpdate", |
|
1357 "_randomValue", |
|
1358 "_failedStart", |
|
1359 "_name", |
|
1360 "_description", |
|
1361 "_homepageURL", |
|
1362 "_addonId", |
|
1363 "_startDate", |
|
1364 "_endDate", |
|
1365 "_branch", |
|
1366 ]), |
|
1367 |
|
1368 DATE_KEYS: new Set([ |
|
1369 "_startDate", |
|
1370 "_endDate", |
|
1371 ]), |
|
1372 |
|
1373 UPGRADE_KEYS: new Map([ |
|
1374 ["_branch", null], |
|
1375 ]), |
|
1376 |
|
1377 ADDON_CHANGE_NONE: 0, |
|
1378 ADDON_CHANGE_INSTALL: 1, |
|
1379 ADDON_CHANGE_UNINSTALL: 2, |
|
1380 ADDON_CHANGE_ENABLE: 4, |
|
1381 |
|
1382 /* |
|
1383 * Initialize entry from the manifest. |
|
1384 * @param data The experiment data from the manifest. |
|
1385 * @return boolean Whether initialization succeeded. |
|
1386 */ |
|
1387 initFromManifestData: function (data) { |
|
1388 if (!this._isManifestDataValid(data)) { |
|
1389 return false; |
|
1390 } |
|
1391 |
|
1392 this._manifestData = data; |
|
1393 |
|
1394 this._randomValue = this._policy.random(); |
|
1395 this._lastChangedDate = this._policy.now(); |
|
1396 |
|
1397 return true; |
|
1398 }, |
|
1399 |
|
1400 get enabled() { |
|
1401 return this._enabled; |
|
1402 }, |
|
1403 |
|
1404 get id() { |
|
1405 return this._manifestData.id; |
|
1406 }, |
|
1407 |
|
1408 get branch() { |
|
1409 return this._branch; |
|
1410 }, |
|
1411 |
|
1412 set branch(v) { |
|
1413 this._branch = v; |
|
1414 }, |
|
1415 |
|
1416 get startDate() { |
|
1417 return this._startDate; |
|
1418 }, |
|
1419 |
|
1420 get endDate() { |
|
1421 if (!this._startDate) { |
|
1422 return null; |
|
1423 } |
|
1424 |
|
1425 let endTime = 0; |
|
1426 |
|
1427 if (!this._enabled) { |
|
1428 return this._endDate; |
|
1429 } |
|
1430 |
|
1431 let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds; |
|
1432 endTime = Math.min(1000 * this._manifestData.endTime, |
|
1433 this._startDate.getTime() + maxActiveMs); |
|
1434 |
|
1435 return new Date(endTime); |
|
1436 }, |
|
1437 |
|
1438 get needsUpdate() { |
|
1439 return this._needsUpdate; |
|
1440 }, |
|
1441 |
|
1442 /* |
|
1443 * Initialize entry from the cache. |
|
1444 * @param data The entry data from the cache. |
|
1445 * @return boolean Whether initialization succeeded. |
|
1446 */ |
|
1447 initFromCacheData: function (data) { |
|
1448 for (let [key, dval] of this.UPGRADE_KEYS) { |
|
1449 if (!(key in data)) { |
|
1450 data[key] = dval; |
|
1451 } |
|
1452 } |
|
1453 |
|
1454 for (let key of this.SERIALIZE_KEYS) { |
|
1455 if (!(key in data) && !this.DATE_KEYS.has(key)) { |
|
1456 this._log.error("initFromCacheData() - missing required key " + key); |
|
1457 return false; |
|
1458 } |
|
1459 }; |
|
1460 |
|
1461 if (!this._isManifestDataValid(data._manifestData)) { |
|
1462 return false; |
|
1463 } |
|
1464 |
|
1465 // Dates are restored separately from epoch ms, everything else is just |
|
1466 // copied in. |
|
1467 |
|
1468 this.SERIALIZE_KEYS.forEach(key => { |
|
1469 if (!this.DATE_KEYS.has(key)) { |
|
1470 this[key] = data[key]; |
|
1471 } |
|
1472 }); |
|
1473 |
|
1474 this.DATE_KEYS.forEach(key => { |
|
1475 if (key in data) { |
|
1476 let date = new Date(); |
|
1477 date.setTime(data[key]); |
|
1478 this[key] = date; |
|
1479 } |
|
1480 }); |
|
1481 |
|
1482 this._lastChangedDate = this._policy.now(); |
|
1483 |
|
1484 return true; |
|
1485 }, |
|
1486 |
|
1487 /* |
|
1488 * Returns a JSON representation of this object. |
|
1489 */ |
|
1490 toJSON: function () { |
|
1491 let obj = {}; |
|
1492 |
|
1493 // Dates are serialized separately as epoch ms. |
|
1494 |
|
1495 this.SERIALIZE_KEYS.forEach(key => { |
|
1496 if (!this.DATE_KEYS.has(key)) { |
|
1497 obj[key] = this[key]; |
|
1498 } |
|
1499 }); |
|
1500 |
|
1501 this.DATE_KEYS.forEach(key => { |
|
1502 if (this[key]) { |
|
1503 obj[key] = this[key].getTime(); |
|
1504 } |
|
1505 }); |
|
1506 |
|
1507 return obj; |
|
1508 }, |
|
1509 |
|
1510 /* |
|
1511 * Update from the experiment data from the manifest. |
|
1512 * @param data The experiment data from the manifest. |
|
1513 * @return boolean Whether updating succeeded. |
|
1514 */ |
|
1515 updateFromManifestData: function (data) { |
|
1516 let old = this._manifestData; |
|
1517 |
|
1518 if (!this._isManifestDataValid(data)) { |
|
1519 return false; |
|
1520 } |
|
1521 |
|
1522 if (this._enabled) { |
|
1523 if (old.xpiHash !== data.xpiHash) { |
|
1524 // A changed hash means we need to update active experiments. |
|
1525 this._needsUpdate = true; |
|
1526 } |
|
1527 } else if (this._failedStart && |
|
1528 (old.xpiHash !== data.xpiHash) || |
|
1529 (old.xpiURL !== data.xpiURL)) { |
|
1530 // Retry installation of previously invalid experiments |
|
1531 // if hash or url changed. |
|
1532 this._failedStart = false; |
|
1533 } |
|
1534 |
|
1535 this._manifestData = data; |
|
1536 this._lastChangedDate = this._policy.now(); |
|
1537 |
|
1538 return true; |
|
1539 }, |
|
1540 |
|
1541 /* |
|
1542 * Is this experiment applicable? |
|
1543 * @return Promise<> Resolved if the experiment is applicable. |
|
1544 * If it is not applicable it is rejected with |
|
1545 * a Promise<string> which contains the reason. |
|
1546 */ |
|
1547 isApplicable: function () { |
|
1548 let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"] |
|
1549 .getService(Ci.nsIVersionComparator); |
|
1550 let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); |
|
1551 let runtime = Cc["@mozilla.org/xre/app-info;1"] |
|
1552 .getService(Ci.nsIXULRuntime); |
|
1553 |
|
1554 let locale = this._policy.locale(); |
|
1555 let channel = this._policy.updatechannel(); |
|
1556 let data = this._manifestData; |
|
1557 |
|
1558 let now = this._policy.now() / 1000; // The manifest times are in seconds. |
|
1559 let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS; |
|
1560 let maxActive = data.maxActiveSeconds || 0; |
|
1561 let startSec = (this.startDate || 0) / 1000; |
|
1562 |
|
1563 this._log.trace("isApplicable() - now=" + now |
|
1564 + ", randomValue=" + this._randomValue |
|
1565 + ", data=" + JSON.stringify(this._manifestData)); |
|
1566 |
|
1567 // Not applicable if it already ran. |
|
1568 |
|
1569 if (!this.enabled && this._endDate) { |
|
1570 return Promise.reject(["was-active"]); |
|
1571 } |
|
1572 |
|
1573 // Define and run the condition checks. |
|
1574 |
|
1575 let simpleChecks = [ |
|
1576 { name: "failedStart", |
|
1577 condition: () => !this._failedStart }, |
|
1578 { name: "disabled", |
|
1579 condition: () => !data.disabled }, |
|
1580 { name: "frozen", |
|
1581 condition: () => !data.frozen || this._enabled }, |
|
1582 { name: "startTime", |
|
1583 condition: () => now >= data.startTime }, |
|
1584 { name: "endTime", |
|
1585 condition: () => now < data.endTime }, |
|
1586 { name: "maxStartTime", |
|
1587 condition: () => !data.maxStartTime || now <= data.maxStartTime }, |
|
1588 { name: "maxActiveSeconds", |
|
1589 condition: () => !this._startDate || now <= (startSec + maxActive) }, |
|
1590 { name: "appName", |
|
1591 condition: () => !data.appName || data.appName.indexOf(app.name) != -1 }, |
|
1592 { name: "minBuildID", |
|
1593 condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID }, |
|
1594 { name: "maxBuildID", |
|
1595 condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID }, |
|
1596 { name: "buildIDs", |
|
1597 condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 }, |
|
1598 { name: "os", |
|
1599 condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 }, |
|
1600 { name: "channel", |
|
1601 condition: () => !data.channel || data.channel.indexOf(channel) != -1 }, |
|
1602 { name: "locale", |
|
1603 condition: () => !data.locale || data.locale.indexOf(locale) != -1 }, |
|
1604 { name: "sample", |
|
1605 condition: () => data.sample === undefined || this._randomValue <= data.sample }, |
|
1606 { name: "version", |
|
1607 condition: () => !data.version || data.version.indexOf(app.version) != -1 }, |
|
1608 { name: "minVersion", |
|
1609 condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 }, |
|
1610 { name: "maxVersion", |
|
1611 condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 }, |
|
1612 ]; |
|
1613 |
|
1614 for (let check of simpleChecks) { |
|
1615 let result = check.condition(); |
|
1616 if (!result) { |
|
1617 this._log.debug("isApplicable() - id=" |
|
1618 + data.id + " - test '" + check.name + "' failed"); |
|
1619 return Promise.reject([check.name]); |
|
1620 } |
|
1621 } |
|
1622 |
|
1623 if (data.jsfilter) { |
|
1624 return this._runFilterFunction(data.jsfilter); |
|
1625 } |
|
1626 |
|
1627 return Promise.resolve(true); |
|
1628 }, |
|
1629 |
|
1630 /* |
|
1631 * Run the jsfilter function from the manifest in a sandbox and return the |
|
1632 * result (forced to boolean). |
|
1633 */ |
|
1634 _runFilterFunction: function (jsfilter) { |
|
1635 this._log.trace("runFilterFunction() - filter: " + jsfilter); |
|
1636 |
|
1637 return Task.spawn(function ExperimentEntry_runFilterFunction_task() { |
|
1638 const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal); |
|
1639 let options = { |
|
1640 sandboxName: "telemetry experiments jsfilter sandbox", |
|
1641 wantComponents: false, |
|
1642 }; |
|
1643 |
|
1644 let sandbox = Cu.Sandbox(nullprincipal); |
|
1645 let context = {}; |
|
1646 context.healthReportPayload = yield this._policy.healthReportPayload(); |
|
1647 context.telemetryPayload = yield this._policy.telemetryPayload(); |
|
1648 |
|
1649 try { |
|
1650 Cu.evalInSandbox(jsfilter, sandbox); |
|
1651 } catch (e) { |
|
1652 this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message); |
|
1653 throw ["jsfilter-evalfailed"]; |
|
1654 } |
|
1655 |
|
1656 // You can't insert arbitrarily complex objects into a sandbox, so |
|
1657 // we serialize everything through JSON. |
|
1658 sandbox._hr = JSON.stringify(yield this._policy.healthReportPayload()); |
|
1659 Object.defineProperty(sandbox, "_t", |
|
1660 { get: () => JSON.stringify(this._policy.telemetryPayload()) }); |
|
1661 |
|
1662 let result = false; |
|
1663 try { |
|
1664 result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox); |
|
1665 } |
|
1666 catch (e) { |
|
1667 this._log.debug("runFilterFunction() - filter function failed: " |
|
1668 + e.message + ", " + e.stack); |
|
1669 throw ["jsfilter-threw", e.message]; |
|
1670 } |
|
1671 finally { |
|
1672 Cu.nukeSandbox(sandbox); |
|
1673 } |
|
1674 |
|
1675 if (!result) { |
|
1676 throw ["jsfilter-false"]; |
|
1677 } |
|
1678 |
|
1679 throw new Task.Result(true); |
|
1680 }.bind(this)); |
|
1681 }, |
|
1682 |
|
1683 /* |
|
1684 * Start running the experiment. |
|
1685 * |
|
1686 * @return Promise<> Resolved when the operation is complete. |
|
1687 */ |
|
1688 start: Task.async(function* () { |
|
1689 this._log.trace("start() for " + this.id); |
|
1690 |
|
1691 this._enabled = true; |
|
1692 return yield this.reconcileAddonState(); |
|
1693 }), |
|
1694 |
|
1695 // Async install of the addon for this experiment, part of the start task above. |
|
1696 _installAddon: Task.async(function* () { |
|
1697 let deferred = Promise.defer(); |
|
1698 |
|
1699 let hash = this._policy.ignoreHashes ? null : this._manifestData.xpiHash; |
|
1700 |
|
1701 let install = yield addonInstallForURL(this._manifestData.xpiURL, hash); |
|
1702 gActiveInstallURLs.add(install.sourceURI.spec); |
|
1703 |
|
1704 let failureHandler = (install, handler) => { |
|
1705 let message = "AddonInstall " + handler + " for " + this.id + ", state=" + |
|
1706 (install.state || "?") + ", error=" + install.error; |
|
1707 this._log.error("_installAddon() - " + message); |
|
1708 this._failedStart = true; |
|
1709 gActiveInstallURLs.delete(install.sourceURI.spec); |
|
1710 |
|
1711 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, |
|
1712 [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]); |
|
1713 |
|
1714 deferred.reject(new Error(message)); |
|
1715 }; |
|
1716 |
|
1717 let listener = { |
|
1718 _expectedID: null, |
|
1719 |
|
1720 onDownloadEnded: install => { |
|
1721 this._log.trace("_installAddon() - onDownloadEnded for " + this.id); |
|
1722 |
|
1723 if (install.existingAddon) { |
|
1724 this._log.warn("_installAddon() - onDownloadEnded, addon already installed"); |
|
1725 } |
|
1726 |
|
1727 if (install.addon.type !== "experiment") { |
|
1728 this._log.error("_installAddon() - onDownloadEnded, wrong addon type"); |
|
1729 install.cancel(); |
|
1730 } |
|
1731 }, |
|
1732 |
|
1733 onInstallStarted: install => { |
|
1734 this._log.trace("_installAddon() - onInstallStarted for " + this.id); |
|
1735 |
|
1736 if (install.existingAddon) { |
|
1737 this._log.warn("_installAddon() - onInstallStarted, addon already installed"); |
|
1738 } |
|
1739 |
|
1740 if (install.addon.type !== "experiment") { |
|
1741 this._log.error("_installAddon() - onInstallStarted, wrong addon type"); |
|
1742 return false; |
|
1743 } |
|
1744 }, |
|
1745 |
|
1746 onInstallEnded: install => { |
|
1747 this._log.trace("_installAddon() - install ended for " + this.id); |
|
1748 gActiveInstallURLs.delete(install.sourceURI.spec); |
|
1749 |
|
1750 this._lastChangedDate = this._policy.now(); |
|
1751 this._startDate = this._policy.now(); |
|
1752 this._enabled = true; |
|
1753 |
|
1754 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, |
|
1755 [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]); |
|
1756 |
|
1757 let addon = install.addon; |
|
1758 this._name = addon.name; |
|
1759 this._addonId = addon.id; |
|
1760 this._description = addon.description || ""; |
|
1761 this._homepageURL = addon.homepageURL || ""; |
|
1762 |
|
1763 // Experiment add-ons default to userDisabled=true. Enable if needed. |
|
1764 if (addon.userDisabled) { |
|
1765 this._log.trace("Add-on is disabled. Enabling."); |
|
1766 listener._expectedID = addon.id; |
|
1767 AddonManager.addAddonListener(listener); |
|
1768 addon.userDisabled = false; |
|
1769 } else { |
|
1770 this._log.trace("Add-on is enabled. start() completed."); |
|
1771 deferred.resolve(); |
|
1772 } |
|
1773 }, |
|
1774 |
|
1775 onEnabled: addon => { |
|
1776 this._log.info("onEnabled() for " + addon.id); |
|
1777 |
|
1778 if (addon.id != listener._expectedID) { |
|
1779 return; |
|
1780 } |
|
1781 |
|
1782 AddonManager.removeAddonListener(listener); |
|
1783 deferred.resolve(); |
|
1784 }, |
|
1785 }; |
|
1786 |
|
1787 ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"] |
|
1788 .forEach(what => { |
|
1789 listener[what] = install => failureHandler(install, what) |
|
1790 }); |
|
1791 |
|
1792 install.addListener(listener); |
|
1793 install.install(); |
|
1794 |
|
1795 return yield deferred.promise; |
|
1796 }), |
|
1797 |
|
1798 /** |
|
1799 * Stop running the experiment if it is active. |
|
1800 * |
|
1801 * @param terminationKind (optional) |
|
1802 * The termination kind, e.g. ADDON_UNINSTALLED or EXPIRED. |
|
1803 * @param terminationReason (optional) |
|
1804 * The termination reason details for termination kind RECHECK. |
|
1805 * @return Promise<> Resolved when the operation is complete. |
|
1806 */ |
|
1807 stop: Task.async(function* (terminationKind, terminationReason) { |
|
1808 this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind); |
|
1809 if (!this._enabled) { |
|
1810 throw new Error("Must not call stop() on an inactive experiment."); |
|
1811 } |
|
1812 |
|
1813 this._enabled = false; |
|
1814 let now = this._policy.now(); |
|
1815 this._lastChangedDate = now; |
|
1816 this._endDate = now; |
|
1817 |
|
1818 let changes = yield this.reconcileAddonState(); |
|
1819 this._logTermination(terminationKind, terminationReason); |
|
1820 |
|
1821 if (terminationKind == TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED) { |
|
1822 changes |= this.ADDON_CHANGE_UNINSTALL; |
|
1823 } |
|
1824 |
|
1825 return changes; |
|
1826 }), |
|
1827 |
|
1828 /** |
|
1829 * Reconcile the state of the add-on against what it's supposed to be. |
|
1830 * |
|
1831 * If we are active, ensure the add-on is enabled and up to date. |
|
1832 * |
|
1833 * If we are inactive, ensure the add-on is not installed. |
|
1834 */ |
|
1835 reconcileAddonState: Task.async(function* () { |
|
1836 this._log.trace("reconcileAddonState()"); |
|
1837 |
|
1838 if (!this._enabled) { |
|
1839 if (!this._addonId) { |
|
1840 this._log.trace("reconcileAddonState() - Experiment is not enabled and " + |
|
1841 "has no add-on. Doing nothing."); |
|
1842 return this.ADDON_CHANGE_NONE; |
|
1843 } |
|
1844 |
|
1845 let addon = yield this._getAddon(); |
|
1846 if (!addon) { |
|
1847 this._log.trace("reconcileAddonState() - Inactive experiment has no " + |
|
1848 "add-on. Doing nothing."); |
|
1849 return this.ADDON_CHANGE_NONE; |
|
1850 } |
|
1851 |
|
1852 this._log.info("reconcileAddonState() - Uninstalling add-on for inactive " + |
|
1853 "experiment: " + addon.id); |
|
1854 gActiveUninstallAddonIDs.add(addon.id); |
|
1855 yield uninstallAddons([addon]); |
|
1856 gActiveUninstallAddonIDs.delete(addon.id); |
|
1857 return this.ADDON_CHANGE_UNINSTALL; |
|
1858 } |
|
1859 |
|
1860 // If we get here, we're supposed to be active. |
|
1861 |
|
1862 let changes = 0; |
|
1863 |
|
1864 // That requires an add-on. |
|
1865 let currentAddon = yield this._getAddon(); |
|
1866 |
|
1867 // If we have an add-on but it isn't up to date, uninstall it |
|
1868 // (to prepare for reinstall). |
|
1869 if (currentAddon && this._needsUpdate) { |
|
1870 this._log.info("reconcileAddonState() - Uninstalling add-on because update " + |
|
1871 "needed: " + currentAddon.id); |
|
1872 gActiveUninstallAddonIDs.add(currentAddon.id); |
|
1873 yield uninstallAddons([currentAddon]); |
|
1874 gActiveUninstallAddonIDs.delete(currentAddon.id); |
|
1875 changes |= this.ADDON_CHANGE_UNINSTALL; |
|
1876 } |
|
1877 |
|
1878 if (!currentAddon || this._needsUpdate) { |
|
1879 this._log.info("reconcileAddonState() - Installing add-on."); |
|
1880 yield this._installAddon(); |
|
1881 changes |= this.ADDON_CHANGE_INSTALL; |
|
1882 } |
|
1883 |
|
1884 let addon = yield this._getAddon(); |
|
1885 if (!addon) { |
|
1886 throw new Error("Could not obtain add-on for experiment that should be " + |
|
1887 "enabled."); |
|
1888 } |
|
1889 |
|
1890 // If we have the add-on and it is enabled, we are done. |
|
1891 if (!addon.userDisabled) { |
|
1892 return changes; |
|
1893 } |
|
1894 |
|
1895 let deferred = Promise.defer(); |
|
1896 |
|
1897 // Else we need to enable it. |
|
1898 let listener = { |
|
1899 onEnabled: enabledAddon => { |
|
1900 if (enabledAddon.id != addon.id) { |
|
1901 return; |
|
1902 } |
|
1903 |
|
1904 AddonManager.removeAddonListener(listener); |
|
1905 deferred.resolve(); |
|
1906 }, |
|
1907 }; |
|
1908 |
|
1909 this._log.info("Activating add-on: " + addon.id); |
|
1910 AddonManager.addAddonListener(listener); |
|
1911 addon.userDisabled = false; |
|
1912 yield deferred.promise; |
|
1913 changes |= this.ADDON_CHANGE_ENABLE; |
|
1914 |
|
1915 this._log.info("Add-on has been enabled: " + addon.id); |
|
1916 return changes; |
|
1917 }), |
|
1918 |
|
1919 /** |
|
1920 * Obtain the underlying Addon from the Addon Manager. |
|
1921 * |
|
1922 * @return Promise<Addon|null> |
|
1923 */ |
|
1924 _getAddon: function () { |
|
1925 if (!this._addonId) { |
|
1926 return Promise.resolve(null); |
|
1927 } |
|
1928 |
|
1929 let deferred = Promise.defer(); |
|
1930 |
|
1931 AddonManager.getAddonByID(this._addonId, (addon) => { |
|
1932 if (addon && addon.appDisabled) { |
|
1933 // Don't return PreviousExperiments. |
|
1934 addon = null; |
|
1935 } |
|
1936 |
|
1937 deferred.resolve(addon); |
|
1938 }); |
|
1939 |
|
1940 return deferred.promise; |
|
1941 }, |
|
1942 |
|
1943 _logTermination: function (terminationKind, terminationReason) { |
|
1944 if (terminationKind === undefined) { |
|
1945 return; |
|
1946 } |
|
1947 |
|
1948 if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) { |
|
1949 this._log.warn("stop() - unknown terminationKind " + terminationKind); |
|
1950 return; |
|
1951 } |
|
1952 |
|
1953 let data = [terminationKind, this.id]; |
|
1954 if (terminationReason) { |
|
1955 data = data.concat(terminationReason); |
|
1956 } |
|
1957 |
|
1958 TelemetryLog.log(TELEMETRY_LOG.TERMINATION_KEY, data); |
|
1959 }, |
|
1960 |
|
1961 /** |
|
1962 * Determine whether an active experiment should be stopped. |
|
1963 */ |
|
1964 shouldStop: function () { |
|
1965 if (!this._enabled) { |
|
1966 throw new Error("shouldStop must not be called on disabled experiments."); |
|
1967 } |
|
1968 |
|
1969 let data = this._manifestData; |
|
1970 let now = this._policy.now() / 1000; // The manifest times are in seconds. |
|
1971 let maxActiveSec = data.maxActiveSeconds || 0; |
|
1972 |
|
1973 let deferred = Promise.defer(); |
|
1974 this.isApplicable().then( |
|
1975 () => deferred.resolve({shouldStop: false}), |
|
1976 reason => deferred.resolve({shouldStop: true, reason: reason}) |
|
1977 ); |
|
1978 |
|
1979 return deferred.promise; |
|
1980 }, |
|
1981 |
|
1982 /* |
|
1983 * Should this be discarded from the cache due to age? |
|
1984 */ |
|
1985 shouldDiscard: function () { |
|
1986 let limit = this._policy.now(); |
|
1987 limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS); |
|
1988 return (this._lastChangedDate < limit); |
|
1989 }, |
|
1990 |
|
1991 /* |
|
1992 * Get next date (in epoch-ms) to schedule a re-evaluation for this. |
|
1993 * Returns 0 if it doesn't need one. |
|
1994 */ |
|
1995 getScheduleTime: function () { |
|
1996 if (this._enabled) { |
|
1997 let now = this._policy.now(); |
|
1998 let startTime = this._startDate.getTime(); |
|
1999 let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds; |
|
2000 return Math.min(1000 * this._manifestData.endTime, maxActiveTime); |
|
2001 } |
|
2002 |
|
2003 if (this._endDate) { |
|
2004 return this._endDate.getTime(); |
|
2005 } |
|
2006 |
|
2007 return 1000 * this._manifestData.startTime; |
|
2008 }, |
|
2009 |
|
2010 /* |
|
2011 * Perform sanity checks on the experiment data. |
|
2012 */ |
|
2013 _isManifestDataValid: function (data) { |
|
2014 this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data)); |
|
2015 |
|
2016 for (let key of this.MANIFEST_REQUIRED_FIELDS) { |
|
2017 if (!(key in data)) { |
|
2018 this._log.error("isManifestDataValid() - missing required key: " + key); |
|
2019 return false; |
|
2020 } |
|
2021 } |
|
2022 |
|
2023 for (let key in data) { |
|
2024 if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) && |
|
2025 !this.MANIFEST_REQUIRED_FIELDS.has(key)) { |
|
2026 this._log.error("isManifestDataValid() - unknown key: " + key); |
|
2027 return false; |
|
2028 } |
|
2029 } |
|
2030 |
|
2031 return true; |
|
2032 }, |
|
2033 }; |
|
2034 |
|
2035 |
|
2036 |
|
2037 /** |
|
2038 * Strip a Date down to its UTC midnight. |
|
2039 * |
|
2040 * This will return a cloned Date object. The original is unchanged. |
|
2041 */ |
|
2042 let stripDateToMidnight = function (d) { |
|
2043 let m = new Date(d); |
|
2044 m.setUTCHours(0, 0, 0, 0); |
|
2045 |
|
2046 return m; |
|
2047 }; |
|
2048 |
|
2049 function ExperimentsLastActiveMeasurement1() { |
|
2050 Metrics.Measurement.call(this); |
|
2051 } |
|
2052 function ExperimentsLastActiveMeasurement2() { |
|
2053 Metrics.Measurement.call(this); |
|
2054 } |
|
2055 |
|
2056 const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT}; |
|
2057 |
|
2058 ExperimentsLastActiveMeasurement1.prototype = Object.freeze({ |
|
2059 __proto__: Metrics.Measurement.prototype, |
|
2060 |
|
2061 name: "info", |
|
2062 version: 1, |
|
2063 |
|
2064 fields: { |
|
2065 lastActive: FIELD_DAILY_LAST_TEXT, |
|
2066 } |
|
2067 }); |
|
2068 ExperimentsLastActiveMeasurement2.prototype = Object.freeze({ |
|
2069 __proto__: Metrics.Measurement.prototype, |
|
2070 |
|
2071 name: "info", |
|
2072 version: 2, |
|
2073 |
|
2074 fields: { |
|
2075 lastActive: FIELD_DAILY_LAST_TEXT, |
|
2076 lastActiveBranch: FIELD_DAILY_LAST_TEXT, |
|
2077 } |
|
2078 }); |
|
2079 |
|
2080 this.ExperimentsProvider = function () { |
|
2081 Metrics.Provider.call(this); |
|
2082 |
|
2083 this._experiments = null; |
|
2084 }; |
|
2085 |
|
2086 ExperimentsProvider.prototype = Object.freeze({ |
|
2087 __proto__: Metrics.Provider.prototype, |
|
2088 |
|
2089 name: "org.mozilla.experiments", |
|
2090 |
|
2091 measurementTypes: [ |
|
2092 ExperimentsLastActiveMeasurement1, |
|
2093 ExperimentsLastActiveMeasurement2, |
|
2094 ], |
|
2095 |
|
2096 _OBSERVERS: [ |
|
2097 EXPERIMENTS_CHANGED_TOPIC, |
|
2098 ], |
|
2099 |
|
2100 postInit: function () { |
|
2101 for (let o of this._OBSERVERS) { |
|
2102 Services.obs.addObserver(this, o, false); |
|
2103 } |
|
2104 |
|
2105 return Promise.resolve(); |
|
2106 }, |
|
2107 |
|
2108 onShutdown: function () { |
|
2109 for (let o of this._OBSERVERS) { |
|
2110 Services.obs.removeObserver(this, o); |
|
2111 } |
|
2112 |
|
2113 return Promise.resolve(); |
|
2114 }, |
|
2115 |
|
2116 observe: function (subject, topic, data) { |
|
2117 switch (topic) { |
|
2118 case EXPERIMENTS_CHANGED_TOPIC: |
|
2119 this.recordLastActiveExperiment(); |
|
2120 break; |
|
2121 } |
|
2122 }, |
|
2123 |
|
2124 collectDailyData: function () { |
|
2125 return this.recordLastActiveExperiment(); |
|
2126 }, |
|
2127 |
|
2128 recordLastActiveExperiment: function () { |
|
2129 if (!gExperimentsEnabled) { |
|
2130 return Promise.resolve(); |
|
2131 } |
|
2132 |
|
2133 if (!this._experiments) { |
|
2134 this._experiments = Experiments.instance(); |
|
2135 } |
|
2136 |
|
2137 let m = this.getMeasurement(ExperimentsLastActiveMeasurement2.prototype.name, |
|
2138 ExperimentsLastActiveMeasurement2.prototype.version); |
|
2139 |
|
2140 return this.enqueueStorageOperation(() => { |
|
2141 return Task.spawn(function* recordTask() { |
|
2142 let todayActive = yield this._experiments.lastActiveToday(); |
|
2143 if (!todayActive) { |
|
2144 this._log.info("No active experiment on this day: " + |
|
2145 this._experiments._policy.now()); |
|
2146 return; |
|
2147 } |
|
2148 |
|
2149 this._log.info("Recording last active experiment: " + todayActive.id); |
|
2150 yield m.setDailyLastText("lastActive", todayActive.id, |
|
2151 this._experiments._policy.now()); |
|
2152 let branch = todayActive.branch; |
|
2153 if (branch) { |
|
2154 yield m.setDailyLastText("lastActiveBranch", branch, |
|
2155 this._experiments._policy.now()); |
|
2156 } |
|
2157 }.bind(this)); |
|
2158 }); |
|
2159 }, |
|
2160 }); |
|
2161 |
|
2162 /** |
|
2163 * An Add-ons Manager provider that knows about old experiments. |
|
2164 * |
|
2165 * This provider exposes read-only add-ons corresponding to previously-active |
|
2166 * experiments. The existence of this provider (and the add-ons it knows about) |
|
2167 * facilitates the display of old experiments in the Add-ons Manager UI with |
|
2168 * very little custom code in that component. |
|
2169 */ |
|
2170 this.Experiments.PreviousExperimentProvider = function (experiments) { |
|
2171 this._experiments = experiments; |
|
2172 this._experimentList = []; |
|
2173 this._log = Log.repository.getLoggerWithMessagePrefix( |
|
2174 "Browser.Experiments.Experiments", |
|
2175 "PreviousExperimentProvider #" + gPreviousProviderCounter++ + "::"); |
|
2176 } |
|
2177 |
|
2178 this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({ |
|
2179 startup: function () { |
|
2180 this._log.trace("startup()"); |
|
2181 Services.obs.addObserver(this, EXPERIMENTS_CHANGED_TOPIC, false); |
|
2182 }, |
|
2183 |
|
2184 shutdown: function () { |
|
2185 this._log.trace("shutdown()"); |
|
2186 Services.obs.removeObserver(this, EXPERIMENTS_CHANGED_TOPIC); |
|
2187 }, |
|
2188 |
|
2189 observe: function (subject, topic, data) { |
|
2190 switch (topic) { |
|
2191 case EXPERIMENTS_CHANGED_TOPIC: |
|
2192 this._updateExperimentList(); |
|
2193 break; |
|
2194 } |
|
2195 }, |
|
2196 |
|
2197 getAddonByID: function (id, cb) { |
|
2198 for (let experiment of this._experimentList) { |
|
2199 if (experiment.id == id) { |
|
2200 cb(new PreviousExperimentAddon(experiment)); |
|
2201 return; |
|
2202 } |
|
2203 } |
|
2204 |
|
2205 cb(null); |
|
2206 }, |
|
2207 |
|
2208 getAddonsByTypes: function (types, cb) { |
|
2209 if (types && types.length > 0 && types.indexOf("experiment") == -1) { |
|
2210 cb([]); |
|
2211 return; |
|
2212 } |
|
2213 |
|
2214 cb([new PreviousExperimentAddon(e) for (e of this._experimentList)]); |
|
2215 }, |
|
2216 |
|
2217 _updateExperimentList: function () { |
|
2218 return this._experiments.getExperiments().then((experiments) => { |
|
2219 let list = [e for (e of experiments) if (!e.active)]; |
|
2220 |
|
2221 let newMap = new Map([[e.id, e] for (e of list)]); |
|
2222 let oldMap = new Map([[e.id, e] for (e of this._experimentList)]); |
|
2223 |
|
2224 let added = [e.id for (e of list) if (!oldMap.has(e.id))]; |
|
2225 let removed = [e.id for (e of this._experimentList) if (!newMap.has(e.id))]; |
|
2226 |
|
2227 for (let id of added) { |
|
2228 this._log.trace("updateExperimentList() - adding " + id); |
|
2229 let wrapper = new PreviousExperimentAddon(newMap.get(id)); |
|
2230 AddonManagerPrivate.callInstallListeners("onExternalInstall", null, wrapper, null, false); |
|
2231 AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false); |
|
2232 } |
|
2233 |
|
2234 for (let id of removed) { |
|
2235 this._log.trace("updateExperimentList() - removing " + id); |
|
2236 let wrapper = new PreviousExperimentAddon(oldMap.get(id)); |
|
2237 AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false); |
|
2238 } |
|
2239 |
|
2240 this._experimentList = list; |
|
2241 |
|
2242 for (let id of added) { |
|
2243 let wrapper = new PreviousExperimentAddon(newMap.get(id)); |
|
2244 AddonManagerPrivate.callAddonListeners("onInstalled", wrapper); |
|
2245 } |
|
2246 |
|
2247 for (let id of removed) { |
|
2248 let wrapper = new PreviousExperimentAddon(oldMap.get(id)); |
|
2249 AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper); |
|
2250 } |
|
2251 |
|
2252 return this._experimentList; |
|
2253 }); |
|
2254 }, |
|
2255 }); |
|
2256 |
|
2257 /** |
|
2258 * An add-on that represents a previously-installed experiment. |
|
2259 */ |
|
2260 function PreviousExperimentAddon(experiment) { |
|
2261 this._id = experiment.id; |
|
2262 this._name = experiment.name; |
|
2263 this._endDate = experiment.endDate; |
|
2264 this._description = experiment.description; |
|
2265 } |
|
2266 |
|
2267 PreviousExperimentAddon.prototype = Object.freeze({ |
|
2268 // BEGIN REQUIRED ADDON PROPERTIES |
|
2269 |
|
2270 get appDisabled() { |
|
2271 return true; |
|
2272 }, |
|
2273 |
|
2274 get blocklistState() { |
|
2275 Ci.nsIBlocklistService.STATE_NOT_BLOCKED |
|
2276 }, |
|
2277 |
|
2278 get creator() { |
|
2279 return new AddonManagerPrivate.AddonAuthor(""); |
|
2280 }, |
|
2281 |
|
2282 get foreignInstall() { |
|
2283 return false; |
|
2284 }, |
|
2285 |
|
2286 get id() { |
|
2287 return this._id; |
|
2288 }, |
|
2289 |
|
2290 get isActive() { |
|
2291 return false; |
|
2292 }, |
|
2293 |
|
2294 get isCompatible() { |
|
2295 return true; |
|
2296 }, |
|
2297 |
|
2298 get isPlatformCompatible() { |
|
2299 return true; |
|
2300 }, |
|
2301 |
|
2302 get name() { |
|
2303 return this._name; |
|
2304 }, |
|
2305 |
|
2306 get pendingOperations() { |
|
2307 return AddonManager.PENDING_NONE; |
|
2308 }, |
|
2309 |
|
2310 get permissions() { |
|
2311 return 0; |
|
2312 }, |
|
2313 |
|
2314 get providesUpdatesSecurely() { |
|
2315 return true; |
|
2316 }, |
|
2317 |
|
2318 get scope() { |
|
2319 return AddonManager.SCOPE_PROFILE; |
|
2320 }, |
|
2321 |
|
2322 get type() { |
|
2323 return "experiment"; |
|
2324 }, |
|
2325 |
|
2326 get userDisabled() { |
|
2327 return true; |
|
2328 }, |
|
2329 |
|
2330 get version() { |
|
2331 return null; |
|
2332 }, |
|
2333 |
|
2334 // END REQUIRED PROPERTIES |
|
2335 |
|
2336 // BEGIN OPTIONAL PROPERTIES |
|
2337 |
|
2338 get description() { |
|
2339 return this._description; |
|
2340 }, |
|
2341 |
|
2342 get updateDate() { |
|
2343 return new Date(this._endDate); |
|
2344 }, |
|
2345 |
|
2346 // END OPTIONAL PROPERTIES |
|
2347 |
|
2348 // BEGIN REQUIRED METHODS |
|
2349 |
|
2350 isCompatibleWith: function (appVersion, platformVersion) { |
|
2351 return true; |
|
2352 }, |
|
2353 |
|
2354 findUpdates: function (listener, reason, appVersion, platformVersion) { |
|
2355 AddonManagerPrivate.callNoUpdateListeners(this, listener, reason, |
|
2356 appVersion, platformVersion); |
|
2357 }, |
|
2358 |
|
2359 // END REQUIRED METHODS |
|
2360 |
|
2361 /** |
|
2362 * The end-date of the experiment, required for the Addon Manager UI. |
|
2363 */ |
|
2364 |
|
2365 get endDate() { |
|
2366 return this._endDate; |
|
2367 }, |
|
2368 |
|
2369 }); |