diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/telemetry/pings | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/telemetry/pings')
9 files changed, 2521 insertions, 0 deletions
diff --git a/toolkit/components/telemetry/pings/BackgroundTask_pingsender.sys.mjs b/toolkit/components/telemetry/pings/BackgroundTask_pingsender.sys.mjs new file mode 100644 index 0000000000..2297e6f94f --- /dev/null +++ b/toolkit/components/telemetry/pings/BackgroundTask_pingsender.sys.mjs @@ -0,0 +1,41 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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 { sendStandalonePing } from "resource://gre/modules/TelemetrySend.sys.mjs"; + +export async function runBackgroundTask(commandLine) { + let sends = []; + for (let i = 0; i < commandLine.length; i += 2) { + sends.push( + sendPing(commandLine.getArgument(i), commandLine.getArgument(i + 1)) + ); + } + + return Promise.all(sends); +} + +// The standalone pingsender utility had an allowlist of endpoints, which was +// added to prevent it from being used as a generic exfiltration utility by +// unrelated malware running on the same system. It's unclear whether a gecko- +// based pingsender would be similarly desirable for that use-case, but it's +// easy enough to implement an allowlist here as well. +const ALLOWED_ENDPOINTS = ["localhost", "incoming.telemetry.mozilla.org"]; + +async function sendPing(endpoint, path) { + console.log(`pingsender: sending ${path} to ${endpoint}`); + + let hostname = new URL(endpoint).hostname; + if (!ALLOWED_ENDPOINTS.includes(hostname)) { + throw new Error(`pingsender: Endpoint ${endpoint} is not allowed`); + } + + let json = await IOUtils.readUTF8(path); + await sendStandalonePing(endpoint, json, { + "User-Agent": "pingsender/2.0", + "X-PingSender-Version": "2.0", + }); + + return IOUtils.remove(path); +} diff --git a/toolkit/components/telemetry/pings/CoveragePing.sys.mjs b/toolkit/components/telemetry/pings/CoveragePing.sys.mjs new file mode 100644 index 0000000000..a41d7dcc7b --- /dev/null +++ b/toolkit/components/telemetry/pings/CoveragePing.sys.mjs @@ -0,0 +1,154 @@ +/* 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"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CommonUtils: "resource://services-common/utils.sys.mjs", + ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); + +const COVERAGE_VERSION = "2"; + +const COVERAGE_ENABLED_PREF = "toolkit.coverage.enabled"; +const LOG_LEVEL_PREF = "toolkit.coverage.log-level"; +const OPT_OUT_PREF = "toolkit.coverage.opt-out"; +const ALREADY_RUN_PREF = `toolkit.coverage.already-run.v${COVERAGE_VERSION}`; +const COVERAGE_UUID_PREF = `toolkit.coverage.uuid.v${COVERAGE_VERSION}`; +const TELEMETRY_ENABLED_PREF = "datareporting.healthreport.uploadEnabled"; +const REPORTING_ENDPOINT_BASE_PREF = `toolkit.coverage.endpoint.base`; +const REPORTING_ENDPOINT = "submit/coverage/coverage"; +const PING_SUBMISSION_TIMEOUT = 30 * 1000; // 30 seconds + +const log = Log.repository.getLogger("Telemetry::CoveragePing"); +log.level = Services.prefs.getIntPref(LOG_LEVEL_PREF, Log.Level.Error); +log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + +export var CoveragePing = Object.freeze({ + async startup() { + if (!Services.prefs.getBoolPref(COVERAGE_ENABLED_PREF, false)) { + log.debug("coverage not enabled"); + return; + } + + if (Services.prefs.getBoolPref(OPT_OUT_PREF, false)) { + log.debug("user has set opt-out pref"); + return; + } + + if (Services.prefs.getBoolPref(ALREADY_RUN_PREF, false)) { + log.debug("already run on this profile"); + return; + } + + if (!Services.prefs.getCharPref(REPORTING_ENDPOINT_BASE_PREF, null)) { + log.error("no endpoint base set"); + return; + } + + try { + await this.reportTelemetrySetting(); + } catch (e) { + log.error("unable to upload payload", e); + } + }, + + // NOTE - this does not use existing Telemetry code or honor Telemetry opt-out prefs, + // by design. It also sends no identifying data like the client ID. See the "coverage ping" + // documentation for details. + reportTelemetrySetting() { + const enabled = Services.prefs.getBoolPref(TELEMETRY_ENABLED_PREF, false); + + const payload = { + appVersion: Services.appinfo.version, + appUpdateChannel: lazy.UpdateUtils.getUpdateChannel(false), + osName: Services.sysinfo.getProperty("name"), + osVersion: Services.sysinfo.getProperty("version"), + telemetryEnabled: enabled, + }; + + let cachedUuid = Services.prefs.getCharPref(COVERAGE_UUID_PREF, null); + if (!cachedUuid) { + // Totally random UUID, just for detecting duplicates. + cachedUuid = lazy.CommonUtils.generateUUID(); + Services.prefs.setCharPref(COVERAGE_UUID_PREF, cachedUuid); + } + + let reportingEndpointBase = Services.prefs.getCharPref( + REPORTING_ENDPOINT_BASE_PREF, + null + ); + + let endpoint = `${reportingEndpointBase}/${REPORTING_ENDPOINT}/${COVERAGE_VERSION}/${cachedUuid}`; + + log.debug(`putting to endpoint ${endpoint} with payload:`, payload); + + let deferred = Promise.withResolvers(); + + let request = new lazy.ServiceRequest({ mozAnon: true }); + request.mozBackgroundRequest = true; + request.timeout = PING_SUBMISSION_TIMEOUT; + + request.open("PUT", endpoint, true); + request.overrideMimeType("text/plain"); + request.setRequestHeader("Content-Type", "application/json; charset=UTF-8"); + request.setRequestHeader("Date", new Date().toUTCString()); + + let errorhandler = event => { + let failure = event.type; + log.error(`error making request to ${endpoint}: ${failure}`); + deferred.reject(event); + }; + + request.onerror = errorhandler; + request.ontimeout = errorhandler; + request.onabort = errorhandler; + + request.onloadend = event => { + let status = request.status; + let statusClass = status - (status % 100); + let success = false; + + if (statusClass === 200) { + // We can treat all 2XX as success. + log.info(`successfully submitted, status: ${status}`); + success = true; + } else if (statusClass === 400) { + // 4XX means that something with the request was broken. + + // TODO: we should handle this better, but for now we should avoid resubmitting + // broken requests by pretending success. + success = true; + log.error( + `error submitting to ${endpoint}, status: ${status} - ping request broken?` + ); + } else if (statusClass === 500) { + // 5XX means there was a server-side error and we should try again later. + log.error( + `error submitting to ${endpoint}, status: ${status} - server error, should retry later` + ); + } else { + // We received an unexpected status code. + log.error( + `error submitting to ${endpoint}, status: ${status}, type: ${event.type}` + ); + } + + if (success) { + Services.prefs.setBoolPref(ALREADY_RUN_PREF, true); + log.debug(`result from PUT: ${request.responseText}`); + deferred.resolve(); + } else { + deferred.reject(event); + } + }; + + request.send(JSON.stringify(payload)); + + return deferred.promise; + }, +}); diff --git a/toolkit/components/telemetry/pings/EventPing.sys.mjs b/toolkit/components/telemetry/pings/EventPing.sys.mjs new file mode 100644 index 0000000000..36e8489e37 --- /dev/null +++ b/toolkit/components/telemetry/pings/EventPing.sys.mjs @@ -0,0 +1,241 @@ +/* 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 sends Telemetry Events periodically: + * https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/event-ping.html + */ + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "resource://gre/modules/Log.sys.mjs", + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const Utils = TelemetryUtils; + +const MS_IN_A_MINUTE = 60 * 1000; + +const DEFAULT_EVENT_LIMIT = 1000; +const DEFAULT_MIN_FREQUENCY_MS = 60 * MS_IN_A_MINUTE; +const DEFAULT_MAX_FREQUENCY_MS = 10 * MS_IN_A_MINUTE; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryEventPing::"; + +const EVENT_LIMIT_REACHED_TOPIC = "event-telemetry-storage-limit-reached"; + +export var Policy = { + setTimeout: (callback, delayMs) => lazy.setTimeout(callback, delayMs), + clearTimeout: id => lazy.clearTimeout(id), + sendPing: (type, payload, options) => + lazy.TelemetryController.submitExternalPing(type, payload, options), +}; + +export var TelemetryEventPing = { + Reason: Object.freeze({ + PERIODIC: "periodic", // Sent the ping containing events from the past periodic interval (default one hour). + MAX: "max", // Sent the ping containing the maximum number (default 1000) of event records, earlier than the periodic interval. + SHUTDOWN: "shutdown", // Recorded data was sent on shutdown. + }), + + EVENT_PING_TYPE: "event", + + _logger: null, + + _testing: false, + + // So that if we quickly reach the max limit we can immediately send. + _lastSendTime: -DEFAULT_MIN_FREQUENCY_MS, + + _processStartTimestamp: 0, + + get dataset() { + return Services.telemetry.canRecordPrereleaseData + ? Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + : Ci.nsITelemetry.DATASET_ALL_CHANNELS; + }, + + startup() { + this._log.trace("Starting up."); + + // Calculate process creation once. + this._processStartTimestamp = + Math.round( + (Date.now() - TelemetryUtils.monotonicNow()) / MS_IN_A_MINUTE + ) * MS_IN_A_MINUTE; + + Services.obs.addObserver(this, EVENT_LIMIT_REACHED_TOPIC); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "maxFrequency", + Utils.Preferences.EventPingMaximumFrequency, + DEFAULT_MAX_FREQUENCY_MS + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "minFrequency", + Utils.Preferences.EventPingMinimumFrequency, + DEFAULT_MIN_FREQUENCY_MS + ); + + this._startTimer(); + }, + + shutdown() { + this._log.trace("Shutting down."); + // removeObserver may throw, which could interrupt shutdown. + try { + Services.obs.removeObserver(this, EVENT_LIMIT_REACHED_TOPIC); + } catch (ex) {} + + this._submitPing(this.Reason.SHUTDOWN, true /* discardLeftovers */); + this._clearTimer(); + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case EVENT_LIMIT_REACHED_TOPIC: + this._log.trace("event limit reached"); + let now = Utils.monotonicNow(); + if (now - this._lastSendTime < this.maxFrequency) { + this._log.trace("can't submit ping immediately as it's too soon"); + this._startTimer( + this.maxFrequency - this._lastSendTime, + this.Reason.MAX, + true /* discardLeftovers*/ + ); + } else { + this._log.trace("submitting ping immediately"); + this._submitPing(this.Reason.MAX); + } + break; + } + }, + + _startTimer( + delay = this.minFrequency, + reason = this.Reason.PERIODIC, + discardLeftovers = false + ) { + this._clearTimer(); + this._timeoutId = Policy.setTimeout( + () => TelemetryEventPing._submitPing(reason, discardLeftovers), + delay + ); + }, + + _clearTimer() { + if (this._timeoutId) { + Policy.clearTimeout(this._timeoutId); + this._timeoutId = null; + } + }, + + /** + * Submits an "event" ping and restarts the timer for the next interval. + * + * @param {String} reason The reason we're sending the ping. One of TelemetryEventPing.Reason. + * @param {bool} discardLeftovers Whether to discard event records left over from a previous ping. + */ + _submitPing(reason, discardLeftovers = false) { + this._log.trace("_submitPing"); + + if (reason !== this.Reason.SHUTDOWN) { + this._startTimer(); + } + + let snapshot = Services.telemetry.snapshotEvents( + this.dataset, + true /* clear */, + DEFAULT_EVENT_LIMIT + ); + + if (!this._testing) { + for (let process of Object.keys(snapshot)) { + snapshot[process] = snapshot[process].filter( + ([, category]) => !category.startsWith("telemetry.test") + ); + } + } + + let eventCount = Object.values(snapshot).reduce( + (acc, val) => acc + val.length, + 0 + ); + if (eventCount === 0) { + // Don't send a ping if we haven't any events. + this._log.trace("not sending event ping due to lack of events"); + return; + } + + // The reason doesn't matter as it will just be echo'd back. + let sessionMeta = lazy.TelemetrySession.getMetadata(reason); + + let payload = { + reason, + processStartTimestamp: this._processStartTimestamp, + sessionId: sessionMeta.sessionId, + subsessionId: sessionMeta.subsessionId, + lostEventsCount: 0, + events: snapshot, + }; + + if (discardLeftovers) { + // Any leftovers must be discarded, the count submitted in the ping. + // This can happen on shutdown or if our max was reached before faster + // than our maxFrequency. + let leftovers = Services.telemetry.snapshotEvents( + this.dataset, + true /* clear */ + ); + let leftoverCount = Object.values(leftovers).reduce( + (acc, val) => acc + val.length, + 0 + ); + payload.lostEventsCount = leftoverCount; + } + + const options = { + addClientId: true, + addEnvironment: true, + usePingSender: reason == this.Reason.SHUTDOWN, + }; + + this._lastSendTime = Utils.monotonicNow(); + Services.telemetry + .getHistogramById("TELEMETRY_EVENT_PING_SENT") + .add(reason); + Policy.sendPing(this.EVENT_PING_TYPE, payload, options); + }, + + /** + * Test-only, restore to initial state. + */ + testReset() { + this._lastSendTime = -DEFAULT_MIN_FREQUENCY_MS; + this._clearTimer(); + this._testing = true; + }, + + get _log() { + if (!this._logger) { + this._logger = lazy.Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + "::" + ); + } + + return this._logger; + }, +}; diff --git a/toolkit/components/telemetry/pings/HealthPing.sys.mjs b/toolkit/components/telemetry/pings/HealthPing.sys.mjs new file mode 100644 index 0000000000..3450f65a3e --- /dev/null +++ b/toolkit/components/telemetry/pings/HealthPing.sys.mjs @@ -0,0 +1,271 @@ +/* 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 collects data on send failures and other critical issues with Telemetry submissions. + */ + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "resource://gre/modules/Log.sys.mjs", + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const Utils = TelemetryUtils; + +const MS_IN_A_MINUTE = 60 * 1000; +// Send health ping every hour +const SEND_TICK_DELAY = 60 * MS_IN_A_MINUTE; + +// Send top 10 discarded pings only to minimize health ping size +const MAX_SEND_DISCARDED_PINGS = 10; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryHealthPing::"; + +export var Policy = { + setSchedulerTickTimeout: (callback, delayMs) => + lazy.setTimeout(callback, delayMs), + clearSchedulerTickTimeout: id => lazy.clearTimeout(id), +}; + +export var TelemetryHealthPing = { + Reason: Object.freeze({ + IMMEDIATE: "immediate", // Ping was sent immediately after recording with no delay. + DELAYED: "delayed", // Recorded data was sent after a delay. + SHUT_DOWN: "shutdown", // Recorded data was sent on shutdown. + }), + + FailureType: Object.freeze({ + DISCARDED_FOR_SIZE: "pingDiscardedForSize", + SEND_FAILURE: "sendFailure", + }), + + OsInfo: Object.freeze({ + name: Services.appinfo.OS, + version: + Services.sysinfo.get("kernel_version") || Services.sysinfo.get("version"), + }), + + HEALTH_PING_TYPE: "health", + + _logger: null, + + // The health ping is sent every every SEND_TICK_DELAY. + // Initialize this so that first failures are sent immediately. + _lastSendTime: -SEND_TICK_DELAY, + + /** + * This stores reported send failures with the following structure: + * { + * type1: { + * subtype1: value, + * ... + * subtypeN: value + * }, + * ... + * } + */ + _failures: {}, + _timeoutId: null, + + /** + * Record a failure to send a ping out. + * @param {String} failureType The type of failure (e.g. "timeout", ...). + * @returns {Promise} Test-only, resolved when the ping is stored or sent. + */ + recordSendFailure(failureType) { + return this._addToFailure(this.FailureType.SEND_FAILURE, failureType); + }, + + /** + * Record that a ping was discarded and its type. + * @param {String} pingType The type of discarded ping (e.g. "main", ...). + * @returns {Promise} Test-only, resolved when the ping is stored or sent. + */ + recordDiscardedPing(pingType) { + return this._addToFailure(this.FailureType.DISCARDED_FOR_SIZE, pingType); + }, + + /** + * Assemble payload. + * @param {String} reason A string indicating the triggering reason (e.g. "immediate", "delayed", "shutdown"). + * @returns {Object} The assembled payload. + */ + _assemblePayload(reason) { + this._log.trace("_assemblePayload()"); + let payload = { + os: this.OsInfo, + reason, + }; + + for (let key of Object.keys(this._failures)) { + if (key === this.FailureType.DISCARDED_FOR_SIZE) { + payload[key] = this._getTopDiscardFailures(this._failures[key]); + } else { + payload[key] = this._failures[key]; + } + } + + return payload; + }, + + /** + * Sort input dictionary descending by value. + * @param {Object} failures A dictionary of failures subtype and count. + * @returns {Object} Sorted failures by value. + */ + _getTopDiscardFailures(failures) { + this._log.trace("_getTopDiscardFailures()"); + let sortedItems = Object.entries(failures).sort((first, second) => { + return second[1] - first[1]; + }); + + let result = {}; + sortedItems.slice(0, MAX_SEND_DISCARDED_PINGS).forEach(([key, value]) => { + result[key] = value; + }); + + return result; + }, + + /** + * Assemble the failure information and submit it. + * @param {String} reason A string indicating the triggering reason (e.g. "immediate", "delayed", "shutdown"). + * @returns {Promise} Test-only promise that resolves when the ping was stored or sent (if any). + */ + _submitPing(reason) { + if (!TelemetryHealthPing.enabled || !this._hasDataToSend()) { + return Promise.resolve(); + } + + this._log.trace("_submitPing(" + reason + ")"); + let payload = this._assemblePayload(reason); + this._clearData(); + this._lastSendTime = Utils.monotonicNow(); + + let options = { + addClientId: true, + usePingSender: reason === this.Reason.SHUT_DOWN, + }; + + return new Promise(r => + // If we submit the health ping immediately, the send task would be triggered again + // before discarding oversized pings from the queue. + // To work around this, we send the ping on the next tick. + Services.tm.dispatchToMainThread(() => + r( + lazy.TelemetryController.submitExternalPing( + this.HEALTH_PING_TYPE, + payload, + options + ) + ) + ) + ); + }, + + /** + * Accumulate failure information and trigger a ping immediately or on timeout. + * @param {String} failureType The type of failure (e.g. "timeout", ...). + * @param {String} failureSubType The subtype of failure (e.g. ping type, ...). + * @returns {Promise} Test-only, resolved when the ping is stored or sent. + */ + _addToFailure(failureType, failureSubType) { + this._log.trace( + "_addToFailure() - with type and subtype: " + + failureType + + " : " + + failureSubType + ); + + if (!(failureType in this._failures)) { + this._failures[failureType] = {}; + } + + let current = this._failures[failureType][failureSubType] || 0; + this._failures[failureType][failureSubType] = current + 1; + + const now = Utils.monotonicNow(); + if (now - this._lastSendTime >= SEND_TICK_DELAY) { + return this._submitPing(this.Reason.IMMEDIATE); + } + + let submissionDelay = SEND_TICK_DELAY - now - this._lastSendTime; + this._timeoutId = Policy.setSchedulerTickTimeout( + () => TelemetryHealthPing._submitPing(this.Reason.DELAYED), + submissionDelay + ); + return Promise.resolve(); + }, + + /** + * @returns {boolean} Check the availability of recorded failures data. + */ + _hasDataToSend() { + return Object.keys(this._failures).length !== 0; + }, + + /** + * Clear recorded failures data. + */ + _clearData() { + this._log.trace("_clearData()"); + this._failures = {}; + }, + + /** + * Clear and reset timeout. + */ + _resetTimeout() { + if (this._timeoutId) { + Policy.clearSchedulerTickTimeout(this._timeoutId); + this._timeoutId = null; + } + }, + + /** + * Submit latest ping on shutdown. + * @returns {Promise} Test-only, resolved when the ping is stored or sent. + */ + shutdown() { + this._log.trace("shutdown()"); + this._resetTimeout(); + return this._submitPing(this.Reason.SHUT_DOWN); + }, + + /** + * Test-only, restore to initial state. + */ + testReset() { + this._lastSendTime = -SEND_TICK_DELAY; + this._clearData(); + this._resetTimeout(); + }, + + get _log() { + if (!this._logger) { + this._logger = lazy.Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + "::" + ); + } + + return this._logger; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + TelemetryHealthPing, + "enabled", + TelemetryUtils.Preferences.HealthPingEnabled, + true +); diff --git a/toolkit/components/telemetry/pings/ModulesPing.sys.mjs b/toolkit/components/telemetry/pings/ModulesPing.sys.mjs new file mode 100644 index 0000000000..222fb7a47e --- /dev/null +++ b/toolkit/components/telemetry/pings/ModulesPing.sys.mjs @@ -0,0 +1,116 @@ +/* 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"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gUpdateTimerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryModules::"; + +// The default is 1 week. +const MODULES_PING_INTERVAL_SECONDS = 7 * 24 * 60 * 60; +const MODULES_PING_INTERVAL_PREFERENCE = + "toolkit.telemetry.modulesPing.interval"; + +const MAX_MODULES_NUM = 512; +const MAX_NAME_LENGTH = 64; +const TRUNCATION_DELIMITER = "\u2026"; + +export var TelemetryModules = Object.freeze({ + _log: Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX), + + start() { + // The list of loaded modules is obtainable only when the profiler is enabled. + // If it isn't, we don't want to send the ping at all. + if (!AppConstants.MOZ_GECKO_PROFILER) { + return; + } + + // Use nsIUpdateTimerManager for a long-duration timer that survives across sessions. + let interval = Services.prefs.getIntPref( + MODULES_PING_INTERVAL_PREFERENCE, + MODULES_PING_INTERVAL_SECONDS + ); + lazy.gUpdateTimerManager.registerTimer( + "telemetry_modules_ping", + this, + interval, + interval != 0 // only skip the first interval if the interval is non-0 + ); + }, + + /** + * Called when the 'telemetry_modules_ping' timer fires. + */ + notify() { + try { + Services.telemetry.getLoadedModules().then( + modules => { + modules = modules.filter(module => !!module.name.length); + + // Cut the list of modules to MAX_MODULES_NUM entries. + if (modules.length > MAX_MODULES_NUM) { + modules = modules.slice(0, MAX_MODULES_NUM); + } + + // Cut the file names of the modules to MAX_NAME_LENGTH characters. + for (let module of modules) { + if (module.name.length > MAX_NAME_LENGTH) { + module.name = + module.name.substr(0, MAX_NAME_LENGTH - 1) + + TRUNCATION_DELIMITER; + } + + if ( + module.debugName !== null && + module.debugName.length > MAX_NAME_LENGTH + ) { + module.debugName = + module.debugName.substr(0, MAX_NAME_LENGTH - 1) + + TRUNCATION_DELIMITER; + } + + if ( + module.certSubject !== undefined && + module.certSubject.length > MAX_NAME_LENGTH + ) { + module.certSubject = + module.certSubject.substr(0, MAX_NAME_LENGTH - 1) + + TRUNCATION_DELIMITER; + } + } + + lazy.TelemetryController.submitExternalPing( + "modules", + { + version: 1, + modules, + }, + { + addClientId: true, + addEnvironment: true, + } + ); + }, + err => this._log.error("notify - promise failed", err) + ); + } catch (ex) { + this._log.error("notify - caught exception", ex); + } + }, +}); diff --git a/toolkit/components/telemetry/pings/TelemetrySession.sys.mjs b/toolkit/components/telemetry/pings/TelemetrySession.sys.mjs new file mode 100644 index 0000000000..f564cd41dd --- /dev/null +++ b/toolkit/components/telemetry/pings/TelemetrySession.sys.mjs @@ -0,0 +1,1411 @@ +/* -*- 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 { Log } from "resource://gre/modules/Log.sys.mjs"; +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryReportingPolicy: + "resource://gre/modules/TelemetryReportingPolicy.sys.mjs", + TelemetryScheduler: "resource://gre/modules/TelemetryScheduler.sys.mjs", + TelemetryStorage: "resource://gre/modules/TelemetryStorage.sys.mjs", +}); + +const Utils = TelemetryUtils; + +// When modifying the payload in incompatible ways, please bump this version number +const PAYLOAD_VERSION = 4; +const PING_TYPE_MAIN = "main"; +const PING_TYPE_SAVED_SESSION = "saved-session"; + +const REASON_ABORTED_SESSION = "aborted-session"; +const REASON_DAILY = "daily"; +const REASON_SAVED_SESSION = "saved-session"; +const REASON_GATHER_PAYLOAD = "gather-payload"; +const REASON_TEST_PING = "test-ping"; +const REASON_ENVIRONMENT_CHANGE = "environment-change"; +const REASON_SHUTDOWN = "shutdown"; + +const ENVIRONMENT_CHANGE_LISTENER = "TelemetrySession::onEnvironmentChange"; + +const MIN_SUBSESSION_LENGTH_MS = + Services.prefs.getIntPref("toolkit.telemetry.minSubsessionLength", 5 * 60) * + 1000; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = + "TelemetrySession" + (Utils.isContentProcess ? "#content::" : "::"); + +// 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 +); + +var gWasDebuggerAttached = false; + +function generateUUID() { + let str = Services.uuid.generateUUID().toString(); + // strip {} + return str.substring(1, str.length - 1); +} + +/** + * This is a policy object used to override behavior for testing. + */ +export var Policy = { + now: () => new Date(), + monotonicNow: Utils.monotonicNow, + generateSessionUUID: () => generateUUID(), + generateSubsessionUUID: () => generateUUID(), +}; + +/** + * Get the ping type based on the payload. + * @param {Object} aPayload The ping payload. + * @return {String} A string representing the ping type. + */ +function getPingType(aPayload) { + // To remain consistent with server-side ping handling, set "saved-session" as the ping + // type for "saved-session" payload reasons. + if (aPayload.info.reason == REASON_SAVED_SESSION) { + return PING_TYPE_SAVED_SESSION; + } + + return PING_TYPE_MAIN; +} + +/** + * Annotate the current session ID with the crash reporter to map potential + * crash pings with the related main ping. + */ +function annotateCrashReport(sessionId) { + try { + Services.appinfo.annotateCrashReport("TelemetrySessionId", sessionId); + } catch (e) { + // Ignore errors when crash reporting is disabled + } +} + +/** + * Read current process I/O counters. + */ +var processInfo = { + _initialized: false, + _IO_COUNTERS: null, + _kernel32: null, + _GetProcessIoCounters: null, + _GetCurrentProcess: null, + getCounters() { + let isWindows = "@mozilla.org/windows-registry-key;1" in Cc; + if (isWindows) { + return this.getCounters_Windows(); + } + return null; + }, + getCounters_Windows() { + if (!this._initialized) { + var { ctypes } = ChromeUtils.importESModule( + "resource://gre/modules/ctypes.sys.mjs" + ); + this._IO_COUNTERS = new ctypes.StructType("IO_COUNTERS", [ + { readOps: ctypes.unsigned_long_long }, + { writeOps: ctypes.unsigned_long_long }, + { otherOps: ctypes.unsigned_long_long }, + { readBytes: ctypes.unsigned_long_long }, + { writeBytes: ctypes.unsigned_long_long }, + { otherBytes: ctypes.unsigned_long_long }, + ]); + try { + this._kernel32 = ctypes.open("Kernel32.dll"); + this._GetProcessIoCounters = this._kernel32.declare( + "GetProcessIoCounters", + ctypes.winapi_abi, + ctypes.bool, // return + ctypes.voidptr_t, // hProcess + this._IO_COUNTERS.ptr + ); // lpIoCounters + this._GetCurrentProcess = this._kernel32.declare( + "GetCurrentProcess", + ctypes.winapi_abi, + ctypes.voidptr_t + ); // return + this._initialized = true; + } catch (err) { + return null; + } + } + let io = new this._IO_COUNTERS(); + if (!this._GetProcessIoCounters(this._GetCurrentProcess(), io.address())) { + return null; + } + return [parseInt(io.readBytes), parseInt(io.writeBytes)]; + }, +}; + +export var TelemetrySession = Object.freeze({ + /** + * Send a ping to a test server. Used only for testing. + */ + testPing() { + return Impl.testPing(); + }, + /** + * Returns the current telemetry payload. + * @param reason Optional, the reason to trigger the payload. + * @param clearSubsession Optional, whether to clear subsession specific data. + * @returns Object + */ + getPayload(reason, clearSubsession = false) { + return Impl.getPayload(reason, clearSubsession); + }, + /** + * Save the session state to a pending file. + * Used only for testing purposes. + */ + testSavePendingPing() { + return Impl.testSavePendingPing(); + }, + /** + * Collect and store information about startup. + */ + gatherStartup() { + return Impl.gatherStartup(); + }, + /** + * Inform the ping which AddOns are installed. + * + * @param aAddOns - The AddOns. + */ + setAddOns(aAddOns) { + return Impl.setAddOns(aAddOns); + }, + /** + * Descriptive metadata + * + * @param reason + * The reason for the telemetry ping, this will be included in the + * returned metadata, + * @return The metadata as a JS object + */ + getMetadata(reason) { + return Impl.getMetadata(reason); + }, + + /** + * Reset the subsession and profile subsession counter. + * This should only be called when the profile should be considered completely new, + * e.g. after opting out of sending Telemetry + */ + resetSubsessionCounter() { + Impl._subsessionCounter = 0; + Impl._profileSubsessionCounter = 0; + }, + + /** + * Used only for testing purposes. + */ + testReset() { + Impl._newProfilePingSent = false; + Impl._sessionId = null; + Impl._subsessionId = null; + Impl._previousSessionId = null; + Impl._previousSubsessionId = null; + Impl._subsessionCounter = 0; + Impl._profileSubsessionCounter = 0; + Impl._subsessionStartActiveTicks = 0; + Impl._sessionActiveTicks = 0; + Impl._isUserActive = true; + Impl._subsessionStartTimeMonotonic = 0; + Impl._lastEnvironmentChangeDate = Policy.monotonicNow(); + this.testUninstall(); + }, + /** + * Triggers shutdown of the module. + */ + shutdown() { + return Impl.shutdownChromeProcess(); + }, + /** + * Used only for testing purposes. + */ + testUninstall() { + try { + Impl.uninstall(); + } catch (ex) { + // Ignore errors + } + }, + /** + * Lightweight init function, called as soon as Firefox starts. + */ + earlyInit(aTesting = false) { + return Impl.earlyInit(aTesting); + }, + /** + * Does the "heavy" Telemetry initialization later on, so we + * don't impact startup performance. + * @return {Promise} Resolved when the initialization completes. + */ + delayedInit() { + return Impl.delayedInit(); + }, + /** + * Send a notification. + */ + observe(aSubject, aTopic, aData) { + return Impl.observe(aSubject, aTopic, aData); + }, + /** + * Marks the "new-profile" ping as sent in the telemetry state file. + * @return {Promise} A promise resolved when the new telemetry state is saved to disk. + */ + markNewProfilePingSent() { + return Impl.markNewProfilePingSent(); + }, + /** + * Returns if the "new-profile" ping has ever been sent for this profile. + * Please note that the returned value is trustworthy only after the delayed setup. + * + * @return {Boolean} True if the new profile ping was sent on this profile, + * false otherwise. + */ + get newProfilePingSent() { + return Impl._newProfilePingSent; + }, + + saveAbortedSessionPing(aProvidedPayload) { + return Impl._saveAbortedSessionPing(aProvidedPayload); + }, + + sendDailyPing() { + return Impl._sendDailyPing(); + }, + + testOnEnvironmentChange(...args) { + return Impl._onEnvironmentChange(...args); + }, +}); + +var Impl = { + _initialized: false, + _logger: null, + _slowSQLStartup: {}, + // The activity state for the user. If false, don't count the next + // active tick. Otherwise, increment the active ticks as usual. + _isUserActive: true, + _startupIO: {}, + // The previous build ID, if this is the first run with a new build. + // Null if this is the first run, or the previous build ID is unknown. + _previousBuildId: null, + // Unique id that identifies this session so the server can cope with duplicate + // submissions, orphaning and other oddities. The id is shared across subsessions. + _sessionId: null, + // Random subsession id. + _subsessionId: null, + // Session id of the previous session, null on first run. + _previousSessionId: null, + // Subsession id of the previous subsession (even if it was in a different session), + // null on first run. + _previousSubsessionId: null, + // The running no. of subsessions since the start of the browser session + _subsessionCounter: 0, + // The running no. of all subsessions for the whole profile life time + _profileSubsessionCounter: 0, + // Date of the last session split + _subsessionStartDate: null, + // Start time of the current subsession using a monotonic clock for the subsession + // length measurements. + _subsessionStartTimeMonotonic: 0, + // The active ticks counted when the subsession starts + _subsessionStartActiveTicks: 0, + // Active ticks in the whole session. + _sessionActiveTicks: 0, + // A task performing delayed initialization of the chrome process + _delayedInitTask: null, + _testing: false, + // An accumulator of total memory across all processes. Only valid once the final child reports. + _lastEnvironmentChangeDate: 0, + // We save whether the "new-profile" ping was sent yet, to + // survive profile refresh and migrations. + _newProfilePingSent: false, + // Keep track of the active observers + _observedTopics: new Set(), + + addObserver(aTopic) { + Services.obs.addObserver(this, aTopic); + this._observedTopics.add(aTopic); + }, + + removeObserver(aTopic) { + Services.obs.removeObserver(this, aTopic); + this._observedTopics.delete(aTopic); + }, + + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + return this._logger; + }, + + /** + * Gets a series of simple measurements (counters). At the moment, this + * only returns startup data from nsIAppStartup.getStartupInfo(). + * @param {Boolean} isSubsession True if this is a subsession, false otherwise. + * @param {Boolean} clearSubsession True if a new subsession is being started, false otherwise. + * + * @return simple measurements as a dictionary. + */ + getSimpleMeasurements: function getSimpleMeasurements( + forSavedSession, + isSubsession, + clearSubsession + ) { + let si = Services.startup.getStartupInfo(); + + // Measurements common to chrome and content processes. + let elapsedTime = Date.now() - si.process; + var ret = { + totalTime: Math.round(elapsedTime / 1000), // totalTime, in seconds + }; + + // Look for app-specific timestamps + var appTimestamps = {}; + try { + let { TelemetryTimestamps } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryTimestamps.sys.mjs" + ); + appTimestamps = TelemetryTimestamps.get(); + } catch (ex) {} + + // Only submit this if the extended set is enabled. + if (!Utils.isContentProcess && Services.telemetry.canRecordExtended) { + try { + ret.addonManager = lazy.AddonManagerPrivate.getSimpleMeasures(); + } catch (ex) {} + } + + if (si.process) { + for (let field of Object.keys(si)) { + if (field == "process") { + continue; + } + ret[field] = si[field] - si.process; + } + + for (let p in appTimestamps) { + if (!(p in ret) && appTimestamps[p]) { + ret[p] = appTimestamps[p] - si.process; + } + } + } + + ret.startupInterrupted = Number(Services.startup.interrupted); + + if (Utils.isContentProcess) { + return ret; + } + + // Measurements specific to chrome process + + // Update debuggerAttached flag + let debugService = Cc["@mozilla.org/xpcom/debug;1"].getService( + Ci.nsIDebug2 + ); + let isDebuggerAttached = debugService.isDebuggerAttached; + gWasDebuggerAttached = gWasDebuggerAttached || isDebuggerAttached; + ret.debuggerAttached = Number(gWasDebuggerAttached); + + let shutdownDuration = Services.telemetry.lastShutdownDuration; + if (shutdownDuration) { + ret.shutdownDuration = shutdownDuration; + } + + let failedProfileLockCount = Services.telemetry.failedProfileLockCount; + if (failedProfileLockCount) { + ret.failedProfileLockCount = failedProfileLockCount; + } + + for (let ioCounter in this._startupIO) { + ret[ioCounter] = this._startupIO[ioCounter]; + } + + let activeTicks = this._sessionActiveTicks; + if (isSubsession) { + activeTicks = this._sessionActiveTicks - this._subsessionStartActiveTicks; + } + + if (clearSubsession) { + this._subsessionStartActiveTicks = this._sessionActiveTicks; + } + + ret.activeTicks = activeTicks; + + return ret; + }, + + getHistograms: function getHistograms(clearSubsession) { + return Services.telemetry.getSnapshotForHistograms( + "main", + clearSubsession, + !this._testing + ); + }, + + getKeyedHistograms(clearSubsession) { + return Services.telemetry.getSnapshotForKeyedHistograms( + "main", + clearSubsession, + !this._testing + ); + }, + + /** + * Get a snapshot of the scalars and clear them. + * @param {subsession} If true, then we collect the data for a subsession. + * @param {clearSubsession} If true, we need to clear the subsession. + * @param {keyed} Take a snapshot of keyed or non keyed scalars. + * @return {Object} The scalar data as a Javascript object, including the + * data from child processes, in the following format: + * {'content': { 'scalarName': ... }, 'gpu': { ... } } + */ + getScalars(subsession, clearSubsession, keyed) { + if (!subsession) { + // We only support scalars for subsessions. + this._log.trace("getScalars - We only support scalars in subsessions."); + return {}; + } + + let scalarsSnapshot = keyed + ? Services.telemetry.getSnapshotForKeyedScalars( + "main", + clearSubsession, + !this._testing + ) + : Services.telemetry.getSnapshotForScalars( + "main", + clearSubsession, + !this._testing + ); + + return scalarsSnapshot; + }, + + /** + * Descriptive metadata + * + * @param reason + * The reason for the telemetry ping, this will be included in the + * returned metadata, + * @return The metadata as a JS object + */ + getMetadata: function getMetadata(reason) { + const sessionStartDate = Utils.toLocalTimeISOString( + Utils.truncateToHours(this._sessionStartDate) + ); + const subsessionStartDate = Utils.toLocalTimeISOString( + Utils.truncateToHours(this._subsessionStartDate) + ); + const monotonicNow = Policy.monotonicNow(); + + let ret = { + reason, + revision: AppConstants.SOURCE_REVISION_URL, + + // Date.getTimezoneOffset() unintuitively returns negative values if we are ahead of + // UTC and vice versa (e.g. -60 for UTC+1). We invert the sign here. + timezoneOffset: -this._subsessionStartDate.getTimezoneOffset(), + previousBuildId: this._previousBuildId, + + sessionId: this._sessionId, + subsessionId: this._subsessionId, + previousSessionId: this._previousSessionId, + previousSubsessionId: this._previousSubsessionId, + + subsessionCounter: this._subsessionCounter, + profileSubsessionCounter: this._profileSubsessionCounter, + + sessionStartDate, + subsessionStartDate, + + // Compute the session and subsession length in seconds. + // We use monotonic clocks as Date() is affected by jumping clocks (leading + // to negative lengths and other issues). + sessionLength: Math.floor(monotonicNow / 1000), + subsessionLength: Math.floor( + (monotonicNow - this._subsessionStartTimeMonotonic) / 1000 + ), + }; + + // TODO: Remove this when bug 1201837 lands. + if (this._addons) { + ret.addons = this._addons; + } + + return ret; + }, + + /** + * Get the current session's payload using the provided + * simpleMeasurements and info, which are typically obtained by a call + * to |this.getSimpleMeasurements| and |this.getMetadata|, + * respectively. + */ + assemblePayloadWithMeasurements( + simpleMeasurements, + info, + reason, + clearSubsession + ) { + const isSubsession = IS_UNIFIED_TELEMETRY && !this._isClassicReason(reason); + clearSubsession = IS_UNIFIED_TELEMETRY && clearSubsession; + this._log.trace( + "assemblePayloadWithMeasurements - reason: " + + reason + + ", submitting subsession data: " + + isSubsession + ); + + // This allows wrapping data retrieval calls in a try-catch block so that + // failures don't break the rest of the ping assembly. + const protect = (fn, defaultReturn = null) => { + try { + return fn(); + } catch (ex) { + this._log.error( + "assemblePayloadWithMeasurements - caught exception", + ex + ); + return defaultReturn; + } + }; + + // Payload common to chrome and content processes. + let payloadObj = { + ver: PAYLOAD_VERSION, + simpleMeasurements, + }; + + // Add extended set measurements common to chrome & content processes + if (Services.telemetry.canRecordExtended) { + payloadObj.log = []; + } + + if (Utils.isContentProcess) { + return payloadObj; + } + + // Additional payload for chrome process. + let measurements = { + histograms: protect(() => this.getHistograms(clearSubsession), {}), + keyedHistograms: protect( + () => this.getKeyedHistograms(clearSubsession), + {} + ), + scalars: protect( + () => this.getScalars(isSubsession, clearSubsession), + {} + ), + keyedScalars: protect( + () => this.getScalars(isSubsession, clearSubsession, true), + {} + ), + }; + + let measurementsContainGPU = Object.keys(measurements).some( + key => "gpu" in measurements[key] + ); + + let measurementsContainSocket = Object.keys(measurements).some( + key => "socket" in measurements[key] + ); + + let measurementsContainUtility = Object.keys(measurements).some( + key => "utility" in measurements[key] + ); + + payloadObj.processes = {}; + let processTypes = ["parent", "content", "extension", "dynamic"]; + // Only include the GPU process if we've accumulated data for it. + if (measurementsContainGPU) { + processTypes.push("gpu"); + } + if (measurementsContainSocket) { + processTypes.push("socket"); + } + if (measurementsContainUtility) { + processTypes.push("utility"); + } + + // Collect per-process measurements. + for (const processType of processTypes) { + let processPayload = {}; + + for (const key in measurements) { + let payloadLoc = processPayload; + // Parent histograms are added to the top-level payload object instead of the process payload. + if ( + processType == "parent" && + (key == "histograms" || key == "keyedHistograms") + ) { + payloadLoc = payloadObj; + } + // The Dynamic process only collects scalars and keyed scalars. + if ( + processType == "dynamic" && + key !== "scalars" && + key !== "keyedScalars" + ) { + continue; + } + + // Process measurements can be empty, set a default value. + payloadLoc[key] = measurements[key][processType] || {}; + } + + // Add process measurements to payload. + payloadObj.processes[processType] = processPayload; + } + + payloadObj.info = info; + + // Add extended set measurements for chrome process. + if (Services.telemetry.canRecordExtended) { + payloadObj.slowSQL = protect(() => Services.telemetry.slowSQL); + payloadObj.fileIOReports = protect( + () => Services.telemetry.fileIOReports + ); + payloadObj.lateWrites = protect(() => Services.telemetry.lateWrites); + + payloadObj.addonDetails = protect(() => + lazy.AddonManagerPrivate.getTelemetryDetails() + ); + + if ( + this._slowSQLStartup && + !!Object.keys(this._slowSQLStartup).length && + (Object.keys(this._slowSQLStartup.mainThread).length || + Object.keys(this._slowSQLStartup.otherThreads).length) + ) { + payloadObj.slowSQLStartup = this._slowSQLStartup; + } + } + + return payloadObj; + }, + + /** + * Start a new subsession. + */ + startNewSubsession() { + this._subsessionStartDate = Policy.now(); + this._subsessionStartTimeMonotonic = Policy.monotonicNow(); + this._previousSubsessionId = this._subsessionId; + this._subsessionId = Policy.generateSubsessionUUID(); + this._subsessionCounter++; + this._profileSubsessionCounter++; + }, + + getSessionPayload: function getSessionPayload(reason, clearSubsession) { + this._log.trace( + "getSessionPayload - reason: " + + reason + + ", clearSubsession: " + + clearSubsession + ); + + let payload; + try { + const isMobile = AppConstants.platform == "android"; + const isSubsession = isMobile ? false : !this._isClassicReason(reason); + + // The order of the next two msSinceProcessStart* calls is somewhat + // important. In theory, `session_time_including_suspend` is supposed to + // ALWAYS be lower or equal than `session_time_excluding_suspend` (because + // the former is a temporal superset of the latter). When a device has not + // been suspended since boot, we want the previous property to hold, + // regardless of the delay during or between the two + // `msSinceProcessStart*` calls. + Services.telemetry.scalarSet( + "browser.engagement.session_time_excluding_suspend", + Services.telemetry.msSinceProcessStartExcludingSuspend() + ); + Services.telemetry.scalarSet( + "browser.engagement.session_time_including_suspend", + Services.telemetry.msSinceProcessStartIncludingSuspend() + ); + + if (isMobile) { + clearSubsession = false; + } + + let measurements = this.getSimpleMeasurements( + reason == REASON_SAVED_SESSION, + isSubsession, + clearSubsession + ); + let info = !Utils.isContentProcess ? this.getMetadata(reason) : null; + payload = this.assemblePayloadWithMeasurements( + measurements, + info, + reason, + clearSubsession + ); + } catch (ex) { + Services.telemetry + .getHistogramById("TELEMETRY_ASSEMBLE_PAYLOAD_EXCEPTION") + .add(1); + throw ex; + } finally { + if (!Utils.isContentProcess && clearSubsession) { + this.startNewSubsession(); + // Persist session data to disk (don't wait until it completes). + let sessionData = this._getSessionDataObject(); + lazy.TelemetryStorage.saveSessionData(sessionData); + + // Notify that there was a subsession split in the parent process. This is an + // internal topic and is only meant for internal Telemetry usage. + Services.obs.notifyObservers( + null, + "internal-telemetry-after-subsession-split" + ); + } + } + + return payload; + }, + + /** + * Send data to the server. Record success/send-time in histograms + */ + send: async function send(reason) { + this._log.trace("send - Reason " + reason); + // populate histograms one last time + await Services.telemetry.gatherMemory(); + + const isSubsession = !this._isClassicReason(reason); + let payload = this.getSessionPayload(reason, isSubsession); + let options = { + addClientId: true, + addEnvironment: true, + }; + return lazy.TelemetryController.submitExternalPing( + getPingType(payload), + payload, + options + ); + }, + + /** + * Attaches the needed observers during Telemetry early init, in the + * chrome process. + */ + attachEarlyObservers() { + this.addObserver("sessionstore-windows-restored"); + if (AppConstants.platform === "android") { + this.addObserver("application-background"); + } + this.addObserver("xul-window-visible"); + + // Attach the active-ticks related observers. + this.addObserver("user-interaction-active"); + this.addObserver("user-interaction-inactive"); + }, + + /** + * Lightweight init function, called as soon as Firefox starts. + */ + earlyInit(testing) { + this._log.trace("earlyInit"); + + this._initStarted = true; + this._testing = testing; + + if (this._initialized && !testing) { + this._log.error("earlyInit - already initialized"); + return; + } + + if (!Services.telemetry.canRecordBase && !testing) { + this._log.config( + "earlyInit - Telemetry recording is disabled, skipping Chrome process setup." + ); + return; + } + + // Generate a unique id once per session so the server can cope with duplicate + // submissions, orphaning and other oddities. The id is shared across subsessions. + this._sessionId = Policy.generateSessionUUID(); + this.startNewSubsession(); + // startNewSubsession sets |_subsessionStartDate| to the current date/time. Use + // the very same value for |_sessionStartDate|. + this._sessionStartDate = this._subsessionStartDate; + + annotateCrashReport(this._sessionId); + + // Record old value and update build ID preference if this is the first + // run with a new build ID. + let previousBuildId = Services.prefs.getStringPref( + TelemetryUtils.Preferences.PreviousBuildID, + null + ); + let thisBuildID = Services.appinfo.appBuildID; + // If there is no previousBuildId preference, we send null to the server. + if (previousBuildId != thisBuildID) { + this._previousBuildId = previousBuildId; + Services.prefs.setStringPref( + TelemetryUtils.Preferences.PreviousBuildID, + thisBuildID + ); + } + + this.attachEarlyObservers(); + }, + + /** + * Does the "heavy" Telemetry initialization later on, so we + * don't impact startup performance. + * @return {Promise} Resolved when the initialization completes. + */ + delayedInit() { + this._log.trace("delayedInit"); + + this._delayedInitTask = (async () => { + try { + this._initialized = true; + + await this._loadSessionData(); + // Update the session data to keep track of new subsessions created before + // the initialization. + await lazy.TelemetryStorage.saveSessionData( + this._getSessionDataObject() + ); + + this.addObserver("idle-daily"); + await Services.telemetry.gatherMemory(); + + Services.telemetry.asyncFetchTelemetryData(function () {}); + + if (IS_UNIFIED_TELEMETRY) { + // Check for a previously written aborted session ping. + await lazy.TelemetryController.checkAbortedSessionPing(); + + // Write the first aborted-session ping as early as possible. Just do that + // if we are not testing, since calling Telemetry.reset() will make a previous + // aborted ping a pending ping. + if (!this._testing) { + await this._saveAbortedSessionPing(); + } + + // The last change date for the environment, used to throttle environment changes. + this._lastEnvironmentChangeDate = Policy.monotonicNow(); + lazy.TelemetryEnvironment.registerChangeListener( + ENVIRONMENT_CHANGE_LISTENER, + (reason, data) => this._onEnvironmentChange(reason, data) + ); + + // Start the scheduler. + // We skip this if unified telemetry is off, so we don't + // trigger the new unified ping types. + lazy.TelemetryScheduler.init(); + } + + this._delayedInitTask = null; + } catch (e) { + this._delayedInitTask = null; + throw e; + } + })(); + + return this._delayedInitTask; + }, + + /** + * On Desktop: Save the "shutdown" ping to disk. + * On Android: Save the "saved-session" ping to disk. + * This needs to be called after TelemetrySend shuts down otherwise pings + * would be sent instead of getting persisted to disk. + */ + saveShutdownPings() { + this._log.trace("saveShutdownPings"); + + // We append the promises to this list and wait + // on all pings to be saved after kicking off their collection. + let p = []; + + if (IS_UNIFIED_TELEMETRY) { + let shutdownPayload = this.getSessionPayload(REASON_SHUTDOWN, false); + + // Only send the shutdown ping using the pingsender from the second + // browsing session on, to mitigate issues with "bot" profiles (see bug 1354482). + const sendOnThisSession = + Services.prefs.getBoolPref( + Utils.Preferences.ShutdownPingSenderFirstSession, + false + ) || !lazy.TelemetryReportingPolicy.isFirstRun(); + let sendWithPingsender = + Services.prefs.getBoolPref( + TelemetryUtils.Preferences.ShutdownPingSender, + false + ) && sendOnThisSession; + + let options = { + addClientId: true, + addEnvironment: true, + usePingSender: sendWithPingsender, + }; + p.push( + lazy.TelemetryController.submitExternalPing( + getPingType(shutdownPayload), + shutdownPayload, + options + ).catch(e => + this._log.error( + "saveShutdownPings - failed to submit shutdown ping", + e + ) + ) + ); + + // Send a duplicate of first-shutdown pings as a new ping type, in order to properly + // evaluate first session profiles (see bug 1390095). + const sendFirstShutdownPing = + Services.prefs.getBoolPref( + Utils.Preferences.ShutdownPingSender, + false + ) && + Services.prefs.getBoolPref( + Utils.Preferences.FirstShutdownPingEnabled, + false + ) && + lazy.TelemetryReportingPolicy.isFirstRun(); + + if (sendFirstShutdownPing) { + let options = { + addClientId: true, + addEnvironment: true, + usePingSender: true, + }; + p.push( + lazy.TelemetryController.submitExternalPing( + "first-shutdown", + shutdownPayload, + options + ).catch(e => + this._log.error( + "saveShutdownPings - failed to submit first shutdown ping", + e + ) + ) + ); + } + } + + if ( + AppConstants.platform == "android" && + Services.telemetry.canRecordExtended + ) { + let payload = this.getSessionPayload(REASON_SAVED_SESSION, false); + + let options = { + addClientId: true, + addEnvironment: true, + }; + p.push( + lazy.TelemetryController.submitExternalPing( + getPingType(payload), + payload, + options + ).catch(e => + this._log.error( + "saveShutdownPings - failed to submit saved-session ping", + e + ) + ) + ); + } + + // Wait on pings to be saved. + return Promise.all(p); + }, + + testSavePendingPing() { + let payload = this.getSessionPayload(REASON_SAVED_SESSION, false); + let options = { + addClientId: true, + addEnvironment: true, + overwrite: true, + }; + return lazy.TelemetryController.addPendingPing( + getPingType(payload), + payload, + options + ); + }, + + /** + * Do some shutdown work that is common to all process types. + */ + uninstall() { + for (let topic of this._observedTopics) { + try { + // Tests may flip Telemetry.canRecordExtended on and off. It can be the case + // that the observer TOPIC_CYCLE_COLLECTOR_BEGIN was not added. + this.removeObserver(topic); + } catch (e) { + this._log.warn("uninstall - Failed to remove " + topic, e); + } + } + }, + + getPayload: function getPayload(reason, clearSubsession) { + this._log.trace("getPayload - clearSubsession: " + clearSubsession); + reason = reason || REASON_GATHER_PAYLOAD; + // This function returns the current Telemetry payload to the caller. + // We only gather startup info once. + if (!Object.keys(this._slowSQLStartup).length) { + this._slowSQLStartup = Services.telemetry.slowSQL; + } + Services.telemetry.gatherMemory(); + return this.getSessionPayload(reason, clearSubsession); + }, + + gatherStartup: function gatherStartup() { + this._log.trace("gatherStartup"); + let counters = processInfo.getCounters(); + if (counters) { + [ + this._startupIO.startupSessionRestoreReadBytes, + this._startupIO.startupSessionRestoreWriteBytes, + ] = counters; + } + this._slowSQLStartup = Services.telemetry.slowSQL; + }, + + setAddOns: function setAddOns(aAddOns) { + this._addons = aAddOns; + }, + + testPing: function testPing() { + return this.send(REASON_TEST_PING); + }, + + /** + * Tracks the number of "ticks" the user was active in. + */ + _onActiveTick(aUserActive) { + const needsUpdate = aUserActive && this._isUserActive; + this._isUserActive = aUserActive; + + // Don't count the first active tick after we get out of + // inactivity, because it is just the start of this active tick. + if (needsUpdate) { + this._sessionActiveTicks++; + Services.telemetry.scalarAdd("browser.engagement.active_ticks", 1); + Glean.browserEngagement.activeTicks.add(1); + } + }, + + /** + * This observer drives telemetry. + */ + observe(aSubject, aTopic, aData) { + this._log.trace("observe - " + aTopic + " notified."); + + switch (aTopic) { + case "xul-window-visible": + this.removeObserver("xul-window-visible"); + var counters = processInfo.getCounters(); + if (counters) { + [ + this._startupIO.startupWindowVisibleReadBytes, + this._startupIO.startupWindowVisibleWriteBytes, + ] = counters; + } + break; + case "sessionstore-windows-restored": + this.removeObserver("sessionstore-windows-restored"); + // Check whether debugger was attached during startup + let debugService = Cc["@mozilla.org/xpcom/debug;1"].getService( + Ci.nsIDebug2 + ); + gWasDebuggerAttached = debugService.isDebuggerAttached; + this.gatherStartup(); + break; + case "idle-daily": + // Enqueue to main-thread, otherwise components may be inited by the + // idle-daily category and miss the gather-telemetry notification. + Services.tm.dispatchToMainThread(function () { + // Notify that data should be gathered now. + // TODO: We are keeping this behaviour for now but it will be removed as soon as + // bug 1127907 lands. + Services.obs.notifyObservers(null, "gather-telemetry"); + }); + break; + + case "application-background": + if (AppConstants.platform !== "android") { + break; + } + // On Android, we can get killed without warning once we are in the background, + // but we may also submit data and/or come back into the foreground without getting + // killed. To deal with this, we save the current session data to file when we are + // put into the background. This handles the following post-backgrounding scenarios: + // 1) We are killed immediately. In this case the current session data (which we + // save to a file) will be loaded and submitted on a future run. + // 2) We submit the data while in the background, and then are killed. In this case + // the file that we saved will be deleted by the usual process in + // finishPingRequest after it is submitted. + // 3) We submit the data, and then come back into the foreground. Same as case (2). + // 4) We do not submit the data, but come back into the foreground. In this case + // we have the option of either deleting the file that we saved (since we will either + // send the live data while in the foreground, or create the file again on the next + // backgrounding), or not (in which case we will delete it on submit, or overwrite + // it on the next backgrounding). Not deleting it is faster, so that's what we do. + let payload = this.getSessionPayload(REASON_SAVED_SESSION, false); + let options = { + addClientId: true, + addEnvironment: true, + overwrite: true, + }; + lazy.TelemetryController.addPendingPing( + getPingType(payload), + payload, + options + ); + break; + case "user-interaction-active": + this._onActiveTick(true); + break; + case "user-interaction-inactive": + this._onActiveTick(false); + break; + } + return undefined; + }, + + /** + * This tells TelemetrySession to uninitialize and save any pending pings. + */ + shutdownChromeProcess() { + this._log.trace("shutdownChromeProcess"); + + let cleanup = () => { + if (IS_UNIFIED_TELEMETRY) { + lazy.TelemetryEnvironment.unregisterChangeListener( + ENVIRONMENT_CHANGE_LISTENER + ); + lazy.TelemetryScheduler.shutdown(); + } + this.uninstall(); + + let reset = () => { + this._initStarted = false; + this._initialized = false; + }; + + return (async () => { + await this.saveShutdownPings(); + + if (IS_UNIFIED_TELEMETRY) { + await lazy.TelemetryController.removeAbortedSessionPing(); + } + + reset(); + })(); + }; + + // We can be in one the following states here: + // 1) delayedInit was never called + // or it was called and + // 2) _delayedInitTask is running now. + // 3) _delayedInitTask finished running already. + + // This handles 1). + if (!this._initStarted) { + return Promise.resolve(); + } + + // This handles 3). + if (!this._delayedInitTask) { + // We already ran the delayed initialization. + return cleanup(); + } + + // This handles 2). + return this._delayedInitTask.then(cleanup); + }, + + /** + * Gather and send a daily ping. + * @return {Promise} Resolved when the ping is sent. + */ + _sendDailyPing() { + this._log.trace("_sendDailyPing"); + let payload = this.getSessionPayload(REASON_DAILY, true); + + let options = { + addClientId: true, + addEnvironment: true, + }; + + let promise = lazy.TelemetryController.submitExternalPing( + getPingType(payload), + payload, + options + ); + + // Also save the payload as an aborted session. If we delay this, aborted-session can + // lag behind for the profileSubsessionCounter and other state, complicating analysis. + if (IS_UNIFIED_TELEMETRY) { + this._saveAbortedSessionPing(payload).catch(e => + this._log.error( + "_sendDailyPing - Failed to save the aborted session ping", + e + ) + ); + } + + return promise; + }, + + /** Loads session data from the session data file. + * @return {Promise<object>} A promise which is resolved with an object when + * loading has completed, with null otherwise. + */ + async _loadSessionData() { + let data = await lazy.TelemetryStorage.loadSessionData(); + + if (!data) { + return null; + } + + if ( + !("profileSubsessionCounter" in data) || + !(typeof data.profileSubsessionCounter == "number") || + !("subsessionId" in data) || + !("sessionId" in data) + ) { + this._log.error("_loadSessionData - session data is invalid"); + Services.telemetry + .getHistogramById("TELEMETRY_SESSIONDATA_FAILED_VALIDATION") + .add(1); + return null; + } + + this._previousSessionId = data.sessionId; + this._previousSubsessionId = data.subsessionId; + // Add |_subsessionCounter| to the |_profileSubsessionCounter| to account for + // new subsession while loading still takes place. This will always be exactly + // 1 - the current subsessions. + this._profileSubsessionCounter = + data.profileSubsessionCounter + this._subsessionCounter; + // If we don't have this flag in the state file, it means that this is an old profile. + // We don't want to send the "new-profile" ping on new profile, so se this to true. + this._newProfilePingSent = + "newProfilePingSent" in data ? data.newProfilePingSent : true; + return data; + }, + + /** + * Get the session data object to serialise to disk. + */ + _getSessionDataObject() { + return { + sessionId: this._sessionId, + subsessionId: this._subsessionId, + profileSubsessionCounter: this._profileSubsessionCounter, + newProfilePingSent: this._newProfilePingSent, + }; + }, + + _onEnvironmentChange(reason, oldEnvironment) { + this._log.trace("_onEnvironmentChange", reason); + + let now = Policy.monotonicNow(); + let timeDelta = now - this._lastEnvironmentChangeDate; + if (timeDelta <= MIN_SUBSESSION_LENGTH_MS) { + this._log.trace( + `_onEnvironmentChange - throttling; last change was ${Math.round( + timeDelta / 1000 + )}s ago.` + ); + return; + } + + this._lastEnvironmentChangeDate = now; + let payload = this.getSessionPayload(REASON_ENVIRONMENT_CHANGE, true); + lazy.TelemetryScheduler.rescheduleDailyPing(payload); + + let options = { + addClientId: true, + addEnvironment: true, + overrideEnvironment: oldEnvironment, + }; + lazy.TelemetryController.submitExternalPing( + getPingType(payload), + payload, + options + ); + }, + + _isClassicReason(reason) { + const classicReasons = [ + REASON_SAVED_SESSION, + REASON_GATHER_PAYLOAD, + REASON_TEST_PING, + ]; + return classicReasons.includes(reason); + }, + + /** + * Get an object describing the current state of this module for AsyncShutdown diagnostics. + */ + _getState() { + return { + initialized: this._initialized, + initStarted: this._initStarted, + haveDelayedInitTask: !!this._delayedInitTask, + }; + }, + + /** + * Saves the aborted session ping to disk. + * @param {Object} [aProvidedPayload=null] A payload object to be used as an aborted + * session ping. The reason of this payload is changed to aborted-session. + * If not provided, a new payload is gathered. + */ + _saveAbortedSessionPing(aProvidedPayload = null) { + this._log.trace("_saveAbortedSessionPing"); + + let payload = null; + if (aProvidedPayload) { + payload = Cu.cloneInto(aProvidedPayload, {}); + // Overwrite the original reason. + payload.info.reason = REASON_ABORTED_SESSION; + } else { + payload = this.getSessionPayload(REASON_ABORTED_SESSION, false); + } + + return lazy.TelemetryController.saveAbortedSessionPing(payload); + }, + + async markNewProfilePingSent() { + this._log.trace("markNewProfilePingSent"); + this._newProfilePingSent = true; + return lazy.TelemetryStorage.saveSessionData(this._getSessionDataObject()); + }, +}; diff --git a/toolkit/components/telemetry/pings/UninstallPing.sys.mjs b/toolkit/components/telemetry/pings/UninstallPing.sys.mjs new file mode 100644 index 0000000000..00c1e80a40 --- /dev/null +++ b/toolkit/components/telemetry/pings/UninstallPing.sys.mjs @@ -0,0 +1,34 @@ +/* -*- 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + WindowsInstallsInfo: + "resource://gre/modules/components-utils/WindowsInstallsInfo.sys.mjs", +}); + +/** + * The Windows-only "uninstall" ping, which is saved to disk for the uninstaller to find. + * The ping is actually assembled by TelemetryControllerParent.saveUninstallPing(). + */ +export var UninstallPing = { + /** + * Maximum number of other installs to count (see + * toolkit/components/telemetry/docs/data/uninstall-ping.rst for motivation) + */ + MAX_OTHER_INSTALLS: 11, + + /** + * Count other installs of this app, based on the values in the TaskBarIDs registry key. + * + */ + getOtherInstallsCount() { + return lazy.WindowsInstallsInfo.getInstallPaths( + this.MAX_OTHER_INSTALLS, + new Set([Services.dirsvc.get("GreBinD", Ci.nsIFile).path]) + ).size; + }, +}; diff --git a/toolkit/components/telemetry/pings/UntrustedModulesPing.sys.mjs b/toolkit/components/telemetry/pings/UntrustedModulesPing.sys.mjs new file mode 100644 index 0000000000..85f565775d --- /dev/null +++ b/toolkit/components/telemetry/pings/UntrustedModulesPing.sys.mjs @@ -0,0 +1,72 @@ +/* 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 periodically sends a Telemetry ping containing information + * about untrusted module loads on Windows. + * + * https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/third-party-modules-ping.html + */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + UpdateTimerManager: [ + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager", + ], +}); + +const DEFAULT_INTERVAL_SECONDS = 24 * 60 * 60; // 1 day + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "TelemetryUntrustedModulesPing::"; +const TIMER_NAME = "telemetry_untrustedmodules_ping"; +const PING_SUBMISSION_NAME = "third-party-modules"; + +export var TelemetryUntrustedModulesPing = Object.freeze({ + _log: Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX), + + start() { + lazy.UpdateTimerManager.registerTimer( + TIMER_NAME, + this, + Services.prefs.getIntPref( + lazy.TelemetryUtils.Preferences.UntrustedModulesPingFrequency, + DEFAULT_INTERVAL_SECONDS + ) + ); + }, + + notify() { + try { + Services.telemetry.getUntrustedModuleLoadEvents().then(payload => { + try { + if (payload) { + lazy.TelemetryController.submitExternalPing( + PING_SUBMISSION_NAME, + payload, + { + addClientId: true, + addEnvironment: true, + } + ); + } + } catch (ex) { + this._log.error("payload handler caught an exception", ex); + } + }); + } catch (ex) { + this._log.error("notify() caught an exception", ex); + } + }, +}); diff --git a/toolkit/components/telemetry/pings/UpdatePing.sys.mjs b/toolkit/components/telemetry/pings/UpdatePing.sys.mjs new file mode 100644 index 0000000000..0334d8d1b4 --- /dev/null +++ b/toolkit/components/telemetry/pings/UpdatePing.sys.mjs @@ -0,0 +1,181 @@ +/* -*- 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 { Log } from "resource://gre/modules/Log.sys.mjs"; + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", +}); + +const LOGGER_NAME = "Toolkit.Telemetry"; +const PING_TYPE = "update"; +const UPDATE_DOWNLOADED_TOPIC = "update-downloaded"; +const UPDATE_STAGED_TOPIC = "update-staged"; + +/** + * This module is responsible for listening to all the relevant update + * signals, gathering the needed information and assembling the "update" + * ping. + */ +export var UpdatePing = { + _enabled: false, + + earlyInit() { + this._log = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + "UpdatePing::" + ); + this._enabled = Services.prefs.getBoolPref( + TelemetryUtils.Preferences.UpdatePing, + false + ); + + this._log.trace("init - enabled: " + this._enabled); + + if (!this._enabled) { + return; + } + + Services.obs.addObserver(this, UPDATE_DOWNLOADED_TOPIC); + Services.obs.addObserver(this, UPDATE_STAGED_TOPIC); + }, + + /** + * Get the information about the update we're going to apply/was just applied + * from the update manager. + * + * @return {nsIUpdate} The information about the update, if available, or null. + */ + _getActiveUpdate() { + let updateManager = Cc["@mozilla.org/updates/update-manager;1"].getService( + Ci.nsIUpdateManager + ); + if (!updateManager || !updateManager.readyUpdate) { + return null; + } + + return updateManager.readyUpdate; + }, + + /** + * Generate an "update" ping with reason "success" and dispatch it + * to the Telemetry system. + * + * @param {String} aPreviousVersion The browser version we updated from. + * @param {String} aPreviousBuildId The browser build id we updated from. + */ + handleUpdateSuccess(aPreviousVersion, aPreviousBuildId) { + if (!this._enabled) { + return; + } + + this._log.trace("handleUpdateSuccess"); + + // An update could potentially change the update channel. Moreover, + // updates can only be applied if the update's channel matches with the build channel. + // There's no way to pass this information from the caller nor the environment as, + // in that case, the environment would report the "new" channel. However, the + // update manager should still have information about the active update: given the + // previous assumptions, we can simply get the channel from the update and assume + // it matches with the state previous to the update. + let update = this._getActiveUpdate(); + + const payload = { + reason: "success", + previousChannel: update ? update.channel : null, + previousVersion: aPreviousVersion, + previousBuildId: aPreviousBuildId, + }; + + const options = { + addClientId: true, + addEnvironment: true, + usePingSender: false, + }; + + lazy.TelemetryController.submitExternalPing( + PING_TYPE, + payload, + options + ).catch(e => + this._log.error("handleUpdateSuccess - failed to submit update ping", e) + ); + }, + + /** + * Generate an "update" ping with reason "ready" and dispatch it + * to the Telemetry system. + * + * @param {String} aUpdateState The state of the downloaded patch. See + * nsIUpdateService.idl for a list of possible values. + */ + _handleUpdateReady(aUpdateState) { + const ALLOWED_STATES = [ + "applied", + "applied-service", + "pending", + "pending-service", + "pending-elevate", + ]; + if (!ALLOWED_STATES.includes(aUpdateState)) { + this._log.trace("Unexpected update state: " + aUpdateState); + return; + } + + // Get the information about the update we're going to apply from the + // update manager. + let update = this._getActiveUpdate(); + if (!update) { + this._log.trace( + "Cannot get the update manager or no update is currently active." + ); + return; + } + + const payload = { + reason: "ready", + targetChannel: update.channel, + targetVersion: update.appVersion, + targetBuildId: update.buildID, + targetDisplayVersion: update.displayVersion, + }; + + const options = { + addClientId: true, + addEnvironment: true, + usePingSender: true, + }; + + lazy.TelemetryController.submitExternalPing( + PING_TYPE, + payload, + options + ).catch(e => + this._log.error("_handleUpdateReady - failed to submit update ping", e) + ); + }, + + /** + * The notifications handler. + */ + observe(aSubject, aTopic, aData) { + this._log.trace("observe - aTopic: " + aTopic); + if (aTopic == UPDATE_DOWNLOADED_TOPIC || aTopic == UPDATE_STAGED_TOPIC) { + this._handleUpdateReady(aData); + } + }, + + shutdown() { + if (!this._enabled) { + return; + } + Services.obs.removeObserver(this, UPDATE_DOWNLOADED_TOPIC); + Services.obs.removeObserver(this, UPDATE_STAGED_TOPIC); + }, +}; |