Source code
Revision control
Copy as Markdown
Other Tools
/* 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
// 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_ENDPOINTS = "discoverystream.endpoints";
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
);
// `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"
);
Glean.deletionRequest.impressionId.set(this._impressionId);
Glean.deletionRequest.contextId.set(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;
}
/**
* 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" };
}
/**
* 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" };
}
/**
* 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" };
}
/**
* 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";
Glean.contextualServicesTopsites.impression[
`${source}_${legacyTelemetryPosition}`
].add(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";
Glean.contextualServicesTopsites.click[
`${source}_${legacyTelemetryPosition}`
].add(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,
corpus_item_id,
received_rank,
recommended_at,
matches_selected_topic,
selected_topics,
is_list_card,
format,
section,
section_position,
} = 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",
...(format ? { format } : {}),
...(section
? {
section,
section_position,
}
: {}),
matches_selected_topic,
selected_topics,
topic,
is_list_card,
position: action.data.action_position,
tile_id,
// We conditionally add in a few props.
...(corpus_item_id ? { corpus_item_id } : {}),
...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
...(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,
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,
// We conditionally add in a few props.
...(corpus_item_id ? { corpus_item_id } : {}),
...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
...(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,
corpus_item_id,
received_rank,
recommended_at,
topic,
matches_selected_topic,
selected_topics,
is_list_card,
format,
section,
section_position,
} = action.data.value ?? {};
Glean.pocket.save.record({
newtab_visit_id: session.session_id,
is_sponsored: card_type === "spoc",
...(format ? { format } : {}),
...(section
? {
section,
section_position,
}
: {}),
topic,
matches_selected_topic,
selected_topics,
position: action.data.action_position,
tile_id,
is_list_card,
// We conditionally add in a few props.
...(corpus_item_id ? { corpus_item_id } : {}),
...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
...(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.`
);
}
// Make sure the callback endpoint is allowed
const allowed = this._prefs.get(PREF_ENDPOINTS).split(",");
if (!allowed.some(prefix => data.url.startsWith(prefix))) {
throw new Error(
`[Unified ads callback] Not one of allowed prefixes (${allowed})`
);
}
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;
}
case at.CARD_SECTION_IMPRESSION: {
const session = this.sessions.get(au.getPortIdOfSender(action));
if (session) {
const { section, section_position } = action.data;
Glean.newtab.sectionsImpression.record({
newtab_visit_id: session.session_id,
section,
section_position,
});
}
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, had_previous_wallpaper } = 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,
had_previous_wallpaper,
});
}
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) {
const { corpus_item_id, scheduled_corpus_item_id } = datum;
if (datum.is_pocket_card) {
Glean.pocket.dismiss.record({
newtab_visit_id: session.session_id,
is_sponsored: datum.card_type === "spoc",
...(datum.format ? { format: datum.format } : {}),
position: datum.pos,
tile_id: datum.id || datum.tile_id,
is_list_card: datum.is_list_card,
...(datum.section
? {
section: datum.section,
section_position: datum.section_position,
}
: {}),
// We conditionally add in a few props.
...(corpus_item_id ? { corpus_item_id } : {}),
...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
...(corpus_item_id || 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 {
const { corpus_item_id, scheduled_corpus_item_id } = tile;
Glean.pocket.impression.record({
newtab_visit_id: session.session_id,
is_sponsored: tile.type === "spoc",
...(tile.format ? { format: tile.format } : {}),
...(tile.section
? {
section: tile.section,
section_position: tile.section_position,
}
: {}),
position: tile.pos,
tile_id: tile.id,
topic: tile.topic,
selected_topics: tile.selectedTopics,
is_list_card: tile.is_list_card,
// We conditionally add in a few props.
...(corpus_item_id ? { corpus_item_id } : {}),
...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
...(corpus_item_id || 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
}
}