diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/telemetry/app | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/telemetry/app')
13 files changed, 9587 insertions, 0 deletions
diff --git a/toolkit/components/telemetry/app/ClientID.sys.mjs b/toolkit/components/telemetry/app/ClientID.sys.mjs new file mode 100644 index 0000000000..77649abeee --- /dev/null +++ b/toolkit/components/telemetry/app/ClientID.sys.mjs @@ -0,0 +1,373 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "ClientID::"; +// Must match ID in TelemetryUtils +const CANARY_CLIENT_ID = "c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CommonUtils: "resource://services-common/utils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "CryptoHash", () => { + return Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" + ); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gDatareportingPath", () => { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "datareporting" + ); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gStateFilePath", () => { + return PathUtils.join(lazy.gDatareportingPath, "state.json"); +}); + +const PREF_CACHED_CLIENTID = "toolkit.telemetry.cachedClientID"; + +/** + * Checks if client ID has a valid format. + * + * @param {String} id A string containing the client ID. + * @return {Boolean} True when the client ID has valid format, or False + * otherwise. + */ +function isValidClientID(id) { + const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return UUID_REGEX.test(id); +} + +export var ClientID = Object.freeze({ + /** + * This returns a promise resolving to the the stable client ID we use for + * data reporting (FHR & Telemetry). + * + * @return {Promise<string>} The stable client ID. + */ + getClientID() { + return ClientIDImpl.getClientID(); + }, + + /** + * Get the client id synchronously without hitting the disk. + * This returns: + * - the current on-disk client id if it was already loaded + * - the client id that we cached into preferences (if any) + * - null otherwise + */ + getCachedClientID() { + return ClientIDImpl.getCachedClientID(); + }, + + async getClientIdHash() { + return ClientIDImpl.getClientIdHash(); + }, + + /** + * Sets the client ID to the canary (known) client ID, + * writing it to disk and updating the cached version. + * + * Use `removeClientID` followed by `getClientID` to clear the + * existing ID and generate a new, random one if required. + * + * @return {Promise<void>} + */ + setCanaryClientID() { + return ClientIDImpl.setCanaryClientID(); + }, + + /** + * Clears the client ID asynchronously, removing it + * from disk. Use `getClientID()` to generate + * a fresh ID after calling this method. + * + * Should only be used if a reset is explicitly requested by the user. + * + * @return {Promise<void>} + */ + removeClientID() { + return ClientIDImpl.removeClientID(); + }, + + /** + * Only used for testing. Invalidates the cached client ID so that it is + * read again from file, but doesn't remove the existing ID from disk. + */ + _reset() { + return ClientIDImpl._reset(); + }, +}); + +var ClientIDImpl = { + _clientID: null, + _clientIDHash: null, + _loadClientIdTask: null, + _saveClientIdTask: null, + _removeClientIdTask: null, + _logger: null, + + _loadClientID() { + if (this._loadClientIdTask) { + return this._loadClientIdTask; + } + + this._loadClientIdTask = this._doLoadClientID(); + let clear = () => (this._loadClientIdTask = null); + this._loadClientIdTask.then(clear, clear); + return this._loadClientIdTask; + }, + + /** + * Load the client ID from the DataReporting Service state file. If it is + * missing, we generate a new one. + */ + async _doLoadClientID() { + this._log.trace(`_doLoadClientID`); + // If there's a removal in progress, let's wait for it + await this._removeClientIdTask; + + // Try to load the client id from the DRS state file. + let hasCurrentClientID = false; + try { + let state = await IOUtils.readJSON(lazy.gStateFilePath); + if (state) { + hasCurrentClientID = this.updateClientID(state.clientID); + if (hasCurrentClientID) { + this._log.trace(`_doLoadClientID: Client IDs loaded from state.`); + return { + clientID: this._clientID, + }; + } + } + } catch (e) { + // fall through to next option + } + + // Absent or broken state file? Check prefs as last resort. + if (!hasCurrentClientID) { + const cachedID = this.getCachedClientID(); + // Calling `updateClientID` with `null` logs an error, which breaks tests. + if (cachedID) { + hasCurrentClientID = this.updateClientID(cachedID); + } + } + + // We're missing the ID from the DRS state file and prefs. + // Generate a new one. + if (!hasCurrentClientID) { + this.updateClientID(lazy.CommonUtils.generateUUID()); + } + this._saveClientIdTask = this._saveClientID(); + + // Wait on persisting the id. Otherwise failure to save the ID would result in + // the client creating and subsequently sending multiple IDs to the server. + // This would appear as multiple clients submitting similar data, which would + // result in orphaning. + await this._saveClientIdTask; + + this._log.trace("_doLoadClientID: New client ID loaded and persisted."); + return { + clientID: this._clientID, + }; + }, + + /** + * Save the client ID to the client ID file. + * + * @return {Promise} A promise resolved when the client ID is saved to disk. + */ + async _saveClientID() { + try { + this._log.trace(`_saveClientID`); + let obj = { + clientID: this._clientID, + }; + await IOUtils.makeDirectory(lazy.gDatareportingPath); + await IOUtils.writeJSON(lazy.gStateFilePath, obj, { + tmpPath: `${lazy.gStateFilePath}.tmp`, + }); + this._saveClientIdTask = null; + } catch (ex) { + if (!DOMException.isInstance(ex) || ex.name !== "AbortError") { + throw ex; + } + } + }, + + /** + * This returns a promise resolving to the the stable client ID we use for + * data reporting (FHR & Telemetry). + * + * @return {Promise<string>} The stable client ID. + */ + async getClientID() { + if (!this._clientID) { + let { clientID } = await this._loadClientID(); + if (AppConstants.platform != "android") { + Glean.legacyTelemetry.clientId.set(clientID); + } + return clientID; + } + + return Promise.resolve(this._clientID); + }, + + /** + * Get the client id synchronously without hitting the disk. + * This returns: + * - the current on-disk client id if it was already loaded + * - the client id that we cached into preferences (if any) + * - null otherwise + */ + getCachedClientID() { + if (this._clientID) { + // Already loaded the client id from disk. + return this._clientID; + } + + // If the client id cache contains a value of the wrong type, + // reset the pref. We need to do this before |getStringPref| since + // it will just return |null| in that case and we won't be able + // to distinguish between the missing pref and wrong type cases. + if ( + Services.prefs.prefHasUserValue(PREF_CACHED_CLIENTID) && + Services.prefs.getPrefType(PREF_CACHED_CLIENTID) != + Ci.nsIPrefBranch.PREF_STRING + ) { + this._log.error( + "getCachedClientID - invalid client id type in preferences, resetting" + ); + Services.prefs.clearUserPref(PREF_CACHED_CLIENTID); + } + + // Not yet loaded, return the cached client id if we have one. + let id = Services.prefs.getStringPref(PREF_CACHED_CLIENTID, null); + if (id === null) { + return null; + } + if (!isValidClientID(id)) { + this._log.error( + "getCachedClientID - invalid client id in preferences, resetting", + id + ); + Services.prefs.clearUserPref(PREF_CACHED_CLIENTID); + return null; + } + return id; + }, + + async getClientIdHash() { + if (!this._clientIDHash) { + let byteArr = new TextEncoder().encode(await this.getClientID()); + let hash = new lazy.CryptoHash("sha256"); + hash.update(byteArr, byteArr.length); + this._clientIDHash = lazy.CommonUtils.bytesAsHex(hash.finish(false)); + } + return this._clientIDHash; + }, + + /* + * Resets the module. This is for testing only. + */ + async _reset() { + await this._loadClientIdTask; + await this._saveClientIdTask; + this._clientID = null; + this._clientIDHash = null; + }, + + async setCanaryClientID() { + this._log.trace("setCanaryClientID"); + this.updateClientID(CANARY_CLIENT_ID); + + this._saveClientIdTask = this._saveClientID(); + await this._saveClientIdTask; + return this._clientID; + }, + + async _doRemoveClientID() { + this._log.trace("_doRemoveClientID"); + + // Reset the cached client ID. + this._clientID = null; + this._clientIDHash = null; + + // Clear the client id from the preference cache. + Services.prefs.clearUserPref(PREF_CACHED_CLIENTID); + + // If there is a save in progress, wait for it to complete. + await this._saveClientIdTask; + + // Remove the client-id-containing state file from disk + await IOUtils.remove(lazy.gStateFilePath); + }, + + async removeClientID() { + this._log.trace("removeClientID"); + + if (AppConstants.platform != "android") { + // We can't clear the client_id in Glean, but we can make it the canary. + Glean.legacyTelemetry.clientId.set(CANARY_CLIENT_ID); + } + + // Wait for the removal. + // Asynchronous calls to getClientID will also be blocked on this. + this._removeClientIdTask = this._doRemoveClientID(); + let clear = () => (this._removeClientIdTask = null); + this._removeClientIdTask.then(clear, clear); + + await this._removeClientIdTask; + }, + + /** + * Sets the client id to the given value and updates the value cached in + * preferences only if the given id is a valid. + * + * @param {String} id A string containing the client ID. + * @return {Boolean} True when the client ID has valid format, or False + * otherwise. + */ + updateClientID(id) { + if (!isValidClientID(id)) { + this._log.error("updateClientID - invalid client ID", id); + return false; + } + + this._clientID = id; + if (AppConstants.platform != "android") { + Glean.legacyTelemetry.clientId.set(id); + } + + this._clientIDHash = null; + Services.prefs.setStringPref(PREF_CACHED_CLIENTID, this._clientID); + return true; + }, + + /** + * A helper for getting access to telemetry logger. + */ + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + + return this._logger; + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetryArchive.sys.mjs b/toolkit/components/telemetry/app/TelemetryArchive.sys.mjs new file mode 100644 index 0000000000..4ac5753154 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryArchive.sys.mjs @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { Preferences } from "resource://gre/modules/Preferences.sys.mjs"; + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryArchive::"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TelemetryStorage: "resource://gre/modules/TelemetryStorage.sys.mjs", +}); + +export var TelemetryArchive = { + /** + * Get a list of the archived pings, sorted by the creation date. + * Note that scanning the archived pings on disk is delayed on startup, + * use promizeInitialized() to access this after scanning. + * + * @return {Promise<sequence<Object>>} + * A list of the archived ping info in the form: + * { id: <string>, + * timestampCreated: <number>, + * type: <string> } + */ + promiseArchivedPingList() { + return TelemetryArchiveImpl.promiseArchivedPingList(); + }, + + /** + * Load an archived ping from disk by id, asynchronously. + * + * @param id {String} The pings UUID. + * @return {Promise<PingData>} A promise resolved with the pings data on success. + */ + promiseArchivedPingById(id) { + return TelemetryArchiveImpl.promiseArchivedPingById(id); + }, + + /** + * Archive a ping and persist it to disk. + * + * @param {object} ping The ping data to archive. + * @return {promise} Promise that is resolved when the ping is successfully archived. + */ + promiseArchivePing(ping) { + return TelemetryArchiveImpl.promiseArchivePing(ping); + }, +}; + +/** + * Checks if pings can be archived. Some products (e.g. Thunderbird) might not want + * to do that. + * @return {Boolean} True if pings should be archived, false otherwise. + */ +function shouldArchivePings() { + return Preferences.get(TelemetryUtils.Preferences.ArchiveEnabled, false); +} + +var TelemetryArchiveImpl = { + _logger: null, + + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + + return this._logger; + }, + + promiseArchivePing(ping) { + if (!shouldArchivePings()) { + this._log.trace("promiseArchivePing - archiving is disabled"); + return Promise.resolve(); + } + + for (let field of ["creationDate", "id", "type"]) { + if (!(field in ping)) { + this._log.warn("promiseArchivePing - missing field " + field); + return Promise.reject(new Error("missing field " + field)); + } + } + + return lazy.TelemetryStorage.saveArchivedPing(ping); + }, + + _buildArchivedPingList(archivedPingsMap) { + let list = Array.from(archivedPingsMap, p => ({ + id: p[0], + timestampCreated: p[1].timestampCreated, + type: p[1].type, + })); + + list.sort((a, b) => a.timestampCreated - b.timestampCreated); + + return list; + }, + + promiseArchivedPingList() { + this._log.trace("promiseArchivedPingList"); + + return lazy.TelemetryStorage.loadArchivedPingList().then(loadedInfo => { + return this._buildArchivedPingList(loadedInfo); + }); + }, + + promiseArchivedPingById(id) { + this._log.trace("promiseArchivedPingById - id: " + id); + return lazy.TelemetryStorage.loadArchivedPing(id); + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetryController.sys.mjs b/toolkit/components/telemetry/app/TelemetryController.sys.mjs new file mode 100644 index 0000000000..e994181f39 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryController.sys.mjs @@ -0,0 +1,41 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This module chooses the correct telemetry controller module to load + * based on the process: + * + * - TelemetryControllerParent is loaded only in the parent process, and + * contains code specific to the parent. + * - TelemetryControllerContent is loaded only in content processes, and + * contains code specific to them. + * + * Both the parent and the content modules load TelemetryControllerBase, + * which contains code which is common to all processes. + * + * This division is important for content process memory usage and + * startup time. The parent-specific code occupies tens of KB of memory + * which, multiplied by the number of content processes we have, adds up + * fast. + */ + +// We can't use Services.appinfo here because tests stub out the appinfo +// service, and if we touch Services.appinfo now, the built-in version +// will be cached in place of the stub. +const isParentProcess = + // eslint-disable-next-line mozilla/use-services + Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).processType === + Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; + +export var TelemetryController; +if (isParentProcess) { + ({ TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryControllerParent.sys.mjs" + )); +} else { + ({ TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryControllerContent.sys.mjs" + )); +} diff --git a/toolkit/components/telemetry/app/TelemetryControllerBase.sys.mjs b/toolkit/components/telemetry/app/TelemetryControllerBase.sys.mjs new file mode 100644 index 0000000000..cf0a7bd372 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryControllerBase.sys.mjs @@ -0,0 +1,141 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryController::"; + +const PREF_BRANCH_LOG = "toolkit.telemetry.log."; +const PREF_LOG_LEVEL = "toolkit.telemetry.log.level"; +const PREF_LOG_DUMP = "toolkit.telemetry.log.dump"; + +const PREF_TELEMETRY_ENABLED = "toolkit.telemetry.enabled"; + +const Preferences = Object.freeze({ + OverridePreRelease: "toolkit.telemetry.testing.overridePreRelease", + Unified: "toolkit.telemetry.unified", +}); + +/** + * Setup Telemetry logging. This function also gets called when loggin related + * preferences change. + */ +var gLogger = null; +var gPrefixLogger = null; +var gLogAppenderDump = null; + +export var TelemetryControllerBase = Object.freeze({ + // Whether the FHR/Telemetry unification features are enabled. + // Changing this pref requires a restart. + IS_UNIFIED_TELEMETRY: Services.prefs.getBoolPref(Preferences.Unified, false), + + Preferences, + + /** + * Returns the state of the Telemetry enabled preference, making sure + * it correctly evaluates to a boolean type. + */ + get isTelemetryEnabled() { + return Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) === true; + }, + + get log() { + if (!gPrefixLogger) { + gPrefixLogger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + return gPrefixLogger; + }, + + configureLogging() { + if (!gLogger) { + gLogger = Log.repository.getLogger(LOGGER_NAME); + + // Log messages need to go to the browser console. + let consoleAppender = new Log.ConsoleAppender(new Log.BasicFormatter()); + gLogger.addAppender(consoleAppender); + + Services.prefs.addObserver(PREF_BRANCH_LOG, this.configureLogging); + } + + // Make sure the logger keeps up with the logging level preference. + gLogger.level = + Log.Level[Services.prefs.getStringPref(PREF_LOG_LEVEL, "Warn")]; + + // If enabled in the preferences, add a dump appender. + let logDumping = Services.prefs.getBoolPref(PREF_LOG_DUMP, false); + if (logDumping != !!gLogAppenderDump) { + if (logDumping) { + gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter()); + gLogger.addAppender(gLogAppenderDump); + } else { + gLogger.removeAppender(gLogAppenderDump); + gLogAppenderDump = null; + } + } + }, + + /** + * Set the Telemetry core recording flag for Unified Telemetry. + */ + setTelemetryRecordingFlags() { + // Enable extended Telemetry on pre-release channels and disable it + // on Release/ESR. + let prereleaseChannels = [ + "nightly", + "nightly-autoland", + "nightly-try", + "aurora", + "beta", + ]; + if (!AppConstants.MOZILLA_OFFICIAL) { + // Turn extended telemetry for local developer builds. + prereleaseChannels.push("default"); + } + const isPrereleaseChannel = prereleaseChannels.includes( + AppConstants.MOZ_UPDATE_CHANNEL + ); + const isReleaseCandidateOnBeta = + AppConstants.MOZ_UPDATE_CHANNEL === "release" && + Services.prefs.getCharPref("app.update.channel", null) === "beta"; + Services.telemetry.canRecordBase = true; + Services.telemetry.canRecordExtended = + isPrereleaseChannel || + isReleaseCandidateOnBeta || + Services.prefs.getBoolPref(this.Preferences.OverridePreRelease, false); + }, + + /** + * Perform telemetry initialization for either chrome or content process. + * @return {Boolean} True if Telemetry is allowed to record at least base (FHR) data, + * false otherwise. + */ + enableTelemetryRecording: function enableTelemetryRecording() { + // Configure base Telemetry recording. + // Unified Telemetry makes it opt-out. If extended Telemetry is enabled, base recording + // is always on as well. + if (this.IS_UNIFIED_TELEMETRY) { + this.setTelemetryRecordingFlags(); + } else { + // We're not on unified Telemetry, stick to the old behaviour for + // supporting Fennec. + Services.telemetry.canRecordBase = Services.telemetry.canRecordExtended = + this.isTelemetryEnabled; + } + + this.log.config( + "enableTelemetryRecording - canRecordBase:" + + Services.telemetry.canRecordBase + + ", canRecordExtended: " + + Services.telemetry.canRecordExtended + ); + + return Services.telemetry.canRecordBase; + }, +}); diff --git a/toolkit/components/telemetry/app/TelemetryControllerContent.sys.mjs b/toolkit/components/telemetry/app/TelemetryControllerContent.sys.mjs new file mode 100644 index 0000000000..e5c11d5d28 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryControllerContent.sys.mjs @@ -0,0 +1,84 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { TelemetryControllerBase } from "resource://gre/modules/TelemetryControllerBase.sys.mjs"; + +export var TelemetryController = Object.freeze({ + /** + * Used only for testing purposes. + */ + testInitLogging() { + TelemetryControllerBase.configureLogging(); + }, + + /** + * Used only for testing purposes. + */ + testSetupContent() { + return Impl.setupContentTelemetry(true); + }, + + /** + * Send a notification. + */ + observe(aSubject, aTopic, aData) { + return Impl.observe(aSubject, aTopic, aData); + }, + + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), +}); + +var Impl = { + // This is true when running in the test infrastructure. + _testMode: false, + + get _log() { + return TelemetryControllerBase.log; + }, + + /** + * This triggers basic telemetry initialization for content processes. + * @param {Boolean} [testing=false] True if we are in test mode, false otherwise. + */ + setupContentTelemetry(testing = false) { + this._testMode = testing; + + // The thumbnail service also runs in a content process, even with e10s off. + // We need to check if e10s is on so we don't submit child payloads for it. + // We still need xpcshell child tests to work, so we skip this if test mode is enabled. + if (testing || Services.appinfo.browserTabsRemoteAutostart) { + // We call |enableTelemetryRecording| here to make sure that Telemetry.canRecord* flags + // are in sync between chrome and content processes. + if (!TelemetryControllerBase.enableTelemetryRecording()) { + this._log.trace( + "setupContentTelemetry - Content process recording disabled." + ); + return; + } + } + Services.telemetry.earlyInit(); + + let options = testing ? { timeout: 0 } : {}; + ChromeUtils.idleDispatch(() => Services.telemetry.delayedInit(), options); + }, + + /** + * This observer drives telemetry. + */ + observe(aSubject, aTopic, aData) { + if (aTopic == "content-process-ready-for-script") { + TelemetryControllerBase.configureLogging(); + + this._log.trace(`observe - ${aTopic} notified.`); + + this.setupContentTelemetry(); + } + }, +}; + +// Used by service registration, which requires a callable function. +export function getTelemetryController() { + return TelemetryController; +} diff --git a/toolkit/components/telemetry/app/TelemetryControllerParent.sys.mjs b/toolkit/components/telemetry/app/TelemetryControllerParent.sys.mjs new file mode 100644 index 0000000000..8008489242 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryControllerParent.sys.mjs @@ -0,0 +1,1435 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs"; +import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs"; +import { DeferredTask } from "resource://gre/modules/DeferredTask.sys.mjs"; + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; +import { TelemetryControllerBase } from "resource://gre/modules/TelemetryControllerBase.sys.mjs"; + +const Utils = TelemetryUtils; + +const PING_FORMAT_VERSION = 4; + +// Delay before intializing telemetry (ms) +const TELEMETRY_DELAY = + Services.prefs.getIntPref("toolkit.telemetry.initDelay", 60) * 1000; +// Delay before initializing telemetry if we're testing (ms) +const TELEMETRY_TEST_DELAY = 1; + +// How long to wait (ms) before sending the new profile ping on the first +// run of a new profile. +const NEWPROFILE_PING_DEFAULT_DELAY = 30 * 60 * 1000; + +// Ping types. +const PING_TYPE_MAIN = "main"; +const PING_TYPE_DELETION_REQUEST = "deletion-request"; +const PING_TYPE_UNINSTALL = "uninstall"; + +// Session ping reasons. +const REASON_GATHER_PAYLOAD = "gather-payload"; +const REASON_GATHER_SUBSESSION_PAYLOAD = "gather-subsession-payload"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ClientID: "resource://gre/modules/ClientID.sys.mjs", + CoveragePing: "resource://gre/modules/CoveragePing.sys.mjs", + ProvenanceData: "resource:///modules/ProvenanceData.sys.mjs", + TelemetryArchive: "resource://gre/modules/TelemetryArchive.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEventPing: "resource://gre/modules/EventPing.sys.mjs", + TelemetryHealthPing: "resource://gre/modules/HealthPing.sys.mjs", + TelemetryModules: "resource://gre/modules/ModulesPing.sys.mjs", + TelemetryReportingPolicy: + "resource://gre/modules/TelemetryReportingPolicy.sys.mjs", + TelemetrySend: "resource://gre/modules/TelemetrySend.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", + TelemetryStorage: "resource://gre/modules/TelemetryStorage.sys.mjs", + TelemetryUntrustedModulesPing: + "resource://gre/modules/UntrustedModulesPing.sys.mjs", + UninstallPing: "resource://gre/modules/UninstallPing.sys.mjs", + UpdatePing: "resource://gre/modules/UpdatePing.sys.mjs", + jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs", +}); + +/** + * This is a policy object used to override behavior for testing. + */ +export var Policy = { + now: () => new Date(), + generatePingId: () => Utils.generateUUID(), + getCachedClientID: () => lazy.ClientID.getCachedClientID(), +}; + +export var TelemetryController = Object.freeze({ + /** + * Used only for testing purposes. + */ + testInitLogging() { + TelemetryControllerBase.configureLogging(); + }, + + /** + * Used only for testing purposes. + */ + testReset() { + return Impl.reset(); + }, + + /** + * Used only for testing purposes. + */ + testSetup() { + return Impl.setupTelemetry(true); + }, + + /** + * Used only for testing purposes. + */ + testShutdown() { + return Impl.shutdown(); + }, + + /** + * Used only for testing purposes. + */ + testPromiseJsProbeRegistration() { + return Promise.resolve(Impl._probeRegistrationPromise); + }, + + /** + * Register 'dynamic builtin' probes from the JSON definition files. + * This is needed to support adding new probes in developer builds + * without rebuilding the whole codebase. + * + * This is not meant to be used outside of local developer builds. + */ + testRegisterJsProbes() { + return Impl.registerJsProbes(); + }, + + /** + * Used only for testing purposes. + */ + testPromiseDeletionRequestPingSubmitted() { + return Promise.resolve(Impl._deletionRequestPingSubmittedPromise); + }, + + /** + * Send a notification. + */ + observe(aSubject, aTopic, aData) { + return Impl.observe(aSubject, aTopic, aData); + }, + + /** + * Submit ping payloads to Telemetry. This will assemble a complete ping, adding + * environment data, client id and some general info. + * Depending on configuration, the ping will be sent to the server (immediately or later) + * and archived locally. + * + * To identify the different pings and to be able to query them pings have a type. + * A type is a string identifier that should be unique to the type ping that is being submitted, + * it should only contain alphanumeric characters and '-' for separation, i.e. satisfy: + * /^[a-z0-9][a-z0-9-]+[a-z0-9]$/i + * + * @param {String} aType The type of the ping. + * @param {Object} aPayload The actual data payload for the ping. + * @param {Object} [aOptions] Options object. + * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client + * id, false otherwise. + * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the + * environment data. + * @param {Object} [aOptions.overrideEnvironment=null] set to override the environment data. + * @param {Boolean} [aOptions.usePingSender=false] if true, send the ping using the PingSender. + * @param {String} [aOptions.overrideClientId=undefined] if set, override the + * client id to the provided value. Implies aOptions.addClientId=true. + * @returns {Promise} Test-only - a promise that resolves with the ping id once the ping is stored or sent. + */ + submitExternalPing(aType, aPayload, aOptions = {}) { + aOptions.addClientId = aOptions.addClientId || false; + aOptions.addEnvironment = aOptions.addEnvironment || false; + aOptions.usePingSender = aOptions.usePingSender || false; + + return Impl.submitExternalPing(aType, aPayload, aOptions); + }, + + /** + * Get the current session ping data as it would be sent out or stored. + * + * @param {bool} aSubsession Whether to get subsession data. Optional, defaults to false. + * @return {object} The current ping data if Telemetry is enabled, null otherwise. + */ + getCurrentPingData(aSubsession = false) { + return Impl.getCurrentPingData(aSubsession); + }, + + /** + * Save a ping to disk. + * + * @param {String} aType The type of the ping. + * @param {Object} aPayload The actual data payload for the ping. + * @param {Object} [aOptions] Options object. + * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client + * id, false otherwise. + * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the + * environment data. + * @param {Boolean} [aOptions.overwrite=false] true overwrites a ping with the same name, + * if found. + * @param {Object} [aOptions.overrideEnvironment=null] set to override the environment data. + * @param {String} [aOptions.overrideClientId=undefined] if set, override the + * client id to the provided value. Implies aOptions.addClientId=true. + * + * @returns {Promise} A promise that resolves with the ping id when the ping is saved to + * disk. + */ + addPendingPing(aType, aPayload, aOptions = {}) { + let options = aOptions; + options.addClientId = aOptions.addClientId || false; + options.addEnvironment = aOptions.addEnvironment || false; + options.overwrite = aOptions.overwrite || false; + + return Impl.addPendingPing(aType, aPayload, options); + }, + + /** + * Check if we have an aborted-session ping from a previous session. + * If so, submit and then remove it. + * + * @return {Promise} Promise that is resolved when the ping is saved. + */ + checkAbortedSessionPing() { + return Impl.checkAbortedSessionPing(); + }, + + /** + * Save an aborted-session ping to disk without adding it to the pending pings. + * + * @param {Object} aPayload The ping payload data. + * @return {Promise} Promise that is resolved when the ping is saved. + */ + saveAbortedSessionPing(aPayload) { + return Impl.saveAbortedSessionPing(aPayload); + }, + + /** + * Remove the aborted-session ping if any exists. + * + * @return {Promise} Promise that is resolved when the ping was removed. + */ + removeAbortedSessionPing() { + return Impl.removeAbortedSessionPing(); + }, + + /** + * Create an uninstall ping and write it to disk, replacing any already present. + * This is stored independently from other pings, and only read by + * the Windows uninstaller. + * + * WINDOWS ONLY, does nothing and resolves immediately on other platforms. + * + * @return {Promise} Resolved when the ping has been saved. + */ + saveUninstallPing() { + return Impl.saveUninstallPing(); + }, + + /** + * Allows the sync ping to tell the controller that it is initializing, so + * should be included in the orderly shutdown process. + * + * @param {Function} aFnShutdown The function to call as telemetry shuts down. + + */ + registerSyncPingShutdown(afnShutdown) { + Impl.registerSyncPingShutdown(afnShutdown); + }, + + /** + * Allows waiting for TelemetryControllers delayed initialization to complete. + * The returned promise is guaranteed to resolve before TelemetryController is shutting down. + * @return {Promise} Resolved when delayed TelemetryController initialization completed. + */ + promiseInitialized() { + return Impl.promiseInitialized(); + }, +}); + +var Impl = { + _initialized: false, + _initStarted: false, // Whether we started setting up TelemetryController. + _shuttingDown: false, // Whether the browser is shutting down. + _shutDown: false, // Whether the browser has shut down. + _logger: null, + _prevValues: {}, + // The previous build ID, if this is the first run with a new build. + // Undefined if this is not the first run, or the previous build ID is unknown. + _previousBuildID: undefined, + _clientID: null, + // A task performing delayed initialization + _delayedInitTask: null, + // The deferred promise resolved when the initialization task completes. + _delayedInitTaskDeferred: null, + + // This is a public barrier Telemetry clients can use to add blockers to the shutdown + // of TelemetryController. + // After this barrier, clients can not submit Telemetry pings anymore. + _shutdownBarrier: new AsyncShutdown.Barrier( + "TelemetryController: Waiting for clients." + ), + // This state is included in the async shutdown annotation for crash pings and reports. + _shutdownState: "Shutdown not started.", + // This is a private barrier blocked by pending async ping activity (sending & saving). + _connectionsBarrier: new AsyncShutdown.Barrier( + "TelemetryController: Waiting for pending ping activity" + ), + // This is true when running in the test infrastructure. + _testMode: false, + // The task performing the delayed sending of the "new-profile" ping. + _delayedNewPingTask: null, + // The promise used to wait for the JS probe registration (dynamic builtin). + _probeRegistrationPromise: null, + // The promise of any outstanding task sending the "deletion-request" ping. + _deletionRequestPingSubmittedPromise: null, + // A function to shutdown the sync/fxa ping, or null if that ping has not + // self-initialized. + _fnSyncPingShutdown: null, + + get _log() { + return TelemetryControllerBase.log; + }, + + /** + * Get the data for the "application" section of the ping. + */ + _getApplicationSection() { + // Querying architecture and update channel can throw. Make sure to recover and null + // those fields. + let arch = null; + try { + arch = Services.sysinfo.get("arch"); + } catch (e) { + this._log.trace( + "_getApplicationSection - Unable to get system architecture.", + e + ); + } + + let updateChannel = null; + try { + updateChannel = Utils.getUpdateChannel(); + } catch (e) { + this._log.trace( + "_getApplicationSection - Unable to get update channel.", + e + ); + } + + return { + architecture: arch, + buildId: Services.appinfo.appBuildID, + name: Services.appinfo.name, + version: Services.appinfo.version, + displayVersion: AppConstants.MOZ_APP_VERSION_DISPLAY, + vendor: Services.appinfo.vendor, + platformVersion: Services.appinfo.platformVersion, + xpcomAbi: Services.appinfo.XPCOMABI, + channel: updateChannel, + }; + }, + + /** + * Assemble a complete ping following the common ping format specification. + * + * @param {String} aType The type of the ping. + * @param {Object} aPayload The actual data payload for the ping. + * @param {Object} aOptions Options object. + * @param {Boolean} aOptions.addClientId true if the ping should contain the client + * id, false otherwise. + * @param {Boolean} aOptions.addEnvironment true if the ping should contain the + * environment data. + * @param {Object} [aOptions.overrideEnvironment=null] set to override the environment data. + * @param {String} [aOptions.overrideClientId=undefined] if set, override the + * client id to the provided value. Implies aOptions.addClientId=true. + * @param {Boolean} [aOptions.useEncryption=false] if true, encrypt data client-side before sending. + * @param {Object} [aOptions.publicKey=null] the public key to use if encryption is enabled (JSON Web Key). + * @param {String} [aOptions.encryptionKeyId=null] the public key ID to use if encryption is enabled. + * @param {String} [aOptions.studyName=null] the study name to use. + * @param {String} [aOptions.schemaName=null] the schema name to use if encryption is enabled. + * @param {String} [aOptions.schemaNamespace=null] the schema namespace to use if encryption is enabled. + * @param {String} [aOptions.schemaVersion=null] the schema version to use if encryption is enabled. + * @param {Boolean} [aOptions.addPioneerId=false] true if the ping should contain the Pioneer id, false otherwise. + * @param {Boolean} [aOptions.overridePioneerId=undefined] if set, override the + * pioneer id to the provided value. Only works if aOptions.addPioneerId=true. + * @returns {Object} An object that contains the assembled ping data. + */ + assemblePing: function assemblePing(aType, aPayload, aOptions = {}) { + this._log.trace( + "assemblePing - Type " + aType + ", aOptions " + JSON.stringify(aOptions) + ); + + // Clone the payload data so we don't race against unexpected changes in subobjects that are + // still referenced by other code. + // We can't trust all callers to do this properly on their own. + let payload = Cu.cloneInto(aPayload, {}); + + // Fill the common ping fields. + let pingData = { + type: aType, + id: Policy.generatePingId(), + creationDate: Policy.now().toISOString(), + version: PING_FORMAT_VERSION, + application: this._getApplicationSection(), + payload, + }; + + if (aOptions.addClientId || aOptions.overrideClientId) { + pingData.clientId = aOptions.overrideClientId || this._clientID; + } + + if (aOptions.addEnvironment) { + pingData.environment = + aOptions.overrideEnvironment || + lazy.TelemetryEnvironment.currentEnvironment; + } + + return pingData; + }, + + /** + * Track any pending ping send and save tasks through the promise passed here. + * This is needed to block shutdown on any outstanding ping activity. + */ + _trackPendingPingTask(aPromise) { + this._connectionsBarrier.client.addBlocker( + "Waiting for ping task", + aPromise + ); + }, + + /** + * Internal function to assemble a complete ping, adding environment data, client id + * and some general info. This waits on the client id to be loaded/generated if it's + * not yet available. Note that this function is synchronous unless we need to load + * the client id. + * Depending on configuration, the ping will be sent to the server (immediately or later) + * and archived locally. + * + * @param {String} aType The type of the ping. + * @param {Object} aPayload The actual data payload for the ping. + * @param {Object} [aOptions] Options object. + * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client + * id, false otherwise. + * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the + * environment data. + * @param {Object} [aOptions.overrideEnvironment=null] set to override the environment data. + * @param {Boolean} [aOptions.usePingSender=false] if true, send the ping using the PingSender. + * @param {Boolean} [aOptions.useEncryption=false] if true, encrypt data client-side before sending. + * @param {Object} [aOptions.publicKey=null] the public key to use if encryption is enabled (JSON Web Key). + * @param {String} [aOptions.encryptionKeyId=null] the public key ID to use if encryption is enabled. + * @param {String} [aOptions.studyName=null] the study name to use. + * @param {String} [aOptions.schemaName=null] the schema name to use if encryption is enabled. + * @param {String} [aOptions.schemaNamespace=null] the schema namespace to use if encryption is enabled. + * @param {String} [aOptions.schemaVersion=null] the schema version to use if encryption is enabled. + * @param {Boolean} [aOptions.addPioneerId=false] true if the ping should contain the Pioneer id, false otherwise. + * @param {Boolean} [aOptions.overridePioneerId=undefined] if set, override the + * pioneer id to the provided value. Only works if aOptions.addPioneerId=true. + * @param {String} [aOptions.overrideClientId=undefined] if set, override the + * client id to the provided value. Implies aOptions.addClientId=true. + * @returns {Promise} Test-only - a promise that is resolved with the ping id once the ping is stored or sent. + */ + async _submitPingLogic(aType, aPayload, aOptions) { + // Make sure to have a clientId if we need one. This cover the case of submitting + // a ping early during startup, before Telemetry is initialized, if no client id was + // cached. + if (!this._clientID && aOptions.addClientId && !aOptions.overrideClientId) { + this._log.trace("_submitPingLogic - Waiting on client id"); + Services.telemetry + .getHistogramById("TELEMETRY_PING_SUBMISSION_WAITING_CLIENTID") + .add(); + // We can safely call |getClientID| here and during initialization: we would still + // spawn and return one single loading task. + this._clientID = await lazy.ClientID.getClientID(); + } + + let pingData = this.assemblePing(aType, aPayload, aOptions); + this._log.trace("submitExternalPing - ping assembled, id: " + pingData.id); + + if (aOptions.useEncryption === true) { + try { + if (!aOptions.publicKey) { + throw new Error("Public key is required when using encryption."); + } + + if ( + !( + aOptions.schemaName && + aOptions.schemaNamespace && + aOptions.schemaVersion + ) + ) { + throw new Error( + "Schema name, namespace, and version are required when using encryption." + ); + } + + const payload = {}; + payload.encryptedData = await lazy.jwcrypto.generateJWE( + aOptions.publicKey, + new TextEncoder().encode(JSON.stringify(aPayload)) + ); + + payload.schemaVersion = aOptions.schemaVersion; + payload.schemaName = aOptions.schemaName; + payload.schemaNamespace = aOptions.schemaNamespace; + + payload.encryptionKeyId = aOptions.encryptionKeyId; + + if (aOptions.addPioneerId === true) { + if (aOptions.overridePioneerId) { + // The caller provided a substitute id, let's use that + // instead of querying the pref. + payload.pioneerId = aOptions.overridePioneerId; + } else { + // This will throw if there is no pioneer ID set. + payload.pioneerId = Services.prefs.getStringPref( + "toolkit.telemetry.pioneerId" + ); + } + payload.studyName = aOptions.studyName; + } + + pingData.payload = payload; + } catch (e) { + this._log.error("_submitPingLogic - Unable to encrypt ping", e); + // Do not attempt to continue + throw e; + } + } + + // Always persist the pings if we are allowed to. We should not yield on any of the + // following operations to keep this function synchronous for the majority of the calls. + let archivePromise = lazy.TelemetryArchive.promiseArchivePing( + pingData + ).catch(e => + this._log.error( + "submitExternalPing - Failed to archive ping " + pingData.id, + e + ) + ); + let p = [archivePromise]; + + p.push( + lazy.TelemetrySend.submitPing(pingData, { + usePingSender: aOptions.usePingSender, + }) + ); + + return Promise.all(p).then(() => pingData.id); + }, + + /** + * Submit ping payloads to Telemetry. + * + * @param {String} aType The type of the ping. + * @param {Object} aPayload The actual data payload for the ping. + * @param {Object} [aOptions] Options object. + * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client + * id, false otherwise. + * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the + * environment data. + * @param {Object} [aOptions.overrideEnvironment=null] set to override the environment data. + * @param {Boolean} [aOptions.usePingSender=false] if true, send the ping using the PingSender. + * @param {Boolean} [aOptions.useEncryption=false] if true, encrypt data client-side before sending. + * @param {Object} [aOptions.publicKey=null] the public key to use if encryption is enabled (JSON Web Key). + * @param {String} [aOptions.encryptionKeyId=null] the public key ID to use if encryption is enabled. + * @param {String} [aOptions.studyName=null] the study name to use. + * @param {String} [aOptions.schemaName=null] the schema name to use if encryption is enabled. + * @param {String} [aOptions.schemaNamespace=null] the schema namespace to use if encryption is enabled. + * @param {String} [aOptions.schemaVersion=null] the schema version to use if encryption is enabled. + * @param {Boolean} [aOptions.addPioneerId=false] true if the ping should contain the Pioneer id, false otherwise. + * @param {Boolean} [aOptions.overridePioneerId=undefined] if set, override the + * pioneer id to the provided value. Only works if aOptions.addPioneerId=true. + * @param {String} [aOptions.overrideClientId=undefined] if set, override the + * client id to the provided value. Implies aOptions.addClientId=true. + * @returns {Promise} Test-only - a promise that is resolved with the ping id once the ping is stored or sent. + */ + submitExternalPing: function send(aType, aPayload, aOptions) { + this._log.trace( + "submitExternalPing - type: " + + aType + + ", aOptions: " + + JSON.stringify(aOptions) + ); + + // Reject pings sent after shutdown. + if (this._shutDown) { + const errorMessage = + "submitExternalPing - Submission is not allowed after shutdown, discarding ping of type: " + + aType; + this._log.error(errorMessage); + return Promise.reject(new Error(errorMessage)); + } + + // Enforce the type string to only contain sane characters. + const typeUuid = /^[a-z0-9][a-z0-9-]+[a-z0-9]$/i; + if (!typeUuid.test(aType)) { + this._log.error("submitExternalPing - invalid ping type: " + aType); + let histogram = Services.telemetry.getKeyedHistogramById( + "TELEMETRY_INVALID_PING_TYPE_SUBMITTED" + ); + histogram.add(aType, 1); + return Promise.reject(new Error("Invalid type string submitted.")); + } + // Enforce that the payload is an object. + if ( + aPayload === null || + typeof aPayload !== "object" || + Array.isArray(aPayload) + ) { + this._log.error( + "submitExternalPing - invalid payload type: " + typeof aPayload + ); + let histogram = Services.telemetry.getHistogramById( + "TELEMETRY_INVALID_PAYLOAD_SUBMITTED" + ); + histogram.add(1); + return Promise.reject(new Error("Invalid payload type submitted.")); + } + + let promise = this._submitPingLogic(aType, aPayload, aOptions); + this._trackPendingPingTask(promise); + return promise; + }, + + /** + * Save a ping to disk. + * + * @param {String} aType The type of the ping. + * @param {Object} aPayload The actual data payload for the ping. + * @param {Object} aOptions Options object. + * @param {Boolean} aOptions.addClientId true if the ping should contain the client id, + * false otherwise. + * @param {Boolean} aOptions.addEnvironment true if the ping should contain the + * environment data. + * @param {Boolean} aOptions.overwrite true overwrites a ping with the same name, if found. + * @param {Object} [aOptions.overrideEnvironment=null] set to override the environment data. + * @param {String} [aOptions.overrideClientId=undefined] if set, override the + * client id to the provided value. Implies aOptions.addClientId=true. + * + * @returns {Promise} A promise that resolves with the ping id when the ping is saved to + * disk. + */ + addPendingPing: function addPendingPing(aType, aPayload, aOptions) { + this._log.trace( + "addPendingPing - Type " + + aType + + ", aOptions " + + JSON.stringify(aOptions) + ); + + let pingData = this.assemblePing(aType, aPayload, aOptions); + + let savePromise = lazy.TelemetryStorage.savePendingPing(pingData); + let archivePromise = lazy.TelemetryArchive.promiseArchivePing( + pingData + ).catch(e => { + this._log.error( + "addPendingPing - Failed to archive ping " + pingData.id, + e + ); + }); + + // Wait for both the archiving and ping persistence to complete. + let promises = [savePromise, archivePromise]; + return Promise.all(promises).then(() => pingData.id); + }, + + /** + * Check whether we have an aborted-session ping. If so add it to the pending pings and archive it. + * + * @return {Promise} Promise that is resolved when the ping is submitted and archived. + */ + async checkAbortedSessionPing() { + let ping = await lazy.TelemetryStorage.loadAbortedSessionPing(); + this._log.trace( + "checkAbortedSessionPing - found aborted-session ping: " + !!ping + ); + if (!ping) { + return; + } + + try { + // Previous aborted-session might have been with a canary client ID. + // Don't send it. + if (ping.clientId != Utils.knownClientID) { + await lazy.TelemetryStorage.addPendingPing(ping); + await lazy.TelemetryArchive.promiseArchivePing(ping); + } + } catch (e) { + this._log.error( + "checkAbortedSessionPing - Unable to add the pending ping", + e + ); + } finally { + await lazy.TelemetryStorage.removeAbortedSessionPing(); + } + }, + + /** + * Save an aborted-session ping to disk without adding it to the pending pings. + * + * @param {Object} aPayload The ping payload data. + * @return {Promise} Promise that is resolved when the ping is saved. + */ + saveAbortedSessionPing(aPayload) { + this._log.trace("saveAbortedSessionPing"); + const options = { addClientId: true, addEnvironment: true }; + const pingData = this.assemblePing(PING_TYPE_MAIN, aPayload, options); + return lazy.TelemetryStorage.saveAbortedSessionPing(pingData); + }, + + removeAbortedSessionPing() { + return lazy.TelemetryStorage.removeAbortedSessionPing(); + }, + + async saveUninstallPing() { + if (AppConstants.platform != "win") { + return undefined; + } + + this._log.trace("saveUninstallPing"); + + let payload = {}; + try { + payload.otherInstalls = lazy.UninstallPing.getOtherInstallsCount(); + this._log.info( + "saveUninstallPing - otherInstalls", + payload.otherInstalls + ); + } catch (e) { + this._log.warn("saveUninstallPing - getOtherInstallCount failed", e); + } + const options = { addClientId: true, addEnvironment: true }; + const pingData = this.assemblePing(PING_TYPE_UNINSTALL, payload, options); + + return lazy.TelemetryStorage.saveUninstallPing(pingData); + }, + + /** + * This triggers basic telemetry initialization and schedules a full initialized for later + * for performance reasons. + * + * This delayed initialization means TelemetryController init can be in the following states: + * 1) setupTelemetry was never called + * or it was called and + * 2) _delayedInitTask was scheduled, but didn't run yet. + * 3) _delayedInitTask is currently running. + * 4) _delayedInitTask finished running and is nulled out. + * + * @return {Promise} Resolved when TelemetryController and TelemetrySession are fully + * initialized. This is only used in tests. + */ + setupTelemetry: function setupTelemetry(testing) { + this._initStarted = true; + this._shuttingDown = false; + this._shutDown = false; + this._testMode = testing; + + this._log.trace("setupTelemetry"); + + if (this._delayedInitTask) { + this._log.error("setupTelemetry - init task already running"); + return this._delayedInitTaskDeferred.promise; + } + + if (this._initialized && !this._testMode) { + this._log.error("setupTelemetry - already initialized"); + return Promise.resolve(); + } + + // Enable adding scalars in artifact builds and build faster modes. + // The function is async: we intentionally don't wait for it to complete + // as we don't want to delay startup. + this._probeRegistrationPromise = this.registerJsProbes(); + + // This will trigger displaying the datachoices infobar. + lazy.TelemetryReportingPolicy.setup(); + + if (!TelemetryControllerBase.enableTelemetryRecording()) { + this._log.config( + "setupChromeProcess - Telemetry recording is disabled, skipping Chrome process setup." + ); + return Promise.resolve(); + } + + this._attachObservers(); + + // Perform a lightweight, early initialization for the component, just registering + // a few observers and initializing the session. + lazy.TelemetrySession.earlyInit(this._testMode); + Services.telemetry.earlyInit(); + + // Annotate crash reports so that we get pings for startup crashes + lazy.TelemetrySend.earlyInit(); + + // For very short session durations, we may never load the client + // id from disk. + // We try to cache it in prefs to avoid this, even though this may + // lead to some stale client ids. + this._clientID = lazy.ClientID.getCachedClientID(); + + // Init the update ping telemetry as early as possible. This won't have + // an impact on startup. + lazy.UpdatePing.earlyInit(); + + // Delay full telemetry initialization to give the browser time to + // run various late initializers. Otherwise our gathered memory + // footprint and other numbers would be too optimistic. + this._delayedInitTaskDeferred = PromiseUtils.defer(); + this._delayedInitTask = new DeferredTask( + async () => { + try { + // TODO: This should probably happen after all the delayed init here. + this._initialized = true; + await lazy.TelemetryEnvironment.delayedInit(); + + // Load the ClientID. + this._clientID = await lazy.ClientID.getClientID(); + + // Fix-up a canary client ID if detected. + const uploadEnabled = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + if (uploadEnabled && this._clientID == Utils.knownClientID) { + this._log.trace( + "Upload enabled, but got canary client ID. Resetting." + ); + await lazy.ClientID.removeClientID(); + this._clientID = await lazy.ClientID.getClientID(); + } else if (!uploadEnabled && this._clientID != Utils.knownClientID) { + this._log.trace( + "Upload disabled, but got a valid client ID. Setting canary client ID." + ); + await lazy.ClientID.setCanaryClientID(); + this._clientID = await lazy.ClientID.getClientID(); + } + + await lazy.TelemetrySend.setup(this._testMode); + + // Perform TelemetrySession delayed init. + await lazy.TelemetrySession.delayedInit(); + await Services.telemetry.delayedInit(); + + if ( + Services.prefs.getBoolPref( + TelemetryUtils.Preferences.NewProfilePingEnabled, + false + ) && + !lazy.TelemetrySession.newProfilePingSent + ) { + // Kick off the scheduling of the new-profile ping. + this.scheduleNewProfilePing(); + } + + // Purge the pings archive by removing outdated pings. We don't wait for + // this task to complete, but TelemetryStorage blocks on it during + // shutdown. + lazy.TelemetryStorage.runCleanPingArchiveTask(); + + // Now that FHR/healthreporter is gone, make sure to remove FHR's DB from + // the profile directory. This is a temporary measure that we should drop + // in the future. + lazy.TelemetryStorage.removeFHRDatabase(); + + // The init sequence is forced to run on shutdown for short sessions and + // we don't want to start TelemetryModules as the timer registration will fail. + if (!this._shuttingDown) { + // Report the modules loaded in the Firefox process. + lazy.TelemetryModules.start(); + + // Send coverage ping. + await lazy.CoveragePing.startup(); + + // Start the untrusted modules ping, which reports events where + // untrusted modules were loaded into the Firefox process. + if (AppConstants.platform == "win") { + lazy.TelemetryUntrustedModulesPing.start(); + } + } + + lazy.TelemetryEventPing.startup(); + + if (uploadEnabled) { + await this.saveUninstallPing().catch(e => + this._log.warn("_delayedInitTask - saveUninstallPing failed", e) + ); + } else { + await lazy.TelemetryStorage.removeUninstallPings().catch(e => + this._log.warn("_delayedInitTask - saveUninstallPing", e) + ); + } + + this._delayedInitTaskDeferred.resolve(); + } catch (e) { + this._delayedInitTaskDeferred.reject(e); + } finally { + this._delayedInitTask = null; + } + }, + this._testMode ? TELEMETRY_TEST_DELAY : TELEMETRY_DELAY, + this._testMode ? 0 : undefined + ); + + IOUtils.sendTelemetry.addBlocker( + "TelemetryController: shutting down", + () => this.shutdown(), + () => this._getState() + ); + + this._delayedInitTask.arm(); + return this._delayedInitTaskDeferred.promise; + }, + + // Do proper shutdown waiting and cleanup. + async _cleanupOnShutdown() { + if (!this._initialized) { + return; + } + + this._detachObservers(); + + // Now do an orderly shutdown. + try { + if (this._delayedNewPingTask) { + await this._delayedNewPingTask.finalize(); + } + + lazy.UpdatePing.shutdown(); + + lazy.TelemetryEventPing.shutdown(); + + // Shutdown the sync ping if it is initialized - this is likely, but not + // guaranteed, to submit a "shutdown" sync ping. + if (this._fnSyncPingShutdown) { + this._fnSyncPingShutdown(); + } + + // Stop the datachoices infobar display. + lazy.TelemetryReportingPolicy.shutdown(); + lazy.TelemetryEnvironment.shutdown(); + + // Stop any ping sending. + await lazy.TelemetrySend.shutdown(); + + // Send latest data. + await lazy.TelemetryHealthPing.shutdown(); + + await lazy.TelemetrySession.shutdown(); + await Services.telemetry.shutdown(); + + // First wait for clients processing shutdown. + await this._shutdownBarrier.wait(); + + // ... and wait for any outstanding async ping activity. + await this._connectionsBarrier.wait(); + + if (AppConstants.platform !== "android") { + // No PingSender on Android. + lazy.TelemetrySend.flushPingSenderBatch(); + } + + // Perform final shutdown operations. + await lazy.TelemetryStorage.shutdown(); + } finally { + // Reset state. + this._initialized = false; + this._initStarted = false; + this._shutDown = true; + } + }, + + shutdown() { + this._log.trace("shutdown"); + + this._shuttingDown = true; + + // We can be in one the following states here: + // 1) setupTelemetry was never called + // or it was called and + // 2) _delayedInitTask was scheduled, but didn't run yet. + // 3) _delayedInitTask is running now. + // 4) _delayedInitTask finished running already. + + // This handles 1). + if (!this._initStarted) { + this._shutDown = true; + return Promise.resolve(); + } + + // This handles 4). + if (!this._delayedInitTask) { + // We already ran the delayed initialization. + return this._cleanupOnShutdown(); + } + + // This handles 2) and 3). + return this._delayedInitTask + .finalize() + .then(() => this._cleanupOnShutdown()); + }, + + /** + * This observer drives telemetry. + */ + observe(aSubject, aTopic, aData) { + // The logger might still be not available at this point. + if (aTopic == "profile-after-change") { + // If we don't have a logger, we need to make sure |Log.repository.getLogger()| is + // called before |getLoggerWithMessagePrefix|. Otherwise logging won't work. + TelemetryControllerBase.configureLogging(); + } + + this._log.trace(`observe - ${aTopic} notified.`); + + switch (aTopic) { + case "profile-after-change": + // profile-after-change is only registered for chrome processes. + return this.setupTelemetry(); + case "nsPref:changed": + if (aData == TelemetryUtils.Preferences.FhrUploadEnabled) { + return this._onUploadPrefChange(); + } + } + return undefined; + }, + + /** + * Register the sync ping's shutdown handler. + */ + registerSyncPingShutdown(fnShutdown) { + if (this._fnSyncPingShutdown) { + throw new Error("The sync ping shutdown handler is already registered."); + } + this._fnSyncPingShutdown = fnShutdown; + }, + + /** + * Get an object describing the current state of this module for AsyncShutdown diagnostics. + */ + _getState() { + return { + initialized: this._initialized, + initStarted: this._initStarted, + haveDelayedInitTask: !!this._delayedInitTask, + shutdownBarrier: this._shutdownBarrier.state, + connectionsBarrier: this._connectionsBarrier.state, + sendModule: lazy.TelemetrySend.getShutdownState(), + haveDelayedNewProfileTask: !!this._delayedNewPingTask, + }; + }, + + /** + * Called whenever the FHR Upload preference changes (e.g. when user disables FHR from + * the preferences panel), this triggers sending the "deletion-request" ping. + */ + _onUploadPrefChange() { + const uploadEnabled = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + if (uploadEnabled) { + this._log.trace( + "_onUploadPrefChange - upload was enabled again. Resetting client ID" + ); + + // Delete cached client ID immediately, so other usage is forced to refetch it. + this._clientID = null; + + // Generate a new client ID and make sure this module uses the new version + let p = (async () => { + await lazy.ClientID.removeClientID(); + let id = await lazy.ClientID.getClientID(); + this._clientID = id; + Services.telemetry.scalarSet("telemetry.data_upload_optin", true); + + await this.saveUninstallPing().catch(e => + this._log.warn("_onUploadPrefChange - saveUninstallPing failed", e) + ); + })(); + + this._shutdownBarrier.client.addBlocker( + "TelemetryController: resetting client ID after data upload was enabled", + p + ); + + return; + } + + let p = (async () => { + try { + // 1. Cancel the current pings. + // 2. Clear unpersisted pings + await lazy.TelemetrySend.clearCurrentPings(); + + // 3. Remove all pending pings + await lazy.TelemetryStorage.removeAppDataPings(); + await lazy.TelemetryStorage.runRemovePendingPingsTask(); + await lazy.TelemetryStorage.removeUninstallPings(); + } catch (e) { + this._log.error( + "_onUploadPrefChange - error clearing pending pings", + e + ); + } finally { + // 4. Reset session and subsession counter + lazy.TelemetrySession.resetSubsessionCounter(); + + // 5. Collect any additional identifiers we want to send in the + // deletion request. + const scalars = Services.telemetry.getSnapshotForScalars( + "deletion-request", + /* clear */ true + ); + + // 6. Set ClientID to a known value + let oldClientId = await lazy.ClientID.getClientID(); + await lazy.ClientID.setCanaryClientID(); + this._clientID = await lazy.ClientID.getClientID(); + + // 7. Send the deletion-request ping. + this._log.trace("_onUploadPrefChange - Sending deletion-request ping."); + this.submitExternalPing( + PING_TYPE_DELETION_REQUEST, + { scalars }, + { overrideClientId: oldClientId } + ); + this._deletionRequestPingSubmittedPromise = null; + } + })(); + + this._deletionRequestPingSubmittedPromise = p; + this._shutdownBarrier.client.addBlocker( + "TelemetryController: removing pending pings after data upload was disabled", + p + ); + + Services.obs.notifyObservers( + null, + TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC + ); + }, + + QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]), + + _attachObservers() { + if (TelemetryControllerBase.IS_UNIFIED_TELEMETRY) { + // Watch the FHR upload setting to trigger "deletion-request" pings. + Services.prefs.addObserver( + TelemetryUtils.Preferences.FhrUploadEnabled, + this, + true + ); + } + }, + + /** + * Remove the preference observer to avoid leaks. + */ + _detachObservers() { + if (TelemetryControllerBase.IS_UNIFIED_TELEMETRY) { + Services.prefs.removeObserver( + TelemetryUtils.Preferences.FhrUploadEnabled, + this + ); + } + }, + + /** + * Allows waiting for TelemetryControllers delayed initialization to complete. + * This will complete before TelemetryController is shutting down. + * @return {Promise} Resolved when delayed TelemetryController initialization completed. + */ + promiseInitialized() { + return this._delayedInitTaskDeferred.promise; + }, + + getCurrentPingData(aSubsession) { + this._log.trace("getCurrentPingData - subsession: " + aSubsession); + + // Telemetry is disabled, don't gather any data. + if (!Services.telemetry.canRecordBase) { + return null; + } + + const reason = aSubsession + ? REASON_GATHER_SUBSESSION_PAYLOAD + : REASON_GATHER_PAYLOAD; + const type = PING_TYPE_MAIN; + const payload = lazy.TelemetrySession.getPayload(reason); + const options = { addClientId: true, addEnvironment: true }; + const ping = this.assemblePing(type, payload, options); + + return ping; + }, + + async reset() { + this._clientID = null; + this._fnSyncPingShutdown = null; + this._detachObservers(); + + let sessionReset = lazy.TelemetrySession.testReset(); + + this._connectionsBarrier = new AsyncShutdown.Barrier( + "TelemetryController: Waiting for pending ping activity" + ); + this._shutdownBarrier = new AsyncShutdown.Barrier( + "TelemetryController: Waiting for clients." + ); + + // We need to kick of the controller setup first for tests that check the + // cached client id. + let controllerSetup = this.setupTelemetry(true); + + await sessionReset; + await lazy.TelemetrySend.reset(); + await lazy.TelemetryStorage.reset(); + await lazy.TelemetryEnvironment.testReset(); + + await controllerSetup; + }, + + /** + * Schedule sending the "new-profile" ping. + */ + scheduleNewProfilePing() { + this._log.trace("scheduleNewProfilePing"); + + const sendDelay = Services.prefs.getIntPref( + TelemetryUtils.Preferences.NewProfilePingDelay, + NEWPROFILE_PING_DEFAULT_DELAY + ); + + try { + // This is asynchronous, but we aren't going to await on it now. Just + // kick it off. + lazy.ProvenanceData.submitProvenanceTelemetry(); + } catch (ex) { + this._log.warn( + "scheduleNewProfilePing - submitProvenanceTelemetry failed", + ex + ); + } + + this._delayedNewPingTask = new DeferredTask(async () => { + try { + await this.sendNewProfilePing(); + } finally { + this._delayedNewPingTask = null; + } + }, sendDelay); + + this._delayedNewPingTask.arm(); + }, + + /** + * Generate and send the new-profile ping + */ + async sendNewProfilePing() { + this._log.trace( + "sendNewProfilePing - shutting down: " + this._shuttingDown + ); + + try { + await lazy.ProvenanceData.submitProvenanceTelemetry(); + } catch (ex) { + this._log.warn( + "sendNewProfilePing - submitProvenanceTelemetry failed", + ex + ); + } + + const scalars = Services.telemetry.getSnapshotForScalars( + "new-profile", + /* clear */ true + ); + + // Generate the payload. + const payload = { + reason: this._shuttingDown ? "shutdown" : "startup", + processes: { + parent: { + scalars: scalars.parent, + }, + }, + }; + + // Generate and send the "new-profile" ping. This uses the + // pingsender if we're shutting down. + let options = { + addClientId: true, + addEnvironment: true, + usePingSender: this._shuttingDown, + }; + // TODO: we need to be smarter about when to send the ping (and save the + // state to file). |requestIdleCallback| is currently only accessible + // through DOM. See bug 1361996. + await TelemetryController.submitExternalPing( + "new-profile", + payload, + options + ).then( + () => lazy.TelemetrySession.markNewProfilePingSent(), + e => + this._log.error( + "sendNewProfilePing - failed to submit new-profile ping", + e + ) + ); + }, + + /** + * Register 'dynamic builtin' probes from the JSON definition files. + * This is needed to support adding new probes in developer builds + * without rebuilding the whole codebase. + * + * This is not meant to be used outside of local developer builds. + */ + async registerJsProbes() { + // We don't support this outside of developer builds. + if (AppConstants.MOZILLA_OFFICIAL && !this._testMode) { + return; + } + + this._log.trace("registerJsProbes - registering builtin JS probes"); + + await this.registerScalarProbes(); + await this.registerEventProbes(); + }, + + _loadProbeDefinitions(filename) { + let probeFile = Services.dirsvc.get("GreD", Ci.nsIFile); + probeFile.append(filename); + if (!probeFile.exists()) { + this._log.trace( + `loadProbeDefinitions - no builtin JS probe file ${filename}` + ); + return null; + } + + return IOUtils.readUTF8(probeFile.path); + }, + + async registerScalarProbes() { + this._log.trace( + "registerScalarProbes - registering scalar builtin JS probes" + ); + + // Load the scalar probes JSON file. + const scalarProbeFilename = "ScalarArtifactDefinitions.json"; + let scalarJSProbes = {}; + try { + let fileContent = await this._loadProbeDefinitions(scalarProbeFilename); + scalarJSProbes = JSON.parse(fileContent, (property, value) => { + // Fixup the "kind" property: it's a string, and we need the constant + // coming from nsITelemetry. + if (property !== "kind" || typeof value != "string") { + return value; + } + + let newValue; + switch (value) { + case "nsITelemetry::SCALAR_TYPE_COUNT": + newValue = Services.telemetry.SCALAR_TYPE_COUNT; + break; + case "nsITelemetry::SCALAR_TYPE_BOOLEAN": + newValue = Services.telemetry.SCALAR_TYPE_BOOLEAN; + break; + case "nsITelemetry::SCALAR_TYPE_STRING": + newValue = Services.telemetry.SCALAR_TYPE_STRING; + break; + } + return newValue; + }); + } catch (ex) { + this._log.error( + `registerScalarProbes - there was an error loading ${scalarProbeFilename}`, + ex + ); + } + + // Register the builtin probes. + for (let category in scalarJSProbes) { + // Expire the expired scalars + for (let name in scalarJSProbes[category]) { + let def = scalarJSProbes[category][name]; + if ( + !def || + !def.expires || + def.expires == "never" || + def.expires == "default" + ) { + continue; + } + if ( + Services.vc.compare(AppConstants.MOZ_APP_VERSION, def.expires) >= 0 + ) { + def.expired = true; + } + } + Services.telemetry.registerBuiltinScalars( + category, + scalarJSProbes[category] + ); + } + }, + + async registerEventProbes() { + this._log.trace( + "registerEventProbes - registering builtin JS Event probes" + ); + + // Load the event probes JSON file. + const eventProbeFilename = "EventArtifactDefinitions.json"; + let eventJSProbes = {}; + try { + let fileContent = await this._loadProbeDefinitions(eventProbeFilename); + eventJSProbes = JSON.parse(fileContent); + } catch (ex) { + this._log.error( + `registerEventProbes - there was an error loading ${eventProbeFilename}`, + ex + ); + } + + // Register the builtin probes. + for (let category in eventJSProbes) { + for (let name in eventJSProbes[category]) { + let def = eventJSProbes[category][name]; + if ( + !def || + !def.expires || + def.expires == "never" || + def.expires == "default" + ) { + continue; + } + if ( + Services.vc.compare(AppConstants.MOZ_APP_VERSION, def.expires) >= 0 + ) { + def.expired = true; + } + } + Services.telemetry.registerBuiltinEvents( + category, + eventJSProbes[category] + ); + } + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetryEnvironment.sys.mjs b/toolkit/components/telemetry/app/TelemetryEnvironment.sys.mjs new file mode 100644 index 0000000000..26c4847770 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryEnvironment.sys.mjs @@ -0,0 +1,2115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; + +const { ObjectUtils } = ChromeUtils.import( + "resource://gre/modules/ObjectUtils.jsm" +); +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { UpdateUtils } from "resource://gre/modules/UpdateUtils.sys.mjs"; + +const Utils = TelemetryUtils; + +import { + AddonManager, + AddonManagerPrivate, +} from "resource://gre/modules/AddonManager.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AttributionCode: "resource:///modules/AttributionCode.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", + WindowsVersionInfo: + "resource://gre/modules/components-utils/WindowsVersionInfo.sys.mjs", +}); +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +// The maximum length of a string (e.g. description) in the addons section. +const MAX_ADDON_STRING_LENGTH = 100; +// The maximum length of a string value in the settings.attribution object. +const MAX_ATTRIBUTION_STRING_LENGTH = 100; +// The maximum lengths for the experiment id and branch in the experiments section. +const MAX_EXPERIMENT_ID_LENGTH = 100; +const MAX_EXPERIMENT_BRANCH_LENGTH = 100; +const MAX_EXPERIMENT_TYPE_LENGTH = 20; +const MAX_EXPERIMENT_ENROLLMENT_ID_LENGTH = 40; + +/** + * This is a policy object used to override behavior for testing. + */ +// eslint-disable-next-line no-unused-vars +export var Policy = { + now: () => new Date(), + _intlLoaded: false, + _browserDelayedStartup() { + if (Policy._intlLoaded) { + return Promise.resolve(); + } + return new Promise(resolve => { + let startupTopic = "browser-delayed-startup-finished"; + Services.obs.addObserver(function observer(subject, topic) { + if (topic == startupTopic) { + Services.obs.removeObserver(observer, startupTopic); + resolve(); + } + }, startupTopic); + }); + }, +}; + +// This is used to buffer calls to setExperimentActive and friends, so that we +// don't prematurely initialize our environment if it is called early during +// startup. +var gActiveExperimentStartupBuffer = new Map(); + +var gGlobalEnvironment; +function getGlobal() { + if (!gGlobalEnvironment) { + gGlobalEnvironment = new EnvironmentCache(); + } + return gGlobalEnvironment; +} + +export var TelemetryEnvironment = { + get currentEnvironment() { + return getGlobal().currentEnvironment; + }, + + onInitialized() { + return getGlobal().onInitialized(); + }, + + delayedInit() { + return getGlobal().delayedInit(); + }, + + registerChangeListener(name, listener) { + return getGlobal().registerChangeListener(name, listener); + }, + + unregisterChangeListener(name) { + return getGlobal().unregisterChangeListener(name); + }, + + /** + * Add an experiment annotation to the environment. + * If an annotation with the same id already exists, it will be overwritten. + * This triggers a new subsession, subject to throttling. + * + * @param {String} id The id of the active experiment. + * @param {String} branch The experiment branch. + * @param {Object} [options] Optional object with options. + * @param {String} [options.type=false] The specific experiment type. + * @param {String} [options.enrollmentId=undefined] The id of the enrollment. + */ + setExperimentActive(id, branch, options = {}) { + if (gGlobalEnvironment) { + gGlobalEnvironment.setExperimentActive(id, branch, options); + } else { + gActiveExperimentStartupBuffer.set(id, { branch, options }); + } + }, + + /** + * Remove an experiment annotation from the environment. + * If the annotation exists, a new subsession will triggered. + * + * @param {String} id The id of the active experiment. + */ + setExperimentInactive(id) { + if (gGlobalEnvironment) { + gGlobalEnvironment.setExperimentInactive(id); + } else { + gActiveExperimentStartupBuffer.delete(id); + } + }, + + /** + * Returns an object containing the data for the active experiments. + * + * The returned object is of the format: + * + * { + * "<experiment id>": { branch: "<branch>" }, + * // … + * } + */ + getActiveExperiments() { + if (gGlobalEnvironment) { + return gGlobalEnvironment.getActiveExperiments(); + } + + const result = {}; + for (const [id, { branch }] of gActiveExperimentStartupBuffer.entries()) { + result[id] = branch; + } + return result; + }, + + shutdown() { + return getGlobal().shutdown(); + }, + + // Policy to use when saving preferences. Exported for using them in tests. + // Reports "<user-set>" if there is a value set on the user branch + RECORD_PREF_STATE: 1, + + // Reports the value set on the user branch, if one is set + RECORD_PREF_VALUE: 2, + + // Reports the active value (set on either the user or default branch) + // for this pref, if one is set + RECORD_DEFAULTPREF_VALUE: 3, + + // Reports "<set>" if a value for this pref is defined on either the user + // or default branch + RECORD_DEFAULTPREF_STATE: 4, + + // Testing method + async testWatchPreferences(prefMap) { + return getGlobal()._watchPreferences(prefMap); + }, + + /** + * Intended for use in tests only. + * + * In multiple tests we need a way to shut and re-start telemetry together + * with TelemetryEnvironment. This is problematic due to the fact that + * TelemetryEnvironment is a singleton. We, therefore, need this helper + * method to be able to re-set TelemetryEnvironment. + */ + testReset() { + return getGlobal().reset(); + }, + + /** + * Intended for use in tests only. + */ + testCleanRestart() { + getGlobal().shutdown(); + gGlobalEnvironment = null; + gActiveExperimentStartupBuffer = new Map(); + return getGlobal(); + }, +}; + +const RECORD_PREF_STATE = TelemetryEnvironment.RECORD_PREF_STATE; +const RECORD_PREF_VALUE = TelemetryEnvironment.RECORD_PREF_VALUE; +const RECORD_DEFAULTPREF_VALUE = TelemetryEnvironment.RECORD_DEFAULTPREF_VALUE; +const RECORD_DEFAULTPREF_STATE = TelemetryEnvironment.RECORD_DEFAULTPREF_STATE; +const DEFAULT_ENVIRONMENT_PREFS = new Map([ + ["app.feedback.baseURL", { what: RECORD_PREF_VALUE }], + ["app.support.baseURL", { what: RECORD_PREF_VALUE }], + ["accessibility.browsewithcaret", { what: RECORD_PREF_VALUE }], + ["accessibility.force_disabled", { what: RECORD_PREF_VALUE }], + ["app.normandy.test-prefs.bool", { what: RECORD_PREF_VALUE }], + ["app.normandy.test-prefs.integer", { what: RECORD_PREF_VALUE }], + ["app.normandy.test-prefs.string", { what: RECORD_PREF_VALUE }], + ["app.shield.optoutstudies.enabled", { what: RECORD_PREF_VALUE }], + ["app.update.interval", { what: RECORD_PREF_VALUE }], + ["app.update.service.enabled", { what: RECORD_PREF_VALUE }], + ["app.update.silent", { what: RECORD_PREF_VALUE }], + ["browser.cache.disk.enable", { what: RECORD_PREF_VALUE }], + ["browser.cache.disk.capacity", { what: RECORD_PREF_VALUE }], + ["browser.cache.memory.enable", { what: RECORD_PREF_VALUE }], + ["browser.formfill.enable", { what: RECORD_PREF_VALUE }], + ["browser.fixup.alternate.enabled", { what: RECORD_DEFAULTPREF_VALUE }], + ["browser.migrate.interactions.bookmarks", { what: RECORD_PREF_VALUE }], + ["browser.migrate.interactions.history", { what: RECORD_PREF_VALUE }], + ["browser.migrate.interactions.passwords", { what: RECORD_PREF_VALUE }], + ["browser.newtabpage.enabled", { what: RECORD_PREF_VALUE }], + ["browser.shell.checkDefaultBrowser", { what: RECORD_PREF_VALUE }], + ["browser.search.region", { what: RECORD_PREF_VALUE }], + ["browser.search.suggest.enabled", { what: RECORD_PREF_VALUE }], + ["browser.search.widget.inNavBar", { what: RECORD_DEFAULTPREF_VALUE }], + ["browser.startup.homepage", { what: RECORD_PREF_STATE }], + ["browser.startup.page", { what: RECORD_PREF_VALUE }], + ["browser.tabs.firefox-view", { what: RECORD_PREF_VALUE }], + ["browser.urlbar.autoFill", { what: RECORD_DEFAULTPREF_VALUE }], + [ + "browser.urlbar.autoFill.adaptiveHistory.enabled", + { what: RECORD_DEFAULTPREF_VALUE }, + ], + [ + "browser.urlbar.dnsResolveSingleWordsAfterSearch", + { what: RECORD_DEFAULTPREF_VALUE }, + ], + [ + "browser.urlbar.quicksuggest.onboardingDialogChoice", + { what: RECORD_DEFAULTPREF_VALUE }, + ], + [ + "browser.urlbar.quicksuggest.dataCollection.enabled", + { what: RECORD_DEFAULTPREF_VALUE }, + ], + ["browser.urlbar.showSearchSuggestionsFirst", { what: RECORD_PREF_VALUE }], + ["browser.urlbar.showSearchTerms.enabled", { what: RECORD_PREF_VALUE }], + [ + "browser.urlbar.suggest.quicksuggest.nonsponsored", + { what: RECORD_DEFAULTPREF_VALUE }, + ], + [ + "browser.urlbar.suggest.quicksuggest.sponsored", + { what: RECORD_DEFAULTPREF_VALUE }, + ], + ["browser.urlbar.suggest.bestmatch", { what: RECORD_DEFAULTPREF_VALUE }], + ["browser.urlbar.suggest.searches", { what: RECORD_PREF_VALUE }], + ["devtools.chrome.enabled", { what: RECORD_PREF_VALUE }], + ["devtools.debugger.enabled", { what: RECORD_PREF_VALUE }], + ["devtools.debugger.remote-enabled", { what: RECORD_PREF_VALUE }], + ["doh-rollout.doorhanger-decision", { what: RECORD_PREF_VALUE }], + ["dom.ipc.plugins.enabled", { what: RECORD_PREF_VALUE }], + ["dom.ipc.processCount", { what: RECORD_PREF_VALUE }], + ["dom.max_script_run_time", { what: RECORD_PREF_VALUE }], + ["editor.truncate_user_pastes", { what: RECORD_PREF_VALUE }], + ["extensions.InstallTrigger.enabled", { what: RECORD_PREF_VALUE }], + ["extensions.InstallTriggerImpl.enabled", { what: RECORD_PREF_VALUE }], + ["extensions.autoDisableScopes", { what: RECORD_PREF_VALUE }], + ["extensions.blocklist.enabled", { what: RECORD_PREF_VALUE }], + ["extensions.enabledScopes", { what: RECORD_PREF_VALUE }], + ["extensions.eventPages.enabled", { what: RECORD_PREF_VALUE }], + ["extensions.formautofill.addresses.enabled", { what: RECORD_PREF_VALUE }], + [ + "extensions.formautofill.addresses.capture.enabled", + { what: RECORD_PREF_VALUE }, + ], + ["extensions.formautofill.creditCards.enabled", { what: RECORD_PREF_VALUE }], + [ + "extensions.formautofill.creditCards.available", + { what: RECORD_PREF_VALUE }, + ], + ["extensions.manifestV3.enabled", { what: RECORD_PREF_VALUE }], + ["extensions.quarantinedDomains.enabled", { what: RECORD_PREF_VALUE }], + ["extensions.strictCompatibility", { what: RECORD_PREF_VALUE }], + ["extensions.update.enabled", { what: RECORD_PREF_VALUE }], + ["extensions.update.url", { what: RECORD_PREF_VALUE }], + ["extensions.update.background.url", { what: RECORD_PREF_VALUE }], + ["extensions.screenshots.disabled", { what: RECORD_PREF_VALUE }], + ["general.config.filename", { what: RECORD_DEFAULTPREF_STATE }], + ["general.smoothScroll", { what: RECORD_PREF_VALUE }], + ["gfx.direct2d.disabled", { what: RECORD_PREF_VALUE }], + ["gfx.direct2d.force-enabled", { what: RECORD_PREF_VALUE }], + ["gfx.webrender.all", { what: RECORD_PREF_VALUE }], + ["gfx.webrender.all.qualified", { what: RECORD_PREF_VALUE }], + ["layers.acceleration.disabled", { what: RECORD_PREF_VALUE }], + ["layers.acceleration.force-enabled", { what: RECORD_PREF_VALUE }], + ["layers.async-pan-zoom.enabled", { what: RECORD_PREF_VALUE }], + ["layers.async-video-oop.enabled", { what: RECORD_PREF_VALUE }], + ["layers.async-video.enabled", { what: RECORD_PREF_VALUE }], + ["layers.d3d11.disable-warp", { what: RECORD_PREF_VALUE }], + ["layers.d3d11.force-warp", { what: RECORD_PREF_VALUE }], + [ + "layers.offmainthreadcomposition.force-disabled", + { what: RECORD_PREF_VALUE }, + ], + ["layers.prefer-d3d9", { what: RECORD_PREF_VALUE }], + ["layers.prefer-opengl", { what: RECORD_PREF_VALUE }], + ["layout.css.devPixelsPerPx", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.enabled", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.lastInstallFailed", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.lastInstallFailReason", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.lastInstallStart", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.lastDownload", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.lastDownloadFailed", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.lastDownloadFailReason", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.lastUpdate", { what: RECORD_PREF_VALUE }], + ["media.gmp-gmpopenh264.visible", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.enabled", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.lastInstallFailed", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.lastInstallFailReason", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.lastInstallStart", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.lastDownload", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.lastDownloadFailed", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.lastDownloadFailReason", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.lastUpdate", { what: RECORD_PREF_VALUE }], + ["media.gmp-widevinecdm.visible", { what: RECORD_PREF_VALUE }], + ["media.gmp-manager.lastCheck", { what: RECORD_PREF_VALUE }], + ["media.gmp-manager.lastEmptyCheck", { what: RECORD_PREF_VALUE }], + ["network.http.windows-sso.enabled", { what: RECORD_PREF_VALUE }], + ["network.proxy.autoconfig_url", { what: RECORD_PREF_STATE }], + ["network.proxy.http", { what: RECORD_PREF_STATE }], + ["network.proxy.ssl", { what: RECORD_PREF_STATE }], + ["network.trr.mode", { what: RECORD_PREF_VALUE }], + ["network.trr.strict_native_fallback", { what: RECORD_DEFAULTPREF_VALUE }], + ["pdfjs.disabled", { what: RECORD_PREF_VALUE }], + ["places.history.enabled", { what: RECORD_PREF_VALUE }], + ["plugins.show_infobar", { what: RECORD_PREF_VALUE }], + ["privacy.firstparty.isolate", { what: RECORD_PREF_VALUE }], + ["privacy.resistFingerprinting", { what: RECORD_PREF_VALUE }], + ["privacy.trackingprotection.enabled", { what: RECORD_PREF_VALUE }], + ["privacy.donottrackheader.enabled", { what: RECORD_PREF_VALUE }], + ["security.enterprise_roots.auto-enabled", { what: RECORD_PREF_VALUE }], + ["security.enterprise_roots.enabled", { what: RECORD_PREF_VALUE }], + ["security.pki.mitm_detected", { what: RECORD_PREF_VALUE }], + ["security.mixed_content.block_active_content", { what: RECORD_PREF_VALUE }], + ["security.mixed_content.block_display_content", { what: RECORD_PREF_VALUE }], + ["security.tls.version.enable-deprecated", { what: RECORD_PREF_VALUE }], + ["signon.management.page.breach-alerts.enabled", { what: RECORD_PREF_VALUE }], + ["signon.autofillForms", { what: RECORD_PREF_VALUE }], + ["signon.generation.enabled", { what: RECORD_PREF_VALUE }], + ["signon.rememberSignons", { what: RECORD_PREF_VALUE }], + ["signon.firefoxRelay.feature", { what: RECORD_PREF_VALUE }], + ["toolkit.telemetry.pioneerId", { what: RECORD_PREF_STATE }], + [ + "widget.content.gtk-high-contrast.enabled", + { what: RECORD_DEFAULTPREF_VALUE }, + ], + ["xpinstall.signatures.required", { what: RECORD_PREF_VALUE }], + ["nimbus.debug", { what: RECORD_PREF_VALUE }], +]); + +const LOGGER_NAME = "Toolkit.Telemetry"; + +const PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled"; +const PREF_DISTRIBUTION_ID = "distribution.id"; +const PREF_DISTRIBUTION_VERSION = "distribution.version"; +const PREF_DISTRIBUTOR = "app.distributor"; +const PREF_DISTRIBUTOR_CHANNEL = "app.distributor.channel"; +const PREF_APP_PARTNER_BRANCH = "app.partner."; +const PREF_PARTNER_ID = "mozilla.partner.id"; + +const COMPOSITOR_CREATED_TOPIC = "compositor:created"; +const COMPOSITOR_PROCESS_ABORTED_TOPIC = "compositor:process-aborted"; +const DISTRIBUTION_CUSTOMIZATION_COMPLETE_TOPIC = + "distribution-customization-complete"; +const GFX_FEATURES_READY_TOPIC = "gfx-features-ready"; +const SEARCH_ENGINE_MODIFIED_TOPIC = "browser-search-engine-modified"; +const SEARCH_SERVICE_TOPIC = "browser-search-service"; +const SESSIONSTORE_WINDOWS_RESTORED_TOPIC = "sessionstore-windows-restored"; +const PREF_CHANGED_TOPIC = "nsPref:changed"; +const GMP_PROVIDER_REGISTERED_TOPIC = "gmp-provider-registered"; +const AUTO_UPDATE_PREF_CHANGE_TOPIC = + UpdateUtils.PER_INSTALLATION_PREFS["app.update.auto"].observerTopic; +const BACKGROUND_UPDATE_PREF_CHANGE_TOPIC = + UpdateUtils.PER_INSTALLATION_PREFS["app.update.background.enabled"] + .observerTopic; +const SERVICES_INFO_CHANGE_TOPIC = "sync-ui-state:update"; + +/** + * Enforces the parameter to a boolean value. + * @param aValue The input value. + * @return {Boolean|Object} If aValue is a boolean or a number, returns its truthfulness + * value. Otherwise, return null. + */ +function enforceBoolean(aValue) { + if (typeof aValue !== "number" && typeof aValue !== "boolean") { + return null; + } + return Boolean(aValue); +} + +/** + * Get the current browser locale. + * @return a string with the locale or null on failure. + */ +function getBrowserLocale() { + try { + return Services.locale.appLocaleAsBCP47; + } catch (e) { + return null; + } +} + +/** + * Get the current OS locale. + * @return a string with the OS locale or null on failure. + */ +function getSystemLocale() { + try { + return Cc["@mozilla.org/intl/ospreferences;1"].getService( + Ci.mozIOSPreferences + ).systemLocale; + } catch (e) { + return null; + } +} + +/** + * Get the current OS locales. + * @return an array of strings with the OS locales or null on failure. + */ +function getSystemLocales() { + try { + return Cc["@mozilla.org/intl/ospreferences;1"].getService( + Ci.mozIOSPreferences + ).systemLocales; + } catch (e) { + return null; + } +} + +/** + * Get the current OS regional preference locales. + * @return an array of strings with the OS regional preference locales or null on failure. + */ +function getRegionalPrefsLocales() { + try { + return Cc["@mozilla.org/intl/ospreferences;1"].getService( + Ci.mozIOSPreferences + ).regionalPrefsLocales; + } catch (e) { + return null; + } +} + +function getIntlSettings() { + return { + requestedLocales: Services.locale.requestedLocales, + availableLocales: Services.locale.availableLocales, + appLocales: Services.locale.appLocalesAsBCP47, + systemLocales: getSystemLocales(), + regionalPrefsLocales: getRegionalPrefsLocales(), + acceptLanguages: Services.prefs + .getComplexValue("intl.accept_languages", Ci.nsIPrefLocalizedString) + .data.split(",") + .map(str => str.trim()), + }; +} + +/** + * Safely get a sysinfo property and return its value. If the property is not + * available, return aDefault. + * + * @param aPropertyName the property name to get. + * @param aDefault the value to return if aPropertyName is not available. + * @return The property value, if available, or aDefault. + */ +function getSysinfoProperty(aPropertyName, aDefault) { + try { + // |getProperty| may throw if |aPropertyName| does not exist. + return Services.sysinfo.getProperty(aPropertyName); + } catch (e) {} + + return aDefault; +} + +/** + * Safely get a gfxInfo field and return its value. If the field is not available, return + * aDefault. + * + * @param aPropertyName the property name to get. + * @param aDefault the value to return if aPropertyName is not available. + * @return The property value, if available, or aDefault. + */ +function getGfxField(aPropertyName, aDefault) { + let gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo); + + try { + // Accessing the field may throw if |aPropertyName| does not exist. + let gfxProp = gfxInfo[aPropertyName]; + if (gfxProp !== undefined && gfxProp !== "") { + return gfxProp; + } + } catch (e) {} + + return aDefault; +} + +/** + * Returns a substring of the input string. + * + * @param {String} aString The input string. + * @param {Integer} aMaxLength The maximum length of the returned substring. If this is + * greater than the length of the input string, we return the whole input string. + * @return {String} The substring or null if the input string is null. + */ +function limitStringToLength(aString, aMaxLength) { + if (typeof aString !== "string") { + return null; + } + return aString.substring(0, aMaxLength); +} + +/** + * Force a value to be a string. + * Only if the value is null, null is returned instead. + */ +function forceToStringOrNull(aValue) { + if (aValue === null) { + return null; + } + + return String(aValue); +} + +/** + * Get the information about a graphic adapter. + * + * @param aSuffix A suffix to add to the properties names. + * @return An object containing the adapter properties. + */ +function getGfxAdapter(aSuffix = "") { + // Note that gfxInfo, and so getGfxField, might return "Unknown" for the RAM on failures, + // not null. + let memoryMB = parseInt(getGfxField("adapterRAM" + aSuffix, null), 10); + if (Number.isNaN(memoryMB)) { + memoryMB = null; + } + + return { + description: getGfxField("adapterDescription" + aSuffix, null), + vendorID: getGfxField("adapterVendorID" + aSuffix, null), + deviceID: getGfxField("adapterDeviceID" + aSuffix, null), + subsysID: getGfxField("adapterSubsysID" + aSuffix, null), + RAM: memoryMB, + driver: getGfxField("adapterDriver" + aSuffix, null), + driverVendor: getGfxField("adapterDriverVendor" + aSuffix, null), + driverVersion: getGfxField("adapterDriverVersion" + aSuffix, null), + driverDate: getGfxField("adapterDriverDate" + aSuffix, null), + }; +} + +/** + * Encapsulates the asynchronous magic interfacing with the addon manager. The builder + * is owned by a parent environment object and is an addon listener. + */ +function EnvironmentAddonBuilder(environment) { + this._environment = environment; + + // The pending task blocks addon manager shutdown. It can either be the initial load + // or a change load. + this._pendingTask = null; + + // Have we added an observer to listen for blocklist changes that still needs to be + // removed: + this._gmpProviderObserverAdded = false; + + // Set to true once initial load is complete and we're watching for changes. + this._loaded = false; + + // The state reported by the shutdown blocker if we hang shutdown. + this._shutdownState = "Initial"; +} +EnvironmentAddonBuilder.prototype = { + /** + * Get the initial set of addons. + * @returns Promise<void> when the initial load is complete. + */ + async init() { + AddonManager.beforeShutdown.addBlocker( + "EnvironmentAddonBuilder", + () => this._shutdownBlocker(), + { fetchState: () => this._shutdownState } + ); + + this._pendingTask = (async () => { + try { + this._shutdownState = "Awaiting _updateAddons"; + // Gather initial addons details + await this._updateAddons(); + + if (!this._environment._addonsAreFull) { + // The addon database has not been loaded, wait for it to + // initialize and gather full data as soon as it does. + this._shutdownState = "Awaiting AddonManagerPrivate.databaseReady"; + await AddonManagerPrivate.databaseReady; + + // Now gather complete addons details. + this._shutdownState = "Awaiting second _updateAddons"; + await this._updateAddons(); + } + } catch (err) { + this._environment._log.error("init - Exception in _updateAddons", err); + } finally { + this._pendingTask = null; + this._shutdownState = "_pendingTask init complete. No longer blocking."; + } + })(); + + return this._pendingTask; + }, + + /** + * Register an addon listener and watch for changes. + */ + watchForChanges() { + this._loaded = true; + AddonManager.addAddonListener(this); + }, + + // AddonListener + onEnabled(addon) { + this._onAddonChange(addon); + }, + onDisabled(addon) { + this._onAddonChange(addon); + }, + onInstalled(addon) { + this._onAddonChange(addon); + }, + onUninstalling(addon) { + this._onAddonChange(addon); + }, + onUninstalled(addon) { + this._onAddonChange(addon); + }, + + _onAddonChange(addon) { + if (addon && addon.isBuiltin && !addon.isSystem) { + return; + } + this._environment._log.trace("_onAddonChange"); + this._checkForChanges("addons-changed"); + }, + + // nsIObserver + observe(aSubject, aTopic, aData) { + this._environment._log.trace("observe - Topic " + aTopic); + if (aTopic == GMP_PROVIDER_REGISTERED_TOPIC) { + Services.obs.removeObserver(this, GMP_PROVIDER_REGISTERED_TOPIC); + this._gmpProviderObserverAdded = false; + let gmpPluginsPromise = this._getActiveGMPlugins(); + gmpPluginsPromise.then( + gmpPlugins => { + let { addons } = this._environment._currentEnvironment; + addons.activeGMPlugins = gmpPlugins; + }, + err => { + this._environment._log.error( + "blocklist observe: Error collecting plugins", + err + ); + } + ); + } + }, + + _checkForChanges(changeReason) { + if (this._pendingTask) { + this._environment._log.trace( + "_checkForChanges - task already pending, dropping change with reason " + + changeReason + ); + return; + } + + this._shutdownState = "_checkForChanges awaiting _updateAddons"; + this._pendingTask = this._updateAddons().then( + result => { + this._pendingTask = null; + this._shutdownState = "No longer blocking, _updateAddons resolved"; + if (result.changed) { + this._environment._onEnvironmentChange( + changeReason, + result.oldEnvironment + ); + } + }, + err => { + this._pendingTask = null; + this._shutdownState = "No longer blocking, _updateAddons rejected"; + this._environment._log.error( + "_checkForChanges: Error collecting addons", + err + ); + } + ); + }, + + _shutdownBlocker() { + if (this._loaded) { + AddonManager.removeAddonListener(this); + if (this._gmpProviderObserverAdded) { + Services.obs.removeObserver(this, GMP_PROVIDER_REGISTERED_TOPIC); + } + } + + // At startup, _pendingTask is set to a Promise that does not resolve + // until the addons database has been read so complete details about + // addons are available. Returning it here will cause it to block + // profileBeforeChange, guranteeing that full information will be + // available by the time profileBeforeChangeTelemetry is fired. + return this._pendingTask; + }, + + /** + * Collect the addon data for the environment. + * + * This should only be called from _pendingTask; otherwise we risk + * running this during addon manager shutdown. + * + * @returns Promise<Object> This returns a Promise resolved with a status object with the following members: + * changed - Whether the environment changed. + * oldEnvironment - Only set if a change occured, contains the environment data before the change. + */ + async _updateAddons() { + this._environment._log.trace("_updateAddons"); + + let addons = { + activeAddons: await this._getActiveAddons(), + theme: await this._getActiveTheme(), + activeGMPlugins: await this._getActiveGMPlugins(), + }; + + let result = { + changed: + !this._environment._currentEnvironment.addons || + !ObjectUtils.deepEqual( + addons.activeAddons, + this._environment._currentEnvironment.addons.activeAddons + ), + }; + + if (result.changed) { + this._environment._log.trace("_updateAddons: addons differ"); + result.oldEnvironment = Cu.cloneInto( + this._environment._currentEnvironment, + {} + ); + } + this._environment._currentEnvironment.addons = addons; + + return result; + }, + + /** + * Get the addon data in object form. + * @return Promise<object> containing the addon data. + */ + async _getActiveAddons() { + // Request addons, asynchronously. + // "theme" is excluded because it is already handled by _getActiveTheme. + let { addons: allAddons, fullData } = await AddonManager.getActiveAddons( + AddonManagerPrivate.getAddonTypesByProvider("XPIProvider").filter( + addonType => addonType != "theme" + ) + ); + + this._environment._addonsAreFull = fullData; + let activeAddons = {}; + for (let addon of allAddons) { + // Don't collect any information about the new built-in search webextensions + if (addon.isBuiltin && !addon.isSystem) { + continue; + } + // Weird addon data in the wild can lead to exceptions while collecting + // the data. + try { + // Make sure to have valid dates. + let updateDate = new Date(Math.max(0, addon.updateDate)); + + activeAddons[addon.id] = { + version: limitStringToLength(addon.version, MAX_ADDON_STRING_LENGTH), + scope: addon.scope, + type: addon.type, + updateDay: Utils.millisecondsToDays(updateDate.getTime()), + isSystem: addon.isSystem, + isWebExtension: addon.isWebExtension, + multiprocessCompatible: true, + }; + + // getActiveAddons() gives limited data during startup and full + // data after the addons database is loaded. + if (fullData) { + let installDate = new Date(Math.max(0, addon.installDate)); + Object.assign(activeAddons[addon.id], { + blocklisted: + addon.blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED, + description: limitStringToLength( + addon.description, + MAX_ADDON_STRING_LENGTH + ), + name: limitStringToLength(addon.name, MAX_ADDON_STRING_LENGTH), + userDisabled: enforceBoolean(addon.userDisabled), + appDisabled: addon.appDisabled, + foreignInstall: enforceBoolean(addon.foreignInstall), + hasBinaryComponents: false, + installDay: Utils.millisecondsToDays(installDate.getTime()), + signedState: addon.signedState, + }); + } + } catch (ex) { + this._environment._log.error( + "_getActiveAddons - An addon was discarded due to an error", + ex + ); + continue; + } + } + + return activeAddons; + }, + + /** + * Get the currently active theme data in object form. + * @return Promise<object> containing the active theme data. + */ + async _getActiveTheme() { + // Request themes, asynchronously. + let { addons: themes } = await AddonManager.getActiveAddons(["theme"]); + + let activeTheme = {}; + // We only store information about the active theme. + let theme = themes.find(theme => theme.isActive); + if (theme) { + // Make sure to have valid dates. + let installDate = new Date(Math.max(0, theme.installDate)); + let updateDate = new Date(Math.max(0, theme.updateDate)); + + activeTheme = { + id: theme.id, + blocklisted: + theme.blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED, + description: limitStringToLength( + theme.description, + MAX_ADDON_STRING_LENGTH + ), + name: limitStringToLength(theme.name, MAX_ADDON_STRING_LENGTH), + userDisabled: enforceBoolean(theme.userDisabled), + appDisabled: theme.appDisabled, + version: limitStringToLength(theme.version, MAX_ADDON_STRING_LENGTH), + scope: theme.scope, + foreignInstall: enforceBoolean(theme.foreignInstall), + hasBinaryComponents: false, + installDay: Utils.millisecondsToDays(installDate.getTime()), + updateDay: Utils.millisecondsToDays(updateDate.getTime()), + }; + } + + return activeTheme; + }, + + /** + * Get the GMPlugins data in object form. + * + * @return Object containing the GMPlugins data. + * + * This should only be called from _pendingTask; otherwise we risk + * running this during addon manager shutdown. + */ + async _getActiveGMPlugins() { + // If we haven't yet loaded the blocklist, pass back dummy data for now, + // and add an observer to update this data as soon as we get it. + if (!AddonManager.hasProvider("GMPProvider")) { + if (!this._gmpProviderObserverAdded) { + Services.obs.addObserver(this, GMP_PROVIDER_REGISTERED_TOPIC); + this._gmpProviderObserverAdded = true; + } + return { + "dummy-gmp": { + version: "0.1", + userDisabled: false, + applyBackgroundUpdates: 1, + }, + }; + } + // Request plugins, asynchronously. + let allPlugins = await AddonManager.getAddonsByTypes(["plugin"]); + + let activeGMPlugins = {}; + for (let plugin of allPlugins) { + // Only get info for active GMplugins. + if (!plugin.isGMPlugin || !plugin.isActive) { + continue; + } + + try { + activeGMPlugins[plugin.id] = { + version: plugin.version, + userDisabled: enforceBoolean(plugin.userDisabled), + applyBackgroundUpdates: plugin.applyBackgroundUpdates, + }; + } catch (ex) { + this._environment._log.error( + "_getActiveGMPlugins - A GMPlugin was discarded due to an error", + ex + ); + continue; + } + } + + return activeGMPlugins; + }, +}; + +function EnvironmentCache() { + this._log = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + "TelemetryEnvironment::" + ); + this._log.trace("constructor"); + + this._shutdown = false; + // Don't allow querying the search service too early to prevent + // impacting the startup performance. + this._canQuerySearch = false; + // To guard against slowing down startup, defer gathering heavy environment + // entries until the session is restored. + this._sessionWasRestored = false; + + // A map of listeners that will be called on environment changes. + this._changeListeners = new Map(); + + // A map of watched preferences which trigger an Environment change when + // modified. Every entry contains a recording policy (RECORD_PREF_*). + this._watchedPrefs = DEFAULT_ENVIRONMENT_PREFS; + + this._currentEnvironment = { + build: this._getBuild(), + partner: this._getPartner(), + system: this._getSystem(), + }; + + this._addObservers(); + + // Build the remaining asynchronous parts of the environment. Don't register change listeners + // until the initial environment has been built. + + let p = [this._updateSettings()]; + this._addonBuilder = new EnvironmentAddonBuilder(this); + p.push(this._addonBuilder.init()); + + this._currentEnvironment.profile = {}; + p.push(this._updateProfile()); + if (AppConstants.MOZ_BUILD_APP == "browser") { + p.push(this._loadAttributionAsync()); + } + p.push(this._loadAsyncUpdateSettings()); + p.push(this._loadIntlData()); + + for (const [ + id, + { branch, options }, + ] of gActiveExperimentStartupBuffer.entries()) { + this.setExperimentActive(id, branch, options); + } + gActiveExperimentStartupBuffer = null; + + let setup = () => { + this._initTask = null; + this._startWatchingPrefs(); + this._addonBuilder.watchForChanges(); + this._updateGraphicsFeatures(); + return this.currentEnvironment; + }; + + this._initTask = Promise.all(p).then( + () => setup(), + err => { + // log errors but eat them for consumers + this._log.error("EnvironmentCache - error while initializing", err); + return setup(); + } + ); + + // Addons may contain partial or full data depending on whether the Addons DB + // has had a chance to load. Do we have full data yet? + this._addonsAreFull = false; +} +EnvironmentCache.prototype = { + /** + * The current environment data. The returned data is cloned to avoid + * unexpected sharing or mutation. + * @returns object + */ + get currentEnvironment() { + return Cu.cloneInto(this._currentEnvironment, {}); + }, + + /** + * Wait for the current enviroment to be fully initialized. + * @returns Promise<object> + */ + onInitialized() { + if (this._initTask) { + return this._initTask; + } + return Promise.resolve(this.currentEnvironment); + }, + + /** + * This gets called when the delayed init completes. + */ + async delayedInit() { + this._processData = await Services.sysinfo.processInfo; + let processData = await Services.sysinfo.processInfo; + // Remove isWow64 and isWowARM64 from processData + // to strip it down to just CPU info + delete processData.isWow64; + delete processData.isWowARM64; + + let oldEnv = null; + if (!this._initTask) { + oldEnv = this.currentEnvironment; + } + + this._cpuData = this._getCPUData(); + // Augment the return value from the promises with cached values + this._cpuData = { ...processData, ...this._cpuData }; + + this._currentEnvironment.system.cpu = this._getCPUData(); + + if (AppConstants.platform == "win") { + this._hddData = await Services.sysinfo.diskInfo; + let osData = await Services.sysinfo.osInfo; + + if (!this._initTask) { + // We've finished creating the initial env, so notify for the update + // This is all a bit awkward because `currentEnvironment` clones + // the object, which we need to pass to the notification, but we + // should only notify once we've updated the current environment... + // Ideally, _onEnvironmentChange should somehow deal with all this + // instead of all the consumers. + oldEnv = this.currentEnvironment; + } + + this._osData = this._getOSData(); + + // Augment the return values from the promises with cached values + this._osData = Object.assign(osData, this._osData); + + this._currentEnvironment.system.os = this._getOSData(); + this._currentEnvironment.system.hdd = this._getHDDData(); + + // Windows only values stored in processData + this._currentEnvironment.system.isWow64 = this._getProcessData().isWow64; + this._currentEnvironment.system.isWowARM64 = + this._getProcessData().isWowARM64; + } + + if (!this._initTask) { + this._onEnvironmentChange("system-info", oldEnv); + } + }, + + /** + * Register a listener for environment changes. + * @param name The name of the listener. If a new listener is registered + * with the same name, the old listener will be replaced. + * @param listener function(reason, oldEnvironment) - Will receive a reason for + the change and the environment data before the change. + */ + registerChangeListener(name, listener) { + this._log.trace("registerChangeListener for " + name); + if (this._shutdown) { + this._log.warn("registerChangeListener - already shutdown"); + return; + } + this._changeListeners.set(name, listener); + }, + + /** + * Unregister from listening to environment changes. + * It's fine to call this on an unitialized TelemetryEnvironment. + * @param name The name of the listener to remove. + */ + unregisterChangeListener(name) { + this._log.trace("unregisterChangeListener for " + name); + if (this._shutdown) { + this._log.warn("registerChangeListener - already shutdown"); + return; + } + this._changeListeners.delete(name); + }, + + setExperimentActive(id, branch, options) { + this._log.trace(`setExperimentActive - id: ${id}, branch: ${branch}`); + // Make sure both the id and the branch have sane lengths. + const saneId = limitStringToLength(id, MAX_EXPERIMENT_ID_LENGTH); + const saneBranch = limitStringToLength( + branch, + MAX_EXPERIMENT_BRANCH_LENGTH + ); + if (!saneId || !saneBranch) { + this._log.error( + "setExperimentActive - the provided arguments are not strings." + ); + return; + } + + // Warn the user about any content truncation. + if (saneId.length != id.length || saneBranch.length != branch.length) { + this._log.warn( + "setExperimentActive - the experiment id or branch were truncated." + ); + } + + // Truncate the experiment type if present. + if (options.hasOwnProperty("type")) { + let type = limitStringToLength(options.type, MAX_EXPERIMENT_TYPE_LENGTH); + if (type.length != options.type.length) { + options.type = type; + this._log.warn( + "setExperimentActive - the experiment type was truncated." + ); + } + } + + // Truncate the enrollment id if present. + if (options.hasOwnProperty("enrollmentId")) { + let enrollmentId = limitStringToLength( + options.enrollmentId, + MAX_EXPERIMENT_ENROLLMENT_ID_LENGTH + ); + if (enrollmentId.length != options.enrollmentId.length) { + options.enrollmentId = enrollmentId; + this._log.warn( + "setExperimentActive - the enrollment id was truncated." + ); + } + } + + let oldEnvironment = Cu.cloneInto(this._currentEnvironment, {}); + // Add the experiment annotation. + let experiments = this._currentEnvironment.experiments || {}; + experiments[saneId] = { branch: saneBranch }; + if (options.hasOwnProperty("type")) { + experiments[saneId].type = options.type; + } + if (options.hasOwnProperty("enrollmentId")) { + experiments[saneId].enrollmentId = options.enrollmentId; + } + this._currentEnvironment.experiments = experiments; + // Notify of the change. + this._onEnvironmentChange("experiment-annotation-changed", oldEnvironment); + }, + + setExperimentInactive(id) { + this._log.trace("setExperimentInactive"); + let experiments = this._currentEnvironment.experiments || {}; + if (id in experiments) { + // Only attempt to notify if a previous annotation was found and removed. + let oldEnvironment = Cu.cloneInto(this._currentEnvironment, {}); + // Remove the experiment annotation. + delete this._currentEnvironment.experiments[id]; + // Notify of the change. + this._onEnvironmentChange( + "experiment-annotation-changed", + oldEnvironment + ); + } + }, + + getActiveExperiments() { + return Cu.cloneInto(this._currentEnvironment.experiments || {}, {}); + }, + + shutdown() { + this._log.trace("shutdown"); + this._shutdown = true; + }, + + /** + * Only used in tests, set the preferences to watch. + * @param aPreferences A map of preferences names and their recording policy. + */ + _watchPreferences(aPreferences) { + this._stopWatchingPrefs(); + this._watchedPrefs = aPreferences; + this._updateSettings(); + this._startWatchingPrefs(); + }, + + /** + * Get an object containing the values for the watched preferences. Depending on the + * policy, the value for a preference or whether it was changed by user is reported. + * + * @return An object containing the preferences values. + */ + _getPrefData() { + let prefData = {}; + for (let [pref, policy] of this._watchedPrefs.entries()) { + let prefValue = this._getPrefValue(pref, policy.what); + + if (prefValue === undefined) { + continue; + } + + prefData[pref] = prefValue; + } + return prefData; + }, + + /** + * Get the value of a preference given the preference name and the policy. + * @param pref Name of the preference. + * @param what Policy of the preference. + * + * @returns The value we need to store for this preference. It can be undefined + * or null if the preference is invalid or has a value set by the user. + */ + _getPrefValue(pref, what) { + // Check the policy for the preference and decide if we need to store its value + // or whether it changed from the default value. + let prefType = Services.prefs.getPrefType(pref); + + if ( + what == TelemetryEnvironment.RECORD_DEFAULTPREF_VALUE || + what == TelemetryEnvironment.RECORD_DEFAULTPREF_STATE + ) { + // For default prefs, make sure they exist + if (prefType == Ci.nsIPrefBranch.PREF_INVALID) { + return undefined; + } + } else if (!Services.prefs.prefHasUserValue(pref)) { + // For user prefs, make sure they are set + return undefined; + } + + if (what == TelemetryEnvironment.RECORD_DEFAULTPREF_STATE) { + return "<set>"; + } else if (what == TelemetryEnvironment.RECORD_PREF_STATE) { + return "<user-set>"; + } else if (prefType == Ci.nsIPrefBranch.PREF_STRING) { + return Services.prefs.getStringPref(pref); + } else if (prefType == Ci.nsIPrefBranch.PREF_BOOL) { + return Services.prefs.getBoolPref(pref); + } else if (prefType == Ci.nsIPrefBranch.PREF_INT) { + return Services.prefs.getIntPref(pref); + } else if (prefType == Ci.nsIPrefBranch.PREF_INVALID) { + return null; + } + throw new Error( + `Unexpected preference type ("${prefType}") for "${pref}".` + ); + }, + + QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]), + + /** + * Start watching the preferences. + */ + _startWatchingPrefs() { + this._log.trace("_startWatchingPrefs - " + this._watchedPrefs); + + Services.prefs.addObserver("", this, true); + }, + + _onPrefChanged(aData) { + this._log.trace("_onPrefChanged"); + let oldEnvironment = Cu.cloneInto(this._currentEnvironment, {}); + this._currentEnvironment.settings.userPrefs[aData] = this._getPrefValue( + aData, + this._watchedPrefs.get(aData).what + ); + this._onEnvironmentChange("pref-changed", oldEnvironment); + }, + + /** + * Do not receive any more change notifications for the preferences. + */ + _stopWatchingPrefs() { + this._log.trace("_stopWatchingPrefs"); + + Services.prefs.removeObserver("", this); + }, + + _addObservers() { + // Watch the search engine change and service topics. + Services.obs.addObserver(this, SESSIONSTORE_WINDOWS_RESTORED_TOPIC); + Services.obs.addObserver(this, COMPOSITOR_CREATED_TOPIC); + Services.obs.addObserver(this, COMPOSITOR_PROCESS_ABORTED_TOPIC); + Services.obs.addObserver(this, DISTRIBUTION_CUSTOMIZATION_COMPLETE_TOPIC); + Services.obs.addObserver(this, GFX_FEATURES_READY_TOPIC); + Services.obs.addObserver(this, SEARCH_ENGINE_MODIFIED_TOPIC); + Services.obs.addObserver(this, SEARCH_SERVICE_TOPIC); + Services.obs.addObserver(this, AUTO_UPDATE_PREF_CHANGE_TOPIC); + Services.obs.addObserver(this, BACKGROUND_UPDATE_PREF_CHANGE_TOPIC); + Services.obs.addObserver(this, SERVICES_INFO_CHANGE_TOPIC); + }, + + _removeObservers() { + Services.obs.removeObserver(this, SESSIONSTORE_WINDOWS_RESTORED_TOPIC); + Services.obs.removeObserver(this, COMPOSITOR_CREATED_TOPIC); + Services.obs.removeObserver(this, COMPOSITOR_PROCESS_ABORTED_TOPIC); + try { + Services.obs.removeObserver( + this, + DISTRIBUTION_CUSTOMIZATION_COMPLETE_TOPIC + ); + } catch (ex) {} + Services.obs.removeObserver(this, GFX_FEATURES_READY_TOPIC); + Services.obs.removeObserver(this, SEARCH_ENGINE_MODIFIED_TOPIC); + Services.obs.removeObserver(this, SEARCH_SERVICE_TOPIC); + Services.obs.removeObserver(this, AUTO_UPDATE_PREF_CHANGE_TOPIC); + Services.obs.removeObserver(this, BACKGROUND_UPDATE_PREF_CHANGE_TOPIC); + Services.obs.removeObserver(this, SERVICES_INFO_CHANGE_TOPIC); + }, + + observe(aSubject, aTopic, aData) { + this._log.trace("observe - aTopic: " + aTopic + ", aData: " + aData); + switch (aTopic) { + case SEARCH_ENGINE_MODIFIED_TOPIC: + if ( + aData != "engine-default" && + aData != "engine-default-private" && + aData != "engine-changed" + ) { + return; + } + if ( + aData == "engine-changed" && + aSubject.QueryInterface(Ci.nsISearchEngine) && + Services.search.defaultEngine != aSubject + ) { + return; + } + // Record the new default search choice and send the change notification. + this._onSearchEngineChange(); + break; + case SEARCH_SERVICE_TOPIC: + if (aData != "init-complete") { + return; + } + // Now that the search engine init is complete, record the default search choice. + this._canQuerySearch = true; + this._updateSearchEngine(); + break; + case GFX_FEATURES_READY_TOPIC: + case COMPOSITOR_CREATED_TOPIC: + // Full graphics information is not available until we have created at + // least one off-main-thread-composited window. Thus we wait for the + // first compositor to be created and then query nsIGfxInfo again. + this._updateGraphicsFeatures(); + break; + case COMPOSITOR_PROCESS_ABORTED_TOPIC: + // Our compositor process has been killed for whatever reason, so refresh + // our reported graphics features and trigger an environment change. + this._onCompositorProcessAborted(); + break; + case DISTRIBUTION_CUSTOMIZATION_COMPLETE_TOPIC: + // Distribution customizations are applied after final-ui-startup. query + // partner prefs again when they are ready. + this._updatePartner(); + Services.obs.removeObserver(this, aTopic); + break; + case SESSIONSTORE_WINDOWS_RESTORED_TOPIC: + this._sessionWasRestored = true; + // Make sure to initialize the search service once we've done restoring + // the windows, so that we don't risk loosing search data. + Services.search.init(); + // The default browser check could take some time, so just call it after + // the session was restored. + this._updateDefaultBrowser(); + break; + case PREF_CHANGED_TOPIC: + let options = this._watchedPrefs.get(aData); + if (options && !options.requiresRestart) { + this._onPrefChanged(aData); + } + break; + case AUTO_UPDATE_PREF_CHANGE_TOPIC: + this._currentEnvironment.settings.update.autoDownload = aData == "true"; + break; + case BACKGROUND_UPDATE_PREF_CHANGE_TOPIC: + this._currentEnvironment.settings.update.background = aData == "true"; + break; + case SERVICES_INFO_CHANGE_TOPIC: + this._updateServicesInfo(); + break; + } + }, + + /** + * Update the default search engine value. + */ + _updateSearchEngine() { + if (!this._canQuerySearch) { + this._log.trace("_updateSearchEngine - ignoring early call"); + return; + } + + this._log.trace( + "_updateSearchEngine - isInitialized: " + Services.search.isInitialized + ); + if (!Services.search.isInitialized) { + return; + } + + // Make sure we have a settings section. + this._currentEnvironment.settings = this._currentEnvironment.settings || {}; + + // Update the search engine entry in the current environment. + const defaultEngineInfo = Services.search.getDefaultEngineInfo(); + this._currentEnvironment.settings.defaultSearchEngine = + defaultEngineInfo.defaultSearchEngine; + this._currentEnvironment.settings.defaultSearchEngineData = { + ...defaultEngineInfo.defaultSearchEngineData, + }; + if ("defaultPrivateSearchEngine" in defaultEngineInfo) { + this._currentEnvironment.settings.defaultPrivateSearchEngine = + defaultEngineInfo.defaultPrivateSearchEngine; + } + if ("defaultPrivateSearchEngineData" in defaultEngineInfo) { + this._currentEnvironment.settings.defaultPrivateSearchEngineData = { + ...defaultEngineInfo.defaultPrivateSearchEngineData, + }; + } + }, + + /** + * Update the default search engine value and trigger the environment change. + */ + _onSearchEngineChange() { + this._log.trace("_onSearchEngineChange"); + + // Finally trigger the environment change notification. + let oldEnvironment = Cu.cloneInto(this._currentEnvironment, {}); + this._updateSearchEngine(); + this._onEnvironmentChange("search-engine-changed", oldEnvironment); + }, + + /** + * Refresh the Telemetry environment and trigger an environment change due to + * a change in compositor process (normally this will mean we've fallen back + * from out-of-process to in-process compositing). + */ + _onCompositorProcessAborted() { + this._log.trace("_onCompositorProcessAborted"); + + // Trigger the environment change notification. + let oldEnvironment = Cu.cloneInto(this._currentEnvironment, {}); + this._updateGraphicsFeatures(); + this._onEnvironmentChange("gfx-features-changed", oldEnvironment); + }, + + /** + * Update the graphics features object. + */ + _updateGraphicsFeatures() { + let gfxData = this._currentEnvironment.system.gfx; + try { + let gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo); + gfxData.features = gfxInfo.getFeatures(); + } catch (e) { + this._log.error("nsIGfxInfo.getFeatures() caught error", e); + } + }, + + /** + * Update the partner prefs. + */ + _updatePartner() { + this._currentEnvironment.partner = this._getPartner(); + }, + + /** + * Get the build data in object form. + * @return Object containing the build data. + */ + _getBuild() { + let buildData = { + applicationId: Services.appinfo.ID || null, + applicationName: Services.appinfo.name || null, + architecture: Services.sysinfo.get("arch"), + buildId: Services.appinfo.appBuildID || null, + version: Services.appinfo.version || null, + vendor: Services.appinfo.vendor || null, + displayVersion: AppConstants.MOZ_APP_VERSION_DISPLAY || null, + platformVersion: Services.appinfo.platformVersion || null, + xpcomAbi: Services.appinfo.XPCOMABI, + updaterAvailable: AppConstants.MOZ_UPDATER, + }; + + return buildData; + }, + + /** + * Determine if we're the default browser. + * @returns null on error, true if we are the default browser, or false otherwise. + */ + _isDefaultBrowser() { + let isDefault = (service, ...args) => { + try { + return !!service.isDefaultBrowser(...args); + } catch (ex) { + this._log.error( + "_isDefaultBrowser - Could not determine if default browser", + ex + ); + return null; + } + }; + + if (!("@mozilla.org/browser/shell-service;1" in Cc)) { + this._log.info( + "_isDefaultBrowser - Could not obtain browser shell service" + ); + return null; + } + + try { + let { ShellService } = ChromeUtils.importESModule( + "resource:///modules/ShellService.sys.mjs" + ); + // This uses the same set of flags used by the pref pane. + return isDefault(ShellService, false, true); + } catch (ex) { + this._log.error("_isDefaultBrowser - Could not obtain shell service JSM"); + } + + try { + let shellService = Cc["@mozilla.org/browser/shell-service;1"].getService( + Ci.nsIShellService + ); + // This uses the same set of flags used by the pref pane. + return isDefault(shellService, true); + } catch (ex) { + this._log.error("_isDefaultBrowser - Could not obtain shell service", ex); + return null; + } + }, + + _updateDefaultBrowser() { + if (AppConstants.platform === "android") { + return; + } + // Make sure to have a settings section. + this._currentEnvironment.settings = this._currentEnvironment.settings || {}; + this._currentEnvironment.settings.isDefaultBrowser = this + ._sessionWasRestored + ? this._isDefaultBrowser() + : null; + }, + + /** + * Update the cached settings data. + */ + _updateSettings() { + let updateChannel = null; + try { + updateChannel = Utils.getUpdateChannel(); + } catch (e) {} + + this._currentEnvironment.settings = { + blocklistEnabled: Services.prefs.getBoolPref( + PREF_BLOCKLIST_ENABLED, + true + ), + e10sEnabled: Services.appinfo.browserTabsRemoteAutostart, + e10sMultiProcesses: Services.appinfo.maxWebProcessCount, + fissionEnabled: Services.appinfo.fissionAutostart, + telemetryEnabled: Utils.isTelemetryEnabled, + locale: getBrowserLocale(), + // We need to wait for browser-delayed-startup-finished to ensure that the locales + // have settled, once that's happened we can get the intl data directly. + intl: Policy._intlLoaded ? getIntlSettings() : {}, + update: { + channel: updateChannel, + enabled: !Services.policies || Services.policies.isAllowed("appUpdate"), + }, + userPrefs: this._getPrefData(), + sandbox: this._getSandboxData(), + }; + + // Services.appinfo.launcherProcessState is not available in all build + // configurations, in which case an exception may be thrown. + try { + this._currentEnvironment.settings.launcherProcessState = + Services.appinfo.launcherProcessState; + } catch (e) {} + + this._currentEnvironment.settings.addonCompatibilityCheckEnabled = + AddonManager.checkCompatibility; + + if (AppConstants.MOZ_BUILD_APP == "browser") { + this._updateAttribution(); + } + this._updateDefaultBrowser(); + this._updateSearchEngine(); + this._loadAsyncUpdateSettingsFromCache(); + }, + + _getSandboxData() { + let effectiveContentProcessLevel = null; + let contentWin32kLockdownState = null; + try { + let sandboxSettings = Cc[ + "@mozilla.org/sandbox/sandbox-settings;1" + ].getService(Ci.mozISandboxSettings); + effectiveContentProcessLevel = + sandboxSettings.effectiveContentSandboxLevel; + + // The possible values for this are defined in the ContentWin32kLockdownState + // enum in security/sandbox/common/SandboxSettings.h + contentWin32kLockdownState = sandboxSettings.contentWin32kLockdownState; + } catch (e) {} + return { + effectiveContentProcessLevel, + contentWin32kLockdownState, + }; + }, + + /** + * Update the cached profile data. + * @returns Promise<> resolved when the I/O is complete. + */ + async _updateProfile() { + let profileAccessor = await lazy.ProfileAge(); + + let creationDate = await profileAccessor.created; + let resetDate = await profileAccessor.reset; + let firstUseDate = await profileAccessor.firstUse; + + this._currentEnvironment.profile.creationDate = + Utils.millisecondsToDays(creationDate); + if (resetDate) { + this._currentEnvironment.profile.resetDate = + Utils.millisecondsToDays(resetDate); + } + if (firstUseDate) { + this._currentEnvironment.profile.firstUseDate = + Utils.millisecondsToDays(firstUseDate); + } + }, + + /** + * Load the attribution data object and updates the environment. + * @returns Promise<> resolved when the I/O is complete. + */ + async _loadAttributionAsync() { + try { + await lazy.AttributionCode.getAttrDataAsync(); + } catch (e) { + // The AttributionCode.sys.mjs module might not be always available + // (e.g. tests). Gracefully handle this. + return; + } + this._updateAttribution(); + }, + + /** + * Update the environment with the cached attribution data. + */ + _updateAttribution() { + let data = null; + try { + data = lazy.AttributionCode.getCachedAttributionData(); + } catch (e) { + // The AttributionCode.sys.mjs module might not be always available + // (e.g. tests). Gracefully handle this. + } + + if (!data || !Object.keys(data).length) { + return; + } + + let attributionData = {}; + for (let key in data) { + attributionData[key] = + // At least one of these may be boolean, and limitStringToLength + // returns null for non-string inputs. + typeof data[key] === "string" + ? limitStringToLength(data[key], MAX_ATTRIBUTION_STRING_LENGTH) + : data[key]; + } + this._currentEnvironment.settings.attribution = attributionData; + }, + + /** + * Load the per-installation update settings, cache them, and add them to the + * environment. + */ + async _loadAsyncUpdateSettings() { + if (AppConstants.MOZ_UPDATER) { + this._updateAutoDownloadCache = + await UpdateUtils.getAppUpdateAutoEnabled(); + this._updateBackgroundCache = await UpdateUtils.readUpdateConfigSetting( + "app.update.background.enabled" + ); + } else { + this._updateAutoDownloadCache = false; + this._updateBackgroundCache = false; + } + this._loadAsyncUpdateSettingsFromCache(); + }, + + /** + * Update the environment with the cached values for per-installation update + * settings. + */ + _loadAsyncUpdateSettingsFromCache() { + if (this._updateAutoDownloadCache !== undefined) { + this._currentEnvironment.settings.update.autoDownload = + this._updateAutoDownloadCache; + } + if (this._updateBackgroundCache !== undefined) { + this._currentEnvironment.settings.update.background = + this._updateBackgroundCache; + } + }, + + /** + * Get i18n data about the system. + * @return A promise of completion. + */ + async _loadIntlData() { + // Wait for the startup topic. + await Policy._browserDelayedStartup(); + this._currentEnvironment.settings.intl = getIntlSettings(); + Policy._intlLoaded = true; + }, + // This exists as a separate function for testing. + async _getFxaSignedInUser() { + return lazy.fxAccounts.getSignedInUser(); + }, + + async _updateServicesInfo() { + let syncEnabled = false; + let accountEnabled = false; + let weaveService = + Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject; + syncEnabled = weaveService && weaveService.enabled; + if (syncEnabled) { + // All sync users are account users, definitely. + accountEnabled = true; + } else { + // Not all account users are sync users. See if they're signed into FxA. + try { + let user = await this._getFxaSignedInUser(); + if (user) { + accountEnabled = true; + } + } catch (e) { + // We don't know. This might be a transient issue which will clear + // itself up later, but the information in telemetry is quite possibly stale + // (this is called from a change listener), so clear it out to avoid + // reporting data which might be wrong until we can figure it out. + delete this._currentEnvironment.services; + this._log.error("_updateServicesInfo() caught error", e); + return; + } + } + this._currentEnvironment.services = { + accountEnabled, + syncEnabled, + }; + }, + + /** + * Get the partner data in object form. + * @return Object containing the partner data. + */ + _getPartner() { + let defaults = Services.prefs.getDefaultBranch(null); + let partnerData = { + distributionId: defaults.getStringPref(PREF_DISTRIBUTION_ID, null), + distributionVersion: defaults.getCharPref( + PREF_DISTRIBUTION_VERSION, + null + ), + partnerId: defaults.getCharPref(PREF_PARTNER_ID, null), + distributor: defaults.getCharPref(PREF_DISTRIBUTOR, null), + distributorChannel: defaults.getCharPref(PREF_DISTRIBUTOR_CHANNEL, null), + }; + + // Get the PREF_APP_PARTNER_BRANCH branch and append its children to partner data. + let partnerBranch = Services.prefs.getDefaultBranch( + PREF_APP_PARTNER_BRANCH + ); + partnerData.partnerNames = partnerBranch.getChildList(""); + + return partnerData; + }, + + _cpuData: null, + /** + * Get the CPU information. + * @return Object containing the CPU information data. + */ + _getCPUData() { + if (this._cpuData) { + return this._cpuData; + } + + this._cpuData = {}; + + const CPU_EXTENSIONS = [ + "hasMMX", + "hasSSE", + "hasSSE2", + "hasSSE3", + "hasSSSE3", + "hasSSE4A", + "hasSSE4_1", + "hasSSE4_2", + "hasAVX", + "hasAVX2", + "hasAES", + "hasEDSP", + "hasARMv6", + "hasARMv7", + "hasNEON", + "hasUserCET", + ]; + + // Enumerate the available CPU extensions. + let availableExts = []; + for (let ext of CPU_EXTENSIONS) { + if (getSysinfoProperty(ext, false)) { + availableExts.push(ext); + } + } + + this._cpuData.extensions = availableExts; + + return this._cpuData; + }, + + _processData: null, + /** + * Get the process information. + * @return Object containing the process information data. + */ + _getProcessData() { + if (this._processData) { + return this._processData; + } + return {}; + }, + + /** + * Get the device information, if we are on a portable device. + * @return Object containing the device information data, or null if + * not a portable device. + */ + _getDeviceData() { + if (AppConstants.platform !== "android") { + return null; + } + + return { + model: getSysinfoProperty("device", null), + manufacturer: getSysinfoProperty("manufacturer", null), + hardware: getSysinfoProperty("hardware", null), + isTablet: getSysinfoProperty("tablet", null), + }; + }, + + _osData: null, + /** + * Get the OS information. + * @return Object containing the OS data. + */ + _getOSData() { + if (this._osData) { + return this._osData; + } + this._osData = { + name: forceToStringOrNull(getSysinfoProperty("name", null)), + version: forceToStringOrNull(getSysinfoProperty("version", null)), + locale: forceToStringOrNull(getSystemLocale()), + }; + + if (AppConstants.platform == "android") { + this._osData.kernelVersion = forceToStringOrNull( + getSysinfoProperty("kernel_version", null) + ); + } else if (AppConstants.platform === "win") { + // The path to the "UBR" key, queried to get additional version details on Windows. + const WINDOWS_UBR_KEY_PATH = + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"; + + let versionInfo = lazy.WindowsVersionInfo.get({ throwOnError: false }); + this._osData.servicePackMajor = versionInfo.servicePackMajor; + this._osData.servicePackMinor = versionInfo.servicePackMinor; + this._osData.windowsBuildNumber = versionInfo.buildNumber; + // We only need the UBR if we're at or above Windows 10. + if ( + typeof this._osData.version === "string" && + Services.vc.compare(this._osData.version, "10") >= 0 + ) { + // Query the UBR key and only add it to the environment if it's available. + // |readRegKey| doesn't throw, but rather returns 'undefined' on error. + let ubr = lazy.WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + WINDOWS_UBR_KEY_PATH, + "UBR", + Ci.nsIWindowsRegKey.WOW64_64 + ); + this._osData.windowsUBR = ubr !== undefined ? ubr : null; + } + } + + return this._osData; + }, + + _hddData: null, + /** + * Get the HDD information. + * @return Object containing the HDD data. + */ + _getHDDData() { + if (this._hddData) { + return this._hddData; + } + let nullData = { model: null, revision: null, type: null }; + return { profile: nullData, binary: nullData, system: nullData }; + }, + + /** + * Get registered security product information. + * @return Object containing the security product data + */ + _getSecurityAppData() { + const maxStringLength = 256; + + const keys = [ + ["registeredAntiVirus", "antivirus"], + ["registeredAntiSpyware", "antispyware"], + ["registeredFirewall", "firewall"], + ]; + + let result = {}; + + for (let [inKey, outKey] of keys) { + let prop = getSysinfoProperty(inKey, null); + if (prop) { + prop = limitStringToLength(prop, maxStringLength).split(";"); + } + + result[outKey] = prop; + } + + return result; + }, + + /** + * Get the GFX information. + * @return Object containing the GFX data. + */ + _getGFXData() { + let gfxData = { + D2DEnabled: getGfxField("D2DEnabled", null), + DWriteEnabled: getGfxField("DWriteEnabled", null), + ContentBackend: getGfxField("ContentBackend", null), + Headless: getGfxField("isHeadless", null), + EmbeddedInFirefoxReality: getGfxField("EmbeddedInFirefoxReality", null), + // The following line is disabled due to main thread jank and will be enabled + // again as part of bug 1154500. + // DWriteVersion: getGfxField("DWriteVersion", null), + adapters: [], + monitors: [], + features: {}, + }; + + if (AppConstants.platform !== "android") { + let gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo); + try { + gfxData.monitors = gfxInfo.getMonitors(); + } catch (e) { + this._log.error("nsIGfxInfo.getMonitors() caught error", e); + } + } + + try { + let gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo); + gfxData.features = gfxInfo.getFeatures(); + } catch (e) { + this._log.error("nsIGfxInfo.getFeatures() caught error", e); + } + + // GfxInfo does not yet expose a way to iterate through all the adapters. + gfxData.adapters.push(getGfxAdapter("")); + gfxData.adapters[0].GPUActive = true; + + // If we have a second adapter add it to the gfxData.adapters section. + let hasGPU2 = getGfxField("adapterDeviceID2", null) !== null; + if (!hasGPU2) { + this._log.trace("_getGFXData - Only one display adapter detected."); + return gfxData; + } + + this._log.trace("_getGFXData - Two display adapters detected."); + + gfxData.adapters.push(getGfxAdapter("2")); + gfxData.adapters[1].GPUActive = getGfxField("isGPU2Active", null); + + return gfxData; + }, + + /** + * Get the system data in object form. + * @return Object containing the system data. + */ + _getSystem() { + let memoryMB = getSysinfoProperty("memsize", null); + if (memoryMB) { + // Send RAM size in megabytes. Rounding because sysinfo doesn't + // always provide RAM in multiples of 1024. + memoryMB = Math.round(memoryMB / 1024 / 1024); + } + + let virtualMB = getSysinfoProperty("virtualmemsize", null); + if (virtualMB) { + // Send the total virtual memory size in megabytes. Rounding because + // sysinfo doesn't always provide RAM in multiples of 1024. + virtualMB = Math.round(virtualMB / 1024 / 1024); + } + + let data = { + memoryMB, + virtualMaxMB: virtualMB, + cpu: this._getCPUData(), + os: this._getOSData(), + hdd: this._getHDDData(), + gfx: this._getGFXData(), + appleModelId: getSysinfoProperty("appleModelId", null), + hasWinPackageId: getSysinfoProperty("hasWinPackageId", null), + }; + + if (AppConstants.platform === "win") { + // This is only sent for Mozilla produced MSIX packages + let winPackageFamilyName = getSysinfoProperty("winPackageFamilyName", ""); + if ( + winPackageFamilyName.startsWith("Mozilla.") || + winPackageFamilyName.startsWith("MozillaCorporation.") + ) { + data = { winPackageFamilyName, ...data }; + } + data = { ...this._getProcessData(), ...data }; + } else if (AppConstants.platform == "android") { + data.device = this._getDeviceData(); + } + + // Windows 8+ + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + data.sec = this._getSecurityAppData(); + } + + return data; + }, + + _onEnvironmentChange(what, oldEnvironment) { + this._log.trace("_onEnvironmentChange for " + what); + + // We are already skipping change events in _checkChanges if there is a pending change task running. + if (this._shutdown) { + this._log.trace("_onEnvironmentChange - Already shut down."); + return; + } + + if (ObjectUtils.deepEqual(this._currentEnvironment, oldEnvironment)) { + this._log.trace("_onEnvironmentChange - Environment didn't change"); + return; + } + + for (let [name, listener] of this._changeListeners) { + try { + this._log.debug("_onEnvironmentChange - calling " + name); + listener(what, oldEnvironment); + } catch (e) { + this._log.error( + "_onEnvironmentChange - listener " + name + " caught error", + e + ); + } + } + }, + + reset() { + this._shutdown = false; + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetryReportingPolicy.sys.mjs b/toolkit/components/telemetry/app/TelemetryReportingPolicy.sys.mjs new file mode 100644 index 0000000000..e7fd747902 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryReportingPolicy.sys.mjs @@ -0,0 +1,587 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +import { Observers } from "resource://services-common/observers.sys.mjs"; +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TelemetrySend: "resource://gre/modules/TelemetrySend.sys.mjs", +}); + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryReportingPolicy::"; + +// Oldest year to allow in date preferences. The FHR infobar was implemented in +// 2012 and no dates older than that should be encountered. +const OLDEST_ALLOWED_ACCEPTANCE_YEAR = 2012; + +const PREF_BRANCH = "datareporting.policy."; + +// The following preferences are deprecated and will be purged during the preferences +// migration process. +const DEPRECATED_FHR_PREFS = [ + PREF_BRANCH + "dataSubmissionPolicyAccepted", + PREF_BRANCH + "dataSubmissionPolicyBypassAcceptance", + PREF_BRANCH + "dataSubmissionPolicyResponseType", + PREF_BRANCH + "dataSubmissionPolicyResponseTime", +]; + +// How much time until we display the data choices notification bar, on the first run. +const NOTIFICATION_DELAY_FIRST_RUN_MSEC = 60 * 1000; // 60s +// Same as above, for the next runs. +const NOTIFICATION_DELAY_NEXT_RUNS_MSEC = 10 * 1000; // 10s + +/** + * This is a policy object used to override behavior within this module. + * Tests override properties on this object to allow for control of behavior + * that would otherwise be very hard to cover. + */ +export var Policy = { + now: () => new Date(), + setShowInfobarTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearShowInfobarTimeout: id => clearTimeout(id), + fakeSessionRestoreNotification: () => { + TelemetryReportingPolicyImpl.observe( + null, + "sessionstore-windows-restored", + null + ); + }, +}; + +/** + * Represents a request to display data policy. + * + * Receivers of these instances are expected to call one or more of the on* + * functions when events occur. + * + * When one of these requests is received, the first thing a callee should do + * is present notification to the user of the data policy. When the notice + * is displayed to the user, the callee should call `onUserNotifyComplete`. + * + * If for whatever reason the callee could not display a notice, + * it should call `onUserNotifyFailed`. + * + * @param {Object} aLog The log object used to log the error in case of failures. + */ +function NotifyPolicyRequest(aLog) { + this._log = aLog; +} + +NotifyPolicyRequest.prototype = Object.freeze({ + /** + * Called when the user is notified of the policy. + */ + onUserNotifyComplete() { + return TelemetryReportingPolicyImpl._userNotified(); + }, + + /** + * Called when there was an error notifying the user about the policy. + * + * @param error + * (Error) Explains what went wrong. + */ + onUserNotifyFailed(error) { + this._log.error("onUserNotifyFailed - " + error); + }, +}); + +export var TelemetryReportingPolicy = { + // The current policy version number. If the version number stored in the prefs + // is smaller than this, data upload will be disabled until the user is re-notified + // about the policy changes. + DEFAULT_DATAREPORTING_POLICY_VERSION: 1, + + /** + * Setup the policy. + */ + setup() { + return TelemetryReportingPolicyImpl.setup(); + }, + + /** + * Shutdown and clear the policy. + */ + shutdown() { + return TelemetryReportingPolicyImpl.shutdown(); + }, + + /** + * Check if we are allowed to upload data. In order to submit data both these conditions + * should be true: + * - The data submission preference should be true. + * - The datachoices infobar should have been displayed. + * + * @return {Boolean} True if we are allowed to upload data, false otherwise. + */ + canUpload() { + return TelemetryReportingPolicyImpl.canUpload(); + }, + + /** + * Check if this is the first time the browser ran. + */ + isFirstRun() { + return TelemetryReportingPolicyImpl.isFirstRun(); + }, + + /** + * Test only method, restarts the policy. + */ + reset() { + return TelemetryReportingPolicyImpl.reset(); + }, + + /** + * Test only method, used to check if user is notified of the policy in tests. + */ + testIsUserNotified() { + return TelemetryReportingPolicyImpl.isUserNotifiedOfCurrentPolicy; + }, + + /** + * Test only method, used to simulate the infobar being shown in xpcshell tests. + */ + testInfobarShown() { + return TelemetryReportingPolicyImpl._userNotified(); + }, + + /** + * Test only method, used to trigger an update of the "first run" state. + */ + testUpdateFirstRun() { + TelemetryReportingPolicyImpl._isFirstRun = undefined; + TelemetryReportingPolicyImpl.isFirstRun(); + }, +}; + +var TelemetryReportingPolicyImpl = { + _logger: null, + // Keep track of the notification status if user wasn't notified already. + _notificationInProgress: false, + // The timer used to show the datachoices notification at startup. + _startupNotificationTimerId: null, + // Keep track of the first session state, as the related preference + // is flipped right after the browser starts. + _isFirstRun: undefined, + + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + + return this._logger; + }, + + /** + * Get the date the policy was notified. + * @return {Object} A date object or null on errors. + */ + get dataSubmissionPolicyNotifiedDate() { + let prefString = Services.prefs.getStringPref( + TelemetryUtils.Preferences.AcceptedPolicyDate, + "0" + ); + let valueInteger = parseInt(prefString, 10); + + // Bail out if we didn't store any value yet. + if (valueInteger == 0) { + this._log.info( + "get dataSubmissionPolicyNotifiedDate - No date stored yet." + ); + return null; + } + + // If an invalid value is saved in the prefs, bail out too. + if (Number.isNaN(valueInteger)) { + this._log.error( + "get dataSubmissionPolicyNotifiedDate - Invalid date stored." + ); + return null; + } + + // Make sure the notification date is newer then the oldest allowed date. + let date = new Date(valueInteger); + if (date.getFullYear() < OLDEST_ALLOWED_ACCEPTANCE_YEAR) { + this._log.error( + "get dataSubmissionPolicyNotifiedDate - The stored date is too old." + ); + return null; + } + + return date; + }, + + /** + * Set the date the policy was notified. + * @param {Object} aDate A valid date object. + */ + set dataSubmissionPolicyNotifiedDate(aDate) { + this._log.trace("set dataSubmissionPolicyNotifiedDate - aDate: " + aDate); + + if (!aDate || aDate.getFullYear() < OLDEST_ALLOWED_ACCEPTANCE_YEAR) { + this._log.error( + "set dataSubmissionPolicyNotifiedDate - Invalid notification date." + ); + return; + } + + Services.prefs.setStringPref( + TelemetryUtils.Preferences.AcceptedPolicyDate, + aDate.getTime().toString() + ); + }, + + /** + * Whether submission of data is allowed. + * + * This is the master switch for remote server communication. If it is + * false, we never request upload or deletion. + */ + get dataSubmissionEnabled() { + // Default is true because we are opt-out. + return Services.prefs.getBoolPref( + TelemetryUtils.Preferences.DataSubmissionEnabled, + true + ); + }, + + get currentPolicyVersion() { + return Services.prefs.getIntPref( + TelemetryUtils.Preferences.CurrentPolicyVersion, + TelemetryReportingPolicy.DEFAULT_DATAREPORTING_POLICY_VERSION + ); + }, + + /** + * The minimum policy version which for dataSubmissionPolicyAccepted to + * to be valid. + */ + get minimumPolicyVersion() { + const minPolicyVersion = Services.prefs.getIntPref( + TelemetryUtils.Preferences.MinimumPolicyVersion, + 1 + ); + + // First check if the current channel has a specific minimum policy version. If not, + // use the general minimum policy version. + let channel = ""; + try { + channel = TelemetryUtils.getUpdateChannel(); + } catch (e) { + this._log.error( + "minimumPolicyVersion - Unable to retrieve the current channel." + ); + return minPolicyVersion; + } + const channelPref = + TelemetryUtils.Preferences.MinimumPolicyVersion + ".channel-" + channel; + return Services.prefs.getIntPref(channelPref, minPolicyVersion); + }, + + get dataSubmissionPolicyAcceptedVersion() { + return Services.prefs.getIntPref( + TelemetryUtils.Preferences.AcceptedPolicyVersion, + 0 + ); + }, + + set dataSubmissionPolicyAcceptedVersion(value) { + Services.prefs.setIntPref( + TelemetryUtils.Preferences.AcceptedPolicyVersion, + value + ); + }, + + /** + * Checks to see if the user has been notified about data submission + * @return {Bool} True if user has been notified and the notification is still valid, + * false otherwise. + */ + get isUserNotifiedOfCurrentPolicy() { + // If we don't have a sane notification date, the user was not notified yet. + if ( + !this.dataSubmissionPolicyNotifiedDate || + this.dataSubmissionPolicyNotifiedDate.getTime() <= 0 + ) { + return false; + } + + // The accepted policy version should not be less than the minimum policy version. + if (this.dataSubmissionPolicyAcceptedVersion < this.minimumPolicyVersion) { + return false; + } + + // Otherwise the user was already notified. + return true; + }, + + /** + * Test only method, restarts the policy. + */ + reset() { + this.shutdown(); + this._isFirstRun = undefined; + return this.setup(); + }, + + /** + * Setup the policy. + */ + setup() { + this._log.trace("setup"); + + // Migrate the data choices infobar, if needed. + this._migratePreferences(); + + // Add the event observers. + Services.obs.addObserver(this, "sessionstore-windows-restored"); + }, + + /** + * Clean up the reporting policy. + */ + shutdown() { + this._log.trace("shutdown"); + + this._detachObservers(); + + Policy.clearShowInfobarTimeout(this._startupNotificationTimerId); + }, + + /** + * Detach the observers that were attached during setup. + */ + _detachObservers() { + Services.obs.removeObserver(this, "sessionstore-windows-restored"); + }, + + /** + * Check if we are allowed to upload data. In order to submit data both these conditions + * should be true: + * - The data submission preference should be true. + * - The datachoices infobar should have been displayed. + * + * @return {Boolean} True if we are allowed to upload data, false otherwise. + */ + canUpload() { + // If data submission is disabled, there's no point in showing the infobar. Just + // forbid to upload. + if (!this.dataSubmissionEnabled) { + return false; + } + + // Submission is enabled. We enable upload if user is notified or we need to bypass + // the policy. + const bypassNotification = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.BypassNotification, + false + ); + return this.isUserNotifiedOfCurrentPolicy || bypassNotification; + }, + + isFirstRun() { + if (this._isFirstRun === undefined) { + this._isFirstRun = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.FirstRun, + true + ); + } + return this._isFirstRun; + }, + + /** + * Migrate the data policy preferences, if needed. + */ + _migratePreferences() { + // Current prefs are mostly the same than the old ones, except for some deprecated ones. + for (let pref of DEPRECATED_FHR_PREFS) { + Services.prefs.clearUserPref(pref); + } + }, + + /** + * Determine whether the user should be notified. + */ + _shouldNotify() { + if (!this.dataSubmissionEnabled) { + this._log.trace( + "_shouldNotify - Data submission disabled by the policy." + ); + return false; + } + + const bypassNotification = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.BypassNotification, + false + ); + if (this.isUserNotifiedOfCurrentPolicy || bypassNotification) { + this._log.trace( + "_shouldNotify - User already notified or bypassing the policy." + ); + return false; + } + + if (this._notificationInProgress) { + this._log.trace( + "_shouldNotify - User not notified, notification already in progress." + ); + return false; + } + + return true; + }, + + /** + * Show the data choices infobar if needed. + */ + _showInfobar() { + if (!this._shouldNotify()) { + return; + } + + this._log.trace("_showInfobar - User not notified, notifying now."); + this._notificationInProgress = true; + let request = new NotifyPolicyRequest(this._log); + Observers.notify("datareporting:notify-data-policy:request", request); + }, + + /** + * Called when the user is notified with the infobar or otherwise. + */ + _userNotified() { + this._log.trace("_userNotified"); + this._recordNotificationData(); + lazy.TelemetrySend.notifyCanUpload(); + }, + + /** + * Record date and the version of the accepted policy. + */ + _recordNotificationData() { + this._log.trace("_recordNotificationData"); + this.dataSubmissionPolicyNotifiedDate = Policy.now(); + this.dataSubmissionPolicyAcceptedVersion = this.currentPolicyVersion; + // The user was notified and the notification data saved: the notification + // is no longer in progress. + this._notificationInProgress = false; + }, + + /** + * Try to open the privacy policy in a background tab instead of showing the infobar. + */ + _openFirstRunPage() { + if (!this._shouldNotify()) { + return false; + } + + let firstRunPolicyURL = Services.prefs.getStringPref( + TelemetryUtils.Preferences.FirstRunURL, + "" + ); + if (!firstRunPolicyURL) { + return false; + } + firstRunPolicyURL = Services.urlFormatter.formatURL(firstRunPolicyURL); + + const { BrowserWindowTracker } = ChromeUtils.import( + "resource:///modules/BrowserWindowTracker.jsm" + ); + let win = BrowserWindowTracker.getTopWindow(); + + if (!win) { + this._log.info( + "Couldn't find browser window to open first-run page. Falling back to infobar." + ); + return false; + } + + // We'll consider the user notified once the privacy policy has been loaded + // in a background tab even if that tab hasn't been selected. + let tab; + let progressListener = {}; + progressListener.onStateChange = ( + aBrowser, + aWebProgress, + aRequest, + aStateFlags, + aStatus + ) => { + if ( + aWebProgress.isTopLevel && + tab && + tab.linkedBrowser == aBrowser && + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + let uri = aBrowser.documentURI; + if ( + uri && + !/^about:(blank|neterror|certerror|blocked)/.test(uri.spec) + ) { + this._userNotified(); + } else { + this._log.info( + "Failed to load first-run page. Falling back to infobar." + ); + this._showInfobar(); + } + removeListeners(); + } + }; + + let removeListeners = () => { + win.removeEventListener("unload", removeListeners); + win.gBrowser.removeTabsProgressListener(progressListener); + }; + + win.addEventListener("unload", removeListeners); + win.gBrowser.addTabsProgressListener(progressListener); + + tab = win.gBrowser.addTab(firstRunPolicyURL, { + inBackground: true, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + return true; + }, + + observe(aSubject, aTopic, aData) { + if (aTopic != "sessionstore-windows-restored") { + return; + } + + if (this.isFirstRun()) { + // We're performing the first run, flip firstRun preference for subsequent runs. + Services.prefs.setBoolPref(TelemetryUtils.Preferences.FirstRun, false); + + try { + if (this._openFirstRunPage()) { + return; + } + } catch (e) { + this._log.error("Failed to open privacy policy tab: " + e); + } + } + + // Show the info bar. + const delay = this.isFirstRun() + ? NOTIFICATION_DELAY_FIRST_RUN_MSEC + : NOTIFICATION_DELAY_NEXT_RUNS_MSEC; + + this._startupNotificationTimerId = Policy.setShowInfobarTimeout( + // Calling |canUpload| eventually shows the infobar, if needed. + () => this._showInfobar(), + delay + ); + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetryScheduler.sys.mjs b/toolkit/components/telemetry/app/TelemetryScheduler.sys.mjs new file mode 100644 index 0000000000..2fc94dc8fc --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryScheduler.sys.mjs @@ -0,0 +1,422 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +import { TelemetrySession } from "resource://gre/modules/TelemetrySession.sys.mjs"; +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"], +}); + +const MIN_SUBSESSION_LENGTH_MS = + Services.prefs.getIntPref("toolkit.telemetry.minSubsessionLength", 5 * 60) * + 1000; + +const LOGGER_NAME = "Toolkit.Telemetry"; + +// Seconds of idle time before pinging. +// On idle-daily a gather-telemetry notification is fired, during it probes can +// start asynchronous tasks to gather data. +const IDLE_TIMEOUT_SECONDS = Services.prefs.getIntPref( + "toolkit.telemetry.idleTimeout", + 5 * 60 +); + +// Execute a scheduler tick every 5 minutes. +const SCHEDULER_TICK_INTERVAL_MS = + Services.prefs.getIntPref( + "toolkit.telemetry.scheduler.tickInterval", + 5 * 60 + ) * 1000; +// When user is idle, execute a scheduler tick every 60 minutes. +const SCHEDULER_TICK_IDLE_INTERVAL_MS = + Services.prefs.getIntPref( + "toolkit.telemetry.scheduler.idleTickInterval", + 60 * 60 + ) * 1000; + +// The maximum time (ms) until the tick should moved from the idle +// queue to the regular queue if it hasn't been executed yet. +const SCHEDULER_TICK_MAX_IDLE_DELAY_MS = 60 * 1000; + +// The frequency at which we persist session data to the disk to prevent data loss +// in case of aborted sessions (currently 5 minutes). +const ABORTED_SESSION_UPDATE_INTERVAL_MS = 5 * 60 * 1000; + +// The tolerance we have when checking if it's midnight (15 minutes). +const SCHEDULER_MIDNIGHT_TOLERANCE_MS = 15 * 60 * 1000; + +/** + * This is a policy object used to override behavior for testing. + */ +export var Policy = { + now: () => new Date(), + setSchedulerTickTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearSchedulerTickTimeout: id => clearTimeout(id), + prioEncode: (batchID, prioParams) => PrioEncoder.encode(batchID, prioParams), +}; + +/** + * TelemetryScheduler contains a single timer driving all regularly-scheduled + * Telemetry related jobs. Having a single place with this logic simplifies + * reasoning about scheduling actions in a single place, making it easier to + * coordinate jobs and coalesce them. + */ +export var TelemetryScheduler = { + // Tracks the main ping + _lastDailyPingTime: 0, + // Tracks the aborted session ping + _lastSessionCheckpointTime: 0, + // Tracks all other pings at regular intervals + _lastPeriodicPingTime: 0, + + _log: null, + + // The timer which drives the scheduler. + _schedulerTimer: null, + // The interval used by the scheduler timer. + _schedulerInterval: 0, + _shuttingDown: true, + _isUserIdle: false, + + /** + * Initialises the scheduler and schedules the first daily/aborted session pings. + */ + init() { + this._log = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + "TelemetryScheduler::" + ); + this._log.trace("init"); + this._shuttingDown = false; + this._isUserIdle = false; + + // Initialize the last daily ping and aborted session last due times to the current time. + // Otherwise, we might end up sending daily pings even if the subsession is not long enough. + let now = Policy.now(); + this._lastDailyPingTime = now.getTime(); + this._lastPeriodicPingTime = now.getTime(); + this._lastSessionCheckpointTime = now.getTime(); + this._rescheduleTimeout(); + + lazy.idleService.addIdleObserver(this, IDLE_TIMEOUT_SECONDS); + Services.obs.addObserver(this, "wake_notification"); + }, + + /** + * Stops the scheduler. + */ + shutdown() { + if (this._shuttingDown) { + if (this._log) { + this._log.error("shutdown - Already shut down"); + } else { + console.error("TelemetryScheduler.shutdown - Already shut down"); + } + return; + } + + this._log.trace("shutdown"); + if (this._schedulerTimer) { + Policy.clearSchedulerTickTimeout(this._schedulerTimer); + this._schedulerTimer = null; + } + + lazy.idleService.removeIdleObserver(this, IDLE_TIMEOUT_SECONDS); + Services.obs.removeObserver(this, "wake_notification"); + + this._shuttingDown = true; + }, + + // Reset some specific innards without shutting down and re-init'ing. + // Test-only method. + testReset() { + this._idleDispatch?.cancel(); + this._idleDispatch = undefined; + }, + + _clearTimeout() { + if (this._schedulerTimer) { + Policy.clearSchedulerTickTimeout(this._schedulerTimer); + } + }, + + /** + * Reschedules the tick timer. + */ + _rescheduleTimeout() { + this._log.trace("_rescheduleTimeout - isUserIdle: " + this._isUserIdle); + if (this._shuttingDown) { + this._log.warn("_rescheduleTimeout - already shutdown"); + return; + } + + this._clearTimeout(); + + const now = Policy.now(); + let timeout = SCHEDULER_TICK_INTERVAL_MS; + + // When the user is idle we want to fire the timer less often. + if (this._isUserIdle) { + timeout = SCHEDULER_TICK_IDLE_INTERVAL_MS; + // We need to make sure though that we don't miss sending pings around + // midnight when we use the longer idle intervals. + const nextMidnight = TelemetryUtils.getNextMidnight(now); + timeout = Math.min(timeout, nextMidnight.getTime() - now.getTime()); + } + + this._log.trace( + "_rescheduleTimeout - scheduling next tick for " + + new Date(now.getTime() + timeout) + ); + this._schedulerTimer = Policy.setSchedulerTickTimeout( + () => this._onSchedulerTick(), + timeout + ); + }, + + _sentPingToday(pingTime, nowDate) { + // This is today's date and also the previous midnight (0:00). + const todayDate = TelemetryUtils.truncateToDays(nowDate); + // We consider a ping sent for today if it occured after or at 00:00 today. + return pingTime >= todayDate.getTime(); + }, + + /** + * Checks if we can send a daily ping or not. + * @param {Object} nowDate A date object. + * @return {Boolean} True if we can send the daily ping, false otherwise. + */ + _isDailyPingDue(nowDate) { + // The daily ping is not due if we already sent one today. + if (this._sentPingToday(this._lastDailyPingTime, nowDate)) { + this._log.trace("_isDailyPingDue - already sent one today"); + return false; + } + + // Avoid overly short sessions. + const timeSinceLastDaily = nowDate.getTime() - this._lastDailyPingTime; + if (timeSinceLastDaily < MIN_SUBSESSION_LENGTH_MS) { + this._log.trace( + "_isDailyPingDue - delaying daily to keep minimum session length" + ); + return false; + } + + this._log.trace("_isDailyPingDue - is due"); + return true; + }, + + /** + * Checks if we can send a regular ping or not. + * @param {Object} nowDate A date object. + * @return {Boolean} True if we can send the regular pings, false otherwise. + */ + _isPeriodicPingDue(nowDate) { + // The periodic ping is not due if we already sent one today. + if (this._sentPingToday(this._lastPeriodicPingTime, nowDate)) { + this._log.trace("_isPeriodicPingDue - already sent one today"); + return false; + } + + this._log.trace("_isPeriodicPingDue - is due"); + return true; + }, + + /** + * An helper function to save an aborted-session ping. + * @param {Number} now The current time, in milliseconds. + * @param {Object} [competingPayload=null] If we are coalescing the daily and the + * aborted-session pings, this is the payload for the former. Note + * that the reason field of this payload will be changed. + * @return {Promise} A promise resolved when the ping is saved. + */ + _saveAbortedPing(now, competingPayload = null) { + this._lastSessionCheckpointTime = now; + return TelemetrySession.saveAbortedSessionPing(competingPayload).catch(e => + this._log.error("_saveAbortedPing - Failed", e) + ); + }, + + /** + * The notifications handler. + */ + observe(aSubject, aTopic, aData) { + this._log.trace("observe - aTopic: " + aTopic); + switch (aTopic) { + case "idle": + // If the user is idle, increase the tick interval. + this._isUserIdle = true; + return this._onSchedulerTick(); + case "active": + // User is back to work, restore the original tick interval. + this._isUserIdle = false; + return this._onSchedulerTick(true); + case "wake_notification": + // The machine woke up from sleep, trigger a tick to avoid sessions + // spanning more than a day. + // This is needed because sleep time does not count towards timeouts + // on Mac & Linux - see bug 1262386, bug 1204823 et al. + return this._onSchedulerTick(true); + } + return undefined; + }, + + /** + * Creates an object with a method `dispatch` that will call `dispatchFn` unless + * the method `cancel` is called beforehand. + * + * This is used to wrap main thread idle dispatch since it does not provide a + * cancel mechanism. + */ + _makeIdleDispatch(dispatchFn) { + this._log.trace("_makeIdleDispatch"); + let fn = dispatchFn; + let l = msg => this._log.trace(msg); // need to bind `this` + return { + cancel() { + fn = undefined; + }, + dispatch(resolve, reject) { + l("_makeIdleDispatch.dispatch - !!fn: " + !!fn); + if (!fn) { + return Promise.resolve().then(resolve, reject); + } + return fn(resolve, reject); + }, + }; + }, + + /** + * Performs a scheduler tick. This function manages Telemetry recurring operations. + * @param {Boolean} [dispatchOnIdle=false] If true, the tick is dispatched in the + * next idle cycle of the main thread. + * @return {Promise} A promise, only used when testing, resolved when the scheduled + * operation completes. + */ + _onSchedulerTick(dispatchOnIdle = false) { + this._log.trace("_onSchedulerTick - dispatchOnIdle: " + dispatchOnIdle); + // This call might not be triggered from a timeout. In that case we don't want to + // leave any previously scheduled timeouts pending. + this._clearTimeout(); + + if (this._idleDispatch) { + this._idleDispatch.cancel(); + } + + if (this._shuttingDown) { + this._log.warn("_onSchedulerTick - already shutdown."); + return Promise.reject(new Error("Already shutdown.")); + } + + let promise = Promise.resolve(); + try { + if (dispatchOnIdle) { + this._idleDispatch = this._makeIdleDispatch((resolve, reject) => { + this._log.trace( + "_onSchedulerTick - ildeDispatchToMainThread dispatch" + ); + return this._schedulerTickLogic().then(resolve, reject); + }); + promise = new Promise((resolve, reject) => + Services.tm.idleDispatchToMainThread(() => { + return this._idleDispatch + ? this._idleDispatch.dispatch(resolve, reject) + : Promise.resolve().then(resolve, reject); + }, SCHEDULER_TICK_MAX_IDLE_DELAY_MS) + ); + } else { + promise = this._schedulerTickLogic(); + } + } catch (e) { + this._log.error("_onSchedulerTick - There was an exception", e); + } finally { + this._rescheduleTimeout(); + } + + // This promise is returned to make testing easier. + return promise; + }, + + /** + * Implements the scheduler logic. + * @return {Promise} Resolved when the scheduled task completes. Only used in tests. + */ + _schedulerTickLogic() { + this._log.trace("_schedulerTickLogic"); + + let nowDate = Policy.now(); + let now = nowDate.getTime(); + + // Check if the daily ping is due. + const shouldSendDaily = this._isDailyPingDue(nowDate); + // Check if other regular pings are due. + const shouldSendPeriodic = this._isPeriodicPingDue(nowDate); + + if (shouldSendPeriodic) { + this._log.trace("_schedulerTickLogic - Periodic ping due."); + this._lastPeriodicPingTime = now; + // Send other pings. + // ...currently no other pings exist + } + + if (shouldSendDaily) { + this._log.trace("_schedulerTickLogic - Daily ping due."); + this._lastDailyPingTime = now; + return TelemetrySession.sendDailyPing(); + } + + // Check if the aborted-session ping is due. If a daily ping was saved above, it was + // already duplicated as an aborted-session ping. + const isAbortedPingDue = + now - this._lastSessionCheckpointTime >= + ABORTED_SESSION_UPDATE_INTERVAL_MS; + if (isAbortedPingDue) { + this._log.trace("_schedulerTickLogic - Aborted session ping due."); + return this._saveAbortedPing(now); + } + + // No ping is due. + this._log.trace("_schedulerTickLogic - No ping due."); + return Promise.resolve(); + }, + + /** + * Re-schedule the daily ping if some other equivalent ping was sent. + * + * This is only called from TelemetrySession when a main ping with reason 'environment-change' + * is sent. + * + * @param {Object} [payload] The payload of the ping that was sent, + * to be stored as an aborted-session ping. + */ + rescheduleDailyPing(payload) { + if (this._shuttingDown) { + this._log.error("rescheduleDailyPing - already shutdown"); + return; + } + + this._log.trace("rescheduleDailyPing"); + let now = Policy.now(); + + // We just generated an environment-changed ping, save it as an aborted session and + // update the schedules. + this._saveAbortedPing(now.getTime(), payload); + + // If we're close to midnight, skip today's daily ping and reschedule it for tomorrow. + let nearestMidnight = TelemetryUtils.getNearestMidnight( + now, + SCHEDULER_MIDNIGHT_TOLERANCE_MS + ); + if (nearestMidnight) { + this._lastDailyPingTime = now.getTime(); + } + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetrySend.sys.mjs b/toolkit/components/telemetry/app/TelemetrySend.sys.mjs new file mode 100644 index 0000000000..935b5adcd6 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetrySend.sys.mjs @@ -0,0 +1,1702 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * This module is responsible for uploading pings to the server and persisting + * pings that can't be send now. + * Those pending pings are persisted on disk and sent at the next opportunity, + * newest first. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { ClientID } from "resource://gre/modules/ClientID.sys.mjs"; +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs"; +import { ServiceRequest } from "resource://gre/modules/ServiceRequest.sys.mjs"; + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + TelemetryHealthPing: "resource://gre/modules/HealthPing.sys.mjs", + TelemetryReportingPolicy: + "resource://gre/modules/TelemetryReportingPolicy.sys.mjs", + TelemetryStorage: "resource://gre/modules/TelemetryStorage.sys.mjs", +}); + +const Utils = TelemetryUtils; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetrySend::"; + +const TOPIC_IDLE_DAILY = "idle-daily"; +// The following topics are notified when Firefox is closing +// because the OS is shutting down. +const TOPIC_QUIT_APPLICATION_GRANTED = "quit-application-granted"; +const TOPIC_QUIT_APPLICATION_FORCED = "quit-application-forced"; +const PREF_CHANGED_TOPIC = "nsPref:changed"; +const TOPIC_PROFILE_CHANGE_NET_TEARDOWN = "profile-change-net-teardown"; + +// Whether the FHR/Telemetry unification features are enabled. +// Changing this pref requires a restart. +const IS_UNIFIED_TELEMETRY = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.Unified, + false +); + +const MS_IN_A_MINUTE = 60 * 1000; + +const PING_TYPE_DELETION_REQUEST = "deletion-request"; + +// We try to spread "midnight" pings out over this interval. +const MIDNIGHT_FUZZING_INTERVAL_MS = 60 * MS_IN_A_MINUTE; +// We delay sending "midnight" pings on this client by this interval. +const MIDNIGHT_FUZZING_DELAY_MS = Math.random() * MIDNIGHT_FUZZING_INTERVAL_MS; + +// Timeout after which we consider a ping submission failed. +export const PING_SUBMIT_TIMEOUT_MS = 1.5 * MS_IN_A_MINUTE; + +// To keep resource usage in check, we limit ping sending to a maximum number +// of pings per minute. +const MAX_PING_SENDS_PER_MINUTE = 10; + +// If we have more pending pings then we can send right now, we schedule the next +// send for after SEND_TICK_DELAY. +const SEND_TICK_DELAY = 1 * MS_IN_A_MINUTE; +// If we had any ping send failures since the last ping, we use a backoff timeout +// for the next ping sends. We increase the delay exponentially up to a limit of +// SEND_MAXIMUM_BACKOFF_DELAY_MS. +// This exponential backoff will be reset by external ping submissions & idle-daily. +const SEND_MAXIMUM_BACKOFF_DELAY_MS = 120 * MS_IN_A_MINUTE; + +// Strings to map from XHR.errorCode to TELEMETRY_SEND_FAILURE_TYPE. +// Echoes XMLHttpRequestMainThread's ErrorType enum. +// Make sure that any additions done to XHR_ERROR_TYPE enum are also mirrored in +// TELEMETRY_SEND_FAILURE_TYPE and TELEMETRY_SEND_FAILURE_TYPE_PER_PING's labels. +const XHR_ERROR_TYPE = [ + "eOK", + "eRequest", + "eUnreachable", + "eChannelOpen", + "eRedirect", + "eTerminated", +]; + +/** + * This is a policy object used to override behavior within this module. + * Tests override properties on this object to allow for control of behavior + * that would otherwise be very hard to cover. + */ +export var Policy = { + now: () => new Date(), + midnightPingFuzzingDelay: () => MIDNIGHT_FUZZING_DELAY_MS, + pingSubmissionTimeout: () => PING_SUBMIT_TIMEOUT_MS, + setSchedulerTickTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearSchedulerTickTimeout: id => clearTimeout(id), + gzipCompressString: data => gzipCompressString(data), +}; + +/** + * Determine if the ping has the new v4 ping format or the legacy v2 one or earlier. + */ +function isV4PingFormat(aPing) { + return ( + "id" in aPing && + "application" in aPing && + "version" in aPing && + aPing.version >= 2 + ); +} + +/** + * Check if the provided ping is a deletion-request ping. + * @param {Object} aPing The ping to check. + * @return {Boolean} True if the ping is a deletion-request ping, false otherwise. + */ +function isDeletionRequestPing(aPing) { + return isV4PingFormat(aPing) && aPing.type == PING_TYPE_DELETION_REQUEST; +} + +/** + * Save the provided ping as a pending ping. + * @param {Object} aPing The ping to save. + * @return {Promise} A promise resolved when the ping is saved. + */ +function savePing(aPing) { + return lazy.TelemetryStorage.savePendingPing(aPing); +} + +function arrayToString(array) { + let buffer = ""; + // String.fromCharCode can only deal with 500,000 characters at + // a time, so chunk the result into parts of that size. + const chunkSize = 500000; + for (let offset = 0; offset < array.length; offset += chunkSize) { + buffer += String.fromCharCode.apply( + String, + array.slice(offset, offset + chunkSize) + ); + } + return buffer; +} + +/** + * @return {String} This returns a string with the gzip compressed data. + */ +export function gzipCompressString(string) { + let observer = { + buffer: null, + onStreamComplete(loader, context, status, length, result) { + this.buffer = arrayToString(result); + }, + }; + + let scs = Cc["@mozilla.org/streamConverters;1"].getService( + Ci.nsIStreamConverterService + ); + let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance( + Ci.nsIStreamLoader + ); + listener.init(observer); + let converter = scs.asyncConvertData("uncompressed", "gzip", listener, null); + let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stringStream.data = string; + converter.onStartRequest(null, null); + converter.onDataAvailable(null, stringStream, 0, string.length); + converter.onStopRequest(null, null, null); + return observer.buffer; +} + +const STANDALONE_PING_TIMEOUT = 30 * 1000; // 30 seconds + +export function sendStandalonePing(endpoint, payload, extraHeaders = {}) { + return new Promise((resolve, reject) => { + let request = new ServiceRequest({ mozAnon: true }); + request.mozBackgroundRequest = true; + request.timeout = STANDALONE_PING_TIMEOUT; + + request.open("POST", endpoint, true); + request.overrideMimeType("text/plain"); + request.setRequestHeader("Content-Type", "application/json; charset=UTF-8"); + request.setRequestHeader("Content-Encoding", "gzip"); + request.setRequestHeader("Date", new Date().toUTCString()); + for (let header in extraHeaders) { + request.setRequestHeader(header, extraHeaders[header]); + } + + request.onload = event => { + if (request.status !== 200) { + reject(event); + } else { + resolve(event); + } + }; + request.onerror = reject; + request.onabort = reject; + request.ontimeout = reject; + + let payloadStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + + const utf8Payload = new TextEncoder().encode(payload); + + payloadStream.data = gzipCompressString(arrayToString(utf8Payload)); + request.sendInputStream(payloadStream); + }); +} + +export var TelemetrySend = { + get pendingPingCount() { + return TelemetrySendImpl.pendingPingCount; + }, + + /** + * Partial setup that runs immediately at startup. This currently triggers + * the crash report annotations. + */ + earlyInit() { + TelemetrySendImpl.earlyInit(); + }, + + /** + * Initializes this module. + * + * @param {Boolean} testing Whether this is run in a test. This changes some behavior + * to enable proper testing. + * @return {Promise} Resolved when setup is finished. + */ + setup(testing = false) { + return TelemetrySendImpl.setup(testing); + }, + + /** + * Shutdown this module - this will cancel any pending ping tasks and wait for + * outstanding async activity like network and disk I/O. + * + * @return {Promise} Promise that is resolved when shutdown is finished. + */ + shutdown() { + return TelemetrySendImpl.shutdown(); + }, + + /** + * Flushes all pings to pingsender that were both + * 1. submitted after profile-change-net-teardown, and + * 2. wanting to be sent using pingsender. + */ + flushPingSenderBatch() { + TelemetrySendImpl.flushPingSenderBatch(); + }, + + /** + * Submit a ping for sending. This will: + * - send the ping right away if possible or + * - save the ping to disk and send it at the next opportunity + * + * @param {Object} ping The ping data to send, must be serializable to JSON. + * @param {Object} [aOptions] Options object. + * @param {Boolean} [options.usePingSender=false] if true, send the ping using the PingSender. + * @return {Promise} Test-only - a promise that is resolved when the ping is sent or saved. + */ + submitPing(ping, options = {}) { + options.usePingSender = options.usePingSender || false; + return TelemetrySendImpl.submitPing(ping, options); + }, + + /** + * Check if sending is disabled. If Telemetry is not allowed to upload, + * pings are not sent to the server. + * If trying to send a deletion-request ping, don't block it. + * + * @param {Object} [ping=null] A ping to be checked. + * @return {Boolean} True if pings can be send to the servers, false otherwise. + */ + sendingEnabled(ping = null) { + return TelemetrySendImpl.sendingEnabled(ping); + }, + + /** + * Notify that we can start submitting data to the servers. + */ + notifyCanUpload() { + return TelemetrySendImpl.notifyCanUpload(); + }, + + /** + * Only used in tests. Used to reset the module data to emulate a restart. + */ + reset() { + return TelemetrySendImpl.reset(); + }, + + /** + * Only used in tests. + */ + setServer(server) { + return TelemetrySendImpl.setServer(server); + }, + + /** + * Clear out unpersisted, yet to be sent, pings and cancel outgoing ping requests. + */ + clearCurrentPings() { + return TelemetrySendImpl.clearCurrentPings(); + }, + + /** + * Only used in tests to wait on outgoing pending pings. + */ + testWaitOnOutgoingPings() { + return TelemetrySendImpl.promisePendingPingActivity(); + }, + + /** + * Only used in tests to set whether it is too late in shutdown to send pings. + */ + testTooLateToSend(tooLate) { + TelemetrySendImpl._tooLateToSend = tooLate; + }, + + /** + * Test-only - this allows overriding behavior to enable ping sending in debug builds. + */ + setTestModeEnabled(testing) { + TelemetrySendImpl.setTestModeEnabled(testing); + }, + + /** + * This returns state info for this module for AsyncShutdown timeout diagnostics. + */ + getShutdownState() { + return TelemetrySendImpl.getShutdownState(); + }, + + /** + * Send a ping using the ping sender. + * This method will not wait for the ping to be sent, instead it will return + * as soon as the pingsender program has been launched. + * + * This method is currently exposed here only for testing purposes as it's + * only used internally. + * + * @param {Array}<Object> pings An array of objects holding url / path pairs + * for each ping to be sent. The URL represent the telemetry server the + * ping will be sent to and the path points to the ping data. The ping + * data files will be deleted if the pings have been submitted + * successfully. + * @param {callback} observer A function called with parameters + * (subject, topic, data) and a topic of "process-finished" or + * "process-failed" after pingsender completion. + * + * @throws NS_ERROR_FAILURE if we couldn't find or run the pingsender + * executable. + * @throws NS_ERROR_NOT_IMPLEMENTED on Android as the pingsender is not + * available. + */ + testRunPingSender(pings, observer) { + return TelemetrySendImpl.runPingSender(pings, observer); + }, +}; + +var CancellableTimeout = { + _deferred: null, + _timer: null, + + /** + * This waits until either the given timeout passed or the timeout was cancelled. + * + * @param {Number} timeoutMs The timeout in ms. + * @return {Promise<bool>} Promise that is resolved with false if the timeout was cancelled, + * false otherwise. + */ + promiseWaitOnTimeout(timeoutMs) { + if (!this._deferred) { + this._deferred = PromiseUtils.defer(); + this._timer = Policy.setSchedulerTickTimeout( + () => this._onTimeout(), + timeoutMs + ); + } + + return this._deferred.promise; + }, + + _onTimeout() { + if (this._deferred) { + this._deferred.resolve(false); + this._timer = null; + this._deferred = null; + } + }, + + cancelTimeout() { + if (this._deferred) { + Policy.clearSchedulerTickTimeout(this._timer); + this._deferred.resolve(true); + this._timer = null; + this._deferred = null; + } + }, +}; + +/** + * SendScheduler implements the timer & scheduling behavior for ping sends. + */ +export var SendScheduler = { + // Whether any ping sends failed since the last tick. If yes, we start with our exponential + // backoff timeout. + _sendsFailed: false, + // The current retry delay after ping send failures. We use this for the exponential backoff, + // increasing this value everytime we had send failures since the last tick. + _backoffDelay: SEND_TICK_DELAY, + _shutdown: false, + _sendTask: null, + // A string that tracks the last seen send task state, null if it never ran. + _sendTaskState: null, + + _logger: null, + + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + "Scheduler::" + ); + } + + return this._logger; + }, + + shutdown() { + this._log.trace("shutdown"); + this._shutdown = true; + CancellableTimeout.cancelTimeout(); + return Promise.resolve(this._sendTask); + }, + + start() { + this._log.trace("start"); + this._sendsFailed = false; + this._backoffDelay = SEND_TICK_DELAY; + this._shutdown = false; + }, + + /** + * Only used for testing, resets the state to emulate a restart. + */ + reset() { + this._log.trace("reset"); + return this.shutdown().then(() => this.start()); + }, + + /** + * Notify the scheduler of a failure in sending out pings that warrants retrying. + * This will trigger the exponential backoff timer behavior on the next tick. + */ + notifySendsFailed() { + this._log.trace("notifySendsFailed"); + if (this._sendsFailed) { + return; + } + + this._sendsFailed = true; + this._log.trace("notifySendsFailed - had send failures"); + }, + + /** + * Returns whether ping submissions are currently throttled. + */ + isThrottled() { + const now = Policy.now(); + const nextPingSendTime = this._getNextPingSendTime(now); + return nextPingSendTime > now.getTime(); + }, + + waitOnSendTask() { + return Promise.resolve(this._sendTask); + }, + + triggerSendingPings(immediately) { + this._log.trace( + "triggerSendingPings - active send task: " + + !!this._sendTask + + ", immediately: " + + immediately + ); + + if (!this._sendTask) { + this._sendTask = this._doSendTask(); + let clear = () => (this._sendTask = null); + this._sendTask.then(clear, clear); + } else if (immediately) { + CancellableTimeout.cancelTimeout(); + } + + return this._sendTask; + }, + + async _doSendTask() { + this._sendTaskState = "send task started"; + this._backoffDelay = SEND_TICK_DELAY; + this._sendsFailed = false; + + const resetBackoffTimer = () => { + this._backoffDelay = SEND_TICK_DELAY; + }; + + for (;;) { + this._log.trace("_doSendTask iteration"); + this._sendTaskState = "start iteration"; + + if (this._shutdown) { + this._log.trace("_doSendTask - shutting down, bailing out"); + this._sendTaskState = "bail out - shutdown check"; + return; + } + + // Get a list of pending pings, sorted by last modified, descending. + // Filter out all the pings we can't send now. This addresses scenarios like "deletion-request" pings + // which can be sent even when upload is disabled. + let pending = lazy.TelemetryStorage.getPendingPingList(); + let current = TelemetrySendImpl.getUnpersistedPings(); + this._log.trace( + "_doSendTask - pending: " + + pending.length + + ", current: " + + current.length + ); + // Note that the two lists contain different kind of data. |pending| only holds ping + // info, while |current| holds actual ping data. + if (!TelemetrySendImpl.sendingEnabled()) { + // If sending is disabled, only handle deletion-request pings + pending = []; + current = current.filter(p => isDeletionRequestPing(p)); + } + this._log.trace( + "_doSendTask - can send - pending: " + + pending.length + + ", current: " + + current.length + ); + + // Bail out if there is nothing to send. + if (!pending.length && !current.length) { + this._log.trace("_doSendTask - no pending pings, bailing out"); + this._sendTaskState = "bail out - no pings to send"; + return; + } + + // If we are currently throttled (e.g. fuzzing to avoid midnight spikes), wait for the next send window. + const now = Policy.now(); + if (this.isThrottled()) { + const nextPingSendTime = this._getNextPingSendTime(now); + this._log.trace( + "_doSendTask - throttled, delaying ping send to " + + new Date(nextPingSendTime) + ); + this._sendTaskState = "wait for throttling to pass"; + + const delay = nextPingSendTime - now.getTime(); + const cancelled = await CancellableTimeout.promiseWaitOnTimeout(delay); + if (cancelled) { + this._log.trace( + "_doSendTask - throttling wait was cancelled, resetting backoff timer" + ); + resetBackoffTimer(); + } + + continue; + } + + let sending = pending.slice(0, MAX_PING_SENDS_PER_MINUTE); + pending = pending.slice(MAX_PING_SENDS_PER_MINUTE); + this._log.trace( + "_doSendTask - triggering sending of " + + sending.length + + " pings now" + + ", " + + pending.length + + " pings waiting" + ); + + this._sendsFailed = false; + const sendStartTime = Policy.now(); + this._sendTaskState = "wait on ping sends"; + await TelemetrySendImpl.sendPings( + current, + sending.map(p => p.id) + ); + if (this._shutdown || TelemetrySend.pendingPingCount == 0) { + this._log.trace( + "_doSendTask - bailing out after sending, shutdown: " + + this._shutdown + + ", pendingPingCount: " + + TelemetrySend.pendingPingCount + ); + this._sendTaskState = "bail out - shutdown & pending check after send"; + return; + } + + // Calculate the delay before sending the next batch of pings. + // We start with a delay that makes us send max. 1 batch per minute. + // If we had send failures in the last batch, we will override this with + // a backoff delay. + const timeSinceLastSend = Policy.now() - sendStartTime; + let nextSendDelay = Math.max(0, SEND_TICK_DELAY - timeSinceLastSend); + + if (!this._sendsFailed) { + this._log.trace( + "_doSendTask - had no send failures, resetting backoff timer" + ); + resetBackoffTimer(); + } else { + const newDelay = Math.min( + SEND_MAXIMUM_BACKOFF_DELAY_MS, + this._backoffDelay * 2 + ); + this._log.trace( + "_doSendTask - had send failures, backing off -" + + " old timeout: " + + this._backoffDelay + + ", new timeout: " + + newDelay + ); + this._backoffDelay = newDelay; + nextSendDelay = this._backoffDelay; + } + + this._log.trace( + "_doSendTask - waiting for next send opportunity, timeout is " + + nextSendDelay + ); + this._sendTaskState = "wait on next send opportunity"; + const cancelled = await CancellableTimeout.promiseWaitOnTimeout( + nextSendDelay + ); + if (cancelled) { + this._log.trace( + "_doSendTask - batch send wait was cancelled, resetting backoff timer" + ); + resetBackoffTimer(); + } + } + }, + + /** + * This helper calculates the next time that we can send pings at. + * Currently this mostly redistributes ping sends from midnight until one hour after + * to avoid submission spikes around local midnight for daily pings. + * + * @param now Date The current time. + * @return Number The next time (ms from UNIX epoch) when we can send pings. + */ + _getNextPingSendTime(now) { + // 1. First we check if the pref is set to skip any delay and send immediately. + // 2. Next we check if the time is between 0am and 1am. If it's not, we send + // immediately. + // 3. If we confirmed the time is indeed between 0am and 1am in step 1, we disallow + // sending before (midnight + fuzzing delay), which is a random time between 0am-1am + // (decided at startup). + + let disableFuzzingDelay = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.DisableFuzzingDelay, + false + ); + if (disableFuzzingDelay) { + return now.getTime(); + } + + const midnight = Utils.truncateToDays(now); + // Don't delay pings if we are not within the fuzzing interval. + if (now.getTime() - midnight.getTime() > MIDNIGHT_FUZZING_INTERVAL_MS) { + return now.getTime(); + } + + // Delay ping send if we are within the midnight fuzzing range. + // We spread those ping sends out between |midnight| and |midnight + midnightPingFuzzingDelay|. + return midnight.getTime() + Policy.midnightPingFuzzingDelay(); + }, + + getShutdownState() { + return { + shutdown: this._shutdown, + hasSendTask: !!this._sendTask, + sendsFailed: this._sendsFailed, + sendTaskState: this._sendTaskState, + backoffDelay: this._backoffDelay, + }; + }, +}; + +export var TelemetrySendImpl = { + _sendingEnabled: false, + // Tracks the shutdown state. + _shutdown: false, + _logger: null, + // This tracks all pending ping requests to the server. + _pendingPingRequests: new Map(), + // This tracks all the pending async ping activity. + _pendingPingActivity: new Set(), + // This is true when running in the test infrastructure. + _testMode: false, + // This holds pings that we currently try and haven't persisted yet. + _currentPings: new Map(), + // Used to skip spawning the pingsender if OS is shutting down. + _isOSShutdown: false, + // Has the network shut down, making it too late to send pings? + _tooLateToSend: false, + // Array of {url, path} awaiting flushPingSenderBatch(). + _pingSenderBatch: [], + + OBSERVER_TOPICS: [ + TOPIC_IDLE_DAILY, + TOPIC_QUIT_APPLICATION_GRANTED, + TOPIC_QUIT_APPLICATION_FORCED, + TOPIC_PROFILE_CHANGE_NET_TEARDOWN, + ], + + OBSERVED_PREFERENCES: [ + TelemetryUtils.Preferences.TelemetryEnabled, + TelemetryUtils.Preferences.FhrUploadEnabled, + ], + + // Whether sending pings has been overridden. + get _overrideOfficialCheck() { + return Services.prefs.getBoolPref( + TelemetryUtils.Preferences.OverrideOfficialCheck, + false + ); + }, + + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + + return this._logger; + }, + + get pendingPingRequests() { + return this._pendingPingRequests; + }, + + get pendingPingCount() { + return ( + lazy.TelemetryStorage.getPendingPingList().length + + this._currentPings.size + ); + }, + + setTestModeEnabled(testing) { + this._testMode = testing; + }, + + earlyInit() { + this._annotateCrashReport(); + + // Install the observer to detect OS shutdown early enough, so + // that we catch this before the delayed setup happens. + Services.obs.addObserver(this, TOPIC_QUIT_APPLICATION_FORCED); + Services.obs.addObserver(this, TOPIC_QUIT_APPLICATION_GRANTED); + }, + + QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]), + + async setup(testing) { + this._log.trace("setup"); + + this._testMode = testing; + + Services.obs.addObserver(this, TOPIC_IDLE_DAILY); + Services.obs.addObserver(this, TOPIC_PROFILE_CHANGE_NET_TEARDOWN); + + this._server = Services.prefs.getStringPref( + TelemetryUtils.Preferences.Server, + undefined + ); + this._sendingEnabled = true; + + // Annotate crash reports so that crash pings are sent correctly and listen + // to pref changes to adjust the annotations accordingly. + for (let pref of this.OBSERVED_PREFERENCES) { + Services.prefs.addObserver(pref, this, true); + } + this._annotateCrashReport(); + + // Check the pending pings on disk now. + try { + await this._checkPendingPings(); + } catch (ex) { + this._log.error("setup - _checkPendingPings rejected", ex); + } + + // Enforce the pending pings storage quota. It could take a while so don't + // block on it. + lazy.TelemetryStorage.runEnforcePendingPingsQuotaTask(); + + // Start sending pings, but don't block on this. + SendScheduler.triggerSendingPings(true); + }, + + /** + * Triggers the crash report annotations depending on the current + * configuration. This communicates to the crash reporter if it can send a + * crash ping or not. This method can be called safely before setup() has + * been called. + */ + _annotateCrashReport() { + try { + const cr = Cc["@mozilla.org/toolkit/crash-reporter;1"]; + if (cr) { + // This needs to use nsICrashReporter because test_TelemetrySend.js + // replaces the crash reporter service, which we can't access here + // as Services caches it. + // eslint-disable-next-line mozilla/use-services + const crs = cr.getService(Ci.nsICrashReporter); + + let clientId = ClientID.getCachedClientID(); + let server = + this._server || + Services.prefs.getStringPref( + TelemetryUtils.Preferences.Server, + undefined + ); + + if ( + !this.sendingEnabled() || + !lazy.TelemetryReportingPolicy.canUpload() + ) { + // If we cannot send pings then clear the crash annotations + crs.removeCrashReportAnnotation("TelemetryClientId"); + crs.removeCrashReportAnnotation("TelemetryServerURL"); + } else { + crs.annotateCrashReport("TelemetryClientId", clientId); + crs.annotateCrashReport("TelemetryServerURL", server); + } + } + } catch (e) { + // Ignore errors when crash reporting is disabled + } + }, + + /** + * Discard old pings from the pending pings and detect overdue ones. + * @return {Boolean} True if we have overdue pings, false otherwise. + */ + async _checkPendingPings() { + // Scan the pending pings - that gives us a list sorted by last modified, descending. + let infos = await lazy.TelemetryStorage.loadPendingPingList(); + this._log.info("_checkPendingPings - pending ping count: " + infos.length); + if (!infos.length) { + this._log.trace("_checkPendingPings - no pending pings"); + return; + } + + const now = Policy.now(); + + // Submit the age of the pending pings. + for (let pingInfo of infos) { + const ageInDays = Utils.millisecondsToDays( + Math.abs(now.getTime() - pingInfo.lastModificationDate) + ); + Services.telemetry + .getHistogramById("TELEMETRY_PENDING_PINGS_AGE") + .add(ageInDays); + } + }, + + async shutdown() { + this._shutdown = true; + + for (let pref of this.OBSERVED_PREFERENCES) { + // FIXME: When running tests this causes errors to be printed out if + // TelemetrySend.shutdown() is called twice in a row without calling + // TelemetrySend.setup() in-between. + Services.prefs.removeObserver(pref, this); + } + + for (let topic of this.OBSERVER_TOPICS) { + try { + Services.obs.removeObserver(this, topic); + } catch (ex) { + this._log.error( + "shutdown - failed to remove observer for " + topic, + ex + ); + } + } + + // We can't send anymore now. + this._sendingEnabled = false; + + // Cancel any outgoing requests. + await this._cancelOutgoingRequests(); + + // Stop any active send tasks. + await SendScheduler.shutdown(); + + // Wait for any outstanding async ping activity. + await this.promisePendingPingActivity(); + + // Save any outstanding pending pings to disk. + await this._persistCurrentPings(); + }, + + flushPingSenderBatch() { + if (this._pingSenderBatch.length === 0) { + return; + } + this._log.trace( + `flushPingSenderBatch - Sending ${this._pingSenderBatch.length} pings.` + ); + this.runPingSender(this._pingSenderBatch); + }, + + reset() { + this._log.trace("reset"); + + this._shutdown = false; + this._currentPings = new Map(); + this._tooLateToSend = false; + this._isOSShutdown = false; + this._sendingEnabled = true; + + const histograms = [ + "TELEMETRY_SUCCESS", + "TELEMETRY_SEND_SUCCESS", + "TELEMETRY_SEND_FAILURE", + "TELEMETRY_SEND_FAILURE_TYPE", + ]; + + histograms.forEach(h => Services.telemetry.getHistogramById(h).clear()); + + const keyedHistograms = ["TELEMETRY_SEND_FAILURE_TYPE_PER_PING"]; + + keyedHistograms.forEach(h => + Services.telemetry.getKeyedHistogramById(h).clear() + ); + + return SendScheduler.reset(); + }, + + /** + * Notify that we can start submitting data to the servers. + */ + notifyCanUpload() { + if (!this._sendingEnabled) { + this._log.trace( + "notifyCanUpload - notifying before sending is enabled. Ignoring." + ); + return Promise.resolve(); + } + // Let the scheduler trigger sending pings if possible, also inform the + // crash reporter that it can send crash pings if appropriate. + SendScheduler.triggerSendingPings(true); + this._annotateCrashReport(); + + return this.promisePendingPingActivity(); + }, + + observe(subject, topic, data) { + let setOSShutdown = () => { + this._log.trace("setOSShutdown - in OS shutdown"); + this._isOSShutdown = true; + }; + + switch (topic) { + case TOPIC_IDLE_DAILY: + SendScheduler.triggerSendingPings(true); + break; + case TOPIC_QUIT_APPLICATION_FORCED: + setOSShutdown(); + break; + case TOPIC_QUIT_APPLICATION_GRANTED: + if (data == "syncShutdown") { + setOSShutdown(); + } + break; + case PREF_CHANGED_TOPIC: + if (this.OBSERVED_PREFERENCES.includes(data)) { + this._annotateCrashReport(); + } + break; + case TOPIC_PROFILE_CHANGE_NET_TEARDOWN: + this._tooLateToSend = true; + break; + } + }, + + /** + * Spawn the PingSender process that sends a ping. This function does + * not return an error or throw, it only logs an error. + * + * Even if the function doesn't fail, it doesn't mean that the ping was + * successfully sent, as we have no control over the spawned process. If it, + * succeeds, the ping is eventually removed from the disk to prevent duplicated + * submissions. + * + * @param {String} pingId The id of the ping to send. + * @param {String} submissionURL The complete Telemetry-compliant URL for the ping. + */ + _sendWithPingSender(pingId, submissionURL) { + this._log.trace( + "_sendWithPingSender - sending " + pingId + " to " + submissionURL + ); + try { + const pingPath = PathUtils.join( + lazy.TelemetryStorage.pingDirectoryPath, + pingId + ); + if (this._tooLateToSend) { + // We're in shutdown. Batch pings destined for pingsender. + this._log.trace("_sendWithPingSender - too late to send. Batching."); + this._pingSenderBatch.push({ url: submissionURL, path: pingPath }); + return; + } + this.runPingSender([{ url: submissionURL, path: pingPath }]); + } catch (e) { + this._log.error("_sendWithPingSender - failed to submit ping", e); + } + }, + + submitPing(ping, options) { + this._log.trace( + "submitPing - ping id: " + + ping.id + + ", options: " + + JSON.stringify(options) + ); + + if (!this.sendingEnabled(ping)) { + this._log.trace("submitPing - Telemetry is not allowed to send pings."); + return Promise.resolve(); + } + + // Send the ping using the PingSender, if requested and the user was + // notified of our policy. We don't support the pingsender on Android, + // so ignore this option on that platform (see bug 1335917). + // Moreover, if the OS is shutting down, we don't want to spawn the + // pingsender as it could unnecessarily slow down OS shutdown. + // Additionally, it could be be killed before it can complete its tasks, + // for example after successfully sending the ping but before removing + // the copy from the disk, resulting in receiving duplicate pings when + // Firefox restarts. + if ( + options.usePingSender && + !this._isOSShutdown && + lazy.TelemetryReportingPolicy.canUpload() && + AppConstants.platform != "android" + ) { + const url = this._buildSubmissionURL(ping); + // Serialize the ping to the disk and then spawn the PingSender. + return savePing(ping).then(() => this._sendWithPingSender(ping.id, url)); + } + + if (!this.canSendNow) { + // Sending is disabled or throttled, add this to the persisted pending pings. + this._log.trace( + "submitPing - can't send ping now, persisting to disk - " + + "canSendNow: " + + this.canSendNow + ); + return savePing(ping); + } + + // Let the scheduler trigger sending pings if possible. + // As a safety mechanism, this resets any currently active throttling. + this._log.trace("submitPing - can send pings, trying to send now"); + this._currentPings.set(ping.id, ping); + SendScheduler.triggerSendingPings(true); + return Promise.resolve(); + }, + + /** + * Only used in tests. + */ + setServer(server) { + this._log.trace("setServer", server); + this._server = server; + }, + + /** + * Clear out unpersisted, yet to be sent, pings and cancel outgoing ping requests. + */ + async clearCurrentPings() { + if (this._shutdown) { + this._log.trace("clearCurrentPings - in shutdown, bailing out"); + return; + } + + // Temporarily disable the scheduler. It must not try to reschedule ping sending + // while we're deleting them. + await SendScheduler.shutdown(); + + // Now that the ping activity has settled, abort outstanding ping requests. + this._cancelOutgoingRequests(); + + // Also, purge current pings. + this._currentPings.clear(); + + // We might have been interrupted and shutdown could have been started. + // We need to bail out in that case to avoid triggering send activity etc. + // at unexpected times. + if (this._shutdown) { + this._log.trace( + "clearCurrentPings - in shutdown, not spinning SendScheduler up again" + ); + return; + } + + // Enable the scheduler again and spin the send task. + SendScheduler.start(); + SendScheduler.triggerSendingPings(true); + }, + + _cancelOutgoingRequests() { + // Abort any pending ping XHRs. + for (let [id, request] of this._pendingPingRequests) { + this._log.trace( + "_cancelOutgoingRequests - aborting ping request for id " + id + ); + try { + request.abort(); + } catch (e) { + this._log.error( + "_cancelOutgoingRequests - failed to abort request for id " + id, + e + ); + } + } + this._pendingPingRequests.clear(); + }, + + sendPings(currentPings, persistedPingIds) { + let pingSends = []; + + // Prioritize health pings to enable low-latency monitoring. + currentPings = [ + ...currentPings.filter(ping => ping.type === "health"), + ...currentPings.filter(ping => ping.type !== "health"), + ]; + + for (let current of currentPings) { + let ping = current; + let p = (async () => { + try { + await this._doPing(ping, ping.id, false); + } catch (ex) { + this._log.info( + "sendPings - ping " + ping.id + " not sent, saving to disk", + ex + ); + await savePing(ping); + } finally { + this._currentPings.delete(ping.id); + } + })(); + + this._trackPendingPingTask(p); + pingSends.push(p); + } + + if (persistedPingIds.length) { + pingSends.push( + this._sendPersistedPings(persistedPingIds).catch(ex => { + this._log.info("sendPings - persisted pings not sent", ex); + }) + ); + } + + return Promise.all(pingSends); + }, + + /** + * Send the persisted pings to the server. + * + * @param {Array<string>} List of ping ids that should be sent. + * + * @return Promise A promise that is resolved when all pings finished sending or failed. + */ + async _sendPersistedPings(pingIds) { + this._log.trace("sendPersistedPings"); + + if (this.pendingPingCount < 1) { + this._log.trace("_sendPersistedPings - no pings to send"); + return; + } + + if (pingIds.length < 1) { + this._log.trace("sendPersistedPings - no pings to send"); + return; + } + + // We can send now. + // If there are any send failures, _doPing() sets up handlers that e.g. trigger backoff timer behavior. + this._log.trace( + "sendPersistedPings - sending " + pingIds.length + " pings" + ); + let pingSendPromises = []; + for (let pingId of pingIds) { + const id = pingId; + pingSendPromises.push( + lazy.TelemetryStorage.loadPendingPing(id) + .then(data => this._doPing(data, id, true)) + .catch(e => + this._log.error("sendPersistedPings - failed to send ping " + id, e) + ) + ); + } + + let promise = Promise.all(pingSendPromises); + this._trackPendingPingTask(promise); + await promise; + }, + + _onPingRequestFinished(success, startTime, id, isPersisted) { + this._log.trace( + "_onPingRequestFinished - success: " + + success + + ", persisted: " + + isPersisted + ); + + let sendId = success ? "TELEMETRY_SEND_SUCCESS" : "TELEMETRY_SEND_FAILURE"; + let hsend = Services.telemetry.getHistogramById(sendId); + let hsuccess = Services.telemetry.getHistogramById("TELEMETRY_SUCCESS"); + + hsend.add(Utils.monotonicNow() - startTime); + hsuccess.add(success); + + if (!success) { + // Let the scheduler know about send failures for triggering backoff timeouts. + SendScheduler.notifySendsFailed(); + } + + if (success && isPersisted) { + return lazy.TelemetryStorage.removePendingPing(id); + } + return Promise.resolve(); + }, + + _buildSubmissionURL(ping) { + const version = isV4PingFormat(ping) + ? AppConstants.TELEMETRY_PING_FORMAT_VERSION + : 1; + return this._server + this._getSubmissionPath(ping) + "?v=" + version; + }, + + _getSubmissionPath(ping) { + // The new ping format contains an "application" section, the old one doesn't. + let pathComponents; + if (isV4PingFormat(ping)) { + // We insert the Ping id in the URL to simplify server handling of duplicated + // pings. + let app = ping.application; + pathComponents = [ + ping.id, + ping.type, + app.name, + app.version, + app.channel, + app.buildId, + ]; + } else { + // This is a ping in the old format. + if (!("slug" in ping)) { + // That's odd, we don't have a slug. Generate one so that TelemetryStorage.sys.mjs works. + ping.slug = Utils.generateUUID(); + } + + // Do we have enough info to build a submission URL? + let payload = "payload" in ping ? ping.payload : null; + if (payload && "info" in payload) { + let info = ping.payload.info; + pathComponents = [ + ping.slug, + info.reason, + info.appName, + info.appVersion, + info.appUpdateChannel, + info.appBuildID, + ]; + } else { + // Only use the UUID as the slug. + pathComponents = [ping.slug]; + } + } + + let slug = pathComponents.join("/"); + return "/submit/telemetry/" + slug; + }, + + _doPingRequest(ping, id, url, options, errorHandler, onloadHandler) { + // Don't send cookies with these requests. + let request = new ServiceRequest({ mozAnon: true }); + request.mozBackgroundRequest = true; + request.timeout = Policy.pingSubmissionTimeout(); + + request.open("POST", url, options); + request.overrideMimeType("text/plain"); + request.setRequestHeader("Content-Type", "application/json; charset=UTF-8"); + request.setRequestHeader("Date", Policy.now().toUTCString()); + request.setRequestHeader("Content-Encoding", "gzip"); + request.onerror = errorHandler; + request.ontimeout = errorHandler; + request.onabort = errorHandler; + request.onload = onloadHandler; + this._pendingPingRequests.set(id, request); + + let startTime = Utils.monotonicNow(); + + // If that's a legacy ping format, just send its payload. + let networkPayload = isV4PingFormat(ping) ? ping : ping.payload; + + const utf8Payload = new TextEncoder().encode( + JSON.stringify(networkPayload) + ); + + Services.telemetry + .getHistogramById("TELEMETRY_STRINGIFY") + .add(Utils.monotonicNow() - startTime); + + let payloadStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + startTime = Utils.monotonicNow(); + payloadStream.data = Policy.gzipCompressString(arrayToString(utf8Payload)); + + // Check the size and drop pings which are too big. + const compressedPingSizeBytes = payloadStream.data.length; + if (compressedPingSizeBytes > lazy.TelemetryStorage.MAXIMUM_PING_SIZE) { + this._log.error( + "_doPing - submitted ping exceeds the size limit, size: " + + compressedPingSizeBytes + ); + Services.telemetry + .getHistogramById("TELEMETRY_PING_SIZE_EXCEEDED_SEND") + .add(); + Services.telemetry + .getHistogramById("TELEMETRY_DISCARDED_SEND_PINGS_SIZE_MB") + .add(Math.floor(compressedPingSizeBytes / 1024 / 1024)); + // We don't need to call |request.abort()| as it was not sent yet. + this._pendingPingRequests.delete(id); + + lazy.TelemetryHealthPing.recordDiscardedPing(ping.type); + return { promise: lazy.TelemetryStorage.removePendingPing(id) }; + } + + Services.telemetry + .getHistogramById("TELEMETRY_COMPRESS") + .add(Utils.monotonicNow() - startTime); + request.sendInputStream(payloadStream); + + return { payloadStream }; + }, + + _doPing(ping, id, isPersisted) { + if (!this.sendingEnabled(ping)) { + // We can't send the pings to the server, so don't try to. + this._log.trace("_doPing - Can't send ping " + ping.id); + return Promise.resolve(); + } + + if (this._tooLateToSend) { + // Too late to send now. Reject so we pend the ping to send it next time. + this._log.trace("_doPing - Too late to send ping " + ping.id); + Services.telemetry + .getHistogramById("TELEMETRY_SEND_FAILURE_TYPE") + .add("eTooLate"); + Services.telemetry + .getKeyedHistogramById("TELEMETRY_SEND_FAILURE_TYPE_PER_PING") + .add(ping.type, "eTooLate"); + return Promise.reject(); + } + + this._log.trace( + "_doPing - server: " + + this._server + + ", persisted: " + + isPersisted + + ", id: " + + id + ); + + const url = this._buildSubmissionURL(ping); + + const monotonicStartTime = Utils.monotonicNow(); + let deferred = PromiseUtils.defer(); + + let onRequestFinished = (success, event) => { + let onCompletion = () => { + if (success) { + deferred.resolve(); + } else { + deferred.reject(event); + } + }; + + this._pendingPingRequests.delete(id); + this._onPingRequestFinished( + success, + monotonicStartTime, + id, + isPersisted + ).then( + () => onCompletion(), + error => { + this._log.error( + "_doPing - request success: " + success + ", error: " + error + ); + onCompletion(); + } + ); + }; + + let retryRequest = request => { + if ( + this._shutdown || + ServiceRequest.isOffline || + Services.startup.shuttingDown || + !request.bypassProxyEnabled || + this._tooLateToSend || + request.bypassProxy || + !request.isProxied + ) { + return false; + } + ServiceRequest.logProxySource(request.channel, "telemetry.send"); + // If the request failed, and it's using a proxy, automatically + // attempt without proxy. + let { payloadStream } = this._doPingRequest( + ping, + id, + url, + { bypassProxy: true }, + errorHandler, + onloadHandler + ); + this.payloadStream = payloadStream; + return true; + }; + + let errorHandler = event => { + let request = event.target; + if (retryRequest(request)) { + return; + } + + let failure = event.type; + if (failure === "error") { + failure = XHR_ERROR_TYPE[request.errorCode]; + } + + lazy.TelemetryHealthPing.recordSendFailure(failure); + + Services.telemetry + .getHistogramById("TELEMETRY_SEND_FAILURE_TYPE") + .add(failure); + Services.telemetry + .getKeyedHistogramById("TELEMETRY_SEND_FAILURE_TYPE_PER_PING") + .add(ping.type, failure); + + this._log.error( + "_doPing - error making request to " + url + ": " + failure + ); + onRequestFinished(false, event); + }; + + let onloadHandler = event => { + let request = event.target; + let status = request.status; + let statusClass = status - (status % 100); + let success = false; + + if (statusClass === 200) { + // We can treat all 2XX as success. + this._log.info("_doPing - successfully loaded, status: " + status); + success = true; + } else if (statusClass === 400) { + // 4XX means that something with the request was broken. + this._log.error( + "_doPing - error submitting to " + + url + + ", status: " + + status + + " - ping request broken?" + ); + Services.telemetry + .getHistogramById("TELEMETRY_PING_EVICTED_FOR_SERVER_ERRORS") + .add(); + // TODO: we should handle this better, but for now we should avoid resubmitting + // broken requests by pretending success. + success = true; + } else if (statusClass === 500) { + // 5XX means there was a server-side error and we should try again later. + this._log.error( + "_doPing - error submitting to " + + url + + ", status: " + + status + + " - server error, should retry later" + ); + } else { + // We received an unexpected status code. + this._log.error( + "_doPing - error submitting to " + + url + + ", status: " + + status + + ", type: " + + event.type + ); + } + if (!success && retryRequest(request)) { + return; + } + + onRequestFinished(success, event); + }; + + let { payloadStream, promise } = this._doPingRequest( + ping, + id, + url, + {}, + errorHandler, + onloadHandler + ); + if (promise) { + return promise; + } + this.payloadStream = payloadStream; + + return deferred.promise; + }, + + /** + * Check if sending is temporarily disabled. + * @return {Boolean} True if we can send pings to the server right now, false if + * sending is temporarily disabled. + */ + get canSendNow() { + // If the reporting policy was not accepted yet, don't send pings. + if (!lazy.TelemetryReportingPolicy.canUpload()) { + return false; + } + + return this._sendingEnabled; + }, + + /** + * Check if sending is disabled. If Telemetry is not allowed to upload, + * pings are not sent to the server. + * If trying to send a "deletion-request" ping, don't block it. + * If unified telemetry is off, don't send pings if Telemetry is disabled. + * + * @param {Object} [ping=null] A ping to be checked. + * @return {Boolean} True if pings can be send to the servers, false otherwise. + */ + sendingEnabled(ping = null) { + // We only send pings from official builds, but allow overriding this for tests. + if ( + !Services.telemetry.isOfficialTelemetry && + !this._testMode && + !this._overrideOfficialCheck + ) { + return false; + } + + // With unified Telemetry, the FHR upload setting controls whether we can send pings. + // The Telemetry pref enables sending extended data sets instead. + if (IS_UNIFIED_TELEMETRY) { + // "deletion-request" pings are sent once even if the upload is disabled. + if (ping && isDeletionRequestPing(ping)) { + return true; + } + return Services.prefs.getBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + } + + // Without unified Telemetry, the Telemetry enabled pref controls ping sending. + return Utils.isTelemetryEnabled; + }, + + /** + * Track any pending ping send and save tasks through the promise passed here. + * This is needed to block shutdown on any outstanding ping activity. + */ + _trackPendingPingTask(promise) { + let clear = () => this._pendingPingActivity.delete(promise); + promise.then(clear, clear); + this._pendingPingActivity.add(promise); + }, + + /** + * Return a promise that allows to wait on pending pings. + * @return {Object<Promise>} A promise resolved when all the pending pings promises + * are resolved. + */ + promisePendingPingActivity() { + this._log.trace("promisePendingPingActivity - Waiting for ping task"); + let p = Array.from(this._pendingPingActivity, p => + p.catch(ex => { + this._log.error( + "promisePendingPingActivity - ping activity had an error", + ex + ); + }) + ); + p.push(SendScheduler.waitOnSendTask()); + return Promise.all(p); + }, + + async _persistCurrentPings() { + for (let [id, ping] of this._currentPings) { + try { + await savePing(ping); + this._log.trace("_persistCurrentPings - saved ping " + id); + } catch (ex) { + this._log.error("_persistCurrentPings - failed to save ping " + id, ex); + } finally { + this._currentPings.delete(id); + } + } + }, + + /** + * Returns the current pending, not yet persisted, pings, newest first. + */ + getUnpersistedPings() { + let current = [...this._currentPings.values()]; + current.reverse(); + return current; + }, + + getShutdownState() { + return { + sendingEnabled: this._sendingEnabled, + pendingPingRequestCount: this._pendingPingRequests.size, + pendingPingActivityCount: this._pendingPingActivity.size, + unpersistedPingCount: this._currentPings.size, + persistedPingCount: lazy.TelemetryStorage.getPendingPingList().length, + schedulerState: SendScheduler.getShutdownState(), + }; + }, + + runPingSender(pings, observer) { + if (AppConstants.platform === "android") { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + let suppressPingsender = Services.prefs.getBoolPref( + "toolkit.telemetry.testing.suppressPingsender", + false + ); + if (suppressPingsender) { + this._log.trace("Silently skipping pingsender call in automation"); + return; + } + + // By default, invoke `pingsender[.exe] URL path ...`. + let exeName = + AppConstants.platform === "win" ? "pingsender.exe" : "pingsender"; + let params = []; + + if (lazy.NimbusFeatures.pingsender.getVariable("backgroundTaskEnabled")) { + // If using pingsender background task, invoke `firefox[.exe] --backgroundtask pingsender URL path ...`. + exeName = + AppConstants.MOZ_APP_NAME + + (AppConstants.platform === "win" ? ".exe" : ""); + params = ["--backgroundtask", "pingsender"]; + } + + this._log.info( + `Invoking '${exeName}${params.length ? " " + params.join(" ") : ""} ...'` + ); + + let exe = Services.dirsvc.get("GreBinD", Ci.nsIFile); + exe.append(exeName); + + params.push(...pings.flatMap(ping => [ping.url, ping.path])); + let process = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + process.init(exe); + process.startHidden = true; + process.noShell = true; + process.runAsync(params, params.length, observer); + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetryStorage.sys.mjs b/toolkit/components/telemetry/app/TelemetryStorage.sys.mjs new file mode 100644 index 0000000000..779eadf851 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryStorage.sys.mjs @@ -0,0 +1,2233 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; +import { Preferences } from "resource://gre/modules/Preferences.sys.mjs"; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryStorage::"; + +const Telemetry = Services.telemetry; +const Utils = TelemetryUtils; + +// Compute the path of the pings archive on the first use. +const DATAREPORTING_DIR = "datareporting"; +const PINGS_ARCHIVE_DIR = "archived"; +const ABORTED_SESSION_FILE_NAME = "aborted-session-ping"; +const SESSION_STATE_FILE_NAME = "session-state.json"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "gDataReportingDir", function () { + return PathUtils.join(PathUtils.profileDir, DATAREPORTING_DIR); +}); +XPCOMUtils.defineLazyGetter(lazy, "gPingsArchivePath", function () { + return PathUtils.join(lazy.gDataReportingDir, PINGS_ARCHIVE_DIR); +}); +XPCOMUtils.defineLazyGetter(lazy, "gAbortedSessionFilePath", function () { + return PathUtils.join(lazy.gDataReportingDir, ABORTED_SESSION_FILE_NAME); +}); +ChromeUtils.defineESModuleGetters(lazy, { + TelemetryHealthPing: "resource://gre/modules/HealthPing.sys.mjs", +}); +// Maxmimum time, in milliseconds, archive pings should be retained. +const MAX_ARCHIVED_PINGS_RETENTION_MS = 60 * 24 * 60 * 60 * 1000; // 60 days + +// Maximum space the archive can take on disk (in Bytes). +const ARCHIVE_QUOTA_BYTES = 120 * 1024 * 1024; // 120 MB +// Maximum space the outgoing pings can take on disk, for Desktop (in Bytes). +const PENDING_PINGS_QUOTA_BYTES_DESKTOP = 15 * 1024 * 1024; // 15 MB +// Maximum space the outgoing pings can take on disk, for Mobile (in Bytes). +const PENDING_PINGS_QUOTA_BYTES_MOBILE = 1024 * 1024; // 1 MB + +// The maximum size a pending/archived ping can take on disk. +const PING_FILE_MAXIMUM_SIZE_BYTES = 1024 * 1024; // 1 MB + +// This special value is submitted when the archive is outside of the quota. +const ARCHIVE_SIZE_PROBE_SPECIAL_VALUE = 300; + +// This special value is submitted when the pending pings is outside of the quota, as +// we don't know the size of the pings above the quota. +const PENDING_PINGS_SIZE_PROBE_SPECIAL_VALUE = 17; + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * This is thrown by |TelemetryStorage.loadPingFile| when reading the ping + * from the disk fails. + */ +function PingReadError( + message = "Error reading the ping file", + becauseNoSuchFile = false +) { + Error.call(this, message); + let error = new Error(); + this.name = "PingReadError"; + this.message = message; + this.stack = error.stack; + this.becauseNoSuchFile = becauseNoSuchFile; +} +PingReadError.prototype = Object.create(Error.prototype); +PingReadError.prototype.constructor = PingReadError; + +/** + * This is thrown by |TelemetryStorage.loadPingFile| when parsing the ping JSON + * content fails. + */ +function PingParseError(message = "Error parsing ping content") { + Error.call(this, message); + let error = new Error(); + this.name = "PingParseError"; + this.message = message; + this.stack = error.stack; +} +PingParseError.prototype = Object.create(Error.prototype); +PingParseError.prototype.constructor = PingParseError; + +/** + * This is a policy object used to override behavior for testing. + */ +export var Policy = { + now: () => new Date(), + getArchiveQuota: () => ARCHIVE_QUOTA_BYTES, + getPendingPingsQuota: () => + AppConstants.platform == "android" + ? PENDING_PINGS_QUOTA_BYTES_MOBILE + : PENDING_PINGS_QUOTA_BYTES_DESKTOP, + /** + * @param {string} id The ID of the ping that will be written into the file. Can be "*" to + * make a pattern to find all pings for this installation. + * @return + * { + * directory: <nsIFile>, // Directory to save pings + * file: <string>, // File name for this ping (or pattern for all pings) + * } + */ + getUninstallPingPath: id => { + // UpdRootD is e.g. C:\ProgramData\Mozilla\updates\<PATH HASH> + const updateDirectory = Services.dirsvc.get("UpdRootD", Ci.nsIFile); + const installPathHash = updateDirectory.leafName; + + return { + // e.g. C:\ProgramData\Mozilla + directory: updateDirectory.parent.parent.clone(), + file: `uninstall_ping_${installPathHash}_${id}.json`, + }; + }, +}; + +/** + * Wait for all promises in iterable to resolve or reject. This function + * always resolves its promise with undefined, and never rejects. + */ +function waitForAll(it) { + let dummy = () => {}; + let promises = Array.from(it, p => p.catch(dummy)); + return Promise.all(promises); +} + +/** + * Permanently intern the given string. This is mainly used for the ping.type + * strings that can be excessively duplicated in the _archivedPings map. Do not + * pass large or temporary strings to this function. + */ +function internString(str) { + return Symbol.keyFor(Symbol.for(str)); +} + +export var TelemetryStorage = { + get pingDirectoryPath() { + return PathUtils.join(PathUtils.profileDir, "saved-telemetry-pings"); + }, + + /** + * The maximum size a ping can have, in bytes. + */ + get MAXIMUM_PING_SIZE() { + return PING_FILE_MAXIMUM_SIZE_BYTES; + }, + /** + * Shutdown & block on any outstanding async activity in this module. + * + * @return {Promise} Promise that is resolved when shutdown is complete. + */ + shutdown() { + return TelemetryStorageImpl.shutdown(); + }, + + /** + * Save an archived ping to disk. + * + * @param {object} ping The ping data to archive. + * @return {promise} Promise that is resolved when the ping is successfully archived. + */ + saveArchivedPing(ping) { + return TelemetryStorageImpl.saveArchivedPing(ping); + }, + + /** + * Load an archived ping from disk. + * + * @param {string} id The pings id. + * @return {promise<object>} Promise that is resolved with the ping data. + */ + loadArchivedPing(id) { + return TelemetryStorageImpl.loadArchivedPing(id); + }, + + /** + * Get a list of info on the archived pings. + * This will scan the archive directory and grab basic data about the existing + * pings out of their filename. + * + * @return {promise<sequence<object>>} + */ + loadArchivedPingList() { + return TelemetryStorageImpl.loadArchivedPingList(); + }, + + /** + * Clean the pings archive by removing old pings. + * This will scan the archive directory. + * + * @return {Promise} Resolved when the cleanup task completes. + */ + runCleanPingArchiveTask() { + return TelemetryStorageImpl.runCleanPingArchiveTask(); + }, + + /** + * Run the task to enforce the pending pings quota. + * + * @return {Promise} Resolved when the cleanup task completes. + */ + runEnforcePendingPingsQuotaTask() { + return TelemetryStorageImpl.runEnforcePendingPingsQuotaTask(); + }, + + /** + * Run the task to remove all the pending pings + * + * @return {Promise} Resolved when the pings are removed. + */ + runRemovePendingPingsTask() { + return TelemetryStorageImpl.runRemovePendingPingsTask(); + }, + + /** + * Remove all pings that are stored in the userApplicationDataDir + * under the "Pending Pings" sub-directory. + */ + removeAppDataPings() { + return TelemetryStorageImpl.removeAppDataPings(); + }, + + /** + * Reset the storage state in tests. + */ + reset() { + return TelemetryStorageImpl.reset(); + }, + + /** + * Test method that allows waiting on the archive clean task to finish. + */ + testCleanupTaskPromise() { + return TelemetryStorageImpl._cleanArchiveTask || Promise.resolve(); + }, + + /** + * Test method that allows waiting on the pending pings quota task to finish. + */ + testPendingQuotaTaskPromise() { + return ( + TelemetryStorageImpl._enforcePendingPingsQuotaTask || Promise.resolve() + ); + }, + + /** + * Save a pending - outgoing - ping to disk and track it. + * + * @param {Object} ping The ping data. + * @return {Promise} Resolved when the ping was saved. + */ + savePendingPing(ping) { + return TelemetryStorageImpl.savePendingPing(ping); + }, + + /** + * Saves session data to disk. + * @param {Object} sessionData The session data. + * @return {Promise} Resolved when the data was saved. + */ + saveSessionData(sessionData) { + return TelemetryStorageImpl.saveSessionData(sessionData); + }, + + /** + * Loads session data from a session data file. + * @return {Promise<object>} Resolved with the session data in object form. + */ + loadSessionData() { + return TelemetryStorageImpl.loadSessionData(); + }, + + /** + * Load a pending ping from disk by id. + * + * @param {String} id The pings id. + * @return {Promise} Resolved with the loaded ping data. + */ + loadPendingPing(id) { + return TelemetryStorageImpl.loadPendingPing(id); + }, + + /** + * Remove a pending ping from disk by id. + * + * @param {String} id The pings id. + * @return {Promise} Resolved when the ping was removed. + */ + removePendingPing(id) { + return TelemetryStorageImpl.removePendingPing(id); + }, + + /** + * Returns a list of the currently pending pings in the format: + * { + * id: <string>, // The pings UUID. + * lastModified: <number>, // Timestamp of the pings last modification. + * } + * This populates the list by scanning the disk. + * + * @return {Promise<sequence>} Resolved with the ping list. + */ + loadPendingPingList() { + return TelemetryStorageImpl.loadPendingPingList(); + }, + + /** + * Returns a list of the currently pending pings in the format: + * { + * id: <string>, // The pings UUID. + * lastModified: <number>, // Timestamp of the pings last modification. + * } + * This does not scan pending pings on disk. + * + * @return {sequence} The current pending ping list. + */ + getPendingPingList() { + return TelemetryStorageImpl.getPendingPingList(); + }, + + /** + * Save an aborted-session ping to disk. This goes to a special location so + * it is not picked up as a pending ping. + * + * @param {object} ping The ping data to save. + * @return {promise} Promise that is resolved when the ping is successfully saved. + */ + saveAbortedSessionPing(ping) { + return TelemetryStorageImpl.saveAbortedSessionPing(ping); + }, + + /** + * Load the aborted-session ping from disk if present. + * + * @return {promise<object>} Promise that is resolved with the ping data if found. + * Otherwise returns null. + */ + loadAbortedSessionPing() { + return TelemetryStorageImpl.loadAbortedSessionPing(); + }, + + /** + * Remove the aborted-session ping if present. + * + * @return {promise} Promise that is resolved once the ping is removed. + */ + removeAbortedSessionPing() { + return TelemetryStorageImpl.removeAbortedSessionPing(); + }, + + /** + * Save an uninstall ping to disk, removing any old ones from this + * installation first. + * This is stored independently from other pings, and only read by + * the Windows uninstaller. + * + * WINDOWS ONLY, does nothing and resolves immediately on other platforms. + * + * @return {promise} Promise that is resolved when the ping has been saved. + */ + saveUninstallPing(ping) { + return TelemetryStorageImpl.saveUninstallPing(ping); + }, + + /** + * Remove all uninstall pings from this installation. + * + * WINDOWS ONLY, does nothing and resolves immediately on other platforms. + * + * @return {promise} Promise that is resolved when the pings have been removed. + */ + removeUninstallPings() { + return TelemetryStorageImpl.removeUninstallPings(); + }, + + /** + * Save a single ping to a file. + * + * @param {object} ping The content of the ping to save. + * @param {string} file The destination file. + * @param {bool} overwrite If |true|, the file will be overwritten if it exists, + * if |false| the file will not be overwritten and no error will be reported if + * the file exists. + * @returns {promise} + */ + savePingToFile(ping, file, overwrite) { + return TelemetryStorageImpl.savePingToFile(ping, file, overwrite); + }, + + /** + * Save a ping to its file. + * + * @param {object} ping The content of the ping to save. + * @param {bool} overwrite If |true|, the file will be overwritten + * if it exists. + * @returns {promise} + */ + savePing(ping, overwrite) { + return TelemetryStorageImpl.savePing(ping, overwrite); + }, + + /** + * Add a ping to the saved pings directory so that it gets saved + * and sent along with other pings. + * + * @param {Object} pingData The ping object. + * @return {Promise} A promise resolved when the ping is saved to the pings directory. + */ + addPendingPing(pingData) { + return TelemetryStorageImpl.addPendingPing(pingData); + }, + + /** + * Remove the file for a ping + * + * @param {object} ping The ping. + * @returns {promise} + */ + cleanupPingFile(ping) { + return TelemetryStorageImpl.cleanupPingFile(ping); + }, + + /** + * Loads a ping file. + * @param {String} aFilePath The path of the ping file. + * @return {Promise<Object>} A promise resolved with the ping content or rejected if the + * ping contains invalid data. + */ + async loadPingFile(aFilePath) { + return TelemetryStorageImpl.loadPingFile(aFilePath); + }, + + /** + * Remove FHR database files. This is temporary and will be dropped in + * the future. + * @return {Promise} Resolved when the database files are deleted. + */ + removeFHRDatabase() { + return TelemetryStorageImpl.removeFHRDatabase(); + }, + + /** + * Only used in tests, builds an archived ping path from the ping metadata. + * @param {String} aPingId The ping id. + * @param {Object} aDate The ping creation date. + * @param {String} aType The ping type. + * @return {String} The full path to the archived ping. + */ + _testGetArchivedPingPath(aPingId, aDate, aType) { + return getArchivedPingPath(aPingId, aDate, aType); + }, + + /** + * Only used in tests, this helper extracts ping metadata from a given filename. + * + * @param fileName {String} The filename. + * @return {Object} Null if the filename didn't match the expected form. + * Otherwise an object with the extracted data in the form: + * { timestamp: <number>, + * id: <string>, + * type: <string> } + */ + _testGetArchivedPingDataFromFileName(aFileName) { + return TelemetryStorageImpl._getArchivedPingDataFromFileName(aFileName); + }, + + /** + * Only used in tests, this helper allows cleaning up the pending ping storage. + */ + testClearPendingPings() { + return TelemetryStorageImpl.runRemovePendingPingsTask(); + }, +}; + +/** + * This object allows the serialisation of asynchronous tasks. This is particularly + * useful to serialise write access to the disk in order to prevent race conditions + * to corrupt the data being written. + * We are using this to synchronize saving to the file that TelemetrySession persists + * its state in. + */ +function SaveSerializer() { + this._queuedOperations = []; + this._queuedInProgress = false; + this._log = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); +} + +SaveSerializer.prototype = { + /** + * Enqueues an operation to a list to serialise their execution in order to prevent race + * conditions. Useful to serialise access to disk. + * + * @param {Function} aFunction The task function to enqueue. It must return a promise. + * @return {Promise} A promise resolved when the enqueued task completes. + */ + enqueueTask(aFunction) { + let promise = new Promise((resolve, reject) => + this._queuedOperations.push([aFunction, resolve, reject]) + ); + + if (this._queuedOperations.length == 1) { + this._popAndPerformQueuedOperation(); + } + return promise; + }, + + /** + * Make sure to flush all the pending operations. + * @return {Promise} A promise resolved when all the pending operations have completed. + */ + flushTasks() { + let dummyTask = () => new Promise(resolve => resolve()); + return this.enqueueTask(dummyTask); + }, + + /** + * Pop a task from the queue, executes it and continue to the next one. + * This function recursively pops all the tasks. + */ + _popAndPerformQueuedOperation() { + if (!this._queuedOperations.length || this._queuedInProgress) { + return; + } + + this._log.trace( + "_popAndPerformQueuedOperation - Performing queued operation." + ); + let [func, resolve, reject] = this._queuedOperations.shift(); + let promise; + + try { + this._queuedInProgress = true; + promise = func(); + } catch (ex) { + this._log.warn( + "_popAndPerformQueuedOperation - Queued operation threw during execution. ", + ex + ); + this._queuedInProgress = false; + reject(ex); + this._popAndPerformQueuedOperation(); + return; + } + + if (!promise || typeof promise.then != "function") { + let msg = "Queued operation did not return a promise: " + func; + this._log.warn("_popAndPerformQueuedOperation - " + msg); + + this._queuedInProgress = false; + reject(new Error(msg)); + this._popAndPerformQueuedOperation(); + return; + } + + promise.then( + result => { + this._queuedInProgress = false; + resolve(result); + this._popAndPerformQueuedOperation(); + }, + error => { + this._log.warn( + "_popAndPerformQueuedOperation - Failure when performing queued operation.", + error + ); + this._queuedInProgress = false; + reject(error); + this._popAndPerformQueuedOperation(); + } + ); + }, +}; + +var TelemetryStorageImpl = { + _logger: null, + // Used to serialize aborted session ping writes to disk. + _abortedSessionSerializer: new SaveSerializer(), + // Used to serialize session state writes to disk. + _stateSaveSerializer: new SaveSerializer(), + + // Tracks the archived pings in a Map of (id -> {timestampCreated, type}). + // We use this to cache info on archived pings to avoid scanning the disk more than once. + _archivedPings: new Map(), + // A set of promises for pings currently being archived + _activelyArchiving: new Set(), + // Track the archive loading task to prevent multiple tasks from being executed. + _scanArchiveTask: null, + // Track the archive cleanup task. + _cleanArchiveTask: null, + // Whether we already scanned the archived pings on disk. + _scannedArchiveDirectory: false, + + // Track the pending ping removal task. + _removePendingPingsTask: null, + + // This tracks all the pending async ping save activity. + _activePendingPingSaves: new Set(), + + // Tracks the pending pings in a Map of (id -> {timestampCreated, type}). + // We use this to cache info on pending pings to avoid scanning the disk more than once. + _pendingPings: new Map(), + + // Track the pending pings enforce quota task. + _enforcePendingPingsQuotaTask: null, + + // Track the shutdown process to bail out of the clean up task quickly. + _shutdown: false, + + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + + return this._logger; + }, + + /** + * Shutdown & block on any outstanding async activity in this module. + * + * @return {Promise} Promise that is resolved when shutdown is complete. + */ + async shutdown() { + this._shutdown = true; + + // If the following tasks are still running, block on them. They will bail out as soon + // as possible. + await this._abortedSessionSerializer.flushTasks().catch(ex => { + this._log.error("shutdown - failed to flush aborted-session writes", ex); + }); + + if (this._cleanArchiveTask) { + await this._cleanArchiveTask.catch(ex => { + this._log.error("shutdown - the archive cleaning task failed", ex); + }); + } + + if (this._enforcePendingPingsQuotaTask) { + await this._enforcePendingPingsQuotaTask.catch(ex => { + this._log.error("shutdown - the pending pings quota task failed", ex); + }); + } + + if (this._removePendingPingsTask) { + await this._removePendingPingsTask.catch(ex => { + this._log.error("shutdown - the pending pings removal task failed", ex); + }); + } + + // Wait on pending pings still being saved. While IOUtils should have shutdown + // blockers in place, we a) have seen weird errors being reported that might + // indicate a bad shutdown path and b) might have completion handlers hanging + // off the save operations that don't expect to be late in shutdown. + await this.promisePendingPingSaves(); + }, + + /** + * Save an archived ping to disk. + * + * @param {object} ping The ping data to archive. + * @return {promise} Promise that is resolved when the ping is successfully archived. + */ + saveArchivedPing(ping) { + let promise = this._saveArchivedPingTask(ping); + this._activelyArchiving.add(promise); + promise.then( + r => { + this._activelyArchiving.delete(promise); + }, + e => { + this._activelyArchiving.delete(promise); + } + ); + return promise; + }, + + async _saveArchivedPingTask(ping) { + const creationDate = new Date(ping.creationDate); + if (this._archivedPings.has(ping.id)) { + const data = this._archivedPings.get(ping.id); + if (data.timestampCreated > creationDate.getTime()) { + this._log.error( + "saveArchivedPing - trying to overwrite newer ping with the same id" + ); + return Promise.reject( + new Error("trying to overwrite newer ping with the same id") + ); + } + this._log.warn( + "saveArchivedPing - overwriting older ping with the same id" + ); + } + + // Get the archived ping path and append the lz4 suffix to it (so we have 'jsonlz4'). + const filePath = + getArchivedPingPath(ping.id, creationDate, ping.type) + "lz4"; + await IOUtils.makeDirectory(PathUtils.parent(filePath)); + await this.savePingToFile( + ping, + filePath, + /* overwrite*/ true, + /* compressed*/ true + ); + + this._archivedPings.set(ping.id, { + timestampCreated: creationDate.getTime(), + type: internString(ping.type), + }); + + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SESSION_PING_COUNT").add(); + return undefined; + }, + + /** + * Load an archived ping from disk. + * + * @param {string} id The pings id. + * @return {promise<object>} Promise that is resolved with the ping data. + */ + async loadArchivedPing(id) { + const data = this._archivedPings.get(id); + if (!data) { + this._log.trace("loadArchivedPing - no ping with id: " + id); + return Promise.reject( + new Error("TelemetryStorage.loadArchivedPing - no ping with id " + id) + ); + } + + const path = getArchivedPingPath( + id, + new Date(data.timestampCreated), + data.type + ); + const pathCompressed = path + "lz4"; + + // Purge pings which are too big. + let checkSize = async function (path) { + const fileSize = await IOUtils.stat(path).then(info => info.size); + if (fileSize > PING_FILE_MAXIMUM_SIZE_BYTES) { + Telemetry.getHistogramById( + "TELEMETRY_DISCARDED_ARCHIVED_PINGS_SIZE_MB" + ).add(Math.floor(fileSize / 1024 / 1024)); + Telemetry.getHistogramById( + "TELEMETRY_PING_SIZE_EXCEEDED_ARCHIVED" + ).add(); + await IOUtils.remove(path, { ignoreAbsent: true }); + throw new Error( + `loadArchivedPing - exceeded the maximum ping size: ${fileSize}` + ); + } + }; + + let ping; + try { + // Try to load a compressed version of the archived ping first. + this._log.trace( + "loadArchivedPing - loading ping from: " + pathCompressed + ); + await checkSize(pathCompressed); + ping = await this.loadPingFile(pathCompressed, /* compressed*/ true); + } catch (ex) { + if (!ex.becauseNoSuchFile) { + throw ex; + } + // If that fails, look for the uncompressed version. + this._log.trace( + "loadArchivedPing - compressed ping not found, loading: " + path + ); + await checkSize(path); + ping = await this.loadPingFile(path, /* compressed*/ false); + } + + return ping; + }, + + /** + * Saves session data to disk. + */ + saveSessionData(sessionData) { + return this._stateSaveSerializer.enqueueTask(() => + this._saveSessionData(sessionData) + ); + }, + + async _saveSessionData(sessionData) { + await IOUtils.makeDirectory(lazy.gDataReportingDir, { + createAncestors: false, + }); + + let filePath = PathUtils.join( + lazy.gDataReportingDir, + SESSION_STATE_FILE_NAME + ); + try { + await IOUtils.writeJSON(filePath, sessionData); + } catch (e) { + this._log.error( + `_saveSessionData - Failed to write session data to ${filePath}`, + e + ); + Telemetry.getHistogramById("TELEMETRY_SESSIONDATA_FAILED_SAVE").add(1); + } + }, + + /** + * Loads session data from the session data file. + * @return {Promise<Object>} A promise resolved with an object on success, + * with null otherwise. + */ + loadSessionData() { + return this._stateSaveSerializer.enqueueTask(() => this._loadSessionData()); + }, + + async _loadSessionData() { + const dataFile = PathUtils.join( + PathUtils.profileDir, + DATAREPORTING_DIR, + SESSION_STATE_FILE_NAME + ); + let content; + try { + content = await IOUtils.readUTF8(dataFile); + } catch (ex) { + this._log.info("_loadSessionData - can not load session data file", ex); + Telemetry.getHistogramById("TELEMETRY_SESSIONDATA_FAILED_LOAD").add(1); + return null; + } + + let data; + try { + data = JSON.parse(content); + } catch (ex) { + this._log.error("_loadSessionData - failed to parse session data", ex); + Telemetry.getHistogramById("TELEMETRY_SESSIONDATA_FAILED_PARSE").add(1); + return null; + } + + return data; + }, + + /** + * Remove an archived ping from disk. + * + * @param {string} id The pings id. + * @param {number} timestampCreated The pings creation timestamp. + * @param {string} type The pings type. + * @return {promise<object>} Promise that is resolved when the pings is removed. + */ + async _removeArchivedPing(id, timestampCreated, type) { + this._log.trace( + "_removeArchivedPing - id: " + + id + + ", timestampCreated: " + + timestampCreated + + ", type: " + + type + ); + const path = getArchivedPingPath(id, new Date(timestampCreated), type); + const pathCompressed = path + "lz4"; + + this._log.trace("_removeArchivedPing - removing ping from: " + path); + await IOUtils.remove(path); + await IOUtils.remove(pathCompressed); + // Remove the ping from the cache. + this._archivedPings.delete(id); + }, + + /** + * Clean the pings archive by removing old pings. + * + * @return {Promise} Resolved when the cleanup task completes. + */ + runCleanPingArchiveTask() { + // If there's an archive cleaning task already running, return it. + if (this._cleanArchiveTask) { + return this._cleanArchiveTask; + } + + // Make sure to clear |_cleanArchiveTask| once done. + let clear = () => (this._cleanArchiveTask = null); + // Since there's no archive cleaning task running, start it. + this._cleanArchiveTask = this._cleanArchive().then(clear, clear); + return this._cleanArchiveTask; + }, + + /** + * Removes pings which are too old from the pings archive. + * @return {Promise} Resolved when the ping age check is complete. + */ + async _purgeOldPings() { + this._log.trace("_purgeOldPings"); + + const nowDate = Policy.now(); + const startTimeStamp = nowDate.getTime(); + + // Keep track of the newest removed month to update the cache, if needed. + let newestRemovedMonthTimestamp = null; + let evictedDirsCount = 0; + let maxDirAgeInMonths = 0; + + // Walk through the monthly subdirs of the form <YYYY-MM>/ + for (const path of await IOUtils.getChildren(lazy.gPingsArchivePath)) { + const info = await IOUtils.stat(path); + if (info.type !== "directory") { + continue; + } + + const name = PathUtils.filename(path); + + if (this._shutdown) { + this._log.trace( + "_purgeOldPings - Terminating the clean up task due to shutdown" + ); + return; + } + + if (!isValidArchiveDir(name)) { + this._log.warn( + `_purgeOldPings - skipping invalidly named subdirectory ${path}` + ); + continue; + } + + const archiveDate = getDateFromArchiveDir(name); + if (!archiveDate) { + this._log.warn( + `_purgeOldPings - skipping invalid subdirectory date ${path}` + ); + continue; + } + + // If this archive directory is older than allowed, remove it. + if ( + startTimeStamp - archiveDate.getTime() > + MAX_ARCHIVED_PINGS_RETENTION_MS + ) { + try { + await IOUtils.remove(path, { recursive: true }); + evictedDirsCount++; + + // Update the newest removed month. + newestRemovedMonthTimestamp = Math.max( + archiveDate, + newestRemovedMonthTimestamp + ); + } catch (ex) { + this._log.error(`_purgeOldPings - Unable to remove ${path}`, ex); + } + } else { + // We're not removing this directory, so record the age for the oldest directory. + const dirAgeInMonths = Utils.getElapsedTimeInMonths( + archiveDate, + nowDate + ); + maxDirAgeInMonths = Math.max(dirAgeInMonths, maxDirAgeInMonths); + } + } + + // Trigger scanning of the archived pings. + await this.loadArchivedPingList(); + + // Refresh the cache: we could still skip this, but it's cheap enough to keep it + // to avoid introducing task dependencies. + if (newestRemovedMonthTimestamp) { + // Scan the archive cache for pings older than the newest directory pruned above. + for (let [id, info] of this._archivedPings) { + const timestampCreated = new Date(info.timestampCreated); + if (timestampCreated.getTime() > newestRemovedMonthTimestamp) { + continue; + } + // Remove outdated pings from the cache. + this._archivedPings.delete(id); + } + } + + const endTimeStamp = Policy.now().getTime(); + + // Save the time it takes to evict old directories and the eviction count. + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_EVICTED_OLD_DIRS").add( + evictedDirsCount + ); + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_EVICTING_DIRS_MS").add( + Math.ceil(endTimeStamp - startTimeStamp) + ); + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_OLDEST_DIRECTORY_AGE").add( + maxDirAgeInMonths + ); + }, + + /** + * Enforce a disk quota for the pings archive. + * @return {Promise} Resolved when the quota check is complete. + */ + async _enforceArchiveQuota() { + this._log.trace("_enforceArchiveQuota"); + let startTimeStamp = Policy.now().getTime(); + + // Build an ordered list, from newer to older, of archived pings. + let pingList = Array.from(this._archivedPings, p => ({ + id: p[0], + timestampCreated: p[1].timestampCreated, + type: p[1].type, + })); + + pingList.sort((a, b) => b.timestampCreated - a.timestampCreated); + + // If our archive is too big, we should reduce it to reach 90% of the quota. + const SAFE_QUOTA = Policy.getArchiveQuota() * 0.9; + // The index of the last ping to keep. Pings older than this one will be deleted if + // the archive exceeds the quota. + let lastPingIndexToKeep = null; + let archiveSizeInBytes = 0; + + // Find the disk size of the archive. + for (let i = 0; i < pingList.length; i++) { + if (this._shutdown) { + this._log.trace( + "_enforceArchiveQuota - Terminating the clean up task due to shutdown" + ); + return; + } + + let ping = pingList[i]; + + // Get the size for this ping. + const fileSize = await getArchivedPingSize( + ping.id, + new Date(ping.timestampCreated), + ping.type + ); + if (!fileSize) { + this._log.warn( + "_enforceArchiveQuota - Unable to find the size of ping " + ping.id + ); + continue; + } + + // Enforce a maximum file size limit on archived pings. + if (fileSize > PING_FILE_MAXIMUM_SIZE_BYTES) { + this._log.error( + "_enforceArchiveQuota - removing file exceeding size limit, size: " + + fileSize + ); + // We just remove the ping from the disk, we don't bother removing it from pingList + // since it won't contribute to the quota. + await this._removeArchivedPing( + ping.id, + ping.timestampCreated, + ping.type + ).catch(e => + this._log.error( + "_enforceArchiveQuota - failed to remove archived ping" + ping.id + ) + ); + Telemetry.getHistogramById( + "TELEMETRY_DISCARDED_ARCHIVED_PINGS_SIZE_MB" + ).add(Math.floor(fileSize / 1024 / 1024)); + Telemetry.getHistogramById( + "TELEMETRY_PING_SIZE_EXCEEDED_ARCHIVED" + ).add(); + continue; + } + + archiveSizeInBytes += fileSize; + + if (archiveSizeInBytes < SAFE_QUOTA) { + // We save the index of the last ping which is ok to keep in order to speed up ping + // pruning. + lastPingIndexToKeep = i; + } else if (archiveSizeInBytes > Policy.getArchiveQuota()) { + // Ouch, our ping archive is too big. Bail out and start pruning! + break; + } + } + + // Save the time it takes to check if the archive is over-quota. + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_CHECKING_OVER_QUOTA_MS").add( + Math.round(Policy.now().getTime() - startTimeStamp) + ); + + let submitProbes = (sizeInMB, evictedPings, elapsedMs) => { + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SIZE_MB").add(sizeInMB); + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_EVICTED_OVER_QUOTA").add( + evictedPings + ); + Telemetry.getHistogramById( + "TELEMETRY_ARCHIVE_EVICTING_OVER_QUOTA_MS" + ).add(elapsedMs); + }; + + // Check if we're using too much space. If not, submit the archive size and bail out. + if (archiveSizeInBytes < Policy.getArchiveQuota()) { + submitProbes(Math.round(archiveSizeInBytes / 1024 / 1024), 0, 0); + return; + } + + this._log.info( + "_enforceArchiveQuota - archive size: " + + archiveSizeInBytes + + "bytes" + + ", safety quota: " + + SAFE_QUOTA + + "bytes" + ); + + startTimeStamp = Policy.now().getTime(); + let pingsToPurge = pingList.slice(lastPingIndexToKeep + 1); + + // Remove all the pings older than the last one which we are safe to keep. + for (let ping of pingsToPurge) { + if (this._shutdown) { + this._log.trace( + "_enforceArchiveQuota - Terminating the clean up task due to shutdown" + ); + return; + } + + // This list is guaranteed to be in order, so remove the pings at its + // beginning (oldest). + await this._removeArchivedPing(ping.id, ping.timestampCreated, ping.type); + } + + const endTimeStamp = Policy.now().getTime(); + submitProbes( + ARCHIVE_SIZE_PROBE_SPECIAL_VALUE, + pingsToPurge.length, + Math.ceil(endTimeStamp - startTimeStamp) + ); + }, + + async _cleanArchive() { + this._log.trace("cleanArchiveTask"); + + if (!(await IOUtils.exists(lazy.gPingsArchivePath))) { + return; + } + + // Remove pings older than allowed. + try { + await this._purgeOldPings(); + } catch (ex) { + this._log.error( + "_cleanArchive - There was an error removing old directories", + ex + ); + } + + // Make sure we respect the archive disk quota. + await this._enforceArchiveQuota(); + }, + + /** + * Run the task to enforce the pending pings quota. + * + * @return {Promise} Resolved when the cleanup task completes. + */ + async runEnforcePendingPingsQuotaTask() { + // If there's a cleaning task already running, return it. + if (this._enforcePendingPingsQuotaTask) { + return this._enforcePendingPingsQuotaTask; + } + + // Since there's no quota enforcing task running, start it. + try { + this._enforcePendingPingsQuotaTask = this._enforcePendingPingsQuota(); + await this._enforcePendingPingsQuotaTask; + } finally { + this._enforcePendingPingsQuotaTask = null; + } + return undefined; + }, + + /** + * Enforce a disk quota for the pending pings. + * @return {Promise} Resolved when the quota check is complete. + */ + async _enforcePendingPingsQuota() { + this._log.trace("_enforcePendingPingsQuota"); + let startTimeStamp = Policy.now().getTime(); + + // Build an ordered list, from newer to older, of pending pings. + let pingList = Array.from(this._pendingPings, p => ({ + id: p[0], + lastModified: p[1].lastModified, + })); + + pingList.sort((a, b) => b.lastModified - a.lastModified); + + // If our pending pings directory is too big, we should reduce it to reach 90% of the quota. + const SAFE_QUOTA = Policy.getPendingPingsQuota() * 0.9; + // The index of the last ping to keep. Pings older than this one will be deleted if + // the pending pings directory size exceeds the quota. + let lastPingIndexToKeep = null; + let pendingPingsSizeInBytes = 0; + + // Find the disk size of the pending pings directory. + for (let i = 0; i < pingList.length; i++) { + if (this._shutdown) { + this._log.trace( + "_enforcePendingPingsQuota - Terminating the clean up task due to shutdown" + ); + return; + } + + let ping = pingList[i]; + + // Get the size for this ping. + const fileSize = await getPendingPingSize(ping.id); + if (!fileSize) { + this._log.warn( + "_enforcePendingPingsQuota - Unable to find the size of ping " + + ping.id + ); + continue; + } + + pendingPingsSizeInBytes += fileSize; + if (pendingPingsSizeInBytes < SAFE_QUOTA) { + // We save the index of the last ping which is ok to keep in order to speed up ping + // pruning. + lastPingIndexToKeep = i; + } else if (pendingPingsSizeInBytes > Policy.getPendingPingsQuota()) { + // Ouch, our pending pings directory size is too big. Bail out and start pruning! + break; + } + } + + // Save the time it takes to check if the pending pings are over-quota. + Telemetry.getHistogramById("TELEMETRY_PENDING_CHECKING_OVER_QUOTA_MS").add( + Math.round(Policy.now().getTime() - startTimeStamp) + ); + + let recordHistograms = (sizeInMB, evictedPings, elapsedMs) => { + Telemetry.getHistogramById("TELEMETRY_PENDING_PINGS_SIZE_MB").add( + sizeInMB + ); + Telemetry.getHistogramById( + "TELEMETRY_PENDING_PINGS_EVICTED_OVER_QUOTA" + ).add(evictedPings); + Telemetry.getHistogramById( + "TELEMETRY_PENDING_EVICTING_OVER_QUOTA_MS" + ).add(elapsedMs); + }; + + // Check if we're using too much space. If not, bail out. + if (pendingPingsSizeInBytes < Policy.getPendingPingsQuota()) { + recordHistograms(Math.round(pendingPingsSizeInBytes / 1024 / 1024), 0, 0); + return; + } + + this._log.info( + "_enforcePendingPingsQuota - size: " + + pendingPingsSizeInBytes + + "bytes" + + ", safety quota: " + + SAFE_QUOTA + + "bytes" + ); + + startTimeStamp = Policy.now().getTime(); + let pingsToPurge = pingList.slice(lastPingIndexToKeep + 1); + + // Remove all the pings older than the last one which we are safe to keep. + for (let ping of pingsToPurge) { + if (this._shutdown) { + this._log.trace( + "_enforcePendingPingsQuota - Terminating the clean up task due to shutdown" + ); + return; + } + + // This list is guaranteed to be in order, so remove the pings at its + // beginning (oldest). + await this.removePendingPing(ping.id); + } + + const endTimeStamp = Policy.now().getTime(); + // We don't know the size of the pending pings directory if we are above the quota, + // since we stop scanning once we reach the quota. We use a special value to show + // this condition. + recordHistograms( + PENDING_PINGS_SIZE_PROBE_SPECIAL_VALUE, + pingsToPurge.length, + Math.ceil(endTimeStamp - startTimeStamp) + ); + }, + + /** + * Reset the storage state in tests. + */ + reset() { + this._shutdown = false; + this._scannedArchiveDirectory = false; + this._archivedPings = new Map(); + this._scannedPendingDirectory = false; + this._pendingPings = new Map(); + }, + + /** + * Get a list of info on the archived pings. + * This will scan the archive directory and grab basic data about the existing + * pings out of their filename. + * + * @return {promise<sequence<object>>} + */ + async loadArchivedPingList() { + // If there's an archive loading task already running, return it. + if (this._scanArchiveTask) { + return this._scanArchiveTask; + } + + await waitForAll(this._activelyArchiving); + + if (this._scannedArchiveDirectory) { + this._log.trace( + "loadArchivedPingList - Archive already scanned, hitting cache." + ); + return this._archivedPings; + } + + // Since there's no archive loading task running, start it. + let result; + try { + this._scanArchiveTask = this._scanArchive(); + result = await this._scanArchiveTask; + } finally { + this._scanArchiveTask = null; + } + return result; + }, + + async _scanArchive() { + this._log.trace("_scanArchive"); + + let submitProbes = (pingCount, dirCount) => { + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SCAN_PING_COUNT").add( + pingCount + ); + Telemetry.getHistogramById("TELEMETRY_ARCHIVE_DIRECTORIES_COUNT").add( + dirCount + ); + }; + + if (!(await IOUtils.exists(lazy.gPingsArchivePath))) { + submitProbes(0, 0); + return new Map(); + } + + let subDirCount = 0; + // Walk through the monthly subdirs of the form <YYYY-MM>/ + for (const path of await IOUtils.getChildren(lazy.gPingsArchivePath)) { + const info = await IOUtils.stat(path); + + if (info.type !== "directory") { + continue; + } + + const name = PathUtils.filename(path); + if (!isValidArchiveDir(name)) { + continue; + } + + subDirCount++; + + this._log.trace(`_scanArchive - checking in subdir: ${path}`); + const pingPaths = []; + for (const ping of await IOUtils.getChildren(path)) { + const info = await IOUtils.stat(ping); + if (info.type !== "directory") { + pingPaths.push(ping); + } + } + + // Now process any ping files of the form "<timestamp>.<uuid>.<type>.[json|jsonlz4]". + for (const path of pingPaths) { + const filename = PathUtils.filename(path); + // data may be null if the filename doesn't match the above format. + let data = this._getArchivedPingDataFromFileName(filename); + if (!data) { + continue; + } + + // In case of conflicts, overwrite only with newer pings. + if (this._archivedPings.has(data.id)) { + const overwrite = + data.timestamp > this._archivedPings.get(data.id).timestampCreated; + this._log.warn( + `_scanArchive - have seen this id before: ${data.id}, overwrite: ${overwrite}` + ); + if (!overwrite) { + continue; + } + + await this._removeArchivedPing( + data.id, + data.timestampCreated, + data.type + ).catch(e => + this._log.warn("_scanArchive - failed to remove ping", e) + ); + } + + this._archivedPings.set(data.id, { + timestampCreated: data.timestamp, + type: internString(data.type), + }); + } + } + + // Mark the archive as scanned, so we no longer hit the disk. + this._scannedArchiveDirectory = true; + // Update the ping and directories count histograms. + submitProbes(this._archivedPings.size, subDirCount); + return this._archivedPings; + }, + + /** + * Save a single ping to a file. + * + * @param {object} ping The content of the ping to save. + * @param {string} file The destination file. + * @param {bool} overwrite If |true|, the file will be overwritten if it exists, + * if |false| the file will not be overwritten and no error will be reported if + * the file exists. + * @param {bool} [compress=false] If |true|, the file will use lz4 compression. Otherwise no + * compression will be used. + * @returns {promise} + */ + async savePingToFile(ping, filePath, overwrite, compress = false) { + try { + this._log.trace("savePingToFile - path: " + filePath); + await IOUtils.writeJSON(filePath, ping, { + compress, + mode: overwrite ? "overwrite" : "create", + tmpPath: `${filePath}.tmp`, + }); + } catch (e) { + if ( + !DOMException.isInstance(e) || + e.name !== "NoModificationAllowedError" + ) { + throw e; + } + } + }, + + /** + * Save a ping to its file. + * + * @param {object} ping The content of the ping to save. + * @param {bool} overwrite If |true|, the file will be overwritten + * if it exists. + * @returns {promise} + */ + async savePing(ping, overwrite) { + await getPingDirectory(); + let file = pingFilePath(ping); + await this.savePingToFile(ping, file, overwrite); + return file; + }, + + /** + * Add a ping to the saved pings directory so that it gets saved + * and sent along with other pings. + * Note: that the original ping file will not be modified. + * + * @param {Object} ping The ping object. + * @return {Promise} A promise resolved when the ping is saved to the pings directory. + */ + addPendingPing(ping) { + return this.savePendingPing(ping); + }, + + /** + * Remove the file for a ping + * + * @param {object} ping The ping. + * @returns {promise} + */ + cleanupPingFile(ping) { + return IOUtils.remove(pingFilePath(ping)); + }, + + savePendingPing(ping) { + let p = this.savePing(ping, true).then(path => { + this._pendingPings.set(ping.id, { + path, + lastModified: Policy.now().getTime(), + }); + this._log.trace("savePendingPing - saved ping with id " + ping.id); + }); + this._trackPendingPingSaveTask(p); + return p; + }, + + async loadPendingPing(id) { + this._log.trace("loadPendingPing - id: " + id); + let info = this._pendingPings.get(id); + if (!info) { + this._log.trace("loadPendingPing - unknown id " + id); + throw new Error( + "TelemetryStorage.loadPendingPing - no ping with id " + id + ); + } + + // Try to get the dimension of the ping. If that fails, update the histograms. + let fileSize = 0; + try { + fileSize = await IOUtils.stat(info.path).then(stat => stat.size); + } catch (e) { + if (!DOMException.isInstance(e) || e.name !== "NotFoundError") { + throw e; + } + // Fall through and let |loadPingFile| report the error. + } + + // Purge pings which are too big. + if (fileSize > PING_FILE_MAXIMUM_SIZE_BYTES) { + await this.removePendingPing(id); + Telemetry.getHistogramById( + "TELEMETRY_DISCARDED_PENDING_PINGS_SIZE_MB" + ).add(Math.floor(fileSize / 1024 / 1024)); + Telemetry.getHistogramById("TELEMETRY_PING_SIZE_EXCEEDED_PENDING").add(); + + // Currently we don't have the ping type available without loading the ping from disk. + // Bug 1384903 will fix that. + lazy.TelemetryHealthPing.recordDiscardedPing("<unknown>"); + throw new Error( + "loadPendingPing - exceeded the maximum ping size: " + fileSize + ); + } + + // Try to load the ping file. Update the related histograms on failure. + let ping; + try { + ping = await this.loadPingFile(info.path, false); + } catch (e) { + // If we failed to load the ping, check what happened and update the histogram. + if (e instanceof PingReadError) { + Telemetry.getHistogramById("TELEMETRY_PENDING_LOAD_FAILURE_READ").add(); + } else if (e instanceof PingParseError) { + Telemetry.getHistogramById( + "TELEMETRY_PENDING_LOAD_FAILURE_PARSE" + ).add(); + } + + // Remove the ping from the cache, so we don't try to load it again. + this._pendingPings.delete(id); + // Then propagate the rejection. + throw e; + } + + return ping; + }, + + removePendingPing(id) { + let info = this._pendingPings.get(id); + if (!info) { + this._log.trace("removePendingPing - unknown id " + id); + return Promise.resolve(); + } + + this._log.trace( + "removePendingPing - deleting ping with id: " + + id + + ", path: " + + info.path + ); + this._pendingPings.delete(id); + return IOUtils.remove(info.path).catch(ex => + this._log.error("removePendingPing - failed to remove ping", ex) + ); + }, + + /** + * Track any pending ping save tasks through the promise passed here. + * This is needed to block on any outstanding ping save activity. + * + * @param {Object<Promise>} The save promise to track. + */ + _trackPendingPingSaveTask(promise) { + let clear = () => this._activePendingPingSaves.delete(promise); + promise.then(clear, clear); + this._activePendingPingSaves.add(promise); + }, + + /** + * Return a promise that allows to wait on pending pings being saved. + * @return {Object<Promise>} A promise resolved when all the pending pings save promises + * are resolved. + */ + promisePendingPingSaves() { + // Make sure to wait for all the promises, even if they reject. We don't need to log + // the failures here, as they are already logged elsewhere. + return waitForAll(this._activePendingPingSaves); + }, + + /** + * Run the task to remove all the pending pings + * + * @return {Promise} Resolved when the pings are removed. + */ + async runRemovePendingPingsTask() { + // If we already have a pending pings removal task active, return that. + if (this._removePendingPingsTask) { + return this._removePendingPingsTask; + } + + // Start the task to remove all pending pings. Also make sure to clear the task once done. + try { + this._removePendingPingsTask = this.removePendingPings(); + await this._removePendingPingsTask; + } finally { + this._removePendingPingsTask = null; + } + return undefined; + }, + + async removePendingPings() { + this._log.trace("removePendingPings - removing all pending pings"); + + // Wait on pending pings still being saved, so so we don't miss removing them. + await this.promisePendingPingSaves(); + + // Individually remove existing pings, so we don't interfere with operations expecting + // the pending pings directory to exist. + const directory = TelemetryStorage.pingDirectoryPath; + + if (!(await IOUtils.exists(directory))) { + this._log.trace( + "removePendingPings - the pending pings directory doesn't exist" + ); + return; + } + + for (const path of await IOUtils.getChildren(directory)) { + let info; + try { + info = await IOUtils.stat(path); + } catch (ex) { + // It is possible there is another task removing a ping in between + // reading the directory and calling stat. + // + // On Windows, attempting to call GetFileAttributesEx() on a file + // pending deletion will result in ERROR_ACCESS_DENIED, which will + // propagate to here as a NotAllowedError. + if ( + DOMException.isInstance(ex) && + (ex.name === "NotFoundError" || ex.name === "NotAllowedError") + ) { + continue; + } + + throw ex; + } + + if (info.type === "directory") { + continue; + } + + try { + await IOUtils.remove(path); + } catch (ex) { + this._log.error( + `removePendingPings - failed to remove file ${path}`, + ex + ); + continue; + } + } + }, + + /** + * Iterate through all pings in the userApplicationDataDir under the "Pending Pings" sub-directory + * and yield each file. + */ + async *_iterateAppDataPings() { + this._log.trace("_iterateAppDataPings"); + + let uAppDataDir; + try { + uAppDataDir = Services.dirsvc.get("UAppData", Ci.nsIFile); + } catch (ex) { + // The test suites might not create and define the "UAppData" directory. + // We account for that here instead of manually going through each test using + // telemetry to manually create the directory and define the constant. + this._log.trace( + "_iterateAppDataPings - userApplicationDataDir is not defined. Is this a test?" + ); + return; + } + + const appDataPendingPings = PathUtils.join( + uAppDataDir.path, + "Pending Pings" + ); + + // Check if appDataPendingPings exists and bail out if it doesn't. + if (!(await IOUtils.exists(appDataPendingPings))) { + this._log.trace( + "_iterateAppDataPings - the AppData pending pings directory doesn't exist." + ); + return; + } + + // Iterate through the pending ping files. + for (const path of await IOUtils.getChildren(appDataPendingPings)) { + const info = await IOUtils.stat(path); + if (info.type !== "directory") { + yield path; + } + } + }, + + /** + * Remove all pings that are stored in the userApplicationDataDir + * under the "Pending Pings" sub-directory. + */ + async removeAppDataPings() { + this._log.trace("removeAppDataPings"); + + for await (const path of this._iterateAppDataPings()) { + try { + await IOUtils.remove(path); + } catch (ex) { + this._log.error( + `removeAppDataPings - failed to remove file ${path}`, + ex + ); + } + } + }, + + /** + * Migrate pings that are stored in the userApplicationDataDir + * under the "Pending Pings" sub-directory. + */ + async _migrateAppDataPings() { + this._log.trace("_migrateAppDataPings"); + + for await (const path of this._iterateAppDataPings()) { + try { + // Load the ping data from the original file. + const pingData = await this.loadPingFile(path); + + // Save it among the pending pings in the user profile, overwrite on + // ping id collision. + await TelemetryStorage.savePing(pingData, true); + } catch (ex) { + this._log.error( + `_migrateAppDataPings - failed to load or migrate file. Removing ${path}`, + ex + ); + } + + try { + // Finally remove the file. + await IOUtils.remove(path); + } catch (ex) { + this._log.error( + `_migrateAppDataPings - failed to remove file ${path}`, + ex + ); + } + } + }, + + loadPendingPingList() { + // If we already have a pending scanning task active, return that. + if (this._scanPendingPingsTask) { + return this._scanPendingPingsTask; + } + + if (this._scannedPendingDirectory) { + this._log.trace( + "loadPendingPingList - Pending already scanned, hitting cache." + ); + return Promise.resolve(this._buildPingList()); + } + + // Since there's no pending pings scan task running, start it. + // Also make sure to clear the task once done. + this._scanPendingPingsTask = this._scanPendingPings().then( + pings => { + this._scanPendingPingsTask = null; + return pings; + }, + ex => { + this._scanPendingPingsTask = null; + throw ex; + } + ); + return this._scanPendingPingsTask; + }, + + getPendingPingList() { + return this._buildPingList(); + }, + + async _scanPendingPings() { + this._log.trace("_scanPendingPings"); + + // Before pruning the pending pings, migrate over the ones from the user + // application data directory (mainly crash pings that failed to be sent). + await this._migrateAppDataPings(); + + const directory = TelemetryStorage.pingDirectoryPath; + if (!(await IOUtils.exists(directory))) { + return []; + } + + const files = []; + for (const path of await IOUtils.getChildren(directory)) { + if (this._shutdown) { + return []; + } + + try { + const info = await IOUtils.stat(path); + if (info.type !== "directory") { + files.push({ path, info }); + } + } catch (ex) { + this._log.error(`_scanPendingPings - failed to stat file ${path}`, ex); + continue; + } + } + + for (const { path, info } of files) { + if (this._shutdown) { + return []; + } + + // Enforce a maximum file size limit on pending pings. + if (info.size > PING_FILE_MAXIMUM_SIZE_BYTES) { + this._log.error( + `_scanPendingPings - removing file exceeding size limit ${path}` + ); + try { + await IOUtils.remove(path); + } catch (ex) { + this._log.error( + `_scanPendingPings - failed to remove file ${path}`, + ex + ); + } finally { + Telemetry.getHistogramById( + "TELEMETRY_DISCARDED_PENDING_PINGS_SIZE_MB" + ).add(Math.floor(info.size / 1024 / 1024)); + Telemetry.getHistogramById( + "TELEMETRY_PING_SIZE_EXCEEDED_PENDING" + ).add(); + + // Currently we don't have the ping type available without loading the ping from disk. + // Bug 1384903 will fix that. + lazy.TelemetryHealthPing.recordDiscardedPing("<unknown>"); + } + continue; + } + + let id = PathUtils.filename(path); + if (!UUID_REGEX.test(id)) { + this._log.trace(`_scanPendingPings - filename is not a UUID: ${id}`); + id = Utils.generateUUID(); + } + + this._pendingPings.set(id, { + path, + lastModified: info.lastModified, + }); + } + + this._scannedPendingDirectory = true; + return this._buildPingList(); + }, + + _buildPingList() { + const list = Array.from(this._pendingPings, p => ({ + id: p[0], + lastModified: p[1].lastModified, + })); + + list.sort((a, b) => b.lastModified - a.lastModified); + return list; + }, + + /** + * Loads a ping file. + * @param {String} aFilePath The path of the ping file. + * @param {Boolean} [aCompressed=false] If |true|, expects the file to be compressed using lz4. + * @return {Promise<Object>} A promise resolved with the ping content or rejected if the + * ping contains invalid data. + * @throws {PingReadError} There was an error while reading the ping file from the disk. + * @throws {PingParseError} There was an error while parsing the JSON content of the ping file. + */ + async loadPingFile(aFilePath, aCompressed = false) { + let rawPing; + try { + rawPing = await IOUtils.readUTF8(aFilePath, { decompress: aCompressed }); + } catch (e) { + this._log.trace(`loadPingfile - unreadable ping ${aFilePath}`, e); + throw new PingReadError( + e.message, + DOMException.isInstance(e) && e.name === "NotFoundError" + ); + } + + let ping; + try { + ping = JSON.parse(rawPing); + } catch (e) { + this._log.trace(`loadPingfile - unparseable ping ${aFilePath}`, e); + await IOUtils.remove(aFilePath).catch(ex => { + this._log.error( + `loadPingFile - failed removing unparseable ping file ${aFilePath}`, + ex + ); + }); + throw new PingParseError(e.message); + } + + return ping; + }, + + /** + * Archived pings are saved with file names of the form: + * "<timestamp>.<uuid>.<type>.[json|jsonlz4]" + * This helper extracts that data from a given filename. + * + * @param fileName {String} The filename. + * @return {Object} Null if the filename didn't match the expected form. + * Otherwise an object with the extracted data in the form: + * { timestamp: <number>, + * id: <string>, + * type: <string> } + */ + _getArchivedPingDataFromFileName(fileName) { + // Extract the parts. + let parts = fileName.split("."); + if (parts.length != 4) { + this._log.trace("_getArchivedPingDataFromFileName - should have 4 parts"); + return null; + } + + let [timestamp, uuid, type, extension] = parts; + if (extension != "json" && extension != "jsonlz4") { + this._log.trace( + "_getArchivedPingDataFromFileName - should have 'json' or 'jsonlz4' extension" + ); + return null; + } + + // Check for a valid timestamp. + timestamp = parseInt(timestamp); + if (Number.isNaN(timestamp)) { + this._log.trace( + "_getArchivedPingDataFromFileName - should have a valid timestamp" + ); + return null; + } + + // Check for a valid UUID. + if (!UUID_REGEX.test(uuid)) { + this._log.trace( + "_getArchivedPingDataFromFileName - should have a valid id" + ); + return null; + } + + // Check for a valid type string. + const typeRegex = /^[a-z0-9][a-z0-9-]+[a-z0-9]$/i; + if (!typeRegex.test(type)) { + this._log.trace( + "_getArchivedPingDataFromFileName - should have a valid type" + ); + return null; + } + + return { + timestamp, + id: uuid, + type, + }; + }, + + async saveAbortedSessionPing(ping) { + this._log.trace( + "saveAbortedSessionPing - ping path: " + lazy.gAbortedSessionFilePath + ); + await IOUtils.makeDirectory(lazy.gDataReportingDir); + + return this._abortedSessionSerializer.enqueueTask(() => + this.savePingToFile(ping, lazy.gAbortedSessionFilePath, true) + ); + }, + + async loadAbortedSessionPing() { + let ping = null; + try { + ping = await this.loadPingFile(lazy.gAbortedSessionFilePath); + } catch (ex) { + if (ex.becauseNoSuchFile) { + this._log.trace("loadAbortedSessionPing - no such file"); + } else { + this._log.error("loadAbortedSessionPing - error loading ping", ex); + } + } + return ping; + }, + + removeAbortedSessionPing() { + return this._abortedSessionSerializer.enqueueTask(async () => { + try { + await IOUtils.remove(lazy.gAbortedSessionFilePath, { + ignoreAbsent: false, + }); + this._log.trace("removeAbortedSessionPing - success"); + } catch (ex) { + if (DOMException.isInstance(ex) && ex.name === "NotFoundError") { + this._log.trace("removeAbortedSessionPing - no such file"); + } else { + this._log.error("removeAbortedSessionPing - error removing ping", ex); + } + } + }); + }, + + async saveUninstallPing(ping) { + if (AppConstants.platform != "win") { + return; + } + + // Remove any old pings from this install first. + await this.removeUninstallPings(); + + let { directory: pingFile, file } = Policy.getUninstallPingPath(ping.id); + pingFile.append(file); + + await this.savePingToFile(ping, pingFile.path, /* overwrite */ true); + }, + + async removeUninstallPings() { + if (AppConstants.platform != "win") { + return; + } + + const { directory, file } = Policy.getUninstallPingPath("*"); + const [prefix, suffix] = file.split("*"); + + for (const path of await IOUtils.getChildren(directory.path)) { + const filename = PathUtils.filename(path); + if (!filename.startsWith(prefix) || !filename.endsWith(suffix)) { + continue; + } + + this._log.trace("removeUninstallPings - removing", path); + try { + await IOUtils.remove(path); + this._log.trace("removeUninstallPings - success"); + } catch (ex) { + if (DOMException.isInstance(ex) && ex.name === "NotFoundError") { + this._log.trace("removeUninstallPings - no such file"); + } else { + this._log.error("removeUninstallPings - error removing ping", ex); + } + } + } + }, + + /** + * Remove FHR database files. This is temporary and will be dropped in + * the future. + * @return {Promise} Resolved when the database files are deleted. + */ + async removeFHRDatabase() { + this._log.trace("removeFHRDatabase"); + + // Let's try to remove the FHR DB with the default filename first. + const FHR_DB_DEFAULT_FILENAME = "healthreport.sqlite"; + + // Even if it's uncommon, there may be 2 additional files: - a "write ahead log" + // (-wal) file and a "shared memory file" (-shm). We need to remove them as well. + let FILES_TO_REMOVE = [ + PathUtils.join(PathUtils.profileDir, FHR_DB_DEFAULT_FILENAME), + PathUtils.join(PathUtils.profileDir, FHR_DB_DEFAULT_FILENAME + "-wal"), + PathUtils.join(PathUtils.profileDir, FHR_DB_DEFAULT_FILENAME + "-shm"), + ]; + + // FHR could have used either the default DB file name or a custom one + // through this preference. + const FHR_DB_CUSTOM_FILENAME = Preferences.get( + "datareporting.healthreport.dbName", + undefined + ); + if (FHR_DB_CUSTOM_FILENAME) { + FILES_TO_REMOVE.push( + PathUtils.join(PathUtils.profileDir, FHR_DB_CUSTOM_FILENAME), + PathUtils.join(PathUtils.profileDir, FHR_DB_CUSTOM_FILENAME + "-wal"), + PathUtils.join(PathUtils.profileDir, FHR_DB_CUSTOM_FILENAME + "-shm") + ); + } + + for (let f of FILES_TO_REMOVE) { + await IOUtils.remove(f).catch(e => + this._log.error(`removeFHRDatabase - failed to remove ${f}`, e) + ); + } + }, +}; + +// Utility functions + +function pingFilePath(ping) { + // Support legacy ping formats, who don't have an "id" field, but a "slug" field. + let pingIdentifier = ping.slug ? ping.slug : ping.id; + + if (typeof pingIdentifier === "undefined" || pingIdentifier === null) { + throw new Error( + "Incompatible ping format -- ping has no slug or id attribute" + ); + } + + return PathUtils.join(TelemetryStorage.pingDirectoryPath, pingIdentifier); +} + +function getPingDirectory() { + return (async function () { + let directory = TelemetryStorage.pingDirectoryPath; + + if (!(await IOUtils.exists(directory))) { + await IOUtils.makeDirectory(directory, { permissions: 0o700 }); + } + + return directory; + })(); +} + +/** + * Build the path to the archived ping. + * @param {String} aPingId The ping id. + * @param {Object} aDate The ping creation date. + * @param {String} aType The ping type. + * @return {String} The full path to the archived ping. + */ +function getArchivedPingPath(aPingId, aDate, aType) { + // Get the ping creation date and generate the archive directory to hold it. Note + // that getMonth returns a 0-based month, so we need to add an offset. + let month = String(aDate.getMonth() + 1); + let archivedPingDir = PathUtils.join( + lazy.gPingsArchivePath, + aDate.getFullYear() + "-" + month.padStart(2, "0") + ); + // Generate the archived ping file path as YYYY-MM/<TIMESTAMP>.UUID.type.json + let fileName = [aDate.getTime(), aPingId, aType, "json"].join("."); + return PathUtils.join(archivedPingDir, fileName); +} + +/** + * Get the size of the ping file on the disk. + * @return {Integer} The file size, in bytes, of the ping file or 0 on errors. + */ +var getArchivedPingSize = async function (aPingId, aDate, aType) { + const path = getArchivedPingPath(aPingId, aDate, aType); + let filePaths = [path + "lz4", path]; + + for (let path of filePaths) { + try { + return (await IOUtils.stat(path)).size; + } catch (e) {} + } + + // That's odd, this ping doesn't seem to exist. + return 0; +}; + +/** + * Get the size of the pending ping file on the disk. + * @return {Integer} The file size, in bytes, of the ping file or 0 on errors. + */ +var getPendingPingSize = async function (aPingId) { + const path = PathUtils.join(TelemetryStorage.pingDirectoryPath, aPingId); + try { + return (await IOUtils.stat(path)).size; + } catch (e) {} + + // That's odd, this ping doesn't seem to exist. + return 0; +}; + +/** + * Check if a directory name is in the "YYYY-MM" format. + * @param {String} aDirName The name of the pings archive directory. + * @return {Boolean} True if the directory name is in the right format, false otherwise. + */ +function isValidArchiveDir(aDirName) { + const dirRegEx = /^[0-9]{4}-[0-9]{2}$/; + return dirRegEx.test(aDirName); +} + +/** + * Gets a date object from an archive directory name. + * @param {String} aDirName The name of the pings archive directory. Must be in the YYYY-MM + * format. + * @return {Object} A Date object or null if the dir name is not valid. + */ +function getDateFromArchiveDir(aDirName) { + let [year, month] = aDirName.split("-"); + year = parseInt(year); + month = parseInt(month); + // Make sure to have sane numbers. + if ( + !Number.isFinite(month) || + !Number.isFinite(year) || + month < 1 || + month > 12 + ) { + return null; + } + return new Date(year, month - 1, 1, 0, 0, 0); +} diff --git a/toolkit/components/telemetry/app/TelemetryTimestamps.sys.mjs b/toolkit/components/telemetry/app/TelemetryTimestamps.sys.mjs new file mode 100644 index 0000000000..2ae5891596 --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryTimestamps.sys.mjs @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This module's purpose is to collect timestamps for important + * application-specific events. + * + * The TelemetryController component attaches the timestamps stored by this module to + * the telemetry submission, substracting the process lifetime so that the times + * are relative to process startup. The overall goal is to produce a basic + * timeline of the startup process. + */ +var timeStamps = {}; + +export var TelemetryTimestamps = { + /** + * Adds a timestamp to the list. The addition of TimeStamps that already have + * a value stored is ignored. + * + * @param name must be a unique, generally "camelCase" descriptor of what the + * timestamp represents. e.g.: "delayedStartupStarted" + * @param value is a timeStamp in milliseconds since the epoch. If omitted, + * defaults to Date.now(). + */ + add: function TT_add(name, value) { + // Default to "now" if not specified + if (value == null) { + value = Date.now(); + } + + if (isNaN(value)) { + throw new Error("Value must be a timestamp"); + } + + // If there's an existing value, just ignore the new value. + if (timeStamps.hasOwnProperty(name)) { + return; + } + + timeStamps[name] = value; + }, + + /** + * Returns a JS object containing all of the timeStamps as properties (can be + * easily serialized to JSON). Used by TelemetryController to retrieve the data + * to attach to the telemetry submission. + */ + get: function TT_get() { + // Return a copy of the object. + return Cu.cloneInto(timeStamps, {}); + }, +}; diff --git a/toolkit/components/telemetry/app/TelemetryUtils.sys.mjs b/toolkit/components/telemetry/app/TelemetryUtils.sys.mjs new file mode 100644 index 0000000000..809460aebc --- /dev/null +++ b/toolkit/components/telemetry/app/TelemetryUtils.sys.mjs @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { TelemetryControllerBase } from "resource://gre/modules/TelemetryControllerBase.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); + +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + +const IS_CONTENT_PROCESS = (function () { + // We cannot use Services.appinfo here because in telemetry xpcshell tests, + // appinfo is initially unavailable, and becomes available only later on. + // eslint-disable-next-line mozilla/use-services + let runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime); + return runtime.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT; +})(); + +export var TelemetryUtils = { + /** + * When telemetry is disabled, identifying information (such as client ID) + * should be removed. A topic event is emitted with a subject that matches + * this constant. When this happens, other systems that store identifying + * information about the client should delete that data. Please ask the + * Firefox Telemetry Team before relying on this topic. + * + * Here is an example of listening for that event: + * + * const { TelemetryUtils } = ChromeUtils.import("resource://gre/modules/TelemetryUtils.jsm"); + * + * class YourClass { + * constructor() { + * Services.obs.addObserver(this, TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC); + * } + * + * observe(subject, topic, data) { + * if (topic == TelemetryUtils.TELEMETRY_UPLOAD_DISABLED_TOPIC) { + * // Telemetry was disabled + * // subject and data are both unused + * } + * } + * } + */ + TELEMETRY_UPLOAD_DISABLED_TOPIC: "telemetry.upload.disabled", + + Preferences: Object.freeze({ + ...TelemetryControllerBase.Preferences, + + // General Preferences + ArchiveEnabled: "toolkit.telemetry.archive.enabled", + CachedClientId: "toolkit.telemetry.cachedClientID", + DisableFuzzingDelay: "toolkit.telemetry.testing.disableFuzzingDelay", + FirstRun: "toolkit.telemetry.reportingpolicy.firstRun", + FirstShutdownPingEnabled: "toolkit.telemetry.firstShutdownPing.enabled", + HealthPingEnabled: "toolkit.telemetry.healthping.enabled", + IPCBatchTimeout: "toolkit.telemetry.ipcBatchTimeout", + OverrideOfficialCheck: "toolkit.telemetry.send.overrideOfficialCheck", + OverrideUpdateChannel: "toolkit.telemetry.overrideUpdateChannel", + Server: "toolkit.telemetry.server", + ShutdownPingSender: "toolkit.telemetry.shutdownPingSender.enabled", + ShutdownPingSenderFirstSession: + "toolkit.telemetry.shutdownPingSender.enabledFirstSession", + TelemetryEnabled: "toolkit.telemetry.enabled", + UntrustedModulesPingFrequency: + "toolkit.telemetry.untrustedModulesPing.frequency", + UpdatePing: "toolkit.telemetry.updatePing.enabled", + NewProfilePingEnabled: "toolkit.telemetry.newProfilePing.enabled", + NewProfilePingDelay: "toolkit.telemetry.newProfilePing.delay", + PreviousBuildID: "toolkit.telemetry.previousBuildID", + + // Event Ping Preferences + EventPingMinimumFrequency: "toolkit.telemetry.eventping.minimumFrequency", + EventPingMaximumFrequency: "toolkit.telemetry.eventping.maximumFrequency", + + // Data reporting Preferences + AcceptedPolicyDate: "datareporting.policy.dataSubmissionPolicyNotifiedTime", + AcceptedPolicyVersion: + "datareporting.policy.dataSubmissionPolicyAcceptedVersion", + BypassNotification: + "datareporting.policy.dataSubmissionPolicyBypassNotification", + CurrentPolicyVersion: "datareporting.policy.currentPolicyVersion", + DataSubmissionEnabled: "datareporting.policy.dataSubmissionEnabled", + FhrUploadEnabled: "datareporting.healthreport.uploadEnabled", + MinimumPolicyVersion: "datareporting.policy.minimumPolicyVersion", + FirstRunURL: "datareporting.policy.firstRunURL", + }), + + /** + * A fixed valid client ID used when Telemetry upload is disabled. + */ + get knownClientID() { + return "c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0"; + }, + + /** + * True if this is a content process. + */ + get isContentProcess() { + return IS_CONTENT_PROCESS; + }, + + /** + * Returns the state of the Telemetry enabled preference, making sure + * it correctly evaluates to a boolean type. + */ + get isTelemetryEnabled() { + return TelemetryControllerBase.isTelemetryEnabled; + }, + + /** + * Turn a millisecond timestamp into a day timestamp. + * + * @param aMsec A number of milliseconds since Unix epoch. + * @return The number of whole days since Unix epoch. + */ + millisecondsToDays(aMsec) { + return Math.floor(aMsec / MILLISECONDS_PER_DAY); + }, + + /** + * Takes a date and returns it truncated to a date with daily precision. + */ + truncateToDays(date) { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 0, + 0, + 0, + 0 + ); + }, + + /** + * Takes a date and returns it truncated to a date with hourly precision. + */ + truncateToHours(date) { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + 0, + 0, + 0 + ); + }, + + /** + * Check if the difference between the times is within the provided tolerance. + * @param {Number} t1 A time in milliseconds. + * @param {Number} t2 A time in milliseconds. + * @param {Number} tolerance The tolerance, in milliseconds. + * @return {Boolean} True if the absolute time difference is within the tolerance, false + * otherwise. + */ + areTimesClose(t1, t2, tolerance) { + return Math.abs(t1 - t2) <= tolerance; + }, + + /** + * Get the next midnight for a date. + * @param {Object} date The date object to check. + * @return {Object} The Date object representing the next midnight. + */ + getNextMidnight(date) { + let nextMidnight = new Date(this.truncateToDays(date)); + nextMidnight.setDate(nextMidnight.getDate() + 1); + return nextMidnight; + }, + + /** + * Get the midnight which is closer to the provided date. + * @param {Object} date The date object to check. + * @param {Number} tolerance The tolerance within we find the closest midnight. + * @return {Object} The Date object representing the closes midnight, or null if midnight + * is not within the midnight tolerance. + */ + getNearestMidnight(date, tolerance) { + let lastMidnight = this.truncateToDays(date); + if (this.areTimesClose(date.getTime(), lastMidnight.getTime(), tolerance)) { + return lastMidnight; + } + + const nextMidnightDate = this.getNextMidnight(date); + if ( + this.areTimesClose(date.getTime(), nextMidnightDate.getTime(), tolerance) + ) { + return nextMidnightDate; + } + return null; + }, + + generateUUID() { + let str = Services.uuid.generateUUID().toString(); + // strip {} + return str.substring(1, str.length - 1); + }, + + /** + * Find how many months passed between two dates. + * @param {Object} aStartDate The starting date. + * @param {Object} aEndDate The ending date. + * @return {Integer} The number of months between the two dates. + */ + getElapsedTimeInMonths(aStartDate, aEndDate) { + return ( + aEndDate.getMonth() - + aStartDate.getMonth() + + 12 * (aEndDate.getFullYear() - aStartDate.getFullYear()) + ); + }, + + /** + * Date.toISOString() gives us UTC times, this gives us local times in + * the ISO date format. See http://www.w3.org/TR/NOTE-datetime + * @param {Object} date The input date. + * @return {String} The local time ISO string. + */ + toLocalTimeISOString(date) { + function padNumber(number, length) { + return number.toString().padStart(length, "0"); + } + + let sign = n => (n >= 0 ? "+" : "-"); + // getTimezoneOffset counter-intuitively returns -60 for UTC+1. + let tzOffset = -date.getTimezoneOffset(); + + // YYYY-MM-DDThh:mm:ss.sTZD (eg 1997-07-16T19:20:30.45+01:00) + return ( + padNumber(date.getFullYear(), 4) + + "-" + + padNumber(date.getMonth() + 1, 2) + + "-" + + padNumber(date.getDate(), 2) + + "T" + + padNumber(date.getHours(), 2) + + ":" + + padNumber(date.getMinutes(), 2) + + ":" + + padNumber(date.getSeconds(), 2) + + "." + + date.getMilliseconds() + + sign(tzOffset) + + padNumber(Math.floor(Math.abs(tzOffset / 60)), 2) + + ":" + + padNumber(Math.abs(tzOffset % 60), 2) + ); + }, + + /** + * @returns {number} The monotonic time since the process start + * or (non-monotonic) Date value if this fails back. + */ + monotonicNow() { + return Services.telemetry.msSinceProcessStart(); + }, + + /** + * @returns {string} The name of the update channel to report + * in telemetry. + * By default, this is the same as the name of the channel that + * the browser uses to download its updates. However in certain + * situations, a single update channel provides multiple (distinct) + * build types, that need to be distinguishable on Telemetry. + */ + getUpdateChannel() { + let overrideChannel = Services.prefs.getCharPref( + this.Preferences.OverrideUpdateChannel, + undefined + ); + if (overrideChannel) { + return overrideChannel; + } + + return lazy.UpdateUtils.getUpdateChannel(false); + }, +}; |