gecko-dev/browser/components/newtab/lib/TelemetryFeed.jsm

673 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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/. */
/* globals Services */
"use strict";
const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
const {actionTypes: at, actionUtils: au} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
const {Prefs} = ChromeUtils.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm");
const {classifySite} = ChromeUtils.import("resource://activity-stream/lib/SiteClassifier.jsm");
ChromeUtils.defineModuleGetter(this, "ASRouterPreferences",
"resource://activity-stream/lib/ASRouterPreferences.jsm");
ChromeUtils.defineModuleGetter(this, "perfService",
"resource://activity-stream/common/PerfService.jsm");
ChromeUtils.defineModuleGetter(this, "PingCentre",
"resource:///modules/PingCentre.jsm");
ChromeUtils.defineModuleGetter(this, "UTEventReporting",
"resource://activity-stream/lib/UTEventReporting.jsm");
ChromeUtils.defineModuleGetter(this, "UpdateUtils",
"resource://gre/modules/UpdateUtils.jsm");
ChromeUtils.defineModuleGetter(this, "HomePage",
"resource:///modules/HomePage.jsm");
ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
"resource://gre/modules/ExtensionSettingsStore.jsm");
XPCOMUtils.defineLazyServiceGetters(this, {
gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
aboutNewTabService: ["@mozilla.org/browser/aboutnewtab-service;1", "nsIAboutNewTabService"],
});
const ACTIVITY_STREAM_ID = "activity-stream";
const ACTIVITY_STREAM_ENDPOINT_PREF = "browser.newtabpage.activity-stream.telemetry.ping.endpoint";
const ACTIVITY_STREAM_ROUTER_ID = "activity-stream-router";
// This is a mapping table between the user preferences and its encoding code
const USER_PREFS_ENCODING = {
"showSearch": 1 << 0,
"feeds.topsites": 1 << 1,
"feeds.section.topstories": 1 << 2,
"feeds.section.highlights": 1 << 3,
"feeds.snippets": 1 << 4,
"showSponsored": 1 << 5,
};
const PREF_IMPRESSION_ID = "impressionId";
const TELEMETRY_PREF = "telemetry";
const EVENTS_TELEMETRY_PREF = "telemetry.ut.events";
this.TelemetryFeed = class TelemetryFeed {
constructor(options) {
this.sessions = new Map();
this._prefs = new Prefs();
this._impressionId = this.getOrCreateImpressionId();
this.telemetryEnabled = this._prefs.get(TELEMETRY_PREF);
this.eventTelemetryEnabled = this._prefs.get(EVENTS_TELEMETRY_PREF);
this._aboutHomeSeen = false;
this._onTelemetryPrefChange = this._onTelemetryPrefChange.bind(this);
this._prefs.observe(TELEMETRY_PREF, this._onTelemetryPrefChange);
this._onEventsTelemetryPrefChange = this._onEventsTelemetryPrefChange.bind(this);
this._prefs.observe(EVENTS_TELEMETRY_PREF, this._onEventsTelemetryPrefChange);
this._classifySite = classifySite;
}
init() {
Services.obs.addObserver(this.browserOpenNewtabStart, "browser-open-newtab-start");
}
getOrCreateImpressionId() {
let impressionId = this._prefs.get(PREF_IMPRESSION_ID);
if (!impressionId) {
impressionId = String(gUUIDGenerator.generateUUID());
this._prefs.set(PREF_IMPRESSION_ID, impressionId);
}
return impressionId;
}
browserOpenNewtabStart() {
perfService.mark("browser-open-newtab-start");
}
setLoadTriggerInfo(port) {
// XXX note that there is a race condition here; we're assuming that no
// other tab will be interleaving calls to browserOpenNewtabStart and
// when at.NEW_TAB_INIT gets triggered by RemotePages and calls this
// method. For manually created windows, it's hard to imagine us hitting
// this race condition.
//
// However, for session restore, where multiple windows with multiple tabs
// might be restored much closer together in time, it's somewhat less hard,
// though it should still be pretty rare.
//
// The fix to this would be making all of the load-trigger notifications
// return some data with their notifications, and somehow propagate that
// data through closures into the tab itself so that we could match them
//
// As of this writing (very early days of system add-on perf telemetry),
// the hypothesis is that hitting this race should be so rare that makes
// more sense to live with the slight data inaccuracy that it would
// introduce, rather than doing the correct but complicated thing. It may
// well be worth reexamining this hypothesis after we have more experience
// with the data.
let data_to_save;
try {
data_to_save = {
load_trigger_ts: perfService.getMostRecentAbsMarkStartByName("browser-open-newtab-start"),
load_trigger_type: "menu_plus_or_keyboard",
};
} catch (e) {
// if no mark was returned, we have nothing to save
return;
}
this.saveSessionPerfData(port, data_to_save);
}
_onTelemetryPrefChange(prefVal) {
this.telemetryEnabled = prefVal;
}
_onEventsTelemetryPrefChange(prefVal) {
this.eventTelemetryEnabled = prefVal;
}
/**
* Lazily initialize PingCentre for Activity Stream to send pings
*/
get pingCentre() {
Object.defineProperty(this, "pingCentre",
{
value: new PingCentre({
topic: ACTIVITY_STREAM_ID,
overrideEndpointPref: ACTIVITY_STREAM_ENDPOINT_PREF,
}),
});
return this.pingCentre;
}
/**
* Lazily initialize a PingCentre client for Activity Stream Router to send pings.
*
* Unlike the PingCentre client for Activity Stream, Activity Stream Router
* uses a separate client with the standard PingCentre endpoint.
*/
get pingCentreForASRouter() {
Object.defineProperty(this, "pingCentreForASRouter",
{value: new PingCentre({topic: ACTIVITY_STREAM_ROUTER_ID})});
return this.pingCentreForASRouter;
}
/**
* Lazily initialize UTEventReporting to send pings
*/
get utEvents() {
Object.defineProperty(this, "utEvents", {value: new UTEventReporting()});
return this.utEvents;
}
/**
* Get encoded user preferences, multiple prefs will be combined via bitwise OR operator
*/
get userPreferences() {
let prefs = 0;
for (const pref of Object.keys(USER_PREFS_ENCODING)) {
if (this._prefs.get(pref)) {
prefs |= USER_PREFS_ENCODING[pref];
}
}
return prefs;
}
/**
* Check if it is in the CFR experiment cohort. ASRouterPreferences lazily parses AS router pref.
*/
get isInCFRCohort() {
for (let provider of ASRouterPreferences.providers) {
if (provider.id === "cfr" && provider.enabled && provider.cohort) {
return true;
}
}
return false;
}
/**
* addSession - Start tracking a new session
*
* @param {string} id the portID of the open session
* @param {string} the URL being loaded for this session (optional)
* @return {obj} Session object
*/
addSession(id, url) {
// XXX refactor to use setLoadTriggerInfo or saveSessionPerfData
// "unexpected" will be overwritten when appropriate
let load_trigger_type = "unexpected";
let load_trigger_ts;
if (!this._aboutHomeSeen && url === "about:home") {
this._aboutHomeSeen = true;
// XXX note that this will be incorrectly set in the following cases:
// session_restore following by clicking on the toolbar button,
// or someone who has changed their default home page preference to
// something else and later clicks the toolbar. It will also be
// incorrectly unset if someone changes their "Home Page" preference to
// about:newtab.
//
// That said, the ratio of these mistakes to correct cases should
// be very small, and these issues should follow away as we implement
// the remaining load_trigger_type values for about:home in issue 3556.
//
// XXX file a bug to implement remaining about:home cases so this
// problem will go away and link to it here.
load_trigger_type = "first_window_opened";
// The real perceived trigger of first_window_opened is the OS-level
// clicking of the icon. We use perfService.timeOrigin because it's the
// earliest number on this time scale that's easy to get.; We could
// actually use 0, but maybe that could be before the browser started?
// [bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406)
// getting sorted out may help clarify. Even better, presumably, would be
// to use the process creation time for the main process, which is
// available, but somewhat harder to get. However, these are all more or
// less proxies for the same thing, so it's not clear how much the better
// numbers really matter, since we (activity stream) only control a
// relatively small amount of the code that's executing between the
// OS-click and when the first <browser> element starts loading. That
// said, it's conceivable that it could help us catch regressions in the
// number of cycles early chrome code takes to execute, but it's likely
// that there are more direct ways to measure that.
load_trigger_ts = perfService.timeOrigin;
}
const session = {
session_id: String(gUUIDGenerator.generateUUID()),
// "unknown" will be overwritten when appropriate
page: url ? url : "unknown",
perf: {
load_trigger_type,
is_preloaded: false,
is_prerendered: false,
},
};
if (load_trigger_ts) {
session.perf.load_trigger_ts = load_trigger_ts;
}
this.sessions.set(id, session);
return session;
}
/**
* endSession - Stop tracking a session
*
* @param {string} portID the portID of the session that just closed
*/
endSession(portID) {
const session = this.sessions.get(portID);
if (!session) {
// It's possible the tab was never visible – in which case, there was no user session.
return;
}
if (session.perf.visibility_event_rcvd_ts) {
session.session_duration = Math.round(perfService.absNow() - session.perf.visibility_event_rcvd_ts);
}
let sessionEndEvent = this.createSessionEndEvent(session);
this.sendEvent(sessionEndEvent);
this.sendUTEvent(sessionEndEvent, this.utEvents.sendSessionEndEvent);
this.sessions.delete(portID);
}
/**
* handlePagePrerendered - Set the session as prerendered
*
* @param {string} portID the portID of the target session
*/
handlePagePrerendered(portID) {
const session = this.sessions.get(portID);
if (!session) {
// It's possible the tab was never visible – in which case, there was no user session.
return;
}
session.perf.is_prerendered = true;
}
/**
* handleNewTabInit - Handle NEW_TAB_INIT, which creates a new session and sets the a flag
* for session.perf based on whether or not this new tab is preloaded
*
* @param {obj} action the Action object
*/
handleNewTabInit(action) {
const session = this.addSession(au.getPortIdOfSender(action), action.data.url);
session.perf.is_preloaded = action.data.browser.getAttribute("preloadedState") === "preloaded";
}
/**
* createPing - Create a ping with common properties
*
* @param {string} id The portID of the session, if a session is relevant (optional)
* @return {obj} A telemetry ping
*/
createPing(portID) {
const ping = {
addon_version: Services.appinfo.appBuildID,
locale: Services.locale.appLocaleAsLangTag,
user_prefs: this.userPreferences,
};
// If the ping is part of a user session, add session-related info
if (portID) {
const session = this.sessions.get(portID) || this.addSession(portID);
Object.assign(ping, {session_id: session.session_id});
if (session.page) {
Object.assign(ping, {page: session.page});
}
}
return ping;
}
/**
* createImpressionStats - Create a ping for an impression stats
*
* @param {ob} action The object with data to be included in the ping.
* For some user interactions.
* @return {obj} A telemetry ping
*/
createImpressionStats(action) {
return Object.assign(
this.createPing(au.getPortIdOfSender(action)),
action.data,
{
action: "activity_stream_impression_stats",
impression_id: this._impressionId,
client_id: "n/a",
session_id: "n/a",
}
);
}
createUserEvent(action) {
return Object.assign(
this.createPing(au.getPortIdOfSender(action)),
action.data,
{action: "activity_stream_user_event"}
);
}
createUndesiredEvent(action) {
return Object.assign(
this.createPing(au.getPortIdOfSender(action)),
{value: 0}, // Default value
action.data,
{action: "activity_stream_undesired_event"}
);
}
createPerformanceEvent(action) {
return Object.assign(
this.createPing(),
action.data,
{action: "activity_stream_performance_event"}
);
}
createSessionEndEvent(session) {
return Object.assign(
this.createPing(),
{
session_id: session.session_id,
page: session.page,
session_duration: session.session_duration,
action: "activity_stream_session",
perf: session.perf,
}
);
}
/**
* Create a ping for AS router event. The client_id is set to "n/a" by default,
* different component can override this by its own telemetry collection policy.
*/
createASRouterEvent(action) {
const ping = {
client_id: "n/a",
addon_version: Services.appinfo.appBuildID,
locale: Services.locale.appLocaleAsLangTag,
impression_id: this._impressionId,
};
const event = Object.assign(ping, action.data);
if (event.action === "cfr_user_event") {
return this.applyCFRPolicy(event);
} else if (event.action === "snippets_user_event") {
return this.applySnippetsPolicy(event);
} else if (event.action === "onboarding_user_event") {
return this.applyOnboardingPolicy(event);
}
return event;
}
/**
* Per Bug 1484035, CFR metrics comply with following policies:
* 1). In release, it collects impression_id, and treats bucket_id as message_id
* 2). In prerelease, it collects client_id and message_id
* 3). In shield experiments conducted in release, it collects client_id and message_id
*/
applyCFRPolicy(ping) {
if (UpdateUtils.getUpdateChannel(true) === "release" && !this.isInCFRCohort) {
ping.message_id = ping.bucket_id || "n/a";
ping.client_id = "n/a";
ping.impression_id = this._impressionId;
} else {
ping.impression_id = "n/a";
// Ping-centre client will fill in the client_id if it's not provided in the ping.
delete ping.client_id;
}
// bucket_id is no longer needed
delete ping.bucket_id;
return ping;
}
/**
* Per Bug 1485069, all the metrics for Snippets in AS router use client_id in
* all the release channels
*/
applySnippetsPolicy(ping) {
// Ping-centre client will fill in the client_id if it's not provided in the ping.
delete ping.client_id;
ping.impression_id = "n/a";
return ping;
}
/**
* Per Bug 1482134, all the metrics for Onboarding in AS router use client_id in
* all the release channels
*/
applyOnboardingPolicy(ping) {
// Ping-centre client will fill in the client_id if it's not provided in the ping.
delete ping.client_id;
ping.impression_id = "n/a";
return ping;
}
sendEvent(event_object) {
if (this.telemetryEnabled) {
this.pingCentre.sendPing(event_object,
{filter: ACTIVITY_STREAM_ID});
}
}
sendUTEvent(event_object, eventFunction) {
if (this.telemetryEnabled && this.eventTelemetryEnabled) {
eventFunction(event_object);
}
}
sendASRouterEvent(event_object) {
if (this.telemetryEnabled) {
this.pingCentreForASRouter.sendPing(event_object,
{filter: ACTIVITY_STREAM_ID});
}
}
handleImpressionStats(action) {
this.sendEvent(this.createImpressionStats(action));
}
handleUserEvent(action) {
let userEvent = this.createUserEvent(action);
this.sendEvent(userEvent);
this.sendUTEvent(userEvent, this.utEvents.sendUserEvent);
}
handleASRouterUserEvent(action) {
let event = this.createASRouterEvent(action);
this.sendASRouterEvent(event);
}
handleUndesiredEvent(action) {
this.sendEvent(this.createUndesiredEvent(action));
}
async sendPageTakeoverData() {
if (this.telemetryEnabled) {
const value = {};
let newtabAffected = false;
let homeAffected = false;
// Check whether or not about:home and about:newtab are set to a custom URL.
// If so, classify them.
if (Services.prefs.getBoolPref("browser.newtabpage.enabled") &&
aboutNewTabService.overridden &&
!aboutNewTabService.newTabURL.startsWith("moz-extension://")) {
value.newtab_url_category = await this._classifySite(aboutNewTabService.newTabURL);
newtabAffected = true;
}
// Check if the newtab page setting is controlled by an extension.
await ExtensionSettingsStore.initialize();
const newtabExtensionInfo = ExtensionSettingsStore.getSetting("url_overrides", "newTabURL");
if (newtabExtensionInfo && newtabExtensionInfo.id) {
value.newtab_extension_id = newtabExtensionInfo.id;
newtabAffected = true;
}
const homePageURL = HomePage.get();
if (!["about:home", "about:blank"].includes(homePageURL) &&
!homePageURL.startsWith("moz-extension://")) {
value.home_url_category = await this._classifySite(homePageURL);
homeAffected = true;
}
const homeExtensionInfo = ExtensionSettingsStore.getSetting("prefs", "homepage_override");
if (homeExtensionInfo && homeExtensionInfo.id) {
value.home_extension_id = homeExtensionInfo.id;
homeAffected = true;
}
let page;
if (newtabAffected && homeAffected) {
page = "both";
} else if (newtabAffected) {
page = "about:newtab";
} else if (homeAffected) {
page = "about:home";
}
if (page) {
const event = Object.assign(
this.createPing(),
{
action: "activity_stream_user_event",
event: "PAGE_TAKEOVER_DATA",
value,
page,
session_id: "n/a",
},
);
this.sendEvent(event);
}
}
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
this.sendPageTakeoverData();
break;
case at.NEW_TAB_INIT:
this.handleNewTabInit(action);
break;
case at.NEW_TAB_UNLOAD:
this.endSession(au.getPortIdOfSender(action));
break;
case at.PAGE_PRERENDERED:
this.handlePagePrerendered(au.getPortIdOfSender(action));
break;
case at.SAVE_SESSION_PERF_DATA:
this.saveSessionPerfData(au.getPortIdOfSender(action), action.data);
break;
case at.TELEMETRY_IMPRESSION_STATS:
this.handleImpressionStats(action);
break;
case at.TELEMETRY_UNDESIRED_EVENT:
this.handleUndesiredEvent(action);
break;
case at.TELEMETRY_USER_EVENT:
this.handleUserEvent(action);
break;
case at.AS_ROUTER_TELEMETRY_USER_EVENT:
this.handleASRouterUserEvent(action);
break;
case at.TELEMETRY_PERFORMANCE_EVENT:
this.sendEvent(this.createPerformanceEvent(action));
break;
case at.UNINIT:
this.uninit();
break;
}
}
/**
* Take all enumerable members of the data object and merge them into
* the session.perf object for the given port, so that it is sent to the
* server when the session ends. All members of the data object should
* be valid values of the perf object, as defined in pings.js and the
* data*.md documentation.
*
* @note Any existing keys with the same names already in the
* session perf object will be overwritten by values passed in here.
*
* @param {String} port The session with which this is associated
* @param {Object} data The perf data to be
*/
saveSessionPerfData(port, data) {
// XXX should use try/catch and send a bad state indicator if this
// get blows up.
let session = this.sessions.get(port);
// XXX Partial workaround for #3118; avoids the worst incorrect associations
// of times with browsers, by associating the load trigger with the
// visibility event as the user is most likely associating the trigger to
// the tab just shown. This helps avoid associating with a preloaded
// browser as those don't get the event until shown. Better fix for more
// cases forthcoming.
//
// XXX the about:home check (and the corresponding test) should go away
// once the load_trigger stuff in addSession is refactored into
// setLoadTriggerInfo.
//
if (data.visibility_event_rcvd_ts && session.page !== "about:home") {
this.setLoadTriggerInfo(port);
}
let timestamp = data.topsites_first_painted_ts;
if (timestamp &&
session.page === "about:home" &&
!HomePage.overridden &&
Services.prefs.getIntPref("browser.startup.page") === 1) {
aboutNewTabService.maybeRecordTopsitesPainted(timestamp);
}
Object.assign(session.perf, data);
}
uninit() {
try {
Services.obs.removeObserver(this.browserOpenNewtabStart,
"browser-open-newtab-start");
} catch (e) {
// Operation can fail when uninit is called before
// init has finished setting up the observer
}
// Only uninit if the getter has initialized it
if (Object.prototype.hasOwnProperty.call(this, "pingCentre")) {
this.pingCentre.uninit();
}
if (Object.prototype.hasOwnProperty.call(this, "utEvents")) {
this.utEvents.uninit();
}
if (Object.prototype.hasOwnProperty.call(this, "pingCentreForASRouter")) {
this.pingCentreForASRouter.uninit();
}
try {
this._prefs.ignore(TELEMETRY_PREF, this._onTelemetryPrefChange);
this._prefs.ignore(EVENTS_TELEMETRY_PREF, this._onEventsTelemetryPrefChange);
} catch (e) {
Cu.reportError(e);
}
// TODO: Send any unfinished sessions
}
};
const EXPORTED_SYMBOLS = [
"TelemetryFeed",
"USER_PREFS_ENCODING",
"PREF_IMPRESSION_ID",
"TELEMETRY_PREF",
"EVENTS_TELEMETRY_PREF",
];