firefox-desktop/browser/components/newtab/lib/TelemetryFeed.sys.mjs
2024-10-24 20:51:07 +02:00

1576 lines
49 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/. */
// We use importESModule here instead of static import so that the Karma test
// environment won't choke on these module. This is because the Karma test
// environment already stubs out XPCOMUtils, AppConstants and RemoteSettings,
// and overrides importESModule to be a no-op (which can't be done for a static
// import statement). MESSAGE_TYPE_HASH / msg isn't something that the tests
// for this module seem to rely on in the Karma environment, but if that ever
// becomes the case, we should import those into unit-entry like we do for the
// ASRouter tests.
// eslint-disable-next-line mozilla/use-static-import
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
// eslint-disable-next-line mozilla/use-static-import
const { MESSAGE_TYPE_HASH: msg } = ChromeUtils.importESModule(
"resource:///modules/asrouter/ActorConstants.mjs"
);
import {
actionTypes as at,
actionUtils as au,
} from "resource://activity-stream/common/Actions.mjs";
import { Prefs } from "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs";
import { classifySite } from "resource://activity-stream/lib/SiteClassifier.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
AboutWelcomeTelemetry:
"resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs",
ClientID: "resource://gre/modules/ClientID.sys.mjs",
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
ExtensionSettingsStore:
"resource://gre/modules/ExtensionSettingsStore.sys.mjs",
HomePage: "resource:///modules/HomePage.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
UTEventReporting: "resource://activity-stream/lib/UTEventReporting.sys.mjs",
UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
pktApi: "chrome://pocket/content/pktApi.sys.mjs",
});
ChromeUtils.defineLazyGetter(
lazy,
"Telemetry",
() => new lazy.AboutWelcomeTelemetry()
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"handoffToAwesomebarPrefValue",
"browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
false,
(preference, previousValue, new_value) =>
Glean.newtabHandoffPreference.enabled.set(new_value)
);
export const PREF_IMPRESSION_ID = "impressionId";
export const TELEMETRY_PREF = "telemetry";
export const EVENTS_TELEMETRY_PREF = "telemetry.ut.events";
export const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled";
export const PREF_UNIFIED_ADS_TILES_ENABLED = "unifiedAds.tiles.enabled";
const PREF_SHOW_SPONSORED_STORIES = "showSponsored";
const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites";
// This is a mapping table between the user preferences and its encoding code
export const USER_PREFS_ENCODING = {
showSearch: 1 << 0,
"feeds.topsites": 1 << 1,
"feeds.section.topstories": 1 << 2,
"feeds.section.highlights": 1 << 3,
[PREF_SHOW_SPONSORED_STORIES]: 1 << 5,
"asrouter.userprefs.cfr.addons": 1 << 6,
"asrouter.userprefs.cfr.features": 1 << 7,
[PREF_SHOW_SPONSORED_TOPSITES]: 1 << 8,
};
// Used as the missing value for timestamps in the session ping
const TIMESTAMP_MISSING_VALUE = -1;
// Page filter for onboarding telemetry, any value other than these will
// be set as "other"
const ONBOARDING_ALLOWED_PAGE_VALUES = [
"about:welcome",
"about:home",
"about:newtab",
];
ChromeUtils.defineLazyGetter(
lazy,
"browserSessionId",
() => lazy.TelemetrySession.getMetadata("").sessionId
);
// The scalar category for TopSites of Contextual Services
const SCALAR_CATEGORY_TOPSITES = "contextual.services.topsites";
// `contextId` is a unique identifier used by Contextual Services
const CONTEXT_ID_PREF = "browser.contextual-services.contextId";
ChromeUtils.defineLazyGetter(lazy, "contextId", () => {
let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null);
if (!_contextId) {
_contextId = String(Services.uuid.generateUUID());
Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId);
}
return _contextId;
});
const ACTIVITY_STREAM_PREF_BRANCH = "browser.newtabpage.activity-stream.";
const NEWTAB_PING_PREFS = {
showSearch: Glean.newtabSearch.enabled,
"feeds.topsites": Glean.topsites.enabled,
[PREF_SHOW_SPONSORED_TOPSITES]: Glean.topsites.sponsoredEnabled,
"feeds.section.topstories": Glean.pocket.enabled,
[PREF_SHOW_SPONSORED_STORIES]: Glean.pocket.sponsoredStoriesEnabled,
topSitesRows: Glean.topsites.rows,
showWeather: Glean.newtab.weatherEnabled,
};
const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
const TOPIC_SELECTION_SELECTED_TOPICS_PREF =
"browser.newtabpage.activity-stream.discoverystream.topicSelection.selectedTopics";
export class TelemetryFeed {
constructor() {
this.sessions = new Map();
this._prefs = new Prefs();
this._impressionId = this.getOrCreateImpressionId();
this._aboutHomeSeen = false;
this._classifySite = classifySite;
this._browserOpenNewtabStart = null;
XPCOMUtils.defineLazyPreferenceGetter(
this,
"SHOW_SPONSORED_STORIES_ENABLED",
`${ACTIVITY_STREAM_PREF_BRANCH}${PREF_SHOW_SPONSORED_STORIES}`,
false
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"SHOW_SPONSORED_TOPSITES_ENABLED",
`${ACTIVITY_STREAM_PREF_BRANCH}${PREF_SHOW_SPONSORED_TOPSITES}`,
false
);
}
get telemetryEnabled() {
return this._prefs.get(TELEMETRY_PREF);
}
get eventTelemetryEnabled() {
return this._prefs.get(EVENTS_TELEMETRY_PREF);
}
get canSendUnifiedAdsSpocCallbacks() {
const unifiedAdsSpocsEnabled = this._prefs.get(
PREF_UNIFIED_ADS_SPOCS_ENABLED
);
return unifiedAdsSpocsEnabled && this.SHOW_SPONSORED_STORIES_ENABLED;
}
get canSendUnifiedAdsTilesCallbacks() {
const unifiedAdsTilesEnabled = this._prefs.get(
PREF_UNIFIED_ADS_TILES_ENABLED
);
return unifiedAdsTilesEnabled && this.SHOW_SPONSORED_TOPSITES_ENABLED;
}
get telemetryClientId() {
Object.defineProperty(this, "telemetryClientId", {
value: lazy.ClientID.getClientID(),
});
return this.telemetryClientId;
}
get processStartTs() {
let startupInfo = Services.startup.getStartupInfo();
let processStartTs = startupInfo.process.getTime();
Object.defineProperty(this, "processStartTs", {
value: processStartTs,
});
return this.processStartTs;
}
init() {
this._beginObservingNewtabPingPrefs();
Services.obs.addObserver(
this.browserOpenNewtabStart,
"browser-open-newtab-start"
);
// Set two scalars for the "deletion-request" ping (See bug 1602064 and 1729474)
Services.telemetry.scalarSet(
"deletion.request.impression_id",
this._impressionId
);
Services.telemetry.scalarSet("deletion.request.context_id", lazy.contextId);
Glean.newtab.locale.set(Services.locale.appLocaleAsBCP47);
Glean.newtabHandoffPreference.enabled.set(
lazy.handoffToAwesomebarPrefValue
);
}
getOrCreateImpressionId() {
let impressionId = this._prefs.get(PREF_IMPRESSION_ID);
if (!impressionId) {
impressionId = String(Services.uuid.generateUUID());
this._prefs.set(PREF_IMPRESSION_ID, impressionId);
}
return impressionId;
}
browserOpenNewtabStart() {
let now = Cu.now();
this._browserOpenNewtabStart = Math.round(this.processStartTs + now);
ChromeUtils.addProfilerMarker(
"UserTiming",
now,
"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 {
if (!this._browserOpenNewtabStart) {
throw new Error("No browser-open-newtab-start recorded.");
}
data_to_save = {
load_trigger_ts: this._browserOpenNewtabStart,
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);
}
/**
* Lazily initialize UTEventReporting to send pings
*/
get utEvents() {
Object.defineProperty(this, "utEvents", {
value: new lazy.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 by querying against the
* experiment manager of Messaging System
*
* @return {bool}
*/
get isInCFRCohort() {
const experimentData = lazy.ExperimentAPI.getExperimentMetaData({
featureId: "cfr",
});
if (experimentData && experimentData.slug) {
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 express this by using the process start
// absolute timestamp.
load_trigger_ts = this.processStartTs;
}
const session = {
session_id: String(Services.uuid.generateUUID()),
// "unknown" will be overwritten when appropriate
page: url ? url : "unknown",
perf: {
load_trigger_type,
is_preloaded: 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;
}
Glean.newtab.closed.record({ newtab_visit_id: session.session_id });
if (
this.telemetryEnabled &&
(lazy.NimbusFeatures.glean.getVariable("newtabPingEnabled") ?? true)
) {
GleanPings.newtab.submit("newtab_session_end");
}
if (session.perf.visibility_event_rcvd_ts) {
let absNow = this.processStartTs + Cu.now();
session.session_duration = Math.round(
absNow - session.perf.visibility_event_rcvd_ts
);
// Rounding all timestamps in perf to ease the data processing on the backend.
// NB: use `TIMESTAMP_MISSING_VALUE` if the value is missing.
session.perf.visibility_event_rcvd_ts = Math.round(
session.perf.visibility_event_rcvd_ts
);
session.perf.load_trigger_ts = Math.round(
session.perf.load_trigger_ts || TIMESTAMP_MISSING_VALUE
);
session.perf.topsites_first_painted_ts = Math.round(
session.perf.topsites_first_painted_ts || TIMESTAMP_MISSING_VALUE
);
} else {
// This session was never shown (i.e. the hidden preloaded newtab), there was no user session either.
this.sessions.delete(portID);
return;
}
let sessionEndEvent = this.createSessionEndEvent(session);
this.sendUTEvent(sessionEndEvent, this.utEvents.sendSessionEndEvent);
this.sessions.delete(portID);
}
/**
* 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.appLocaleAsBCP47,
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;
}
createUserEvent(action) {
return Object.assign(
this.createPing(au.getPortIdOfSender(action)),
action.data,
{ action: "activity_stream_user_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,
profile_creation_date:
lazy.TelemetryEnvironment.currentEnvironment.profile.resetDate ||
lazy.TelemetryEnvironment.currentEnvironment.profile.creationDate,
});
}
/**
* 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.
*/
async createASRouterEvent(action) {
let event = {
...action.data,
addon_version: Services.appinfo.appBuildID,
locale: Services.locale.appLocaleAsBCP47,
};
const session = this.sessions.get(au.getPortIdOfSender(action));
if (event.event_context && typeof event.event_context === "object") {
event.event_context = JSON.stringify(event.event_context);
}
switch (event.action) {
case "cfr_user_event":
event = await this.applyCFRPolicy(event);
break;
case "badge_user_event":
event = await this.applyToolbarBadgePolicy(event);
break;
case "infobar_user_event":
event = await this.applyInfoBarPolicy(event);
break;
case "spotlight_user_event":
event = await this.applySpotlightPolicy(event);
break;
case "toast_notification_user_event":
event = await this.applyToastNotificationPolicy(event);
break;
case "moments_user_event":
event = await this.applyMomentsPolicy(event);
break;
case "onboarding_user_event":
event = await this.applyOnboardingPolicy(event, session);
break;
case "menu_message_user_event":
event = await this.applyMenuMessagePolicy(event);
break;
case "asrouter_undesired_event":
event = this.applyUndesiredEventPolicy(event);
break;
default:
event = { ping: event };
break;
}
return event;
}
/**
* Per Bug 1484035, CFR metrics comply with following policies:
* 1). In release, it collects impression_id and bucket_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
* 4). In Private Browsing windows, unless in experiment, collects impression_id and bucket_id
*/
async applyCFRPolicy(ping) {
if (
(lazy.UpdateUtils.getUpdateChannel(true) === "release" ||
ping.is_private) &&
!this.isInCFRCohort
) {
ping.message_id = "n/a";
ping.impression_id = this._impressionId;
} else {
ping.client_id = await this.telemetryClientId;
}
delete ping.action;
delete ping.is_private;
return { ping, pingType: "cfr" };
}
/**
* Per Bug 1482134, all the metrics for What's New panel use client_id in
* all the release channels
*/
async applyToolbarBadgePolicy(ping) {
ping.client_id = await this.telemetryClientId;
ping.browser_session_id = lazy.browserSessionId;
// Attach page info to `event_context` if there is a session associated with this ping
delete ping.action;
return { ping, pingType: "toolbar-badge" };
}
async applyInfoBarPolicy(ping) {
ping.client_id = await this.telemetryClientId;
ping.browser_session_id = lazy.browserSessionId;
delete ping.action;
return { ping, pingType: "infobar" };
}
async applySpotlightPolicy(ping) {
ping.client_id = await this.telemetryClientId;
ping.browser_session_id = lazy.browserSessionId;
delete ping.action;
return { ping, pingType: "spotlight" };
}
async applyToastNotificationPolicy(ping) {
ping.client_id = await this.telemetryClientId;
ping.browser_session_id = lazy.browserSessionId;
delete ping.action;
return { ping, pingType: "toast_notification" };
}
async applyMenuMessagePolicy(ping) {
ping.client_id = await this.telemetryClientId;
ping.browser_session_id = lazy.browserSessionId;
delete ping.action;
return { ping, pingType: "menu" };
}
/**
* Per Bug 1484035, Moments 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
*/
async applyMomentsPolicy(ping) {
if (
lazy.UpdateUtils.getUpdateChannel(true) === "release" &&
!this.isInCFRCohort
) {
ping.message_id = "n/a";
ping.impression_id = this._impressionId;
} else {
ping.client_id = await this.telemetryClientId;
}
delete ping.action;
return { ping, pingType: "moments" };
}
/**
* Per Bug 1482134, all the metrics for Onboarding in AS router use client_id in
* all the release channels
*/
async applyOnboardingPolicy(ping, session) {
ping.client_id = await this.telemetryClientId;
ping.browser_session_id = lazy.browserSessionId;
// Attach page info to `event_context` if there is a session associated with this ping
if (ping.action === "onboarding_user_event" && session && session.page) {
let event_context;
try {
event_context = ping.event_context
? JSON.parse(ping.event_context)
: {};
} catch (e) {
// If `ping.event_context` is not a JSON serialized string, then we create a `value`
// key for it
event_context = { value: ping.event_context };
}
if (ONBOARDING_ALLOWED_PAGE_VALUES.includes(session.page)) {
event_context.page = session.page;
} else {
console.error(`Invalid 'page' for Onboarding event: ${session.page}`);
}
ping.event_context = JSON.stringify(event_context);
}
delete ping.action;
return { ping, pingType: "onboarding" };
}
applyUndesiredEventPolicy(ping) {
ping.impression_id = this._impressionId;
delete ping.action;
return { ping, pingType: "undesired-events" };
}
sendUTEvent(event_object, eventFunction) {
if (this.telemetryEnabled && this.eventTelemetryEnabled) {
eventFunction(event_object);
}
}
handleTopSitesSponsoredImpressionStats(action) {
const { data } = action;
const {
type,
position,
source,
advertiser: advertiser_name,
tile_id,
} = data;
// Legacy telemetry expects 1-based tile positions.
const legacyTelemetryPosition = position + 1;
const unifiedAdsTilesEnabled = this._prefs.get(
PREF_UNIFIED_ADS_TILES_ENABLED
);
let pingType;
const session = this.sessions.get(au.getPortIdOfSender(action));
if (type === "impression") {
pingType = "topsites-impression";
Services.telemetry.keyedScalarAdd(
`${SCALAR_CATEGORY_TOPSITES}.impression`,
`${source}_${legacyTelemetryPosition}`,
1
);
if (session) {
Glean.topsites.impression.record({
advertiser_name,
tile_id,
newtab_visit_id: session.session_id,
is_sponsored: true,
position,
});
}
} else if (type === "click") {
pingType = "topsites-click";
Services.telemetry.keyedScalarAdd(
`${SCALAR_CATEGORY_TOPSITES}.click`,
`${source}_${legacyTelemetryPosition}`,
1
);
if (session) {
Glean.topsites.click.record({
advertiser_name,
tile_id,
newtab_visit_id: session.session_id,
is_sponsored: true,
position,
});
}
} else {
console.error("Unknown ping type for sponsored TopSites impression");
return;
}
Glean.topSites.pingType.set(pingType);
Glean.topSites.position.set(legacyTelemetryPosition);
Glean.topSites.source.set(source);
Glean.topSites.tileId.set(tile_id);
if (data.reporting_url && !unifiedAdsTilesEnabled) {
Glean.topSites.reportingUrl.set(data.reporting_url);
}
Glean.topSites.advertiser.set(advertiser_name);
Glean.topSites.contextId.set(lazy.contextId);
GleanPings.topSites.submit();
if (data.reporting_url && this.canSendUnifiedAdsTilesCallbacks) {
// Send callback events to MARS unified ads api
this.sendUnifiedAdsCallbackEvent({
url: data.reporting_url,
position,
});
}
}
handleTopSitesOrganicImpressionStats(action) {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (!session) {
return;
}
switch (action.data?.type) {
case "impression":
Glean.topsites.impression.record({
newtab_visit_id: session.session_id,
is_sponsored: false,
position: action.data.position,
});
break;
case "click":
Glean.topsites.click.record({
newtab_visit_id: session.session_id,
is_sponsored: false,
position: action.data.position,
});
break;
default:
break;
}
}
handleUserEvent(action) {
let userEvent = this.createUserEvent(action);
this.sendUTEvent(userEvent, this.utEvents.sendUserEvent);
}
handleDiscoveryStreamUserEvent(action) {
const pocket_logged_in_status = lazy.pktApi.isUserLoggedIn();
Glean.pocket.isSignedIn.set(pocket_logged_in_status);
this.handleUserEvent({
...action,
data: {
...(action.data || {}),
value: {
...(action.data?.value || {}),
pocket_logged_in_status,
},
},
});
const session = this.sessions.get(au.getPortIdOfSender(action));
switch (action.data?.event) {
case "CLICK": {
const {
card_type,
topic,
recommendation_id,
tile_id,
shim,
fetchTimestamp,
firstVisibleTimestamp,
feature,
scheduled_corpus_item_id,
received_rank,
recommended_at,
matches_selected_topic,
selected_topics,
is_list_card,
} = action.data.value ?? {};
if (
action.data.source === "POPULAR_TOPICS" ||
card_type === "topics_widget"
) {
Glean.pocket.topicClick.record({
newtab_visit_id: session.session_id,
topic,
});
} else if (action.data.source === "FEATURE_HIGHLIGHT") {
Glean.newtab.tooltipClick.record({
newtab_visit_id: session.session_id,
feature,
});
} else if (["spoc", "organic"].includes(card_type)) {
Glean.pocket.click.record({
newtab_visit_id: session.session_id,
is_sponsored: card_type === "spoc",
matches_selected_topic,
selected_topics,
topic,
is_list_card,
position: action.data.action_position,
tile_id,
...(scheduled_corpus_item_id
? {
scheduled_corpus_item_id,
received_rank,
recommended_at,
}
: {
recommendation_id,
}),
});
if (shim) {
if (this.canSendUnifiedAdsSpocCallbacks) {
// Send unified ads callback event
this.sendUnifiedAdsCallbackEvent({
url: shim,
position: action.data.action_position,
});
} else {
Glean.pocket.shim.set(shim);
if (fetchTimestamp) {
Glean.pocket.fetchTimestamp.set(fetchTimestamp * 1000);
}
if (firstVisibleTimestamp) {
Glean.pocket.newtabCreationTimestamp.set(
firstVisibleTimestamp * 1000
);
}
GleanPings.spoc.submit("click");
}
}
}
break;
}
case "POCKET_THUMBS_DOWN":
case "POCKET_THUMBS_UP": {
const {
tile_id,
recommendation_id,
scheduled_corpus_item_id,
received_rank,
recommended_at,
thumbs_up,
thumbs_down,
topic,
} = action.data.value ?? {};
Glean.pocket.thumbVotingInteraction.record({
newtab_visit_id: session.session_id,
tile_id,
...(scheduled_corpus_item_id
? {
scheduled_corpus_item_id,
received_rank,
recommended_at,
}
: {
recommendation_id,
}),
thumbs_up,
thumbs_down,
topic,
});
break;
}
case "SAVE_TO_POCKET": {
const {
tile_id,
recommendation_id,
newtabCreationTimestamp,
fetchTimestamp,
shim,
card_type,
scheduled_corpus_item_id,
received_rank,
recommended_at,
topic,
matches_selected_topic,
selected_topics,
is_list_card,
} = action.data.value ?? {};
Glean.pocket.save.record({
newtab_visit_id: session.session_id,
is_sponsored: card_type === "spoc",
topic,
matches_selected_topic,
selected_topics,
position: action.data.action_position,
tile_id,
is_list_card,
...(scheduled_corpus_item_id
? {
scheduled_corpus_item_id,
received_rank,
recommended_at,
}
: {
recommendation_id,
}),
});
if (shim) {
Glean.pocket.shim.set(shim);
if (fetchTimestamp) {
Glean.pocket.fetchTimestamp.set(fetchTimestamp * 1000);
}
if (newtabCreationTimestamp) {
Glean.pocket.newtabCreationTimestamp.set(
newtabCreationTimestamp * 1000
);
}
GleanPings.spoc.submit("save");
}
break;
}
case "FAKESPOT_CLICK": {
const { product_id, category } = action.data.value ?? {};
Glean.newtab.fakespotClick.record({
newtab_visit_id: session.session_id,
product_id,
category,
});
break;
}
case "FAKESPOT_CATEGORY": {
const { category } = action.data.value ?? {};
Glean.newtab.fakespotCategory.record({
newtab_visit_id: session.session_id,
category,
});
break;
}
}
}
async handleASRouterUserEvent(action) {
const { ping, pingType } = await this.createASRouterEvent(action);
if (!pingType) {
console.error("Unknown ping type for ASRouter telemetry");
return;
}
// Now that the action has become a ping, we can echo it to Glean.
if (this.telemetryEnabled) {
lazy.Telemetry.submitGleanPingForPing({ ...ping, pingType });
}
}
/**
* This function submits callback events to the MARS unified ads service.
*/
async sendUnifiedAdsCallbackEvent(data = { url: null, position: null }) {
if (!data.url) {
throw new Error(
`[Unified ads callback] Missing argument (No url). Cannot send telemetry event.`
);
}
// data.position can be 0 (0)
if (!data.position && data.position !== 0) {
throw new Error(
`[Unified ads callback] Missing argument (No position). Cannot send telemetry event.`
);
}
const url = new URL(data.url);
url.searchParams.append("position", data.position);
try {
await fetch(url.toString());
} catch (error) {
console.error("Error:", error);
}
}
/**
* This function is used by ActivityStreamStorage to report errors
* trying to access IndexedDB.
*/
SendASRouterUndesiredEvent(data) {
this.handleASRouterUserEvent({
data: { ...data, action: "asrouter_undesired_event" },
});
}
async sendPageTakeoverData() {
if (this.telemetryEnabled) {
const value = {};
let homeAffected = false;
let newtabCategory = "disabled";
let homePageCategory = "disabled";
// 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")) {
newtabCategory = "enabled";
if (
lazy.AboutNewTab.newTabURLOverridden &&
!lazy.AboutNewTab.newTabURL.startsWith("moz-extension://")
) {
value.newtab_url_category = await this._classifySite(
lazy.AboutNewTab.newTabURL
);
newtabCategory = value.newtab_url_category;
}
}
// Check if the newtab page setting is controlled by an extension.
await lazy.ExtensionSettingsStore.initialize();
const newtabExtensionInfo = lazy.ExtensionSettingsStore.getSetting(
"url_overrides",
"newTabURL"
);
if (newtabExtensionInfo && newtabExtensionInfo.id) {
value.newtab_extension_id = newtabExtensionInfo.id;
newtabCategory = "extension";
}
const homePageURL = lazy.HomePage.get();
if (
!["about:home", "about:blank"].includes(homePageURL) &&
!homePageURL.startsWith("moz-extension://")
) {
value.home_url_category = await this._classifySite(homePageURL);
homeAffected = true;
homePageCategory = value.home_url_category;
}
const homeExtensionInfo = lazy.ExtensionSettingsStore.getSetting(
"prefs",
"homepage_override"
);
if (homeExtensionInfo && homeExtensionInfo.id) {
value.home_extension_id = homeExtensionInfo.id;
homeAffected = true;
homePageCategory = "extension";
}
if (!homeAffected && !lazy.HomePage.overridden) {
homePageCategory = "enabled";
}
Glean.newtab.newtabCategory.set(newtabCategory);
Glean.newtab.homepageCategory.set(homePageCategory);
if (lazy.NimbusFeatures.glean.getVariable("newtabPingEnabled") ?? true) {
GleanPings.newtab.submit("component_init");
}
}
}
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.SAVE_SESSION_PERF_DATA:
this.saveSessionPerfData(au.getPortIdOfSender(action), action.data);
break;
case at.DISCOVERY_STREAM_IMPRESSION_STATS:
this.handleDiscoveryStreamImpressionStats(
au.getPortIdOfSender(action),
action.data
);
break;
case at.DISCOVERY_STREAM_USER_EVENT:
this.handleDiscoveryStreamUserEvent(action);
break;
case at.TELEMETRY_USER_EVENT:
this.handleUserEvent(action);
break;
case at.TOP_SITES_SPONSORED_IMPRESSION_STATS:
this.handleTopSitesSponsoredImpressionStats(action);
break;
case at.TOP_SITES_ORGANIC_IMPRESSION_STATS:
this.handleTopSitesOrganicImpressionStats(action);
break;
case at.UNINIT:
this.uninit();
break;
case at.ABOUT_SPONSORED_TOP_SITES:
this.handleAboutSponsoredTopSites(action);
break;
case at.BLOCK_URL:
this.handleBlockUrl(action);
break;
case at.WALLPAPER_CATEGORY_CLICK:
case at.WALLPAPER_CLICK:
case at.WALLPAPERS_FEATURE_HIGHLIGHT_DISMISSED:
case at.WALLPAPERS_FEATURE_HIGHLIGHT_CTA_CLICKED:
this.handleWallpaperUserEvent(action);
break;
case at.SET_PREF:
this.handleSetPref(action);
break;
case at.WEATHER_IMPRESSION:
case at.WEATHER_LOAD_ERROR:
case at.WEATHER_OPEN_PROVIDER_URL:
case at.WEATHER_LOCATION_DATA_UPDATE:
this.handleWeatherUserEvent(action);
break;
case at.TOPIC_SELECTION_USER_OPEN:
case at.TOPIC_SELECTION_USER_DISMISS:
case at.TOPIC_SELECTION_USER_SAVE:
this.handleTopicSelectionUserEvent(action);
break;
case at.FAKESPOT_DISMISS: {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (session) {
Glean.newtab.fakespotDismiss.record({
newtab_visit_id: session.session_id,
});
}
break;
}
case at.FAKESPOT_CTA_CLICK: {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (session) {
Glean.newtab.fakespotCtaClick.record({
newtab_visit_id: session.session_id,
});
}
break;
}
case at.OPEN_ABOUT_FAKESPOT: {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (session) {
Glean.newtab.fakespotAboutClick.record({
newtab_visit_id: session.session_id,
});
}
break;
}
// The remaining action types come from ASRouter, which doesn't use
// Actions from Actions.mjs, but uses these other custom strings.
case msg.TOOLBAR_BADGE_TELEMETRY:
// Intentional fall-through
case msg.TOOLBAR_PANEL_TELEMETRY:
// Intentional fall-through
case msg.MOMENTS_PAGE_TELEMETRY:
// Intentional fall-through
case msg.DOORHANGER_TELEMETRY:
// Intentional fall-through
case msg.INFOBAR_TELEMETRY:
// Intentional fall-through
case msg.SPOTLIGHT_TELEMETRY:
// Intentional fall-through
case msg.TOAST_NOTIFICATION_TELEMETRY:
// Intentional fall-through
case msg.MENU_MESSAGE_TELEMETRY:
// Intentional fall-through
case msg.AS_ROUTER_TELEMETRY_USER_EVENT:
this.handleASRouterUserEvent(action);
break;
}
}
handleTopicSelectionUserEvent(action) {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (session) {
switch (action.type) {
case "TOPIC_SELECTION_USER_OPEN":
Glean.newtab.topicSelectionOpen.record({
newtab_visit_id: session.session_id,
});
break;
case "TOPIC_SELECTION_USER_DISMISS":
Glean.newtab.topicSelectionDismiss.record({
newtab_visit_id: session.session_id,
});
break;
case "TOPIC_SELECTION_USER_SAVE":
Glean.newtab.topicSelectionTopicsSaved.record({
newtab_visit_id: session.session_id,
topics: action.data.topics,
previous_topics: action.data.previous_topics,
first_save: action.data.first_save,
});
break;
default:
break;
}
}
}
handleSetPref(action) {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (action.data.name === "weather.display") {
if (!session) {
return;
}
Glean.newtab.weatherChangeDisplay.record({
newtab_visit_id: session.session_id,
weather_display_mode: action.data.value,
});
}
}
handleWeatherUserEvent(action) {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (!session) {
return;
}
// Weather specific telemtry events can be added and parsed here.
switch (action.type) {
case "WEATHER_IMPRESSION":
Glean.newtab.weatherImpression.record({
newtab_visit_id: session.session_id,
});
break;
case "WEATHER_LOAD_ERROR":
Glean.newtab.weatherLoadError.record({
newtab_visit_id: session.session_id,
});
break;
case "WEATHER_OPEN_PROVIDER_URL":
Glean.newtab.weatherOpenProviderUrl.record({
newtab_visit_id: session.session_id,
});
break;
case "WEATHER_LOCATION_DATA_UPDATE":
Glean.newtab.weatherLocationSelected.record({
newtab_visit_id: session.session_id,
});
break;
default:
break;
}
}
handleWallpaperUserEvent(action) {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (!session) {
return;
}
// Wallpaper specific telemtry events can be added and parsed here.
switch (action.type) {
case "WALLPAPER_CATEGORY_CLICK":
Glean.newtab.wallpaperCategoryClick.record({
newtab_visit_id: session.session_id,
selected_category: action.data,
});
break;
case "WALLPAPER_CLICK":
{
const { data } = action;
const { selected_wallpaper, hadPreviousWallpaper } = data;
// if either of the wallpaper prefs are truthy, they had a previous wallpaper
Glean.newtab.wallpaperClick.record({
newtab_visit_id: session.session_id,
selected_wallpaper,
hadPreviousWallpaper,
});
}
break;
case "WALLPAPERS_FEATURE_HIGHLIGHT_CTA_CLICKED":
Glean.newtab.wallpaperHighlightCtaClick.record({
newtab_visit_id: session.session_id,
});
break;
case "WALLPAPERS_FEATURE_HIGHLIGHT_DISMISSED":
Glean.newtab.wallpaperHighlightDismissed.record({
newtab_visit_id: session.session_id,
});
break;
default:
break;
}
}
handleBlockUrl(action) {
const session = this.sessions.get(au.getPortIdOfSender(action));
// TODO: Do we want to not send this unless there's a newtab_visit_id?
if (!session) {
return;
}
// Despite the action name, this is actually a bulk dismiss action:
// it can be applied to multiple topsites simultaneously.
const { data } = action;
for (const datum of data) {
if (datum.is_pocket_card) {
Glean.pocket.dismiss.record({
newtab_visit_id: session.session_id,
is_sponsored: datum.card_type === "spoc",
position: datum.pos,
tile_id: datum.id || datum.tile_id,
is_list_card: datum.is_list_card,
...(datum.scheduled_corpus_item_id
? {
scheduled_corpus_item_id: datum.scheduled_corpus_item_id,
received_rank: datum.received_rank,
recommended_at: datum.recommended_at,
}
: {
recommendation_id: datum.recommendation_id,
}),
});
continue;
}
const { position, advertiser_name, tile_id, isSponsoredTopSite } = datum;
Glean.topsites.dismiss.record({
advertiser_name,
tile_id,
newtab_visit_id: session.session_id,
is_sponsored: !!isSponsoredTopSite,
position,
});
}
}
handleAboutSponsoredTopSites(action) {
const session = this.sessions.get(au.getPortIdOfSender(action));
const { data } = action;
const { position, advertiser_name, tile_id } = data;
if (session) {
Glean.topsites.showPrivacyClick.record({
advertiser_name,
tile_id,
newtab_visit_id: session.session_id,
position,
});
}
}
/**
* Handle impression stats actions from Discovery Stream.
*
* @param {String} port The session port with which this is associated
* @param {Object} data The impression data structured as {source: "SOURCE", tiles: [{id: 123}]}
*
*/
handleDiscoveryStreamImpressionStats(port, data) {
let session = this.sessions.get(port);
if (!session) {
throw new Error("Session does not exist.");
}
const { tiles } = data;
tiles.forEach(tile => {
// if the tile has a category it is a product tile from fakespot
if (tile.type === "fakespot") {
Glean.newtab.fakespotProductImpression.record({
newtab_visit_id: session.session_id,
product_id: tile.id,
category: tile.category,
});
} else {
Glean.pocket.impression.record({
newtab_visit_id: session.session_id,
is_sponsored: tile.type === "spoc",
position: tile.pos,
tile_id: tile.id,
topic: tile.topic,
selected_topics: tile.selectedTopics,
is_list_card: tile.is_list_card,
...(tile.scheduled_corpus_item_id
? {
scheduled_corpus_item_id: tile.scheduled_corpus_item_id,
received_rank: tile.received_rank,
recommended_at: tile.recommended_at,
}
: {
recommendation_id: tile.recommendation_id,
}),
});
}
if (tile.shim) {
if (this.canSendUnifiedAdsSpocCallbacks) {
// Send unified ads callback event
this.sendUnifiedAdsCallbackEvent({
url: tile.shim,
position: tile.pos,
});
} else {
Glean.pocket.shim.set(tile.shim);
if (tile.fetchTimestamp) {
Glean.pocket.fetchTimestamp.set(tile.fetchTimestamp * 1000);
}
if (data.firstVisibleTimestamp) {
Glean.pocket.newtabCreationTimestamp.set(
data.firstVisibleTimestamp * 1000
);
}
GleanPings.spoc.submit("impression");
}
}
});
}
/**
* 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" &&
!lazy.HomePage.overridden &&
Services.prefs.getIntPref("browser.startup.page") === 1
) {
lazy.AboutNewTab.maybeRecordTopsitesPainted(timestamp);
}
Object.assign(session.perf, data);
if (data.visibility_event_rcvd_ts && !session.newtabOpened) {
session.newtabOpened = true;
const source = ONBOARDING_ALLOWED_PAGE_VALUES.includes(session.page)
? session.page
: "other";
Glean.newtab.opened.record({
newtab_visit_id: session.session_id,
source,
window_inner_height: data.window_inner_height,
window_inner_width: data.window_inner_width,
});
}
}
_beginObservingNewtabPingPrefs() {
Services.prefs.addObserver(ACTIVITY_STREAM_PREF_BRANCH, this);
for (const pref of Object.keys(NEWTAB_PING_PREFS)) {
const fullPrefName = ACTIVITY_STREAM_PREF_BRANCH + pref;
this._setNewtabPrefMetrics(fullPrefName, false);
}
Glean.pocket.isSignedIn.set(lazy.pktApi.isUserLoggedIn());
Services.prefs.addObserver(TOP_SITES_BLOCKED_SPONSORS_PREF, this);
this._setBlockedSponsorsMetrics();
Services.prefs.addObserver(TOPIC_SELECTION_SELECTED_TOPICS_PREF, this);
this._setTopicSelectionSelectedTopicsMetrics();
}
_stopObservingNewtabPingPrefs() {
Services.prefs.removeObserver(ACTIVITY_STREAM_PREF_BRANCH, this);
Services.prefs.removeObserver(TOP_SITES_BLOCKED_SPONSORS_PREF, this);
Services.prefs.removeObserver(TOPIC_SELECTION_SELECTED_TOPICS_PREF, this);
}
observe(subject, topic, data) {
if (data === TOP_SITES_BLOCKED_SPONSORS_PREF) {
this._setBlockedSponsorsMetrics();
} else if (data === TOPIC_SELECTION_SELECTED_TOPICS_PREF) {
this._setTopicSelectionSelectedTopicsMetrics();
} else {
this._setNewtabPrefMetrics(data, true);
}
}
_setNewtabPrefMetrics(fullPrefName, isChanged) {
const pref = fullPrefName.slice(ACTIVITY_STREAM_PREF_BRANCH.length);
if (!Object.hasOwn(NEWTAB_PING_PREFS, pref)) {
return;
}
const metric = NEWTAB_PING_PREFS[pref];
switch (Services.prefs.getPrefType(fullPrefName)) {
case Services.prefs.PREF_BOOL:
metric.set(Services.prefs.getBoolPref(fullPrefName));
break;
case Services.prefs.PREF_INT:
metric.set(Services.prefs.getIntPref(fullPrefName));
break;
}
if (isChanged) {
switch (fullPrefName) {
case `${ACTIVITY_STREAM_PREF_BRANCH}feeds.topsites`:
case `${ACTIVITY_STREAM_PREF_BRANCH}${PREF_SHOW_SPONSORED_TOPSITES}`:
Glean.topsites.prefChanged.record({
pref_name: fullPrefName,
new_value: Services.prefs.getBoolPref(fullPrefName),
});
break;
}
}
}
_setBlockedSponsorsMetrics() {
let blocklist;
try {
blocklist = JSON.parse(
Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]")
);
} catch (e) {}
if (blocklist) {
Glean.newtab.blockedSponsors.set(blocklist);
}
}
_setTopicSelectionSelectedTopicsMetrics() {
let topiclist;
try {
topiclist = Services.prefs.getStringPref(
TOPIC_SELECTION_SELECTED_TOPICS_PREF,
""
);
} catch (e) {}
if (topiclist) {
// Note: Beacuse Glean is expecting a string list, the
// value of the pref needs to be converted to an array
topiclist = topiclist.split(",").map(s => s.trim());
Glean.newtab.selectedTopics.set(topiclist);
}
}
uninit() {
this._stopObservingNewtabPingPrefs();
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
}
// TODO: Send any unfinished sessions
}
}