gecko-dev/browser/modules/BrowserUsageTelemetry.jsm
Jared Wein 3d2a8005c8 Bug 727668 - Show bookmarks toolbar on the new tab page. r=Gijs,fluent-reviewers,marionette-reviewers,whimboo
This adds the ability to force the bookmarks toolbar to appear on all pages. The checkbox in the toolbar context menu will reflect if the toolbar will appear outside of the newtab page. The toolbar will always appear on the newtab page. Profiles that already had the toolbar showing will have a migration to keep their experience unchanged.

Differential Revision: https://phabricator.services.mozilla.com/D89222
2020-10-26 02:32:07 +00:00

1548 lines
48 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/. */
"use strict";
var EXPORTED_SYMBOLS = [
"BrowserUsageTelemetry",
"getUniqueDomainsVisitedInPast24Hours",
"URICountListener",
"MINIMUM_TAB_COUNT_INTERVAL_MS",
];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
AppConstants: "resource://gre/modules/AppConstants.jsm",
ClientID: "resource://gre/modules/ClientID.jsm",
BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
CustomizableUI: "resource:///modules/CustomizableUI.jsm",
PageActions: "resource:///modules/PageActions.jsm",
PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
SearchTelemetry: "resource:///modules/SearchTelemetry.jsm",
Services: "resource://gre/modules/Services.jsm",
setTimeout: "resource://gre/modules/Timer.jsm",
clearTimeout: "resource://gre/modules/Timer.jsm",
UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
});
// This pref is in seconds!
XPCOMUtils.defineLazyPreferenceGetter(
this,
"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";
// A list of known search origins.
const KNOWN_SEARCH_SOURCES = [
"abouthome",
"contextmenu",
"newtab",
"searchbar",
"system",
"urlbar",
"urlbar-searchmode",
"webextension",
];
const KNOWN_ONEOFF_SOURCES = [
"oneoff-urlbar",
"oneoff-searchbar",
"unknown", // Edge case: this is the searchbar (see bug 1195733 comment 7).
];
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",
// This should appear last as some of the above are inside the nav bar.
"nav-bar": "nav-bar",
};
// A list of the expected panes in about:preferences
const PREFERENCES_PANES = [
"paneHome",
"paneGeneral",
"panePrivacy",
"paneSearch",
"paneSearchResults",
"paneSync",
"paneContainers",
"paneExperimental",
];
const IGNORABLE_EVENTS = new WeakMap();
const KNOWN_ADDONS = [];
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 = 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;
}
function shouldRecordSearchCount(tabbrowser) {
return (
!PrivateBrowsingUtils.isWindowPrivate(tabbrowser.ownerGlobal) ||
!Services.prefs.getBoolPref("browser.engagement.search_counts.pbm", false)
);
}
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);
},
onStateChange(browser, webProgress, request, stateFlags, status) {
if (
!webProgress.isTopLevel ||
!(stateFlags & Ci.nsIWebProgressListener.STATE_STOP) ||
!(stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW)
) {
return;
}
if (!(request instanceof Ci.nsIChannel) || !this.isHttpURI(request.URI)) {
return;
}
BrowserUsageTelemetry._recordSiteOriginsPerLoadedTabs();
},
onLocationChange(browser, webProgress, request, uri, flags) {
if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) {
// By default, assume we no longer need to track this tab.
SearchTelemetry.stopTrackingBrowser(browser);
}
// 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 =
!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 (
shouldRecordSearchCount(browser.getTabBrowser()) &&
!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
) {
SearchTelemetry.updateTrackingStatus(browser, uriSpec);
}
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 (gRecentVisitedOriginsExpiry) {
let timeoutId = setTimeout(() => {
this._domain24hrSet.delete(baseDomain);
this._timeouts.delete(timeoutId);
}, 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 => clearTimeout(timeoutId));
this._timeouts.clear();
this._domain24hrSet.clear();
},
QueryInterface: ChromeUtils.generateQI([
"nsIWebProgressListener",
"nsISupportsWeakReference",
]),
};
let BrowserUsageTelemetry = {
/**
* This is a policy object used to override behavior for testing.
*/
Policy: {
getTelemetryClientId: async () => ClientID.getClientID(),
getUpdateDirectory: () => Services.dirsvc.get("UpdRootD", Ci.nsIFile),
readProfileCountFile: async path => IOUtils.readUTF8(path),
writeProfileCountFile: async (path, data) =>
IOUtils.writeAtomicUTF8(path, data),
},
_inited: false,
init() {
this._lastRecordTabCount = 0;
this._lastRecordLoadedTabCount = 0;
this._lastRecordSiteOriginsPerLoadedTabs = 0;
this._setupAfterRestore();
this._inited = true;
Services.prefs.addObserver("browser.tabs.extraDragSpace", this);
Services.prefs.addObserver("browser.tabs.drawInTitlebar", this);
this._recordUITelemetry();
},
/**
* 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.extraDragSpace":
this._recordWidgetChange(
"drag-space",
Services.prefs.getBoolPref("browser.tabs.extraDragSpace")
? "on"
: "off",
"pref"
);
break;
case "browser.tabs.drawInTitlebar":
this._recordWidgetChange(
"titlebar",
Services.prefs.getBoolPref("browser.tabs.drawInTitlebar")
? "off"
: "on",
"pref"
);
break;
}
break;
}
},
handleEvent(event) {
switch (event.type) {
case "TabOpen":
this._onTabOpen(getOpenTabsAndWinsCounts());
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;
}
},
/**
* The main entry point for recording search related Telemetry. This includes
* search counts and engagement measurements.
*
* Telemetry records only search counts per engine and action origin, but
* nothing pertaining to the search contents themselves.
*
* @param {tabbrowser} tabbrowser
* The tabbrowser where the search was loaded.
* @param {nsISearchEngine} engine
* The engine handling the search.
* @param {String} source
* Where the search originated from. See KNOWN_SEARCH_SOURCES for allowed
* values.
* @param {Object} [details] Options object.
* @param {Boolean} [details.isOneOff=false]
* true if this event was generated by a one-off search.
* @param {Boolean} [details.isSuggestion=false]
* true if this event was generated by a suggested search.
* @param {Boolean} [details.isFormHistory=false]
* true if this event was generated by a form history result.
* @param {String} [details.alias=null]
* The search engine alias used in the search, if any.
* @param {Object} [details.type=null]
* The object describing the event that triggered the search.
* @throws if source is not in the known sources list.
*/
recordSearch(tabbrowser, engine, source, details = {}) {
if (!shouldRecordSearchCount(tabbrowser)) {
return;
}
const countIdPrefix = `${engine.telemetryId}.`;
const countIdSource = countIdPrefix + source;
let histogram = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
if (details.isOneOff) {
if (!KNOWN_ONEOFF_SOURCES.includes(source)) {
// Silently drop the error if this bogus call
// came from 'urlbar' or 'searchbar'. They're
// calling |recordSearch| twice from two different
// code paths because they want to record the search
// in SEARCH_COUNTS.
if (["urlbar", "searchbar"].includes(source)) {
histogram.add(countIdSource);
PartnerLinkAttribution.makeSearchEngineRequest(
engine,
details.url
).catch(Cu.reportError);
return;
}
throw new Error("Unknown source for one-off search: " + source);
}
} else {
if (!KNOWN_SEARCH_SOURCES.includes(source)) {
throw new Error("Unknown source for search: " + source);
}
if (
details.alias &&
engine.isAppProvided &&
engine.aliases.includes(details.alias)
) {
// This is a keyword search using an AppProvided engine.
// Record the source as "alias", not "urlbar".
histogram.add(countIdPrefix + "alias");
} else {
histogram.add(countIdSource);
}
}
// Dispatch the search signal to other handlers.
this._handleSearchAction(engine, source, details);
},
_recordSearch(engine, url, source, action = null) {
// The one-off buttons are logged in two places, if we hit here with the
// action as oneoff and no url, then we are hitting the attribution case
// in `recordSearch` above. Really this needs re-architecturing so we
// do not have two distinct calls to `recordSearch` for one-offs
// (see bug 1662553).
if (!(action == "oneoff" && !url)) {
PartnerLinkAttribution.makeSearchEngineRequest(engine, url).catch(
Cu.reportError
);
}
let scalarKey = action ? "search_" + action : "search";
Services.telemetry.keyedScalarAdd(
"browser.engagement.navigation." + source,
scalarKey,
1
);
Services.telemetry.recordEvent("navigation", "search", source, action, {
engine: engine.telemetryId,
});
},
/**
* Records entry into the Urlbar's search mode.
*
* Telemetry records only which search mode is entered and how it was entered.
* It does not record anything pertaining to searches made within search mode.
* @param {object} searchMode
* A search mode object. See UrlbarInput.setSearchMode documentation for
* details.
*/
recordSearchMode(searchMode) {
// Search mode preview is not search mode. Recording it would just create
// noise.
if (searchMode.isPreview) {
return;
}
let scalarKey;
if (searchMode.engineName) {
let engine = Services.search.getEngineByName(searchMode.engineName);
let resultDomain = engine.getResultDomain();
// For built-in engines, sanitize the data in a few special cases to make
// analysis easier.
if (!engine.isAppProvided) {
scalarKey = "other";
} else if (resultDomain.includes("amazon.")) {
// Group all the localized Amazon sites together.
scalarKey = "Amazon";
} else if (resultDomain.endsWith("wikipedia.org")) {
// Group all the localized Wikipedia sites together.
scalarKey = "Wikipedia";
} else {
scalarKey = searchMode.engineName;
}
} else if (searchMode.source) {
scalarKey = UrlbarUtils.getResultSourceName(searchMode.source) || "other";
}
Services.telemetry.keyedScalarAdd(
"urlbar.searchmode." + searchMode.entry,
scalarKey,
1
);
},
_handleSearchAction(engine, source, details) {
switch (source) {
case "urlbar":
case "oneoff-urlbar":
case "searchbar":
case "oneoff-searchbar":
case "unknown": // Edge case: this is the searchbar (see bug 1195733 comment 7).
this._handleSearchAndUrlbar(engine, source, details);
break;
case "urlbar-searchmode":
this._handleSearchAndUrlbar(engine, "urlbar_searchmode", details);
break;
case "abouthome":
this._recordSearch(engine, details.url, "about_home", "enter");
break;
case "newtab":
this._recordSearch(engine, details.url, "about_newtab", "enter");
break;
case "contextmenu":
case "system":
case "webextension":
this._recordSearch(engine, details.url, source);
break;
}
},
/**
* This function handles the "urlbar", "urlbar-oneoff", "searchbar" and
* "searchbar-oneoff" sources.
*/
_handleSearchAndUrlbar(engine, source, details) {
// We want "urlbar" and "urlbar-oneoff" (and similar cases) to go in the same
// scalar, but in a different key.
// When using one-offs in the searchbar we get an "unknown" source. See bug
// 1195733 comment 7 for the context. Fix-up the label here.
const sourceName =
source === "unknown" ? "searchbar" : source.replace("oneoff-", "");
const isOneOff = !!details.isOneOff;
if (isOneOff) {
// We will receive a signal from the "urlbar"/"searchbar" even when the
// search came from "oneoff-urlbar". That's because both signals
// are propagated from search.xml. Skip it if that's the case.
// Moreover, we skip the "unknown" source that comes from the searchbar
// when performing searches from the default search engine. See bug 1195733
// comment 7 for context.
if (["urlbar", "searchbar", "unknown"].includes(source)) {
return;
}
// If that's a legit one-off search signal, record it using the relative key.
this._recordSearch(engine, details.url, sourceName, "oneoff");
return;
}
// The search was not a one-off. It was a search with the default search engine.
if (details.isFormHistory) {
// It came from a form history result.
this._recordSearch(engine, details.url, sourceName, "formhistory");
return;
} else if (details.isSuggestion) {
// It came from a suggested search, so count it as such.
this._recordSearch(engine, details.url, sourceName, "suggestion");
return;
} else if (details.alias) {
// This one came from a search that used an alias.
this._recordSearch(engine, details.url, sourceName, "alias");
return;
}
// The search signal was generated by typing something and pressing enter.
this._recordSearch(engine, details.url, sourceName, "enter");
},
/**
* Records the method by which the user selected a result from the urlbar.
*
* @param {Event} event
* The event that triggered the selection.
* @param {number} index
* The index that the user chose in the popup, or -1 if there wasn't a
* selection.
* @param {string} userSelectionBehavior
* How the user cycled through results before picking the current match.
* Could be one of "tab", "arrow" or "none".
*/
recordUrlbarSelectedResultMethod(
event,
index,
userSelectionBehavior = "none"
) {
this._recordUrlOrSearchbarSelectedResultMethod(
event,
index,
"FX_URLBAR_SELECTED_RESULT_METHOD",
userSelectionBehavior
);
},
/**
* Records the method by which the user selected a searchbar result.
*
* @param {Event} event
* The event that triggered the selection.
* @param {number} highlightedIndex
* The index that the user chose in the popup, or -1 if there wasn't a
* selection.
*/
recordSearchbarSelectedResultMethod(event, highlightedIndex) {
this._recordUrlOrSearchbarSelectedResultMethod(
event,
highlightedIndex,
"FX_SEARCHBAR_SELECTED_RESULT_METHOD",
"none"
);
},
_recordUrlOrSearchbarSelectedResultMethod(
event,
highlightedIndex,
histogramID,
userSelectionBehavior
) {
// If the contents of the histogram are changed then
// `UrlbarTestUtils.SELECTED_RESULT_METHODS` should also be updated.
let histogram = Services.telemetry.getHistogramById(histogramID);
// command events are from the one-off context menu. Treat them as clicks.
// Note that we don't care about MouseEvent subclasses here, since
// those are not clicks.
let isClick =
event &&
(ChromeUtils.getClassName(event) == "MouseEvent" ||
event.type == "command");
let category;
if (isClick) {
category = "click";
} else if (highlightedIndex >= 0) {
switch (userSelectionBehavior) {
case "tab":
category = "tabEnterSelection";
break;
case "arrow":
category = "arrowEnterSelection";
break;
case "rightClick":
// Selected by right mouse button.
category = "rightClickEnter";
break;
default:
category = "enterSelection";
}
} else {
category = "enter";
}
histogram.add(category);
},
/**
* 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"
);
value = (value == "never").toString();
} else {
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");
widgetMap.set(
"drag-space",
Services.prefs.getBoolPref("browser.tabs.extraDragSpace") ? "on" : "off"
);
// Drawing in the titlebar means not showing the titlebar, hence the negation.
widgetMap.set(
"titlebar",
Services.prefs.getBoolPref("browser.tabs.drawInTitlebar", true)
? "off"
: "on"
);
for (let area of 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 = 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 = 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 CustomizableUI.areas) {
if (node.closest(`#${CSS.escape(area)}`)) {
for (let widget of 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)) {
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);
},
_getWidgetContainer(node) {
if (node.localName == "key") {
return "keyboard";
}
if (node.ownerDocument.URL == AppConstants.BROWSER_CHROME_URL) {
// 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];
}
}
} else if (node.ownerDocument.URL.startsWith("about:preferences")) {
// 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 types = [event.type];
let sourceEvent = event;
while (sourceEvent.sourceEvent) {
sourceEvent = sourceEvent.sourceEvent;
types.push(sourceEvent.type);
}
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;
while (!UI_TARGET_ELEMENTS.includes(node.localName)) {
node = node.parentNode;
if (!node) {
// A click on a space or label or something we're not interested in.
return;
}
}
let item = this._getWidgetID(node);
let source = this._getWidgetContainer(node);
if (item && source) {
let scalar = `browser.ui.interaction.${source.replace("-", "_")}`;
Services.telemetry.keyedScalarAdd(scalar, telemetryId(item), 1);
}
},
/**
* 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 } = CustomizableUI.getPlacementOfWidget(widgetId);
let { position: urlPosition } = 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 = 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.
* @param {Object} [counts] The counts returned by `getOpenTabsAndWindowCounts`.
*/
_onTabOpen({ tabCount, loadedTabCount }) {
// Update the "tab opened" count and its maximum.
Services.telemetry.scalarAdd(TAB_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
Services.telemetry.scalarSetMaximum(MAX_TAB_COUNT_SCALAR_NAME, tabCount);
this._recordTabCounts({ tabCount, loadedTabCount });
},
_onTabPinned(target) {
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") {
// This is currently a windows-only feature.
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")) {
Cu.reportError(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) {
Cu.reportError(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
);
},
/**
* Record telemetry about the ratio of number of site origins per number of
* loaded tabs.
*
* This will only record the telemetry if it has been five minutes since the
* last recording.
*/
_recordSiteOriginsPerLoadedTabs() {
const currentTime = Date.now();
if (
currentTime >
this._lastRecordSiteOriginsPerLoadedTabs + MINIMUM_TAB_COUNT_INTERVAL_MS
) {
this._lastRecordSiteOriginsPerLoadedTabs = currentTime;
// If this is the first load, we discard it because it is likely just the
// browser opening for the first time.
if (this._lastRecordSiteOriginsPerLoadedTabs === 0) {
return;
}
const { loadedTabCount } = getOpenTabsAndWinsCounts();
const siteOrigins = BrowserUtils.computeSiteOriginCount(
Services.wm.getEnumerator("navigator:browser"),
false
);
const histogramId = this._getSiteOriginHistogram(loadedTabCount);
// Telemetry doesn't support float values.
Services.telemetry
.getHistogramById(histogramId)
.add(Math.trunc((100 * siteOrigins) / loadedTabCount));
}
},
_siteOriginHistogramIds: [
[1, 1, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_1"],
[2, 4, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_2_4"],
[5, 9, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_5_9"],
[10, 14, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_10_14"],
[15, 19, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_15_19"],
[20, 24, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_20_24"],
[25, 29, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_25_29"],
[31, 34, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_30_34"],
[35, 39, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_35_39"],
[40, 44, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_40_44"],
[45, 49, "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_45_49"],
],
/**
* Return the appropriate histogram ID for the given loaded tab count.
*
* Unique site origin telemetry is split across several histograms so that it
* can approximate a unique site origin vs loaded tab count curve.
*
* @param {number} [loadedTabCount] The number of loaded tabs.
*/
_getSiteOriginHistogram(loadedTabCount) {
for (const [min, max, histogramId] of this._siteOriginHistogramIds) {
if (min <= loadedTabCount && loadedTabCount <= max) {
return histogramId;
}
}
return "FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_LOADED_TABS_50_PLUS";
},
};
// Used by nsIBrowserUsage
function getUniqueDomainsVisitedInPast24Hours() {
return URICountListener.uniqueDomainsVisitedInPast24Hours;
}