gecko-dev/browser/modules/BrowserUsageTelemetry.jsm

752 строки
25 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",
"URICountListener",
"URLBAR_SELECTED_RESULT_TYPES",
"URLBAR_SELECTED_RESULT_METHODS",
"MINIMUM_TAB_COUNT_INTERVAL_MS",
];
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm", null);
XPCOMUtils.defineLazyModuleGetters(this, {
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
SearchTelemetry: "resource:///modules/SearchTelemetry.jsm",
Services: "resource://gre/modules/Services.jsm",
setTimeout: "resource://gre/modules/Timer.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";
const AUTOCOMPLETE_ENTER_TEXT_TOPIC = "autocomplete-did-enter-text";
// 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 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",
"urlbar",
"webextension",
];
const KNOWN_ONEOFF_SOURCES = [
"oneoff-urlbar",
"oneoff-searchbar",
"unknown", // Edge case: this is the searchbar (see bug 1195733 comment 7).
];
/**
* The buckets used for logging telemetry to the FX_URLBAR_SELECTED_RESULT_TYPE
* histogram.
*/
const URLBAR_SELECTED_RESULT_TYPES = {
autofill: 0,
bookmark: 1,
history: 2,
keyword: 3,
searchengine: 4,
searchsuggestion: 5,
switchtab: 6,
tag: 7,
visiturl: 8,
remotetab: 9,
extension: 10,
"preloaded-top-site": 11,
};
/**
* This maps the categories used by the FX_URLBAR_SELECTED_RESULT_METHOD and
* FX_SEARCHBAR_SELECTED_RESULT_METHOD histograms to their indexes in the
* `labels` array. This only needs to be used by tests that need to map from
* category names to indexes in histogram snapshots. Actual app code can use
* these category names directly when they add to a histogram.
*/
const URLBAR_SELECTED_RESULT_METHODS = {
enter: 0,
enterSelection: 1,
click: 2,
arrowEnterSelection: 3,
tabEnterSelection: 4,
rightClickEnter: 5,
};
const MINIMUM_TAB_COUNT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, in ms
function getOpenTabsAndWinsCounts() {
let tabCount = 0;
let winCount = 0;
for (let win of Services.wm.getEnumerator("navigator:browser")) {
winCount++;
tabCount += win.gBrowser.tabs.length;
}
return { tabCount, winCount };
}
function getTabCount() {
return getOpenTabsAndWinsCounts().tabCount;
}
function getSearchEngineId(engine) {
if (engine) {
if (engine.identifier) {
return engine.identifier;
}
if (engine.name) {
return "other-" + engine.name;
}
}
return "other";
}
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(),
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) {
// 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._recordTabCount();
// 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) {
setTimeout(() => {
this._domain24hrSet.delete(baseDomain);
}, gRecentVisitedOriginsExpiry * 1000);
}
},
/**
* 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._domain24hrSet.clear();
},
QueryInterface: ChromeUtils.generateQI([Ci.nsIWebProgressListener,
Ci.nsISupportsWeakReference]),
};
let urlbarListener = {
// This is needed for recordUrlbarSelectedResultMethod().
selectedIndex: -1,
init() {
Services.obs.addObserver(this, AUTOCOMPLETE_ENTER_TEXT_TOPIC, true);
},
uninit() {
Services.obs.removeObserver(this, AUTOCOMPLETE_ENTER_TEXT_TOPIC);
},
observe(subject, topic, data) {
switch (topic) {
case AUTOCOMPLETE_ENTER_TEXT_TOPIC:
this._handleURLBarTelemetry(subject.QueryInterface(Ci.nsIAutoCompleteInput));
break;
}
},
/**
* Used to log telemetry when the user enters text in the urlbar.
*
* @param {nsIAutoCompleteInput} input The autocomplete element where the
* text was entered.
*/
_handleURLBarTelemetry(input) {
if (!input || input.id != "urlbar") {
return;
}
if (input.inPrivateContext || input.popup.selectedIndex < 0) {
this.selectedIndex = -1;
return;
}
// Except for the history popup, the urlbar always has a selection. The
// first result at index 0 is the "heuristic" result that indicates what
// will happen when you press the Enter key. Treat it as no selection.
this.selectedIndex =
input.popup.selectedIndex > 0 || !input.popup._isFirstResultHeuristic ?
input.popup.selectedIndex :
-1;
let controller =
input.popup.view.QueryInterface(Ci.nsIAutoCompleteController);
let idx = input.popup.selectedIndex;
let value = controller.getValueAt(idx);
let action = input._parseActionUrl(value);
let actionType;
if (action) {
actionType =
action.type == "searchengine" && action.params.searchSuggestion ?
"searchsuggestion" :
action.type;
}
if (!actionType) {
let styles = new Set(controller.getStyleAt(idx).split(/\s+/));
let style = ["preloaded-top-site", "autofill", "tag", "bookmark"].find(s => styles.has(s));
actionType = style || "history";
}
Services.telemetry
.getHistogramById("FX_URLBAR_SELECTED_RESULT_INDEX")
.add(idx);
// You can add values but don't change any of the existing values.
// Otherwise you'll break our data.
if (actionType in URLBAR_SELECTED_RESULT_TYPES) {
Services.telemetry
.getHistogramById("FX_URLBAR_SELECTED_RESULT_TYPE")
.add(URLBAR_SELECTED_RESULT_TYPES[actionType]);
Services.telemetry
.getKeyedHistogramById("FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE")
.add(actionType, idx);
} else {
Cu.reportError("Unknown FX_URLBAR_SELECTED_RESULT_TYPE type: " +
actionType);
}
},
QueryInterface: ChromeUtils.generateQI([Ci.nsIObserver,
Ci.nsISupportsWeakReference]),
};
let BrowserUsageTelemetry = {
_inited: false,
init() {
this._lastRecordTabCount = 0;
urlbarListener.init();
this._setupAfterRestore();
this._inited = true;
},
/**
* 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([Ci.nsIObserver,
Ci.nsISupportsWeakReference]),
uninit() {
if (!this._inited) {
return;
}
Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC);
Services.obs.removeObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC);
urlbarListener.uninit();
},
observe(subject, topic, data) {
switch (topic) {
case DOMWINDOW_OPENED_TOPIC:
this._onWindowOpen(subject);
break;
case TELEMETRY_SUBSESSIONSPLIT_TOPIC:
this.afterSubsessionSplit();
break;
}
},
handleEvent(event) {
switch (event.type) {
case "TabOpen":
this._onTabOpen();
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);
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 {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 isOneOff = !!details.isOneOff;
const countId = getSearchEngineId(engine) + "." + source;
if (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)) {
Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").add(countId);
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);
}
let histogram = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
histogram.add(countId);
if (details.alias &&
engine.wrappedJSObject._internalAliases.includes(details.alias)) {
let aliasCountId = getSearchEngineId(engine) + ".alias";
histogram.add(aliasCountId);
}
}
// Dispatch the search signal to other handlers.
this._handleSearchAction(engine, source, details);
},
_recordSearch(engine, source, action = null) {
let scalarKey = action ? "search_" + action : "search";
Services.telemetry.keyedScalarAdd("browser.engagement.navigation." + source,
scalarKey, 1);
Services.telemetry.recordEvent("navigation", "search", source, action,
{ engine: getSearchEngineId(engine) });
},
_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 "abouthome":
this._recordSearch(engine, "about_home", "enter");
break;
case "newtab":
this._recordSearch(engine, "about_newtab", "enter");
break;
case "contextmenu":
case "webextension":
this._recordSearch(engine, 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, sourceName, "oneoff");
return;
}
// The search was not a one-off. It was a search with the default search engine.
if (details.isSuggestion) {
// It came from a suggested search, so count it as such.
this._recordSearch(engine, sourceName, "suggestion");
return;
} else if (details.alias) {
// This one came from a search that used an alias.
this._recordSearch(engine, sourceName, "alias");
return;
}
// The search signal was generated by typing something and pressing enter.
this._recordSearch(engine, sourceName, "enter");
},
/**
* Records the method by which the user selected a urlbar result.
*
* @param {Event} event
* The event that triggered the 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, userSelectionBehavior = "none") {
// The reason this method relies on urlbarListener instead of having the
// caller pass in an index is that by the time the urlbar handles a
// selection, the selection in its popup has been cleared, so it's not easy
// to tell which popup index was selected. Fortunately this file already
// has urlbarListener, which gets notified of selections in the urlbar
// before the popup selection is cleared, so just use that.
this._recordUrlOrSearchbarSelectedResultMethod(
event, urlbarListener.selectedIndex,
"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) {
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);
},
/**
* Adds listeners to a single chrome window.
*/
_registerWindow(win) {
win.addEventListener("unload", this);
win.addEventListener("TabOpen", 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.defaultView.gBrowser.tabContainer.removeEventListener(TAB_RESTORING_TOPIC, this);
win.defaultView.gBrowser.removeTabsProgressListener(URICountListener);
},
/**
* Updates the tab counts.
* @param {Number} [newTabCount=0] The count of the opened tabs across all windows. This
* is computed manually if not provided.
*/
_onTabOpen(tabCount = 0) {
// Use the provided tab count if available. Otherwise, go on and compute it.
tabCount = tabCount || getOpenTabsAndWinsCounts().tabCount;
// 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._recordTabCount(tabCount);
},
/**
* 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.tabCount);
};
win.addEventListener("load", onLoad);
},
_recordTabCount(tabCount) {
let currentTime = Date.now();
if (currentTime > this._lastRecordTabCount + MINIMUM_TAB_COUNT_INTERVAL_MS) {
if (tabCount === undefined) {
tabCount = getTabCount();
}
Services.telemetry.getHistogramById("TAB_COUNT").add(tabCount);
this._lastRecordTabCount = currentTime;
}
},
};