fune/browser/modules/BrowserUsageTelemetry.sys.mjs
Chris H-C 0bf8a2c00f Bug 1889111 - Instrument Firefox Desktop UI Interactions with events r=TravisLong,kcochrane,Gijs
Assumption: Browser Usage Telemetry (ideally) records only and all interesting
interactions with Firefox Desktop's UI, and preserving syntax and semantics
when instrumenting using events is valuable.

Value this provides over existing keyed scalars:
* Order of operations (did three tabs open and then three tabs close, or did
  a single tab open-close three times?)
* Flow control (several atomic interactions combine to a user task. flow_id
  grouping allows us to see that easily in analysis. e.g. Open a tab, open
  prefs, privacy prefs, change a setting.)
* Glean

This is aiming for prototype quality and a prototype lifetime, to see if it's
worth investing more than just a week or two into.

Differential Revision: https://phabricator.services.mozilla.com/D207908
2024-05-03 15:19:15 +00:00

1466 lines
45 KiB
JavaScript

/* -*- 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ClientID: "resource://gre/modules/ClientID.sys.mjs",
CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
PageActions: "resource:///modules/PageActions.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs",
WindowsInstallsInfo:
"resource://gre/modules/components-utils/WindowsInstallsInfo.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
});
// This pref is in seconds!
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"gRecentVisitedOriginsExpiry",
"browser.engagement.recent_visited_origins.expiry"
);
// The upper bound for the count of the visited unique domain names.
const MAX_UNIQUE_VISITED_DOMAINS = 100;
// Observed topic names.
const TAB_RESTORING_TOPIC = "SSTabRestoring";
const TELEMETRY_SUBSESSIONSPLIT_TOPIC =
"internal-telemetry-after-subsession-split";
const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
// Probe names.
const MAX_TAB_COUNT_SCALAR_NAME = "browser.engagement.max_concurrent_tab_count";
const MAX_WINDOW_COUNT_SCALAR_NAME =
"browser.engagement.max_concurrent_window_count";
const TAB_OPEN_EVENT_COUNT_SCALAR_NAME =
"browser.engagement.tab_open_event_count";
const MAX_TAB_PINNED_COUNT_SCALAR_NAME =
"browser.engagement.max_concurrent_tab_pinned_count";
const TAB_PINNED_EVENT_COUNT_SCALAR_NAME =
"browser.engagement.tab_pinned_event_count";
const WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME =
"browser.engagement.window_open_event_count";
const UNIQUE_DOMAINS_COUNT_SCALAR_NAME =
"browser.engagement.unique_domains_count";
const TOTAL_URI_COUNT_SCALAR_NAME = "browser.engagement.total_uri_count";
const UNFILTERED_URI_COUNT_SCALAR_NAME =
"browser.engagement.unfiltered_uri_count";
const TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE_SCALAR_NAME =
"browser.engagement.total_uri_count_normal_and_private_mode";
export const MINIMUM_TAB_COUNT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, in ms
// The elements we consider to be interactive.
const UI_TARGET_ELEMENTS = [
"menuitem",
"toolbarbutton",
"key",
"command",
"checkbox",
"input",
"button",
"image",
"radio",
"richlistitem",
];
// The containers of interactive elements that we care about and their pretty
// names. These should be listed in order of most-specific to least-specific,
// when iterating JavaScript will guarantee that ordering and so we will find
// the most specific area first.
const BROWSER_UI_CONTAINER_IDS = {
"toolbar-menubar": "menu-bar",
TabsToolbar: "tabs-bar",
PersonalToolbar: "bookmarks-bar",
"appMenu-popup": "app-menu",
tabContextMenu: "tabs-context",
contentAreaContextMenu: "content-context",
"widget-overflow-list": "overflow-menu",
"widget-overflow-fixed-list": "pinned-overflow-menu",
"page-action-buttons": "pageaction-urlbar",
pageActionPanel: "pageaction-panel",
"unified-extensions-area": "unified-extensions-area",
"allTabsMenu-allTabsView": "alltabs-menu",
// This should appear last as some of the above are inside the nav bar.
"nav-bar": "nav-bar",
};
const ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS = {
[BROWSER_UI_CONTAINER_IDS.tabContextMenu]: "tabs-context-entrypoint",
};
// A list of the expected panes in about:preferences
const PREFERENCES_PANES = [
"paneHome",
"paneGeneral",
"panePrivacy",
"paneSearch",
"paneSearchResults",
"paneSync",
"paneContainers",
"paneExperimental",
"paneMoreFromMozilla",
];
const IGNORABLE_EVENTS = new WeakMap();
const KNOWN_ADDONS = [];
// Buttons that, when clicked, set a preference to true. The convention
// is that the preference is named:
//
// browser.engagement.<button id>.has-used
//
// and is defaulted to false.
const SET_USAGE_PREF_BUTTONS = [
"downloads-button",
"fxa-toolbar-menu-button",
"home-button",
"sidebar-button",
"library-button",
];
// Buttons that, when clicked, increase a counter. The convention
// is that the preference is named:
//
// browser.engagement.<button id>.used-count
//
// and doesn't have a default value.
const SET_USAGECOUNT_PREF_BUTTONS = [
"pageAction-panel-copyURL",
"pageAction-panel-emailLink",
"pageAction-panel-pinTab",
"pageAction-panel-screenshots_mozilla_org",
"pageAction-panel-shareURL",
];
// Places context menu IDs.
const PLACES_CONTEXT_MENU_ID = "placesContext";
const PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID =
"placesContext_open:newcontainertab";
// Commands used to open history or bookmark links from places context menu.
const PLACES_OPEN_COMMANDS = [
"placesCmd_open",
"placesCmd_open:window",
"placesCmd_open:privatewindow",
"placesCmd_open:tab",
];
// How long of a delay between events means the start of a new flow?
// Used by Browser UI Interaction event instrumentation.
// Default: 5min.
const FLOW_IDLE_TIME = 5 * 60 * 1000;
function telemetryId(widgetId, obscureAddons = true) {
// Add-on IDs need to be obscured.
function addonId(id) {
if (!obscureAddons) {
return id;
}
let pos = KNOWN_ADDONS.indexOf(id);
if (pos < 0) {
pos = KNOWN_ADDONS.length;
KNOWN_ADDONS.push(id);
}
return `addon${pos}`;
}
if (widgetId.endsWith("-browser-action")) {
widgetId = addonId(
widgetId.substring(0, widgetId.length - "-browser-action".length)
);
} else if (widgetId.startsWith("pageAction-")) {
let actionId;
if (widgetId.startsWith("pageAction-urlbar-")) {
actionId = widgetId.substring("pageAction-urlbar-".length);
} else if (widgetId.startsWith("pageAction-panel-")) {
actionId = widgetId.substring("pageAction-panel-".length);
}
if (actionId) {
let action = lazy.PageActions.actionForID(actionId);
widgetId = action?._isMozillaAction ? actionId : addonId(actionId);
}
} else if (widgetId.startsWith("ext-keyset-id-")) {
// Webextension command shortcuts don't have an id on their key element so
// we see the id from the keyset that contains them.
widgetId = addonId(widgetId.substring("ext-keyset-id-".length));
} else if (widgetId.startsWith("ext-key-id-")) {
// The command for a webextension sidebar action is an exception to the above rule.
widgetId = widgetId.substring("ext-key-id-".length);
if (widgetId.endsWith("-sidebar-action")) {
widgetId = addonId(
widgetId.substring(0, widgetId.length - "-sidebar-action".length)
);
}
}
return widgetId.replace(/_/g, "-");
}
function getOpenTabsAndWinsCounts() {
let loadedTabCount = 0;
let tabCount = 0;
let winCount = 0;
for (let win of Services.wm.getEnumerator("navigator:browser")) {
winCount++;
tabCount += win.gBrowser.tabs.length;
for (const tab of win.gBrowser.tabs) {
if (tab.getAttribute("pending") !== "true") {
loadedTabCount += 1;
}
}
}
return { loadedTabCount, tabCount, winCount };
}
function getPinnedTabsCount() {
let pinnedTabs = 0;
for (let win of Services.wm.getEnumerator("navigator:browser")) {
pinnedTabs += [...win.ownerGlobal.gBrowser.tabs].filter(
t => t.pinned
).length;
}
return pinnedTabs;
}
export let URICountListener = {
// A set containing the visited domains, see bug 1271310.
_domainSet: new Set(),
// A set containing the visited origins during the last 24 hours (similar to domains, but not quite the same)
_domain24hrSet: new Set(),
// A map to keep track of the URIs loaded from the restored tabs.
_restoredURIsMap: new WeakMap(),
// Ongoing expiration timeouts.
_timeouts: new Set(),
isHttpURI(uri) {
// Only consider http(s) schemas.
return uri.schemeIs("http") || uri.schemeIs("https");
},
addRestoredURI(browser, uri) {
if (!this.isHttpURI(uri)) {
return;
}
this._restoredURIsMap.set(browser, uri.spec);
},
onLocationChange(browser, webProgress, request, uri, flags) {
if (
!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) &&
webProgress.isTopLevel
) {
// By default, assume we no longer need to track this tab.
lazy.SearchSERPTelemetry.stopTrackingBrowser(
browser,
lazy.SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION
);
}
// Don't count this URI if it's an error page.
if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
return;
}
// We only care about top level loads.
if (!webProgress.isTopLevel) {
return;
}
// The SessionStore sets the URI of a tab first, firing onLocationChange the
// first time, then manages content loading using its scheduler. Once content
// loads, we will hit onLocationChange again.
// We can catch the first case by checking for null requests: be advised that
// this can also happen when navigating page fragments, so account for it.
if (
!request &&
!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
) {
return;
}
// Don't include URI and domain counts when in private mode.
let shouldCountURI =
!lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal) ||
Services.prefs.getBoolPref(
"browser.engagement.total_uri_count.pbm",
false
);
// Track URI loads, even if they're not http(s).
let uriSpec = null;
try {
uriSpec = uri.spec;
} catch (e) {
// If we have troubles parsing the spec, still count this as
// an unfiltered URI.
if (shouldCountURI) {
Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
}
return;
}
// Don't count about:blank and similar pages, as they would artificially
// inflate the counts.
if (browser.ownerGlobal.gInitialPages.includes(uriSpec)) {
return;
}
// If the URI we're loading is in the _restoredURIsMap, then it comes from a
// restored tab. If so, let's skip it and remove it from the map as we want to
// count page refreshes.
if (this._restoredURIsMap.get(browser) === uriSpec) {
this._restoredURIsMap.delete(browser);
return;
}
// The URI wasn't from a restored tab. Count it among the unfiltered URIs.
// If this is an http(s) URI, this also gets counted by the "total_uri_count"
// probe.
if (shouldCountURI) {
Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
}
if (!this.isHttpURI(uri)) {
return;
}
if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) {
lazy.SearchSERPTelemetry.updateTrackingStatus(
browser,
uriSpec,
webProgress.loadType
);
} else {
lazy.SearchSERPTelemetry.updateTrackingSinglePageApp(
browser,
uriSpec,
webProgress.loadType,
flags
);
}
// Update total URI count, including when in private mode.
Services.telemetry.scalarAdd(
TOTAL_URI_COUNT_NORMAL_AND_PRIVATE_MODE_SCALAR_NAME,
1
);
Glean.browserEngagement.uriCount.add(1);
if (!shouldCountURI) {
return;
}
// Update the URI counts.
Services.telemetry.scalarAdd(TOTAL_URI_COUNT_SCALAR_NAME, 1);
// Update tab count
BrowserUsageTelemetry._recordTabCounts(getOpenTabsAndWinsCounts());
// Unique domains should be aggregated by (eTLD + 1): x.test.com and y.test.com
// are counted once as test.com.
let baseDomain;
try {
// Even if only considering http(s) URIs, |getBaseDomain| could still throw
// due to the URI containing invalid characters or the domain actually being
// an ipv4 or ipv6 address.
baseDomain = Services.eTLD.getBaseDomain(uri);
} catch (e) {
return;
}
// We only want to count the unique domains up to MAX_UNIQUE_VISITED_DOMAINS.
if (this._domainSet.size < MAX_UNIQUE_VISITED_DOMAINS) {
this._domainSet.add(baseDomain);
Services.telemetry.scalarSet(
UNIQUE_DOMAINS_COUNT_SCALAR_NAME,
this._domainSet.size
);
}
this._domain24hrSet.add(baseDomain);
if (lazy.gRecentVisitedOriginsExpiry) {
let timeoutId = lazy.setTimeout(() => {
this._domain24hrSet.delete(baseDomain);
this._timeouts.delete(timeoutId);
}, lazy.gRecentVisitedOriginsExpiry * 1000);
this._timeouts.add(timeoutId);
}
},
/**
* Reset the counts. This should be called when breaking a session in Telemetry.
*/
reset() {
this._domainSet.clear();
},
/**
* Returns the number of unique domains visited in this session during the
* last 24 hours.
*/
get uniqueDomainsVisitedInPast24Hours() {
return this._domain24hrSet.size;
},
/**
* Resets the number of unique domains visited in this session.
*/
resetUniqueDomainsVisitedInPast24Hours() {
this._timeouts.forEach(timeoutId => lazy.clearTimeout(timeoutId));
this._timeouts.clear();
this._domain24hrSet.clear();
},
QueryInterface: ChromeUtils.generateQI([
"nsIWebProgressListener",
"nsISupportsWeakReference",
]),
};
export let BrowserUsageTelemetry = {
/**
* This is a policy object used to override behavior for testing.
*/
Policy: {
getTelemetryClientId: async () => lazy.ClientID.getClientID(),
getUpdateDirectory: () => Services.dirsvc.get("UpdRootD", Ci.nsIFile),
readProfileCountFile: async path => IOUtils.readUTF8(path),
writeProfileCountFile: async (path, data) => IOUtils.writeUTF8(path, data),
},
_inited: false,
init() {
this._lastRecordTabCount = 0;
this._lastRecordLoadedTabCount = 0;
this._setupAfterRestore();
this._inited = true;
Services.prefs.addObserver("browser.tabs.inTitlebar", this);
this._recordUITelemetry();
this._onTabsOpenedTask = new lazy.DeferredTask(
() => this._onTabsOpened(),
0
);
},
/**
* Resets the masked add-on identifiers. Only for use in tests.
*/
_resetAddonIds() {
KNOWN_ADDONS.length = 0;
},
/**
* Handle subsession splits in the parent process.
*/
afterSubsessionSplit() {
// Scalars just got cleared due to a subsession split. We need to set the maximum
// concurrent tab and window counts so that they reflect the correct value for the
// new subsession.
const counts = getOpenTabsAndWinsCounts();
Services.telemetry.scalarSetMaximum(
MAX_TAB_COUNT_SCALAR_NAME,
counts.tabCount
);
Services.telemetry.scalarSetMaximum(
MAX_WINDOW_COUNT_SCALAR_NAME,
counts.winCount
);
// Reset the URI counter.
URICountListener.reset();
},
QueryInterface: ChromeUtils.generateQI([
"nsIObserver",
"nsISupportsWeakReference",
]),
uninit() {
if (!this._inited) {
return;
}
Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC);
Services.obs.removeObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC);
},
observe(subject, topic, data) {
switch (topic) {
case DOMWINDOW_OPENED_TOPIC:
this._onWindowOpen(subject);
break;
case TELEMETRY_SUBSESSIONSPLIT_TOPIC:
this.afterSubsessionSplit();
break;
case "nsPref:changed":
switch (data) {
case "browser.tabs.inTitlebar":
this._recordWidgetChange(
"titlebar",
Services.appinfo.drawInTitlebar ? "off" : "on",
"pref"
);
break;
}
break;
}
},
handleEvent(event) {
switch (event.type) {
case "TabOpen":
this._onTabOpen();
break;
case "TabPinned":
this._onTabPinned();
break;
case "unload":
this._unregisterWindow(event.target);
break;
case TAB_RESTORING_TOPIC:
// We're restoring a new tab from a previous or crashed session.
// We don't want to track the URIs from these tabs, so let
// |URICountListener| know about them.
let browser = event.target.linkedBrowser;
URICountListener.addRestoredURI(browser, browser.currentURI);
const { loadedTabCount } = getOpenTabsAndWinsCounts();
this._recordTabCounts({ loadedTabCount });
break;
}
},
/**
* This gets called shortly after the SessionStore has finished restoring
* windows and tabs. It counts the open tabs and adds listeners to all the
* windows.
*/
_setupAfterRestore() {
// Make sure to catch new chrome windows and subsession splits.
Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
Services.obs.addObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC, true);
// Attach the tabopen handlers to the existing Windows.
for (let win of Services.wm.getEnumerator("navigator:browser")) {
this._registerWindow(win);
}
// Get the initial tab and windows max counts.
const counts = getOpenTabsAndWinsCounts();
Services.telemetry.scalarSetMaximum(
MAX_TAB_COUNT_SCALAR_NAME,
counts.tabCount
);
Services.telemetry.scalarSetMaximum(
MAX_WINDOW_COUNT_SCALAR_NAME,
counts.winCount
);
},
_buildWidgetPositions() {
let widgetMap = new Map();
const toolbarState = nodeId => {
let value;
if (nodeId == "PersonalToolbar") {
value = Services.prefs.getCharPref(
"browser.toolbars.bookmarks.visibility",
"newtab"
);
if (value != "newtab") {
return value == "never" ? "off" : "on";
}
return value;
}
value = Services.xulStore.getValue(
AppConstants.BROWSER_CHROME_URL,
nodeId,
"collapsed"
);
if (value) {
return value == "true" ? "off" : "on";
}
return "off";
};
widgetMap.set(
BROWSER_UI_CONTAINER_IDS.PersonalToolbar,
toolbarState("PersonalToolbar")
);
let menuBarHidden =
Services.xulStore.getValue(
AppConstants.BROWSER_CHROME_URL,
"toolbar-menubar",
"autohide"
) != "false";
widgetMap.set("menu-toolbar", menuBarHidden ? "off" : "on");
// Drawing in the titlebar means not showing the titlebar, hence the negation.
widgetMap.set("titlebar", Services.appinfo.drawInTitlebar ? "off" : "on");
for (let area of lazy.CustomizableUI.areas) {
if (!(area in BROWSER_UI_CONTAINER_IDS)) {
continue;
}
let position = BROWSER_UI_CONTAINER_IDS[area];
if (area == "nav-bar") {
position = `${BROWSER_UI_CONTAINER_IDS[area]}-start`;
}
let widgets = lazy.CustomizableUI.getWidgetsInArea(area);
for (let widget of widgets) {
if (!widget) {
continue;
}
if (widget.id.startsWith("customizableui-special-")) {
continue;
}
if (area == "nav-bar" && widget.id == "urlbar-container") {
position = `${BROWSER_UI_CONTAINER_IDS[area]}-end`;
continue;
}
widgetMap.set(widget.id, position);
}
}
let actions = lazy.PageActions.actions;
for (let action of actions) {
if (action.pinnedToUrlbar) {
widgetMap.set(action.id, "pageaction-urlbar");
}
}
return widgetMap;
},
_getWidgetID(node) {
// We want to find a sensible ID for this element.
if (!node) {
return null;
}
// See if this is a customizable widget.
if (node.ownerDocument.URL == AppConstants.BROWSER_CHROME_URL) {
// First find if it is inside one of the customizable areas.
for (let area of lazy.CustomizableUI.areas) {
if (node.closest(`#${CSS.escape(area)}`)) {
for (let widget of lazy.CustomizableUI.getWidgetIdsInArea(area)) {
if (
// We care about the buttons on the tabs themselves.
widget == "tabbrowser-tabs" ||
// We care about the page action and other buttons in here.
widget == "urlbar-container" ||
// We care about the actual menu items.
widget == "menubar-items" ||
// We care about individual bookmarks here.
widget == "personal-bookmarks"
) {
continue;
}
if (node.closest(`#${CSS.escape(widget)}`)) {
return widget;
}
}
break;
}
}
}
if (node.id) {
return node.id;
}
// A couple of special cases in the tabs.
for (let cls of ["bookmark-item", "tab-icon-sound", "tab-close-button"]) {
if (!node.classList.contains(cls)) {
continue;
}
if (cls == "bookmark-item" && node.parentElement.id.includes("history")) {
return "history-item";
}
return cls;
}
// One of these will at least let us know what the widget is for.
let possibleAttributes = [
"preference",
"command",
"observes",
"data-l10n-id",
];
// The key attribute on key elements is the actual key to listen for.
if (node.localName != "key") {
possibleAttributes.unshift("key");
}
for (let idAttribute of possibleAttributes) {
if (node.hasAttribute(idAttribute)) {
return node.getAttribute(idAttribute);
}
}
return this._getWidgetID(node.parentElement);
},
_getBrowserWidgetContainer(node) {
// Find the container holding this element.
for (let containerId of Object.keys(BROWSER_UI_CONTAINER_IDS)) {
let container = node.ownerDocument.getElementById(containerId);
if (container && container.contains(node)) {
return BROWSER_UI_CONTAINER_IDS[containerId];
}
}
// Treat toolbar context menu items that relate to tabs as the tab menu:
if (
node.closest("#toolbar-context-menu") &&
node.getAttribute("contexttype") == "tabbar"
) {
return BROWSER_UI_CONTAINER_IDS.tabContextMenu;
}
return null;
},
_getWidgetContainer(node) {
if (node.localName == "key") {
return "keyboard";
}
const { URL } = node.ownerDocument;
if (URL == AppConstants.BROWSER_CHROME_URL) {
return this._getBrowserWidgetContainer(node);
}
if (
URL.startsWith("about:preferences") ||
URL.startsWith("about:settings")
) {
// Find the element's category.
let container = node.closest("[data-category]");
if (!container) {
return null;
}
let pane = container.getAttribute("data-category");
if (!PREFERENCES_PANES.includes(pane)) {
pane = "paneUnknown";
}
return `preferences_${pane}`;
}
return null;
},
lastClickTarget: null,
ignoreEvent(event) {
IGNORABLE_EVENTS.set(event, true);
},
_recordCommand(event) {
if (IGNORABLE_EVENTS.get(event)) {
return;
}
let sourceEvent = event;
while (sourceEvent.sourceEvent) {
sourceEvent = sourceEvent.sourceEvent;
}
let lastTarget = this.lastClickTarget?.get();
if (
lastTarget &&
sourceEvent.type == "command" &&
sourceEvent.target.contains(lastTarget)
) {
// Ignore a command event triggered by a click.
this.lastClickTarget = null;
return;
}
this.lastClickTarget = null;
if (sourceEvent.type == "click") {
// Only care about main button clicks.
if (sourceEvent.button != 0) {
return;
}
// This click may trigger a command event so retain the target to be able
// to dedupe that event.
this.lastClickTarget = Cu.getWeakReference(sourceEvent.target);
}
// We should never see events from web content as they are fired in a
// content process, but let's be safe.
let url = sourceEvent.target.ownerDocument.documentURIObject;
if (!url.schemeIs("chrome") && !url.schemeIs("about")) {
return;
}
// This is what events targetted at content will actually look like.
if (sourceEvent.target.localName == "browser") {
return;
}
// Find the actual element we're interested in.
let node = sourceEvent.target;
const isAboutPreferences =
node.ownerDocument.URL.startsWith("about:preferences") ||
node.ownerDocument.URL.startsWith("about:settings");
while (
!UI_TARGET_ELEMENTS.includes(node.localName) &&
!node.classList?.contains("wants-telemetry") &&
// We are interested in links on about:preferences as well.
!(
isAboutPreferences &&
(node.getAttribute("is") === "text-link" || node.localName === "a")
)
) {
node = node.parentNode;
if (!node?.parentNode) {
// A click on a space or label or top-level document or something we're
// not interested in.
return;
}
}
if (sourceEvent.type === "command") {
const { command, ownerDocument, parentNode } = node;
// Check if this command is for a history or bookmark link being opened
// from the context menu. In this case, we are interested in the DOM node
// for the link, not the menu item itself.
if (
PLACES_OPEN_COMMANDS.includes(command) ||
parentNode?.parentNode?.id === PLACES_OPEN_IN_CONTAINER_TAB_MENU_ID
) {
node = ownerDocument.getElementById(PLACES_CONTEXT_MENU_ID).triggerNode;
}
}
let item = this._getWidgetID(node);
let source = this._getWidgetContainer(node);
if (item && source) {
this.recordInteractionEvent(item, source);
let scalar = `browser.ui.interaction.${source.replace(/-/g, "_")}`;
Services.telemetry.keyedScalarAdd(scalar, telemetryId(item), 1);
if (SET_USAGECOUNT_PREF_BUTTONS.includes(item)) {
let pref = `browser.engagement.${item}.used-count`;
Services.prefs.setIntPref(pref, Services.prefs.getIntPref(pref, 0) + 1);
}
if (SET_USAGE_PREF_BUTTONS.includes(item)) {
Services.prefs.setBoolPref(`browser.engagement.${item}.has-used`, true);
}
}
if (ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source]) {
let contextMenu = ENTRYPOINT_TRACKED_CONTEXT_MENU_IDS[source];
let triggerContainer = this._getWidgetContainer(
node.closest("menupopup")?.triggerNode
);
if (triggerContainer) {
this.recordInteractionEvent(item, contextMenu);
let scalar = `browser.ui.interaction.${contextMenu.replace(/-/g, "_")}`;
Services.telemetry.keyedScalarAdd(
scalar,
telemetryId(triggerContainer),
1
);
}
}
},
_flowId: null,
_flowIdTS: 0,
recordInteractionEvent(widgetId, source) {
// A note on clocks. Cu.now() is monotonic, but its behaviour across
// computer sleeps is different per platform.
// We're okay with this for flows because we're looking at idle times
// on the order of minutes and within the same machine, so the weirdest
// thing we may expect is a flow that accidentally continues across a
// sleep. Until we have evidence that this is common, we're in the clear.
if (!this._flowId || this._flowIdTS + FLOW_IDLE_TIME < Cu.now()) {
// We submit the ping full o' events on every new flow,
// including at startup.
GleanPings.prototypeNoCodeEvents.submit();
// We use a GUID here because we need to identify events in a flow
// out of all events from all flows across all clients.
this._flowId = Services.uuid.generateUUID();
}
this._flowIdTS = Cu.now();
const extra = {
source,
widgetId: telemetryId(widgetId),
flowId: this._flowId,
};
Glean.browserUsage.interaction.record(extra);
},
/**
* Listens for UI interactions in the window.
*/
_addUsageListeners(win) {
// Listen for command events from the UI.
win.addEventListener("command", event => this._recordCommand(event), true);
win.addEventListener("click", event => this._recordCommand(event), true);
},
/**
* A public version of the private method to take care of the `nav-bar-start`,
* `nav-bar-end` thing that callers shouldn't have to care about. It also
* accepts the DOM ids for the areas rather than the cleaner ones we report
* to telemetry.
*/
recordWidgetChange(widgetId, newPos, reason) {
try {
if (newPos) {
newPos = BROWSER_UI_CONTAINER_IDS[newPos];
}
if (newPos == "nav-bar") {
let { position } = lazy.CustomizableUI.getPlacementOfWidget(widgetId);
let { position: urlPosition } =
lazy.CustomizableUI.getPlacementOfWidget("urlbar-container");
newPos = newPos + (urlPosition > position ? "-start" : "-end");
}
this._recordWidgetChange(widgetId, newPos, reason);
} catch (e) {
console.error(e);
}
},
recordToolbarVisibility(toolbarId, newState, reason) {
if (typeof newState != "string") {
newState = newState ? "on" : "off";
}
this._recordWidgetChange(
BROWSER_UI_CONTAINER_IDS[toolbarId],
newState,
reason
);
},
_recordWidgetChange(widgetId, newPos, reason) {
// In some cases (like when add-ons are detected during startup) this gets
// called before we've reported the initial positions. Ignore such cases.
if (!this.widgetMap) {
return;
}
if (widgetId == "urlbar-container") {
// We don't report the position of the url bar, it is after nav-bar-start
// and before nav-bar-end. But moving it means the widgets around it have
// effectively moved so update those.
let position = "nav-bar-start";
let widgets = lazy.CustomizableUI.getWidgetsInArea("nav-bar");
for (let widget of widgets) {
if (!widget) {
continue;
}
if (widget.id.startsWith("customizableui-special-")) {
continue;
}
if (widget.id == "urlbar-container") {
position = "nav-bar-end";
continue;
}
// This will do nothing if the position hasn't changed.
this._recordWidgetChange(widget.id, position, reason);
}
return;
}
let oldPos = this.widgetMap.get(widgetId);
if (oldPos == newPos) {
return;
}
let action = "move";
if (!oldPos) {
action = "add";
} else if (!newPos) {
action = "remove";
}
let key = `${telemetryId(widgetId, false)}_${action}_${oldPos ?? "na"}_${
newPos ?? "na"
}_${reason}`;
Services.telemetry.keyedScalarAdd("browser.ui.customized_widgets", key, 1);
if (newPos) {
this.widgetMap.set(widgetId, newPos);
} else {
this.widgetMap.delete(widgetId);
}
},
_recordUITelemetry() {
this.widgetMap = this._buildWidgetPositions();
for (let [widgetId, position] of this.widgetMap.entries()) {
let key = `${telemetryId(widgetId, false)}_pinned_${position}`;
Services.telemetry.keyedScalarSet(
"browser.ui.toolbar_widgets",
key,
true
);
}
},
/**
* Adds listeners to a single chrome window.
*/
_registerWindow(win) {
this._addUsageListeners(win);
win.addEventListener("unload", this);
win.addEventListener("TabOpen", this, true);
win.addEventListener("TabPinned", this, true);
win.gBrowser.tabContainer.addEventListener(TAB_RESTORING_TOPIC, this);
win.gBrowser.addTabsProgressListener(URICountListener);
},
/**
* Removes listeners from a single chrome window.
*/
_unregisterWindow(win) {
win.removeEventListener("unload", this);
win.removeEventListener("TabOpen", this, true);
win.removeEventListener("TabPinned", this, true);
win.defaultView.gBrowser.tabContainer.removeEventListener(
TAB_RESTORING_TOPIC,
this
);
win.defaultView.gBrowser.removeTabsProgressListener(URICountListener);
},
/**
* Updates the tab counts.
*/
_onTabOpen() {
// Update the "tab opened" count and its maximum.
Services.telemetry.scalarAdd(TAB_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
// In the case of opening multiple tabs at once, avoid enumerating all open
// tabs and windows each time a tab opens.
this._onTabsOpenedTask.disarm();
this._onTabsOpenedTask.arm();
},
/**
* Update tab counts after opening multiple tabs.
*/
_onTabsOpened() {
const { tabCount, loadedTabCount } = getOpenTabsAndWinsCounts();
Services.telemetry.scalarSetMaximum(MAX_TAB_COUNT_SCALAR_NAME, tabCount);
this._recordTabCounts({ tabCount, loadedTabCount });
},
_onTabPinned() {
const pinnedTabs = getPinnedTabsCount();
// Update the "tab pinned" count and its maximum.
Services.telemetry.scalarAdd(TAB_PINNED_EVENT_COUNT_SCALAR_NAME, 1);
Services.telemetry.scalarSetMaximum(
MAX_TAB_PINNED_COUNT_SCALAR_NAME,
pinnedTabs
);
},
/**
* Tracks the window count and registers the listeners for the tab count.
* @param{Object} win The window object.
*/
_onWindowOpen(win) {
// Make sure to have a |nsIDOMWindow|.
if (!(win instanceof Ci.nsIDOMWindow)) {
return;
}
let onLoad = () => {
win.removeEventListener("load", onLoad);
// Ignore non browser windows.
if (
win.document.documentElement.getAttribute("windowtype") !=
"navigator:browser"
) {
return;
}
this._registerWindow(win);
// Track the window open event and check the maximum.
const counts = getOpenTabsAndWinsCounts();
Services.telemetry.scalarAdd(WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
Services.telemetry.scalarSetMaximum(
MAX_WINDOW_COUNT_SCALAR_NAME,
counts.winCount
);
// We won't receive the "TabOpen" event for the first tab within a new window.
// Account for that.
this._onTabOpen(counts);
};
win.addEventListener("load", onLoad);
},
/**
* Record telemetry about the given tab counts.
*
* Telemetry for each count will only be recorded if the value isn't
* `undefined`.
*
* @param {object} [counts] The tab counts to register with telemetry.
* @param {number} [counts.tabCount] The number of tabs in all browsers.
* @param {number} [counts.loadedTabCount] The number of loaded (i.e., not
* pending) tabs in all browsers.
*/
_recordTabCounts({ tabCount, loadedTabCount }) {
let currentTime = Date.now();
if (
tabCount !== undefined &&
currentTime > this._lastRecordTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
) {
Services.telemetry.getHistogramById("TAB_COUNT").add(tabCount);
this._lastRecordTabCount = currentTime;
}
if (
loadedTabCount !== undefined &&
currentTime >
this._lastRecordLoadedTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS
) {
Services.telemetry
.getHistogramById("LOADED_TAB_COUNT")
.add(loadedTabCount);
this._lastRecordLoadedTabCount = currentTime;
}
},
_checkProfileCountFileSchema(fileData) {
// Verifies that the schema of the file is the expected schema
if (typeof fileData.version != "string") {
throw new Error("Schema Mismatch Error: Bad type for 'version' field");
}
if (!Array.isArray(fileData.profileTelemetryIds)) {
throw new Error(
"Schema Mismatch Error: Bad type for 'profileTelemetryIds' field"
);
}
for (let profileTelemetryId of fileData.profileTelemetryIds) {
if (typeof profileTelemetryId != "string") {
throw new Error(
"Schema Mismatch Error: Bad type for an element of 'profileTelemetryIds'"
);
}
}
},
// Reports the number of Firefox profiles on this machine to telemetry.
async reportProfileCount() {
if (
AppConstants.platform != "win" ||
!AppConstants.MOZ_TELEMETRY_REPORTING
) {
// This is currently a windows-only feature.
// Also, this function writes directly to disk, without using the usual
// telemetry recording functions. So we excplicitly check if telemetry
// reporting was disabled at compile time, and we do not do anything in
// case.
return;
}
// To report only as much data as we need, we will bucket our values.
// Rather than the raw value, we will report the greatest value in the list
// below that is no larger than the raw value.
const buckets = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 1000, 10000];
// We need both the C:\ProgramData\Mozilla directory and the install
// directory hash to create the profile count file path. We can easily
// reassemble this from the update directory, which looks like:
// C:\ProgramData\Mozilla\updates\hash
// Retrieving the directory this way also ensures that the "Mozilla"
// directory is created with the correct permissions.
// The ProgramData directory, by default, grants write permissions only to
// file creators. The directory service calls GetCommonUpdateDirectory,
// which makes sure the the directory is created with user-writable
// permissions.
const updateDirectory = BrowserUsageTelemetry.Policy.getUpdateDirectory();
const hash = updateDirectory.leafName;
const profileCountFilename = "profile_count_" + hash + ".json";
let profileCountFile = updateDirectory.parent.parent;
profileCountFile.append(profileCountFilename);
let readError = false;
let fileData;
try {
let json = await BrowserUsageTelemetry.Policy.readProfileCountFile(
profileCountFile.path
);
fileData = JSON.parse(json);
BrowserUsageTelemetry._checkProfileCountFileSchema(fileData);
} catch (ex) {
// Note that since this also catches the "no such file" error, this is
// always the template that we use when writing to the file for the first
// time.
fileData = { version: "1", profileTelemetryIds: [] };
if (!(ex.name == "NotFoundError")) {
console.error(ex);
// Don't just return here on a read error. We need to send the error
// value to telemetry and we want to attempt to fix the file.
// However, we will still report an error for this ping, even if we
// fix the file. This is to prevent always sending a profile count of 1
// if, for some reason, we always get a read error but never a write
// error.
readError = true;
}
}
let writeError = false;
let currentTelemetryId =
await BrowserUsageTelemetry.Policy.getTelemetryClientId();
// Don't add our telemetry ID to the file if we've already reached the
// largest bucket. This prevents the file size from growing forever.
if (
!fileData.profileTelemetryIds.includes(currentTelemetryId) &&
fileData.profileTelemetryIds.length < Math.max(...buckets)
) {
fileData.profileTelemetryIds.push(currentTelemetryId);
try {
await BrowserUsageTelemetry.Policy.writeProfileCountFile(
profileCountFile.path,
JSON.stringify(fileData)
);
} catch (ex) {
console.error(ex);
writeError = true;
}
}
// Determine the bucketed value to report
let rawProfileCount = fileData.profileTelemetryIds.length;
let valueToReport = 0;
for (let bucket of buckets) {
if (bucket <= rawProfileCount && bucket > valueToReport) {
valueToReport = bucket;
}
}
if (readError || writeError) {
// We convey errors via a profile count of 0.
valueToReport = 0;
}
Services.telemetry.scalarSet(
"browser.engagement.profile_count",
valueToReport
);
// Manually mirror to Glean
Glean.browserEngagement.profileCount.set(valueToReport);
},
/**
* Check if this is the first run of this profile since installation,
* if so then send installation telemetry.
*
* @param {nsIFile} [dataPathOverride] Optional, full data file path, for tests.
* @param {Array<string>} [msixPackagePrefixes] Optional, list of prefixes to
consider "existing" installs when looking at installed MSIX packages.
Defaults to prefixes for builds produced in Firefox automation.
* @return {Promise}
* @resolves When the event has been recorded, or if the data file was not found.
* @rejects JavaScript exception on any failure.
*/
async reportInstallationTelemetry(
dataPathOverride,
msixPackagePrefixes = ["Mozilla.Firefox", "Mozilla.MozillaFirefox"]
) {
if (AppConstants.platform != "win") {
// This is a windows-only feature.
return;
}
const TIMESTAMP_PREF = "app.installation.timestamp";
const lastInstallTime = Services.prefs.getStringPref(TIMESTAMP_PREF, null);
const wpm = Cc["@mozilla.org/windows-package-manager;1"].createInstance(
Ci.nsIWindowsPackageManager
);
let installer_type = "";
let pfn;
try {
pfn = Services.sysinfo.getProperty("winPackageFamilyName");
} catch (e) {}
function getInstallData() {
// We only care about where _any_ other install existed - no
// need to count more than 1.
const installPaths = lazy.WindowsInstallsInfo.getInstallPaths(
1,
new Set([Services.dirsvc.get("GreBinD", Ci.nsIFile).path])
);
const msixInstalls = new Set();
// We're just going to eat all errors here -- we don't want the event
// to go unsent if we were unable to look for MSIX installs.
try {
wpm
.findUserInstalledPackages(msixPackagePrefixes)
.forEach(i => msixInstalls.add(i));
if (pfn) {
msixInstalls.delete(pfn);
}
} catch (ex) {}
return {
installPaths,
msixInstalls,
};
}
let extra = {};
if (pfn) {
if (lastInstallTime != null) {
// We've already seen this install
return;
}
// First time seeing this install, record the timestamp.
Services.prefs.setStringPref(TIMESTAMP_PREF, wpm.getInstalledDate());
let install_data = getInstallData();
installer_type = "msix";
// Build the extra event data
extra.version = AppConstants.MOZ_APP_VERSION;
extra.build_id = AppConstants.MOZ_BUILDID;
// The next few keys are static for the reasons described
// No way to detect whether or not we were installed by an admin
extra.admin_user = "false";
// Always false at the moment, because we create a new profile
// on first launch
extra.profdir_existed = "false";
// Obviously false for MSIX installs
extra.from_msi = "false";
// We have no way of knowing whether we were installed via the GUI,
// through the command line, or some Enterprise management tool.
extra.silent = "false";
// There's no way to change the install path for an MSIX package
extra.default_path = "true";
extra.install_existed = install_data.msixInstalls.has(pfn).toString();
install_data.msixInstalls.delete(pfn);
extra.other_inst = (!!install_data.installPaths.size).toString();
extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();
} else {
let dataPath = dataPathOverride;
if (!dataPath) {
dataPath = Services.dirsvc.get("GreD", Ci.nsIFile);
dataPath.append("installation_telemetry.json");
}
let dataBytes;
try {
dataBytes = await IOUtils.read(dataPath.path);
} catch (ex) {
if (ex.name == "NotFoundError") {
// Many systems will not have the data file, return silently if not found as
// there is nothing to record.
return;
}
throw ex;
}
const dataString = new TextDecoder("utf-16").decode(dataBytes);
const data = JSON.parse(dataString);
if (lastInstallTime && data.install_timestamp == lastInstallTime) {
// We've already seen this install
return;
}
// First time seeing this install, record the timestamp.
Services.prefs.setStringPref(TIMESTAMP_PREF, data.install_timestamp);
let install_data = getInstallData();
installer_type = data.installer_type;
// Installation timestamp is not intended to be sent with telemetry,
// remove it to emphasize this point.
delete data.install_timestamp;
// Build the extra event data
extra.version = data.version;
extra.build_id = data.build_id;
extra.admin_user = data.admin_user.toString();
extra.install_existed = data.install_existed.toString();
extra.profdir_existed = data.profdir_existed.toString();
extra.other_inst = (!!install_data.installPaths.size).toString();
extra.other_msix_inst = (!!install_data.msixInstalls.size).toString();
if (data.installer_type == "full") {
extra.silent = data.silent.toString();
extra.from_msi = data.from_msi.toString();
extra.default_path = data.default_path.toString();
}
}
// Record the event
Services.telemetry.setEventRecordingEnabled("installation", true);
Services.telemetry.recordEvent(
"installation",
"first_seen",
installer_type,
null,
extra
);
},
};
// Used by nsIBrowserUsage
export function getUniqueDomainsVisitedInPast24Hours() {
return URICountListener.uniqueDomainsVisitedInPast24Hours;
}