tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

TelemetryFeed.sys.mjs (69238B)


      1 // We're using console.error() to debug, so we'll be keeping this rule handy
      2 /* eslint no-console: ["error", { allow: ["error"] }] */
      3 
      4 /* This Source Code Form is subject to the terms of the Mozilla Public
      5 * License, v. 2.0. If a copy of the MPL was not distributed with this
      6 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      7 
      8 // We use importESModule here instead of static import so that the Karma test
      9 // environment won't choke on these module. This is because the Karma test
     10 // environment already stubs out XPCOMUtils and RemoteSettings, and overrides
     11 // importESModule to be a no-op (which can't be done for a static import
     12 // statement).
     13 // eslint-disable-next-line mozilla/use-static-import
     14 const { XPCOMUtils } = ChromeUtils.importESModule(
     15  "resource://gre/modules/XPCOMUtils.sys.mjs"
     16 );
     17 
     18 import {
     19  actionTypes as at,
     20  actionUtils as au,
     21 } from "resource://newtab/common/Actions.mjs";
     22 import { Prefs } from "resource://newtab/lib/ActivityStreamPrefs.sys.mjs";
     23 import { classifySite } from "resource://newtab/lib/SiteClassifier.sys.mjs";
     24 
     25 const lazy = {};
     26 
     27 ChromeUtils.defineESModuleGetters(lazy, {
     28  AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
     29  ClientEnvironmentBase:
     30    "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
     31  ClientID: "resource://gre/modules/ClientID.sys.mjs",
     32  ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
     33  ExtensionSettingsStore:
     34    "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
     35  ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
     36  HomePage: "resource:///modules/HomePage.sys.mjs",
     37  ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
     38  Region: "resource://gre/modules/Region.sys.mjs",
     39  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
     40  UTEventReporting: "resource://newtab/lib/UTEventReporting.sys.mjs",
     41  NewTabContentPing: "resource://newtab/lib/NewTabContentPing.sys.mjs",
     42  NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
     43  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     44 });
     45 
     46 export const PREF_IMPRESSION_ID = "impressionId";
     47 export const TELEMETRY_PREF = "telemetry";
     48 export const EVENTS_TELEMETRY_PREF = "telemetry.ut.events";
     49 export const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled";
     50 export const PREF_UNIFIED_ADS_TILES_ENABLED = "unifiedAds.tiles.enabled";
     51 const PREF_ENDPOINTS = "discoverystream.endpoints";
     52 const PREF_SHOW_SPONSORED_STORIES = "showSponsored";
     53 const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites";
     54 const BLANK_HOMEPAGE_URL = "chrome://browser/content/blanktab.html";
     55 const PREF_PRIVATE_PING_ENABLED = "telemetry.privatePing.enabled";
     56 const PREF_REDACT_NEWTAB_PING_NEABLED =
     57  "telemetry.privatePing.redactNewtabPing.enabled";
     58 const PREF_PRIVATE_PING_INFERRED_ENABLED =
     59  "telemetry.privatePing.inferredInterests.enabled";
     60 const PREF_NEWTAB_PING_ENABLED = "browser.newtabpage.ping.enabled";
     61 const PREF_USER_INFERRED_PERSONALIZATION =
     62  "discoverystream.sections.personalization.inferred.user.enabled";
     63 const PREF_SYSTEM_INFERRED_PERSONALIZATION =
     64  "discoverystream.sections.personalization.inferred.enabled";
     65 const PREF_SECTIONS_PERSONALIZATION_ENABLED =
     66  "discoverystream.sections.personalization.enabled";
     67 const PREF_SOV_FRECENCY_EXPOSURE = "sov.frecency.exposure";
     68 
     69 const TOP_STORIES_SECTION_NAME = "top_stories_section";
     70 
     71 /**
     72    Additional parameters defined in the newTabTrainHop experimenter method
     73 
     74    trainhopConfig.newtabPrivatePing.randomContentProbabilityEpsilonMicro
     75    Epsilon for randomizing content impression and click telemetry using the RandomizedReponse method
     76    in the newtab_content ping , as integer multipled by 1e6
     77 
     78    trainhopConfig.newtabPrivatePing.dailyEventCap
     79    Maximum newtab_content events that can be sent in 24 hour period.
     80 */
     81 const TRAINHOP_PREF_RANDOM_CLICK_PROBABILITY_MICRO =
     82  "randomContentClickProbabilityEpsilonMicro";
     83 
     84 /**
     85 *    Maximum newtab_content events that can be sent in 24 hour period.
     86 */
     87 const TRAINHOP_PREF_DAILY_EVENT_CAP = "dailyEventCap";
     88 
     89 const TRAINHOP_PREF_DAILY_CLICK_EVENT_CAP = "dailyClickEventCap";
     90 const TRAINHOP_PREF_WEEKLY_CLICK_EVENT_CAP = "weeklyClickEventCap";
     91 
     92 // This is a mapping table between the user preferences and its encoding code
     93 export const USER_PREFS_ENCODING = {
     94  showSearch: 1 << 0,
     95  "feeds.topsites": 1 << 1,
     96  "feeds.section.topstories": 1 << 2,
     97  "feeds.section.highlights": 1 << 3,
     98  [PREF_SHOW_SPONSORED_STORIES]: 1 << 5,
     99  "asrouter.userprefs.cfr.addons": 1 << 6,
    100  "asrouter.userprefs.cfr.features": 1 << 7,
    101  [PREF_SHOW_SPONSORED_TOPSITES]: 1 << 8,
    102 };
    103 
    104 const PRIVATE_PING_SURFACE_COUNTRY_MAP = {
    105  // This will be expanded to other surfaces as we expand the reach of the private content ping
    106  NEW_TAB_EN_US: ["US", "CA"],
    107  NEW_TAB_DE_DE: ["DE", "CH", "AT"],
    108  NEW_TAB_EN_GB: ["GB", "IE"],
    109  NEW_TAB_FR_FR: ["FR", "BE"],
    110 };
    111 
    112 // Used as the missing value for timestamps in the session ping
    113 const TIMESTAMP_MISSING_VALUE = -1;
    114 
    115 // Page filter for onboarding telemetry, any value other than these will
    116 // be set as "other"
    117 const ONBOARDING_ALLOWED_PAGE_VALUES = [
    118  "about:welcome",
    119  "about:home",
    120  "about:newtab",
    121 ];
    122 
    123 const PREF_SURFACE_ID = "telemetry.surfaceId";
    124 
    125 const CONTENT_PING_VERSION = 2;
    126 
    127 const ACTIVITY_STREAM_PREF_BRANCH = "browser.newtabpage.activity-stream.";
    128 
    129 const NEWTAB_PING_PREFS = {
    130  showSearch: Glean.newtabSearch.enabled,
    131  "feeds.topsites": Glean.topsites.enabled,
    132  [PREF_SHOW_SPONSORED_TOPSITES]: Glean.topsites.sponsoredEnabled,
    133  "feeds.section.highlights": Glean.newtab.highlightsEnabled,
    134  "feeds.section.topstories": Glean.pocket.enabled,
    135  [PREF_SHOW_SPONSORED_STORIES]: Glean.pocket.sponsoredStoriesEnabled,
    136  topSitesRows: Glean.topsites.rows,
    137  showWeather: Glean.newtab.weatherEnabled,
    138 };
    139 
    140 const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
    141 const TOPIC_SELECTION_SELECTED_TOPICS_PREF =
    142  "browser.newtabpage.activity-stream.discoverystream.topicSelection.selectedTopics";
    143 export class TelemetryFeed {
    144  constructor() {
    145    this.sessions = new Map();
    146    this._prefs = new Prefs();
    147    this._impressionId = this.getOrCreateImpressionId();
    148    this._aboutHomeSeen = false;
    149    this._classifySite = classifySite;
    150    this._browserOpenNewtabStart = null;
    151    this._privateRandomContentTelemetryProbablityValues = {};
    152 
    153    this.newtabContentPing = new lazy.NewTabContentPing();
    154    this._initialized = false;
    155 
    156    XPCOMUtils.defineLazyPreferenceGetter(
    157      this,
    158      "SHOW_SPONSORED_STORIES_ENABLED",
    159      `${ACTIVITY_STREAM_PREF_BRANCH}${PREF_SHOW_SPONSORED_STORIES}`,
    160      false
    161    );
    162 
    163    XPCOMUtils.defineLazyPreferenceGetter(
    164      this,
    165      "SHOW_SPONSORED_TOPSITES_ENABLED",
    166      `${ACTIVITY_STREAM_PREF_BRANCH}${PREF_SHOW_SPONSORED_TOPSITES}`,
    167      false
    168    );
    169  }
    170 
    171  get telemetryEnabled() {
    172    return this._prefs.get(TELEMETRY_PREF);
    173  }
    174 
    175  get eventTelemetryEnabled() {
    176    return this._prefs.get(EVENTS_TELEMETRY_PREF);
    177  }
    178 
    179  get privatePingEnabled() {
    180    return this._prefs.get(PREF_PRIVATE_PING_ENABLED);
    181  }
    182 
    183  get redactNewTabPingEnabled() {
    184    return this._prefs.get(PREF_REDACT_NEWTAB_PING_NEABLED);
    185  }
    186 
    187  get privatePingInferredInterestsEnabled() {
    188    return (
    189      this._prefs.get(PREF_PRIVATE_PING_INFERRED_ENABLED) &&
    190      this._prefs.get(PREF_USER_INFERRED_PERSONALIZATION) &&
    191      this._prefs.get(PREF_SYSTEM_INFERRED_PERSONALIZATION)
    192    );
    193  }
    194 
    195  get sectionsPersonalizationEnabled() {
    196    return this._prefs.get(PREF_SECTIONS_PERSONALIZATION_ENABLED);
    197  }
    198 
    199  get inferredInterests() {
    200    return this.store.getState()?.InferredPersonalization
    201      ?.coarsePrivateInferredInterests;
    202  }
    203 
    204  get clientInfo() {
    205    return lazy.ClientEnvironmentBase;
    206  }
    207 
    208  get canSendUnifiedAdsSpocCallbacks() {
    209    const unifiedAdsSpocsEnabled = this._prefs.get(
    210      PREF_UNIFIED_ADS_SPOCS_ENABLED
    211    );
    212 
    213    return unifiedAdsSpocsEnabled && this.SHOW_SPONSORED_STORIES_ENABLED;
    214  }
    215 
    216  get canSendUnifiedAdsTilesCallbacks() {
    217    const unifiedAdsTilesEnabled = this._prefs.get(
    218      PREF_UNIFIED_ADS_TILES_ENABLED
    219    );
    220 
    221    return unifiedAdsTilesEnabled && this.SHOW_SPONSORED_TOPSITES_ENABLED;
    222  }
    223 
    224  get telemetryClientId() {
    225    Object.defineProperty(this, "telemetryClientId", {
    226      value: lazy.ClientID.getClientID(),
    227    });
    228    return this.telemetryClientId;
    229  }
    230 
    231  get processStartTs() {
    232    let startupInfo = Services.startup.getStartupInfo();
    233    let processStartTs = startupInfo.process.getTime();
    234 
    235    Object.defineProperty(this, "processStartTs", {
    236      value: processStartTs,
    237    });
    238    return this.processStartTs;
    239  }
    240 
    241  init() {
    242    // TODO: It looks like (at least) browser_newtab_glean.js and
    243    // browser_newtab_ping.js depend on most of the following to be executed
    244    // even if init() is called more than once. That feels fragile.
    245 
    246    this._beginObservingNewtabPingPrefs();
    247 
    248    if (!this._initialized) {
    249      this._initialized = true;
    250      Services.obs.addObserver(
    251        this.browserOpenNewtabStart,
    252        "browser-open-newtab-start"
    253      );
    254    }
    255 
    256    // Set two scalars for the "deletion-request" ping (See bug 1602064 and 1729474)
    257    Glean.deletionRequest.impressionId.set(this._impressionId);
    258    if (!lazy.ContextId.rotationEnabled) {
    259      Glean.deletionRequest.contextId.set(
    260        lazy.ContextId.requestSynchronously()
    261      );
    262    }
    263    Glean.newtab.locale.set(Services.locale.appLocaleAsBCP47);
    264  }
    265 
    266  getOrCreateImpressionId() {
    267    let impressionId = this._prefs.get(PREF_IMPRESSION_ID);
    268    if (!impressionId) {
    269      impressionId = String(Services.uuid.generateUUID());
    270      this._prefs.set(PREF_IMPRESSION_ID, impressionId);
    271    }
    272    return impressionId;
    273  }
    274 
    275  browserOpenNewtabStart() {
    276    let now = ChromeUtils.now();
    277    this._browserOpenNewtabStart = Math.round(this.processStartTs + now);
    278 
    279    ChromeUtils.addProfilerMarker(
    280      "UserTiming",
    281      now,
    282      "browser-open-newtab-start"
    283    );
    284  }
    285 
    286  /**
    287   * Retrieves most recently followed sections (maximum 2 sections)
    288   *
    289   * @returns {string[]} comma separated string of section UUID's
    290   */
    291  getFollowedSections() {
    292    const sections =
    293      this.store?.getState()?.DiscoveryStream.sectionPersonalization;
    294    if (sections) {
    295      // filter to only include followedTopics
    296      const followed = Object.entries(sections).filter(
    297        ([, info]) => info.isFollowed
    298      );
    299      // sort from most recently followed to oldest. If followedAt is falsey, treat it as the oldest
    300      followed.sort((a, b) => {
    301        const aDate = a[1].followedAt ? new Date(a[1].followedAt) : 0;
    302        const bDate = b[1].followedAt ? new Date(b[1].followedAt) : 0;
    303        return bDate - aDate;
    304      });
    305 
    306      return followed.slice(0, 2).map(([sectionId]) => sectionId);
    307    }
    308    return [];
    309  }
    310 
    311  setLoadTriggerInfo(port) {
    312    // XXX note that there is a race condition here; we're assuming that no
    313    // other tab will be interleaving calls to browserOpenNewtabStart and
    314    // when at.NEW_TAB_INIT gets triggered by RemotePages and calls this
    315    // method.  For manually created windows, it's hard to imagine us hitting
    316    // this race condition.
    317    //
    318    // However, for session restore, where multiple windows with multiple tabs
    319    // might be restored much closer together in time, it's somewhat less hard,
    320    // though it should still be pretty rare.
    321    //
    322    // The fix to this would be making all of the load-trigger notifications
    323    // return some data with their notifications, and somehow propagate that
    324    // data through closures into the tab itself so that we could match them
    325    //
    326    // As of this writing (very early days of system add-on perf telemetry),
    327    // the hypothesis is that hitting this race should be so rare that makes
    328    // more sense to live with the slight data inaccuracy that it would
    329    // introduce, rather than doing the correct but complicated thing.  It may
    330    // well be worth reexamining this hypothesis after we have more experience
    331    // with the data.
    332 
    333    let data_to_save;
    334    try {
    335      if (!this._browserOpenNewtabStart) {
    336        throw new Error("No browser-open-newtab-start recorded.");
    337      }
    338      data_to_save = {
    339        load_trigger_ts: this._browserOpenNewtabStart,
    340        load_trigger_type: "menu_plus_or_keyboard",
    341      };
    342    } catch (e) {
    343      // if no mark was returned, we have nothing to save
    344      return;
    345    }
    346    this.saveSessionPerfData(port, data_to_save);
    347  }
    348 
    349  /**
    350   * Lazily initialize UTEventReporting to send pings
    351   */
    352  get utEvents() {
    353    Object.defineProperty(this, "utEvents", {
    354      value: new lazy.UTEventReporting(),
    355    });
    356    return this.utEvents;
    357  }
    358 
    359  /**
    360   * Get encoded user preferences, multiple prefs will be combined via bitwise OR operator
    361   */
    362  get userPreferences() {
    363    let prefs = 0;
    364 
    365    for (const pref of Object.keys(USER_PREFS_ENCODING)) {
    366      if (this._prefs.get(pref)) {
    367        prefs |= USER_PREFS_ENCODING[pref];
    368      }
    369    }
    370    return prefs;
    371  }
    372 
    373  /**
    374   * Removes fields that link to any user content preference.
    375   * Redactions only occur if the appropriate pref is enabled.
    376   *
    377   * @param {*} pingDict Input dictionary
    378   * @param {boolean} isSponsored Is this in ad, in which case there is nothing we can redact currently
    379   * @returns {*} Possibly redacted dictionary
    380   */
    381  redactNewTabPing(pingDict, isSponsored = false) {
    382    if (this.redactNewTabPingEnabled && !isSponsored) {
    383      const {
    384        // eslint-disable-next-line no-unused-vars
    385        corpus_item_id,
    386        // eslint-disable-next-line no-unused-vars
    387        scheduled_corpus_item_id,
    388        // eslint-disable-next-line no-unused-vars
    389        section,
    390        // eslint-disable-next-line no-unused-vars
    391        selected_topics,
    392        // eslint-disable-next-line no-unused-vars
    393        tile_id,
    394        // eslint-disable-next-line no-unused-vars
    395        topic,
    396        ...result
    397      } = pingDict;
    398      result.content_redacted = true;
    399      return result;
    400    }
    401    // For spocs we need to retain the tile id.
    402    if (this.redactNewTabPingEnabled && isSponsored) {
    403      const {
    404        // eslint-disable-next-line no-unused-vars
    405        section,
    406        // eslint-disable-next-line no-unused-vars
    407        selected_topics,
    408        // eslint-disable-next-line no-unused-vars
    409        topic,
    410        ...result
    411      } = pingDict;
    412      result.content_redacted = true;
    413      return result;
    414    }
    415 
    416    return pingDict; // No modification
    417  }
    418 
    419  /**
    420   * addSession - Start tracking a new session
    421   *
    422   * @param  {string} id the portID of the open session
    423   * @param  {string} the URL being loaded for this session (optional)
    424   * @return {obj}    Session object
    425   */
    426  addSession(id, url) {
    427    // XXX refactor to use setLoadTriggerInfo or saveSessionPerfData
    428 
    429    // "unexpected" will be overwritten when appropriate
    430    let load_trigger_type = "unexpected";
    431    let load_trigger_ts;
    432 
    433    if (!this._aboutHomeSeen && url === "about:home") {
    434      this._aboutHomeSeen = true;
    435 
    436      // XXX note that this will be incorrectly set in the following cases:
    437      // session_restore following by clicking on the toolbar button,
    438      // or someone who has changed their default home page preference to
    439      // something else and later clicks the toolbar.  It will also be
    440      // incorrectly unset if someone changes their "Home Page" preference to
    441      // about:newtab.
    442      //
    443      // That said, the ratio of these mistakes to correct cases should
    444      // be very small, and these issues should follow away as we implement
    445      // the remaining load_trigger_type values for about:home in issue 3556.
    446      //
    447      // XXX file a bug to implement remaining about:home cases so this
    448      // problem will go away and link to it here.
    449      load_trigger_type = "first_window_opened";
    450 
    451      // The real perceived trigger of first_window_opened is the OS-level
    452      // clicking of the icon. We express this by using the process start
    453      // absolute timestamp.
    454      load_trigger_ts = this.processStartTs;
    455    }
    456 
    457    const session = {
    458      session_id: String(Services.uuid.generateUUID()),
    459      // "unknown" will be overwritten when appropriate
    460      page: url ? url : "unknown",
    461      perf: {
    462        load_trigger_type,
    463        is_preloaded: false,
    464      },
    465    };
    466 
    467    if (load_trigger_ts) {
    468      session.perf.load_trigger_ts = load_trigger_ts;
    469    }
    470 
    471    this.sessions.set(id, session);
    472    return session;
    473  }
    474 
    475  /**
    476   * endSession - Stop tracking a session
    477   *
    478   * @param  {string} portID the portID of the session that just closed
    479   */
    480  async endSession(portID) {
    481    const session = this.sessions.get(portID);
    482    if (!session) {
    483      // It's possible the tab was never visible – in which case, there was no user session.
    484      return;
    485    }
    486 
    487    Glean.newtab.closed.record({ newtab_visit_id: session.session_id });
    488    if (
    489      this.telemetryEnabled &&
    490      Services.prefs.getBoolPref(PREF_NEWTAB_PING_ENABLED, true)
    491    ) {
    492      GleanPings.newtab.submit("newtab_session_end");
    493      if (this.privatePingEnabled) {
    494        this.configureContentPing();
    495      }
    496    }
    497 
    498    if (session.perf.visibility_event_rcvd_ts) {
    499      let absNow = this.processStartTs + ChromeUtils.now();
    500      session.session_duration = Math.round(
    501        absNow - session.perf.visibility_event_rcvd_ts
    502      );
    503 
    504      // Rounding all timestamps in perf to ease the data processing on the backend.
    505      // NB: use `TIMESTAMP_MISSING_VALUE` if the value is missing.
    506      session.perf.visibility_event_rcvd_ts = Math.round(
    507        session.perf.visibility_event_rcvd_ts
    508      );
    509      session.perf.load_trigger_ts = Math.round(
    510        session.perf.load_trigger_ts || TIMESTAMP_MISSING_VALUE
    511      );
    512      session.perf.topsites_first_painted_ts = Math.round(
    513        session.perf.topsites_first_painted_ts || TIMESTAMP_MISSING_VALUE
    514      );
    515    } else {
    516      // This session was never shown (i.e. the hidden preloaded newtab), there was no user session either.
    517      this.sessions.delete(portID);
    518      return;
    519    }
    520 
    521    let sessionEndEvent = this.createSessionEndEvent(session);
    522    this.sendUTEvent(sessionEndEvent, this.utEvents.sendSessionEndEvent);
    523    this.sessions.delete(portID);
    524  }
    525 
    526  /**
    527   * handleNewTabInit - Handle NEW_TAB_INIT, which creates a new session and sets the a flag
    528   *                    for session.perf based on whether or not this new tab is preloaded
    529   *
    530   * @param  {obj} action the Action object
    531   */
    532  handleNewTabInit(action) {
    533    const session = this.addSession(
    534      au.getPortIdOfSender(action),
    535      action.data.url
    536    );
    537    session.perf.is_preloaded =
    538      action.data.browser.getAttribute("preloadedState") === "preloaded";
    539  }
    540 
    541  /**
    542   * createPing - Create a ping with common properties
    543   *
    544   * @param  {string} id The portID of the session, if a session is relevant (optional)
    545   * @return {obj}    A telemetry ping
    546   */
    547  createPing(portID) {
    548    const ping = {
    549      addon_version: Services.appinfo.appBuildID,
    550      locale: Services.locale.appLocaleAsBCP47,
    551      user_prefs: this.userPreferences,
    552    };
    553 
    554    // If the ping is part of a user session, add session-related info
    555    if (portID) {
    556      const session = this.sessions.get(portID) || this.addSession(portID);
    557      Object.assign(ping, { session_id: session.session_id });
    558 
    559      if (session.page) {
    560        Object.assign(ping, { page: session.page });
    561      }
    562    }
    563    return ping;
    564  }
    565 
    566  createUserEvent(action) {
    567    return Object.assign(
    568      this.createPing(au.getPortIdOfSender(action)),
    569      action.data,
    570      { action: "activity_stream_user_event" }
    571    );
    572  }
    573 
    574  createSessionEndEvent(session) {
    575    return Object.assign(this.createPing(), {
    576      session_id: session.session_id,
    577      page: session.page,
    578      session_duration: session.session_duration,
    579      action: "activity_stream_session",
    580      perf: session.perf,
    581      profile_creation_date:
    582        lazy.TelemetryEnvironment.currentEnvironment.profile.resetDate ||
    583        lazy.TelemetryEnvironment.currentEnvironment.profile.creationDate,
    584    });
    585  }
    586 
    587  sendUTEvent(event_object, eventFunction) {
    588    if (this.telemetryEnabled && this.eventTelemetryEnabled) {
    589      eventFunction(event_object);
    590    }
    591  }
    592 
    593  sovEnabled() {
    594    const { values } = this.store?.getState()?.Prefs || {};
    595    const trainhopSovEnabled = values?.trainhopConfig?.sov?.enabled;
    596    return trainhopSovEnabled;
    597  }
    598 
    599  frecencyBoostedHasExposure() {
    600    const { values } = this.store?.getState()?.Prefs || {};
    601    return values?.[PREF_SOV_FRECENCY_EXPOSURE];
    602  }
    603 
    604  async handleTopSitesSponsoredImpressionStats(action) {
    605    const { data } = action;
    606    const {
    607      type,
    608      position,
    609      source,
    610      advertiser: advertiser_name,
    611      tile_id,
    612      visible_topsites,
    613      frecency_boosted = false,
    614    } = data;
    615    // Legacy telemetry expects 1-based tile positions.
    616    const legacyTelemetryPosition = position + 1;
    617 
    618    const unifiedAdsTilesEnabled = this._prefs.get(
    619      PREF_UNIFIED_ADS_TILES_ENABLED
    620    );
    621 
    622    let pingType;
    623    const session = this.sessions.get(au.getPortIdOfSender(action));
    624 
    625    if (type === "impression") {
    626      pingType = "topsites-impression";
    627      Glean.contextualServicesTopsites.impression[
    628        `${source}_${legacyTelemetryPosition}`
    629      ].add(1);
    630      if (session) {
    631        if (this.sovEnabled()) {
    632          if (this.privatePingEnabled) {
    633            this.newtabContentPing.recordEvent("topSitesImpression", {
    634              advertiser_name,
    635              tile_id,
    636              is_sponsored: true,
    637              position,
    638              visible_topsites,
    639              frecency_boosted,
    640              frecency_boosted_has_exposure: this.frecencyBoostedHasExposure(),
    641            });
    642          }
    643        } else {
    644          Glean.topsites.impression.record({
    645            advertiser_name,
    646            tile_id,
    647            newtab_visit_id: session.session_id,
    648            is_sponsored: true,
    649            position,
    650            visible_topsites,
    651          });
    652        }
    653      }
    654    } else if (type === "click") {
    655      pingType = "topsites-click";
    656      Glean.contextualServicesTopsites.click[
    657        `${source}_${legacyTelemetryPosition}`
    658      ].add(1);
    659      if (session) {
    660        if (this.sovEnabled()) {
    661          if (this.privatePingEnabled) {
    662            this.newtabContentPing.recordEvent("topSitesClick", {
    663              advertiser_name,
    664              tile_id,
    665              is_sponsored: true,
    666              position,
    667              visible_topsites,
    668              frecency_boosted,
    669              frecency_boosted_has_exposure: this.frecencyBoostedHasExposure(),
    670            });
    671          }
    672        } else {
    673          Glean.topsites.click.record({
    674            advertiser_name,
    675            tile_id,
    676            newtab_visit_id: session.session_id,
    677            is_sponsored: true,
    678            position,
    679            visible_topsites,
    680          });
    681        }
    682      }
    683    } else {
    684      console.error("Unknown ping type for sponsored TopSites impression");
    685      return;
    686    }
    687 
    688    if (this.sovEnabled()) {
    689      Glean.topSites.pingType.set(pingType);
    690      Glean.topSites.position.set(legacyTelemetryPosition);
    691      Glean.topSites.source.set(source);
    692      Glean.topSites.tileId.set(tile_id);
    693      if (data.reporting_url && !unifiedAdsTilesEnabled) {
    694        Glean.topSites.reportingUrl.set(data.reporting_url);
    695      }
    696      Glean.topSites.advertiser.set(advertiser_name);
    697      Glean.topSites.contextId.set(await lazy.ContextId.request());
    698      GleanPings.topSites.submit();
    699    }
    700 
    701    if (data.reporting_url && this.canSendUnifiedAdsTilesCallbacks) {
    702      // Send callback events to MARS unified ads api
    703      this.sendUnifiedAdsCallbackEvent({
    704        url: data.reporting_url,
    705        position,
    706      });
    707    }
    708  }
    709 
    710  handleTopSitesOrganicImpressionStats(action) {
    711    const session = this.sessions.get(au.getPortIdOfSender(action));
    712    if (!session) {
    713      return;
    714    }
    715    const visible_topsites = action.data?.visible_topsites;
    716 
    717    switch (action.data?.type) {
    718      case "impression":
    719        Glean.topsites.impression.record({
    720          newtab_visit_id: session.session_id,
    721          is_sponsored: false,
    722          position: action.data.position,
    723          is_pinned: !!action.data.isPinned,
    724          visible_topsites,
    725          smart_scores: JSON.stringify(action.data.smartScores),
    726          smart_weights: JSON.stringify(action.data.smartWeights),
    727        });
    728        break;
    729 
    730      case "click":
    731        Glean.topsites.click.record({
    732          newtab_visit_id: session.session_id,
    733          is_sponsored: false,
    734          position: action.data.position,
    735          is_pinned: !!action.data.isPinned,
    736          visible_topsites,
    737          smart_scores: JSON.stringify(action.data.smartScores),
    738          smart_weights: JSON.stringify(action.data.smartWeights),
    739        });
    740        break;
    741 
    742      default:
    743        break;
    744    }
    745  }
    746 
    747  /**
    748   * Records the duration that spoc (ads) placeholders were visible to the user.
    749   * This tracks how long placeholder content is shown before being replaced
    750   * with actual sponsored content when using onDemand mode.
    751   *
    752   * @param {number} action.data.duration - Duration in milliseconds
    753   */
    754  handleSpocPlaceholderDuration(action) {
    755    const { duration } = action.data;
    756    if (duration !== undefined && duration >= 0) {
    757      Glean.pocket.spocPlaceholderDuration.accumulateSingleSample(duration);
    758    }
    759  }
    760 
    761  handleUserEvent(action) {
    762    let userEvent = this.createUserEvent(action);
    763    try {
    764      this.sendUTEvent(userEvent, this.utEvents.sendUserEvent);
    765    } catch (error) {}
    766 
    767    const session = this.sessions.get(au.getPortIdOfSender(action));
    768    if (!session) {
    769      return;
    770    }
    771 
    772    switch (action.data?.event) {
    773      case "PIN": {
    774        Glean.topsites.pin.record({
    775          newtab_visit_id: session.session_id,
    776          is_sponsored: false,
    777          position: action.data.action_position,
    778        });
    779        break;
    780      }
    781      case "UNPIN": {
    782        Glean.topsites.unpin.record({
    783          newtab_visit_id: session.session_id,
    784          is_sponsored: false,
    785          position: action.data.action_position,
    786        });
    787        break;
    788      }
    789      case "TOP_SITES_ADD": {
    790        Glean.topsites.add.record({
    791          newtab_visit_id: session.session_id,
    792          is_sponsored: false,
    793          position: action.data.action_position,
    794        });
    795        break;
    796      }
    797      case "TOP_SITES_EDIT": {
    798        Glean.topsites.edit.record({
    799          newtab_visit_id: session.session_id,
    800          is_sponsored: false,
    801          position: action.data.action_position,
    802          has_title_changed: action.data.hasTitleChanged,
    803          has_url_changed: action.data.hasURLChanged,
    804        });
    805        break;
    806      }
    807      case "WEATHER_DETECT_LOCATION": {
    808        Glean.newtab.weatherDetectLocation.record({
    809          newtab_visit_id: session.session_id,
    810        });
    811        break;
    812      }
    813    }
    814  }
    815 
    816  /**
    817   * @returns Flat list of all articles for the New Tab. Does not include spocs (ads)
    818   */
    819  getAllRecommendations() {
    820    const merinoData = this.store?.getState()?.DiscoveryStream?.feeds.data;
    821    return Object.values(merinoData ?? {}).flatMap(
    822      feed => feed?.data?.recommendations ?? []
    823    );
    824  }
    825 
    826  /**
    827   * @returns Number of articles for the New Tab. Does not include spocs (ads)
    828   */
    829  getRecommendationCount() {
    830    const merinoData = this.store?.getState()?.DiscoveryStream?.feeds.data;
    831    return Object.values(merinoData ?? {}).reduce(
    832      (count, feed) => count + (feed.data?.recommendations?.length || 0),
    833      0
    834    );
    835  }
    836 
    837  /**
    838   * Occasionally replaces a content item with another that is in the feed.
    839   *
    840   * @param {*} item
    841   * @returns Same item, but another item occasionally based on probablility setting.
    842   * Sponsored items are unchanged
    843   */
    844  randomizeOrganicContentEvent(item) {
    845    if (item.is_sponsored) {
    846      return item; // Don't alter spocs
    847    }
    848    const epsilon =
    849      this._privateRandomContentTelemetryProbablityValues?.epsilon ?? 0;
    850    if (!epsilon) {
    851      return item;
    852    }
    853    if (!("n" in this._privateRandomContentTelemetryProbablityValues)) {
    854      // We cache the number of items in the feed because it's computationally expensive to compute.
    855      // This may not be ideal, but the number of content items typically is very similar over reloads
    856      this._privateRandomContentTelemetryProbablityValues.n =
    857        this.getRecommendationCount();
    858    }
    859    const { n } = this._privateRandomContentTelemetryProbablityValues;
    860    if (!n || n < 10) {
    861      // None or very view articles. We're in an intermediate or error state.
    862      return item;
    863    }
    864    const cache_key = `probability_${epsilon}_${n}`; // Lookup of probability for a item size
    865    if (!(cache_key in this._privateRandomContentTelemetryProbablityValues)) {
    866      this._privateRandomContentTelemetryProbablityValues[cache_key] = {
    867        p: Math.exp(epsilon) / (Math.exp(epsilon) + n - 1),
    868      };
    869    }
    870 
    871    const { p } =
    872      this._privateRandomContentTelemetryProbablityValues[cache_key];
    873    if (lazy.NewTabContentPing.decideWithProbability(p)) {
    874      return item;
    875    }
    876    const allRecs = this.getAllRecommendations(); // Number of recommendations has changed
    877    if (!allRecs.length) {
    878      return item;
    879    }
    880 
    881    // Update number of recs for next round of checks for next round
    882    this._privateRandomContentTelemetryProbablityValues.n = allRecs.length;
    883 
    884    const randomIndex = lazy.NewTabContentPing.secureRandIntInRange(
    885      allRecs.length
    886    );
    887    let randomItem = allRecs[randomIndex];
    888    const resultItem = {
    889      ...item,
    890      topic: randomItem.topic,
    891      corpus_item_id: randomItem.corpus_item_id,
    892    };
    893    // If we're replacing a non top stories item, then assign the appropriate
    894    // section to the item
    895    if (
    896      resultItem.section &&
    897      resultItem.section !== TOP_STORIES_SECTION_NAME &&
    898      randomItem.section
    899    ) {
    900      resultItem.section = randomItem.section;
    901      resultItem.section_position = randomItem.section_position;
    902    }
    903    return resultItem;
    904  }
    905 
    906  handleDiscoveryStreamUserEvent(action) {
    907    this.handleUserEvent({
    908      ...action,
    909      data: {
    910        ...(action.data || {}),
    911        value: {
    912          ...(action.data?.value || {}),
    913        },
    914      },
    915    });
    916    const session = this.sessions.get(au.getPortIdOfSender(action));
    917 
    918    switch (action.data?.event) {
    919      // TODO: Determine if private window should be tracked?
    920      // case "OPEN_PRIVATE_WINDOW":
    921      case "OPEN_NEW_WINDOW":
    922      case "CLICK": {
    923        const {
    924          card_type,
    925          corpus_item_id,
    926          event_source,
    927          feature,
    928          fetchTimestamp,
    929          firstVisibleTimestamp,
    930          format,
    931          is_section_followed,
    932          layout_name,
    933          matches_selected_topic,
    934          received_rank,
    935          recommendation_id,
    936          recommended_at,
    937          scheduled_corpus_item_id,
    938          section_position,
    939          section,
    940          selected_topics,
    941          shim,
    942          tile_id,
    943          topic,
    944        } = action.data.value ?? {};
    945 
    946        if (
    947          action.data.source === "POPULAR_TOPICS" ||
    948          card_type === "topics_widget"
    949        ) {
    950          Glean.pocket.topicClick.record({
    951            newtab_visit_id: session.session_id,
    952            topic,
    953          });
    954        } else if (action.data.source === "FEATURE_HIGHLIGHT") {
    955          Glean.newtab.tooltipClick.record({
    956            newtab_visit_id: session.session_id,
    957            feature,
    958          });
    959        } else if (["spoc", "organic"].includes(card_type)) {
    960          const is_sponsored = card_type === "spoc";
    961          const gleanData = {
    962            newtab_visit_id: session.session_id,
    963            is_sponsored,
    964            ...(format ? { format } : {}),
    965            ...(section
    966              ? {
    967                  section,
    968                  section_position,
    969                  ...(this.sectionsPersonalizationEnabled
    970                    ? { is_section_followed: !!is_section_followed }
    971                    : {}),
    972                  layout_name,
    973                }
    974              : {}),
    975            matches_selected_topic,
    976            selected_topics,
    977            topic,
    978            position: action.data.action_position,
    979            tile_id,
    980            event_source,
    981            // We conditionally add in a few props.
    982            ...(corpus_item_id ? { corpus_item_id } : {}),
    983            ...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
    984            ...(corpus_item_id || scheduled_corpus_item_id
    985              ? {
    986                  received_rank,
    987                  recommended_at,
    988                }
    989              : {
    990                  recommendation_id,
    991                }),
    992          };
    993          Glean.pocket.click.record({
    994            ...this.redactNewTabPing(gleanData, is_sponsored),
    995            newtab_visit_id: session.session_id,
    996          });
    997          if (this.privatePingEnabled) {
    998            this.newtabContentPing.recordEvent(
    999              "click",
   1000              this.randomizeOrganicContentEvent(gleanData)
   1001            );
   1002          }
   1003          if (shim) {
   1004            if (this.canSendUnifiedAdsSpocCallbacks) {
   1005              // Send unified ads callback event
   1006              this.sendUnifiedAdsCallbackEvent({
   1007                url: shim,
   1008                position: action.data.action_position,
   1009              });
   1010            } else {
   1011              Glean.pocket.shim.set(shim);
   1012              if (fetchTimestamp) {
   1013                Glean.pocket.fetchTimestamp.set(fetchTimestamp * 1000);
   1014              }
   1015              if (firstVisibleTimestamp) {
   1016                Glean.pocket.newtabCreationTimestamp.set(
   1017                  firstVisibleTimestamp * 1000
   1018                );
   1019              }
   1020              GleanPings.spoc.submit("click");
   1021            }
   1022          }
   1023        }
   1024 
   1025        break;
   1026      }
   1027      // Bug 1969452 - Feature Highlight Telemetry Events
   1028      case "FEATURE_HIGHLIGHT_DISMISS":
   1029      case "FEATURE_HIGHLIGHT_IMPRESSION":
   1030      case "FEATURE_HIGHLIGHT_OPEN": {
   1031        // Note that Feature Highlight CLICK events are covered via newtab.tooltipClick Glean event
   1032        const { feature } = action.data.value ?? {};
   1033 
   1034        if (!feature) {
   1035          throw new Error(
   1036            `Feature ID parameter is missing from ${action.data?.event}`
   1037          );
   1038        }
   1039 
   1040        if (action.data.event === "FEATURE_HIGHLIGHT_DISMISS") {
   1041          Glean.newtab.featureHighlightDismiss.record({
   1042            newtab_visit_id: session.session_id,
   1043            feature,
   1044          });
   1045        } else if (action.data.event === "FEATURE_HIGHLIGHT_IMPRESSION") {
   1046          Glean.newtab.featureHighlightImpression.record({
   1047            newtab_visit_id: session.session_id,
   1048            feature,
   1049          });
   1050        } else if (action.data.event === "FEATURE_HIGHLIGHT_OPEN") {
   1051          Glean.newtab.featureHighlightOpen.record({
   1052            newtab_visit_id: session.session_id,
   1053            feature,
   1054          });
   1055        }
   1056 
   1057        break;
   1058      }
   1059    }
   1060  }
   1061 
   1062  /**
   1063   * This function submits callback events to the MARS unified ads service.
   1064   */
   1065 
   1066  async sendUnifiedAdsCallbackEvent(data = { url: null, position: null }) {
   1067    if (!data.url) {
   1068      throw new Error(
   1069        `[Unified ads callback] Missing argument (No url). Cannot send telemetry event.`
   1070      );
   1071    }
   1072 
   1073    // data.position can be 0 (0)
   1074    if (!data.position && data.position !== 0) {
   1075      throw new Error(
   1076        `[Unified ads callback] Missing argument (No position). Cannot send telemetry event.`
   1077      );
   1078    }
   1079 
   1080    // Make sure the callback endpoint is allowed
   1081    const allowed =
   1082      this._prefs
   1083        .get(PREF_ENDPOINTS)
   1084        .split(",")
   1085        .map(item => item.trim())
   1086        .filter(item => item) || [];
   1087    if (!allowed.some(prefix => data.url.startsWith(prefix))) {
   1088      throw new Error(
   1089        `[Unified ads callback] Not one of allowed prefixes (${allowed})`
   1090      );
   1091    }
   1092 
   1093    const url = new URL(data.url);
   1094    url.searchParams.append("position", data.position);
   1095 
   1096    const marsOhttpEnabled = Services.prefs.getBoolPref(
   1097      "browser.newtabpage.activity-stream.unifiedAds.ohttp.enabled",
   1098      false
   1099    );
   1100    const ohttpRelayURL = Services.prefs.getStringPref(
   1101      "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL",
   1102      ""
   1103    );
   1104    const ohttpConfigURL = Services.prefs.getStringPref(
   1105      "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL",
   1106      ""
   1107    );
   1108 
   1109    let fetchPromise;
   1110    const fetchUrl = url.toString();
   1111 
   1112    if (marsOhttpEnabled) {
   1113      if (!ohttpRelayURL) {
   1114        console.error(
   1115          new Error(
   1116            `OHTTP was configured for ${fetchUrl} but we didn't have a valid ohttpRelayURL`
   1117          )
   1118        );
   1119      }
   1120      if (!ohttpConfigURL) {
   1121        console.error(
   1122          new Error(
   1123            `OHTTP was configured for ${fetchUrl} but we didn't have a valid ohttpConfigURL`
   1124          )
   1125        );
   1126      }
   1127 
   1128      const headers = new Headers();
   1129      const controller = new AbortController();
   1130      const { signal } = controller;
   1131 
   1132      const options = {
   1133        method: "GET",
   1134        headers,
   1135        signal,
   1136      };
   1137 
   1138      let config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL);
   1139      if (!config) {
   1140        console.error(
   1141          new Error(
   1142            `OHTTP was configured for ${fetchUrl} but we couldn't fetch a valid config`
   1143          )
   1144        );
   1145      }
   1146 
   1147      fetchPromise = lazy.ObliviousHTTP.ohttpRequest(
   1148        ohttpRelayURL,
   1149        config,
   1150        fetchUrl,
   1151        options
   1152      );
   1153    } else {
   1154      fetchPromise = fetch(fetchUrl);
   1155    }
   1156 
   1157    try {
   1158      await fetchPromise;
   1159    } catch (error) {
   1160      console.error("Error:", error);
   1161    }
   1162  }
   1163 
   1164  async sendPageTakeoverData() {
   1165    if (this.telemetryEnabled) {
   1166      const value = {};
   1167      let homeAffected = false;
   1168      let newtabCategory = "disabled";
   1169      let homePageCategory = "disabled";
   1170 
   1171      // Check whether or not about:home and about:newtab are set to a custom URL.
   1172      // If so, classify them.
   1173      if (Services.prefs.getBoolPref("browser.newtabpage.enabled")) {
   1174        newtabCategory = "enabled";
   1175        if (
   1176          lazy.AboutNewTab.newTabURLOverridden &&
   1177          !lazy.ExtensionUtils.isExtensionUrl(lazy.AboutNewTab.newTabURL)
   1178        ) {
   1179          value.newtab_url_category = await this._classifySite(
   1180            lazy.AboutNewTab.newTabURL
   1181          );
   1182          newtabCategory = value.newtab_url_category;
   1183        }
   1184      }
   1185      // Check if the newtab page setting is controlled by an extension.
   1186      await lazy.ExtensionSettingsStore.initialize();
   1187      const newtabExtensionInfo = lazy.ExtensionSettingsStore.getSetting(
   1188        "url_overrides",
   1189        "newTabURL"
   1190      );
   1191      if (newtabExtensionInfo && newtabExtensionInfo.id) {
   1192        value.newtab_extension_id = newtabExtensionInfo.id;
   1193        newtabCategory = "extension";
   1194      }
   1195 
   1196      const homePageURL = lazy.HomePage.get();
   1197      if (
   1198        !["about:home", "about:blank", BLANK_HOMEPAGE_URL].includes(
   1199          homePageURL
   1200        ) &&
   1201        !lazy.ExtensionUtils.isExtensionUrl(homePageURL)
   1202      ) {
   1203        value.home_url_category = await this._classifySite(homePageURL);
   1204        homeAffected = true;
   1205        homePageCategory = value.home_url_category;
   1206      }
   1207      const homeExtensionInfo = lazy.ExtensionSettingsStore.getSetting(
   1208        "prefs",
   1209        "homepage_override"
   1210      );
   1211      if (homeExtensionInfo && homeExtensionInfo.id) {
   1212        value.home_extension_id = homeExtensionInfo.id;
   1213        homeAffected = true;
   1214        homePageCategory = "extension";
   1215      }
   1216      if (!homeAffected && !lazy.HomePage.overridden) {
   1217        homePageCategory = "enabled";
   1218      }
   1219 
   1220      Glean.newtab.newtabCategory.set(newtabCategory);
   1221      Glean.newtab.homepageCategory.set(homePageCategory);
   1222 
   1223      if (Services.prefs.getBoolPref(PREF_NEWTAB_PING_ENABLED, true)) {
   1224        if (this.privatePingEnabled) {
   1225          this.configureContentPing();
   1226        }
   1227        GleanPings.newtab.submit("component_init");
   1228      }
   1229    }
   1230  }
   1231 
   1232  /**
   1233   * Populates the newtab-content ping with metrics, and the schedules
   1234   * submission of the ping via NewTabContentPing.
   1235   */
   1236  async configureContentPing() {
   1237    let privateMetrics = {};
   1238    const prefs = this.store.getState()?.Prefs.values; // Needed for experimenter configs
   1239    const inferredInterests =
   1240      this.privatePingInferredInterestsEnabled && this.inferredInterests;
   1241    if (inferredInterests) {
   1242      privateMetrics.inferredInterests = inferredInterests;
   1243    }
   1244    this._privateRandomContentTelemetryProbablityValues = {
   1245      epsilon:
   1246        (prefs?.trainhopConfig?.newtabPrivatePing?.[
   1247          TRAINHOP_PREF_RANDOM_CLICK_PROBABILITY_MICRO
   1248        ] || 0) / 1e6,
   1249    };
   1250    const privatePingConfig = prefs?.trainhopConfig?.newtabPrivatePing || {};
   1251    // Set the daily cap for content pings
   1252    const impressionCap = privatePingConfig[TRAINHOP_PREF_DAILY_EVENT_CAP] || 0;
   1253    this.newtabContentPing.setMaxEventsPerDay(impressionCap);
   1254    const clickDailyCap =
   1255      privatePingConfig[TRAINHOP_PREF_DAILY_CLICK_EVENT_CAP] || 0;
   1256    this.newtabContentPing.setMaxClickEventsPerDay(clickDailyCap);
   1257    const weeklyClickCap =
   1258      privatePingConfig[TRAINHOP_PREF_WEEKLY_CLICK_EVENT_CAP] || 0;
   1259    this.newtabContentPing.setMaxClickEventsPerWeek(weeklyClickCap);
   1260 
   1261    // When we have a coarse interest vector we want to make sure there isn't
   1262    // anything additionaly identifable as a unique identifier. Therefore,
   1263    // when interest vectors are used we reduce our context profile somewhat.
   1264    const reduceTrackingInformation = !!inferredInterests;
   1265 
   1266    if (!reduceTrackingInformation) {
   1267      const followed = this.getFollowedSections();
   1268      privateMetrics.followedSections = followed;
   1269    }
   1270    const surfaceId = this._prefs.get(PREF_SURFACE_ID);
   1271    privateMetrics.surfaceId = surfaceId;
   1272 
   1273    const curCountry = lazy.Region.home;
   1274    if (PRIVATE_PING_SURFACE_COUNTRY_MAP[surfaceId]) {
   1275      // This is a market that supports inferred
   1276      // Only include supported current countries for the surface to reduce identifiability.
   1277      // Default to first country on the list
   1278      privateMetrics.country = PRIVATE_PING_SURFACE_COUNTRY_MAP[
   1279        surfaceId
   1280      ].includes(curCountry)
   1281        ? curCountry
   1282        : PRIVATE_PING_SURFACE_COUNTRY_MAP[surfaceId][0];
   1283    }
   1284 
   1285    if (prefs.inferredPersonalizationConfig?.normalized_time_zone_offset) {
   1286      privateMetrics.utcOffset = lazy.NewTabUtils.getUtcOffset(surfaceId);
   1287    }
   1288    // To prevent fingerprinting we only send one current experiment / branch
   1289    const experimentMetadata =
   1290      lazy.NimbusFeatures.pocketNewtab.getEnrollmentMetadata();
   1291    privateMetrics.experimentName = experimentMetadata?.slug ?? "";
   1292    privateMetrics.experimentBranch = experimentMetadata?.branch ?? "";
   1293    privateMetrics.pingVersion = CONTENT_PING_VERSION;
   1294    this.newtabContentPing.scheduleSubmission(privateMetrics);
   1295  }
   1296 
   1297  async onAction(action) {
   1298    switch (action.type) {
   1299      case at.INIT:
   1300        this.init();
   1301        await this.sendPageTakeoverData();
   1302        break;
   1303      case at.NEW_TAB_INIT:
   1304        this.handleNewTabInit(action);
   1305        break;
   1306      case at.NEW_TAB_UNLOAD:
   1307        this.endSession(au.getPortIdOfSender(action));
   1308        break;
   1309      case at.SAVE_SESSION_PERF_DATA:
   1310        this.saveSessionPerfData(au.getPortIdOfSender(action), action.data);
   1311        break;
   1312      case at.DISCOVERY_STREAM_IMPRESSION_STATS:
   1313        this.handleDiscoveryStreamImpressionStats(
   1314          au.getPortIdOfSender(action),
   1315          action.data
   1316        );
   1317        break;
   1318      case at.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION:
   1319        this.handleSpocPlaceholderDuration(action);
   1320        break;
   1321      case at.DISCOVERY_STREAM_USER_EVENT:
   1322        this.handleDiscoveryStreamUserEvent(action);
   1323        break;
   1324      case at.TELEMETRY_USER_EVENT:
   1325        this.handleUserEvent(action);
   1326        break;
   1327      case at.TOP_SITES_SPONSORED_IMPRESSION_STATS:
   1328        this.handleTopSitesSponsoredImpressionStats(action);
   1329        break;
   1330      case at.TOP_SITES_ORGANIC_IMPRESSION_STATS:
   1331        this.handleTopSitesOrganicImpressionStats(action);
   1332        break;
   1333      case at.UNINIT:
   1334        this.uninit();
   1335        break;
   1336      case at.ABOUT_SPONSORED_TOP_SITES:
   1337        this.handleAboutSponsoredTopSites(action);
   1338        break;
   1339      case at.BLOCK_URL:
   1340        this.handleBlockUrl(action);
   1341        break;
   1342      case at.WALLPAPER_CATEGORY_CLICK:
   1343      case at.WALLPAPER_CLICK:
   1344      case at.WALLPAPERS_FEATURE_HIGHLIGHT_DISMISSED:
   1345      case at.WALLPAPERS_FEATURE_HIGHLIGHT_CTA_CLICKED:
   1346      case at.WALLPAPER_UPLOAD:
   1347        this.handleWallpaperUserEvent(action);
   1348        break;
   1349      case at.SET_PREF:
   1350        this.handleSetPref(action);
   1351        break;
   1352      case at.WEATHER_IMPRESSION:
   1353      case at.WEATHER_LOAD_ERROR:
   1354      case at.WEATHER_OPEN_PROVIDER_URL:
   1355      case at.WEATHER_LOCATION_DATA_UPDATE:
   1356      case at.WEATHER_OPT_IN_PROMPT_SELECTION:
   1357        this.handleWeatherUserEvent(action);
   1358        break;
   1359      case at.TOPIC_SELECTION_USER_OPEN:
   1360      case at.TOPIC_SELECTION_USER_DISMISS:
   1361      case at.TOPIC_SELECTION_USER_SAVE:
   1362        this.handleTopicSelectionUserEvent(action);
   1363        break;
   1364      case at.BLOCK_SECTION:
   1365      // Intentional fall-through
   1366      case at.CARD_SECTION_IMPRESSION:
   1367      // Intentional fall-through
   1368      case at.FOLLOW_SECTION:
   1369      // Intentional fall-through
   1370      case at.UNBLOCK_SECTION:
   1371      // Intentional fall-through
   1372      case at.UNFOLLOW_SECTION: {
   1373        this.handleCardSectionUserEvent(action);
   1374        break;
   1375      }
   1376      case at.INLINE_SELECTION_CLICK:
   1377      // Intentional fall-through
   1378      case at.INLINE_SELECTION_IMPRESSION:
   1379        this.handleInlineSelectionUserEvent(action);
   1380        break;
   1381      case at.REPORT_AD_OPEN:
   1382      case at.REPORT_AD_SUBMIT:
   1383        this.handleReportAdUserEvent(action);
   1384        break;
   1385      case at.REPORT_CONTENT_OPEN:
   1386      case at.REPORT_CONTENT_SUBMIT:
   1387        this.handleReportContentUserEvent(action);
   1388        break;
   1389      case at.WIDGETS_LISTS_USER_EVENT:
   1390      case at.WIDGETS_LISTS_USER_IMPRESSION:
   1391      case at.WIDGETS_TIMER_USER_EVENT:
   1392      case at.WIDGETS_TIMER_USER_IMPRESSION:
   1393        this.handleWidgetsUserEvent(action);
   1394        break;
   1395      case at.PROMO_CARD_CLICK:
   1396      case at.PROMO_CARD_DISMISS:
   1397      case at.PROMO_CARD_IMPRESSION:
   1398        this.handlePromoCardUserEvent(action);
   1399        break;
   1400    }
   1401  }
   1402 
   1403  handlePromoCardUserEvent(action) {
   1404    const session = this.sessions.get(au.getPortIdOfSender(action));
   1405    if (session) {
   1406      const payload = {
   1407        newtab_visit_id: session.visit_id,
   1408      };
   1409 
   1410      switch (action.type) {
   1411        case at.PROMO_CARD_CLICK:
   1412          Glean.newtab.promoCardClick.record(payload);
   1413          break;
   1414        case at.PROMO_CARD_DISMISS:
   1415          Glean.newtab.promoCardDismiss.record(payload);
   1416          break;
   1417        case at.PROMO_CARD_IMPRESSION:
   1418          Glean.newtab.promoCardImpression.record(payload);
   1419          break;
   1420      }
   1421    }
   1422  }
   1423 
   1424  handleWidgetsUserEvent(action) {
   1425    const session = this.sessions.get(au.getPortIdOfSender(action));
   1426    if (session) {
   1427      const payload = {
   1428        newtab_visit_id: session.visit_id,
   1429      };
   1430      switch (action.type) {
   1431        case "WIDGETS_LISTS_USER_EVENT":
   1432          Glean.newtab.widgetsListsUserEvent.record({
   1433            ...payload,
   1434            user_action: action.data.userAction,
   1435          });
   1436          break;
   1437        case "WIDGETS_LISTS_USER_IMPRESSION":
   1438          Glean.newtab.widgetsListsImpression.record(payload);
   1439          break;
   1440        case "WIDGETS_TIMER_USER_EVENT":
   1441          Glean.newtab.widgetsTimerUserEvent.record({
   1442            ...payload,
   1443            user_action: action.data.userAction,
   1444          });
   1445          break;
   1446        case "WIDGETS_TIMER_USER_IMPRESSION":
   1447          Glean.newtab.widgetsTimerImpression.record(payload);
   1448          break;
   1449      }
   1450    }
   1451  }
   1452 
   1453  async handleReportAdUserEvent(action) {
   1454    const { placement_id, position, report_reason, reporting_url } =
   1455      action.data || {};
   1456 
   1457    const url = new URL(reporting_url);
   1458    url.searchParams.append("placement_id", placement_id);
   1459    url.searchParams.append("reason", report_reason);
   1460    url.searchParams.append("position", position);
   1461    const adResponse = url.toString();
   1462 
   1463    const allowed =
   1464      this._prefs
   1465        .get(PREF_ENDPOINTS)
   1466        .split(",")
   1467        .map(item => item.trim())
   1468        .filter(item => item) || [];
   1469 
   1470    if (!allowed.some(prefix => adResponse.startsWith(prefix))) {
   1471      throw new Error(
   1472        `[Unified ads callback] Not one of allowed prefixes (${allowed})`
   1473      );
   1474    }
   1475 
   1476    try {
   1477      await fetch(adResponse);
   1478    } catch (error) {
   1479      console.error("Error:", error);
   1480    }
   1481  }
   1482 
   1483  handleReportContentUserEvent(action) {
   1484    const session = this.sessions.get(au.getPortIdOfSender(action));
   1485    const {
   1486      card_type,
   1487      corpus_item_id,
   1488      report_reason,
   1489      scheduled_corpus_item_id,
   1490      section_position,
   1491      section,
   1492      title,
   1493      topic,
   1494      url,
   1495    } = action.data || {};
   1496 
   1497    if (session) {
   1498      switch (action.type) {
   1499        case "REPORT_CONTENT_OPEN": {
   1500          if (!this.privatePingEnabled) {
   1501            return;
   1502          }
   1503 
   1504          const gleanData = {
   1505            corpus_item_id,
   1506            scheduled_corpus_item_id,
   1507          };
   1508 
   1509          Glean.newtabContent.reportContentOpen.record(gleanData);
   1510 
   1511          break;
   1512        }
   1513        case "REPORT_CONTENT_SUBMIT": {
   1514          const gleanData = {
   1515            card_type,
   1516            corpus_item_id,
   1517            report_reason,
   1518            scheduled_corpus_item_id,
   1519            section_position,
   1520            section,
   1521            title,
   1522            topic,
   1523            url,
   1524          };
   1525 
   1526          if (this.privatePingEnabled) {
   1527            Glean.newtabContent.reportContentSubmit.record(gleanData);
   1528          }
   1529          break;
   1530        }
   1531      }
   1532    }
   1533  }
   1534 
   1535  handleCardSectionUserEvent(action) {
   1536    const session = this.sessions.get(au.getPortIdOfSender(action));
   1537    if (session) {
   1538      const {
   1539        section,
   1540        section_position,
   1541        event_source,
   1542        is_section_followed,
   1543        layout_name,
   1544      } = action.data;
   1545      const gleanDataForPrivatePing = {
   1546        section,
   1547        section_position,
   1548        event_source,
   1549      };
   1550 
   1551      const gleanDataForNewtabPing = {
   1552        ...gleanDataForPrivatePing,
   1553        newtab_visit_id: session.session_id,
   1554      };
   1555 
   1556      switch (action.type) {
   1557        case "BLOCK_SECTION":
   1558          Glean.newtab.sectionsBlockSection.record(
   1559            this.redactNewTabPing(gleanDataForNewtabPing)
   1560          );
   1561          if (this.privatePingEnabled) {
   1562            this.newtabContentPing.recordEvent(
   1563              "sectionsBlockSection",
   1564              gleanDataForPrivatePing
   1565            );
   1566          }
   1567          break;
   1568        case "UNBLOCK_SECTION":
   1569          Glean.newtab.sectionsUnblockSection.record(
   1570            this.redactNewTabPing(gleanDataForNewtabPing)
   1571          );
   1572          if (this.privatePingEnabled) {
   1573            this.newtabContentPing.recordEvent(
   1574              "sectionsUnblockSection",
   1575              gleanDataForPrivatePing
   1576            );
   1577          }
   1578          break;
   1579        case "CARD_SECTION_IMPRESSION":
   1580          {
   1581            const gleanData = {
   1582              newtab_visit_id: session.session_id,
   1583              section,
   1584              section_position,
   1585              ...(this.sectionsPersonalizationEnabled
   1586                ? { is_section_followed: !!is_section_followed }
   1587                : {}),
   1588              layout_name,
   1589            };
   1590            Glean.newtab.sectionsImpression.record(
   1591              this.redactNewTabPing(gleanData)
   1592            );
   1593            if (this.privatePingEnabled) {
   1594              this.newtabContentPing.recordEvent("sectionsImpression", {
   1595                section,
   1596                section_position,
   1597                ...(this.sectionsPersonalizationEnabled
   1598                  ? { is_section_followed: !!is_section_followed }
   1599                  : {}),
   1600              });
   1601            }
   1602          }
   1603          break;
   1604        case "FOLLOW_SECTION": {
   1605          Glean.newtab.sectionsFollowSection.record(
   1606            this.redactNewTabPing(gleanDataForNewtabPing)
   1607          );
   1608          if (this.privatePingEnabled) {
   1609            this.newtabContentPing.recordEvent(
   1610              "sectionsFollowSection",
   1611              gleanDataForPrivatePing
   1612            );
   1613          }
   1614          break;
   1615        }
   1616        case "UNFOLLOW_SECTION":
   1617          Glean.newtab.sectionsUnfollowSection.record(
   1618            this.redactNewTabPing(gleanDataForNewtabPing)
   1619          );
   1620          if (this.privatePingEnabled) {
   1621            this.newtabContentPing.recordEvent(
   1622              "sectionsUnfollowSection",
   1623              gleanDataForPrivatePing
   1624            );
   1625          }
   1626          break;
   1627        default:
   1628          break;
   1629      }
   1630    }
   1631  }
   1632 
   1633  handleInlineSelectionUserEvent(action) {
   1634    const session = this.sessions.get(au.getPortIdOfSender(action));
   1635    if (session) {
   1636      switch (action.type) {
   1637        case "INLINE_SELECTION_CLICK": {
   1638          const { topic, section_position, position, is_followed } =
   1639            action.data;
   1640          Glean.newtab.inlineSelectionClick.record({
   1641            newtab_visit_id: session.session_id,
   1642            topic,
   1643            section_position,
   1644            position,
   1645            is_followed,
   1646          });
   1647          break;
   1648        }
   1649        case "INLINE_SELECTION_IMPRESSION":
   1650          Glean.newtab.inlineSelectionImpression.record({
   1651            newtab_visit_id: session.session_id,
   1652            section_position: action.data.section_position,
   1653          });
   1654          break;
   1655      }
   1656    }
   1657  }
   1658 
   1659  handleTopicSelectionUserEvent(action) {
   1660    const session = this.sessions.get(au.getPortIdOfSender(action));
   1661    if (session) {
   1662      switch (action.type) {
   1663        case "TOPIC_SELECTION_USER_OPEN":
   1664          Glean.newtab.topicSelectionOpen.record({
   1665            newtab_visit_id: session.session_id,
   1666          });
   1667          break;
   1668        case "TOPIC_SELECTION_USER_DISMISS":
   1669          Glean.newtab.topicSelectionDismiss.record({
   1670            newtab_visit_id: session.session_id,
   1671          });
   1672          break;
   1673        case "TOPIC_SELECTION_USER_SAVE":
   1674          Glean.newtab.topicSelectionTopicsSaved.record({
   1675            newtab_visit_id: session.session_id,
   1676            topics: action.data.topics,
   1677            previous_topics: action.data.previous_topics,
   1678            first_save: action.data.first_save,
   1679          });
   1680          break;
   1681        default:
   1682          break;
   1683      }
   1684    }
   1685  }
   1686 
   1687  handleSetPref(action) {
   1688    const session = this.sessions.get(au.getPortIdOfSender(action));
   1689    if (!session) {
   1690      return;
   1691    }
   1692    switch (action.data.name) {
   1693      case "weather.display":
   1694        Glean.newtab.weatherChangeDisplay.record({
   1695          newtab_visit_id: session.session_id,
   1696          weather_display_mode: action.data.value,
   1697        });
   1698        break;
   1699      case "widgets.lists.enabled":
   1700        Glean.newtab.widgetsListsChangeDisplay.record({
   1701          newtab_visit_id: session.session_id,
   1702          display_status: action.data.value,
   1703        });
   1704        break;
   1705      case "widgets.focusTimer.enabled":
   1706        Glean.newtab.widgetsTimerChangeDisplay.record({
   1707          newtab_visit_id: session.session_id,
   1708          display_status: action.data.value,
   1709        });
   1710        break;
   1711    }
   1712  }
   1713 
   1714  handleWeatherUserEvent(action) {
   1715    const session = this.sessions.get(au.getPortIdOfSender(action));
   1716 
   1717    if (!session) {
   1718      return;
   1719    }
   1720 
   1721    // Weather specific telemtry events can be added and parsed here.
   1722    switch (action.type) {
   1723      case "WEATHER_IMPRESSION":
   1724        Glean.newtab.weatherImpression.record({
   1725          newtab_visit_id: session.session_id,
   1726        });
   1727        break;
   1728      case "WEATHER_LOAD_ERROR":
   1729        Glean.newtab.weatherLoadError.record({
   1730          newtab_visit_id: session.session_id,
   1731        });
   1732        break;
   1733      case "WEATHER_OPEN_PROVIDER_URL":
   1734        Glean.newtab.weatherOpenProviderUrl.record({
   1735          newtab_visit_id: session.session_id,
   1736        });
   1737        break;
   1738      case "WEATHER_LOCATION_DATA_UPDATE":
   1739        Glean.newtab.weatherLocationSelected.record({
   1740          newtab_visit_id: session.session_id,
   1741        });
   1742        break;
   1743      case "WEATHER_OPT_IN_PROMPT_SELECTION":
   1744        Glean.newtab.weatherOptInSelection.record({
   1745          newtab_visit_id: session.session_id,
   1746          user_selection: action.data,
   1747        });
   1748        break;
   1749      default:
   1750        break;
   1751    }
   1752  }
   1753 
   1754  handleWallpaperUserEvent(action) {
   1755    const session = this.sessions.get(au.getPortIdOfSender(action));
   1756 
   1757    if (!session) {
   1758      return;
   1759    }
   1760 
   1761    const { data } = action;
   1762 
   1763    // Wallpaper specific telemtry events can be added and parsed here.
   1764    switch (action.type) {
   1765      case "WALLPAPER_CATEGORY_CLICK":
   1766        Glean.newtab.wallpaperCategoryClick.record({
   1767          newtab_visit_id: session.session_id,
   1768          selected_category: action.data,
   1769        });
   1770        break;
   1771      case "WALLPAPER_CLICK":
   1772        {
   1773          const {
   1774            selected_wallpaper,
   1775            had_previous_wallpaper,
   1776            had_uploaded_previously,
   1777          } = data;
   1778 
   1779          // if either of the wallpaper prefs are truthy, they had a previous wallpaper
   1780          Glean.newtab.wallpaperClick.record({
   1781            newtab_visit_id: session.session_id,
   1782            selected_wallpaper,
   1783            had_previous_wallpaper,
   1784            had_uploaded_previously,
   1785          });
   1786        }
   1787        break;
   1788      case "WALLPAPERS_FEATURE_HIGHLIGHT_CTA_CLICKED":
   1789        Glean.newtab.wallpaperHighlightCtaClick.record({
   1790          newtab_visit_id: session.session_id,
   1791        });
   1792        break;
   1793      case "WALLPAPERS_FEATURE_HIGHLIGHT_DISMISSED":
   1794        Glean.newtab.wallpaperHighlightDismissed.record({
   1795          newtab_visit_id: session.session_id,
   1796        });
   1797        break;
   1798      default:
   1799        break;
   1800    }
   1801  }
   1802 
   1803  handleBlockUrl(action) {
   1804    const session = this.sessions.get(au.getPortIdOfSender(action));
   1805    // TODO: Do we want to not send this unless there's a newtab_visit_id?
   1806    if (!session) {
   1807      return;
   1808    }
   1809 
   1810    // Despite the action name, this is actually a bulk dismiss action:
   1811    // it can be applied to multiple topsites simultaneously.
   1812    const { data } = action;
   1813    for (const datum of data) {
   1814      const { corpus_item_id, scheduled_corpus_item_id } = datum;
   1815 
   1816      if (datum.is_pocket_card) {
   1817        const gleanData = {
   1818          is_sponsored: datum.card_type === "spoc",
   1819          ...(datum.format ? { format: datum.format } : {}),
   1820          position: datum.position,
   1821          tile_id: datum.id || datum.tile_id,
   1822          ...(datum.section
   1823            ? {
   1824                section: datum.section,
   1825                section_position: datum.section_position,
   1826                ...(this.sectionsPersonalizationEnabled
   1827                  ? { is_section_followed: !!datum.is_section_followed }
   1828                  : {}),
   1829              }
   1830            : {}),
   1831          // We conditionally add in a few props.
   1832          ...(corpus_item_id ? { corpus_item_id } : {}),
   1833          ...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
   1834          ...(corpus_item_id || scheduled_corpus_item_id
   1835            ? {
   1836                received_rank: datum.received_rank,
   1837                recommended_at: datum.recommended_at,
   1838              }
   1839            : {
   1840                recommendation_id: datum.recommendation_id,
   1841              }),
   1842        };
   1843 
   1844        Glean.pocket.dismiss.record({
   1845          ...this.redactNewTabPing(gleanData, gleanData.is_sponsored),
   1846          newtab_visit_id: session.session_id,
   1847        });
   1848 
   1849        if (this.privatePingEnabled) {
   1850          this.newtabContentPing.recordEvent("dismiss", gleanData);
   1851        }
   1852        continue;
   1853      }
   1854      // Only log a topsites.dismiss telemetry event if the action came from TopSites section
   1855      if (action.source === "TOP_SITES") {
   1856        const { position, advertiser_name, tile_id, isSponsoredTopSite } =
   1857          datum;
   1858        if (this.sovEnabled() && isSponsoredTopSite) {
   1859          if (this.privatePingEnabled) {
   1860            this.newtabContentPing.recordEvent("topSitesDismiss", {
   1861              advertiser_name,
   1862              tile_id,
   1863              is_sponsored: !!isSponsoredTopSite,
   1864              position,
   1865            });
   1866          }
   1867        } else {
   1868          Glean.topsites.dismiss.record({
   1869            advertiser_name,
   1870            tile_id,
   1871            newtab_visit_id: session.session_id,
   1872            is_sponsored: !!isSponsoredTopSite,
   1873            position,
   1874          });
   1875        }
   1876      }
   1877    }
   1878  }
   1879 
   1880  handleAboutSponsoredTopSites(action) {
   1881    const session = this.sessions.get(au.getPortIdOfSender(action));
   1882    const { data } = action;
   1883    const { position, advertiser_name, tile_id } = data;
   1884 
   1885    if (session) {
   1886      if (this.sovEnabled()) {
   1887        if (this.privatePingEnabled) {
   1888          this.newtabContentPing.recordEvent("topSitesShowPrivacyClick", {
   1889            advertiser_name,
   1890            tile_id,
   1891            position,
   1892          });
   1893        }
   1894      } else {
   1895        Glean.topsites.showPrivacyClick.record({
   1896          advertiser_name,
   1897          tile_id,
   1898          newtab_visit_id: session.session_id,
   1899          position,
   1900        });
   1901      }
   1902    }
   1903  }
   1904 
   1905  /**
   1906   * Handle impression stats actions from Discovery Stream.
   1907   *
   1908   * @param {string} port  The session port with which this is associated
   1909   * @param {object} data  The impression data structured as {source: "SOURCE", tiles: [{id: 123}]}
   1910   */
   1911  handleDiscoveryStreamImpressionStats(port, data) {
   1912    let session = this.sessions.get(port);
   1913 
   1914    if (!session) {
   1915      throw new Error("Session does not exist.");
   1916    }
   1917 
   1918    const { tiles } = data;
   1919 
   1920    tiles.forEach(tile => {
   1921      const { corpus_item_id, scheduled_corpus_item_id } = tile;
   1922      const is_sponsored = tile.type === "spoc";
   1923      const gleanData = {
   1924        is_sponsored,
   1925        ...(tile.format ? { format: tile.format } : {}),
   1926        ...(tile.section
   1927          ? {
   1928              section: tile.section,
   1929              section_position: tile.section_position,
   1930              ...(this.sectionsPersonalizationEnabled
   1931                ? { is_section_followed: !!tile.is_section_followed }
   1932                : {}),
   1933              layout_name: tile.layout_name,
   1934            }
   1935          : {}),
   1936        position: tile.pos,
   1937        tile_id: tile.id,
   1938        topic: tile.topic,
   1939        selected_topics: tile.selectedTopics,
   1940        is_list_card: tile.is_list_card,
   1941        // We conditionally add in a few props.
   1942        ...(corpus_item_id ? { corpus_item_id } : {}),
   1943        ...(scheduled_corpus_item_id ? { scheduled_corpus_item_id } : {}),
   1944        ...(corpus_item_id || scheduled_corpus_item_id
   1945          ? {
   1946              received_rank: tile.received_rank,
   1947              recommended_at: tile.recommended_at,
   1948            }
   1949          : {
   1950              recommendation_id: tile.recommendation_id,
   1951            }),
   1952      };
   1953      Glean.pocket.impression.record({
   1954        ...this.redactNewTabPing(gleanData, is_sponsored),
   1955        newtab_visit_id: session.session_id,
   1956      });
   1957      if (this.privatePingEnabled) {
   1958        this.newtabContentPing.recordEvent("impression", gleanData);
   1959      }
   1960 
   1961      if (tile.shim) {
   1962        if (this.canSendUnifiedAdsSpocCallbacks) {
   1963          // Send unified ads callback event
   1964          this.sendUnifiedAdsCallbackEvent({
   1965            url: tile.shim,
   1966            position: tile.pos,
   1967          });
   1968        } else {
   1969          Glean.pocket.shim.set(tile.shim);
   1970          if (tile.fetchTimestamp) {
   1971            Glean.pocket.fetchTimestamp.set(tile.fetchTimestamp * 1000);
   1972          }
   1973          if (data.firstVisibleTimestamp) {
   1974            Glean.pocket.newtabCreationTimestamp.set(
   1975              data.firstVisibleTimestamp * 1000
   1976            );
   1977          }
   1978          GleanPings.spoc.submit("impression");
   1979        }
   1980      }
   1981    });
   1982  }
   1983 
   1984  /**
   1985   * Take all enumerable members of the data object and merge them into
   1986   * the session.perf object for the given port, so that it is sent to the
   1987   * server when the session ends.  All members of the data object should
   1988   * be valid values of the perf object, as defined in pings.js and the
   1989   * data*.md documentation.
   1990   *
   1991   * Note: Any existing keys with the same names already in the
   1992   * session perf object will be overwritten by values passed in here.
   1993   *
   1994   * @param {string} port  The session with which this is associated
   1995   * @param {object} data  The perf data to be
   1996   */
   1997  saveSessionPerfData(port, data) {
   1998    // XXX should use try/catch and send a bad state indicator if this
   1999    // get blows up.
   2000    let session = this.sessions.get(port);
   2001 
   2002    // XXX Partial workaround for #3118; avoids the worst incorrect associations
   2003    // of times with browsers, by associating the load trigger with the
   2004    // visibility event as the user is most likely associating the trigger to
   2005    // the tab just shown. This helps avoid associating with a preloaded
   2006    // browser as those don't get the event until shown. Better fix for more
   2007    // cases forthcoming.
   2008    //
   2009    // XXX the about:home check (and the corresponding test) should go away
   2010    // once the load_trigger stuff in addSession is refactored into
   2011    // setLoadTriggerInfo.
   2012    //
   2013    if (data.visibility_event_rcvd_ts && session.page !== "about:home") {
   2014      this.setLoadTriggerInfo(port);
   2015    }
   2016 
   2017    let timestamp = data.topsites_first_painted_ts;
   2018 
   2019    if (
   2020      timestamp &&
   2021      session.page === "about:home" &&
   2022      !lazy.HomePage.overridden &&
   2023      Services.prefs.getIntPref("browser.startup.page") === 1
   2024    ) {
   2025      lazy.AboutNewTab.maybeRecordTopsitesPainted(timestamp);
   2026    }
   2027 
   2028    Object.assign(session.perf, data);
   2029 
   2030    if (data.visibility_event_rcvd_ts && !session.newtabOpened) {
   2031      session.newtabOpened = true;
   2032      const source = ONBOARDING_ALLOWED_PAGE_VALUES.includes(session.page)
   2033        ? session.page
   2034        : "other";
   2035      Glean.newtab.opened.record({
   2036        newtab_visit_id: session.session_id,
   2037        source,
   2038        window_inner_height: data.window_inner_height,
   2039        window_inner_width: data.window_inner_width,
   2040      });
   2041    }
   2042  }
   2043 
   2044  _beginObservingNewtabPingPrefs() {
   2045    Services.prefs.addObserver(ACTIVITY_STREAM_PREF_BRANCH, this);
   2046 
   2047    for (const pref of Object.keys(NEWTAB_PING_PREFS)) {
   2048      const fullPrefName = ACTIVITY_STREAM_PREF_BRANCH + pref;
   2049      this._setNewtabPrefMetrics(fullPrefName, false);
   2050    }
   2051 
   2052    Services.prefs.addObserver(TOP_SITES_BLOCKED_SPONSORS_PREF, this);
   2053    this._setBlockedSponsorsMetrics();
   2054 
   2055    Services.prefs.addObserver(TOPIC_SELECTION_SELECTED_TOPICS_PREF, this);
   2056    this._setTopicSelectionSelectedTopicsMetrics();
   2057  }
   2058 
   2059  _stopObservingNewtabPingPrefs() {
   2060    Services.prefs.removeObserver(ACTIVITY_STREAM_PREF_BRANCH, this);
   2061    Services.prefs.removeObserver(TOP_SITES_BLOCKED_SPONSORS_PREF, this);
   2062    Services.prefs.removeObserver(TOPIC_SELECTION_SELECTED_TOPICS_PREF, this);
   2063  }
   2064 
   2065  observe(subject, topic, data) {
   2066    if (data === TOP_SITES_BLOCKED_SPONSORS_PREF) {
   2067      this._setBlockedSponsorsMetrics();
   2068    } else if (data === TOPIC_SELECTION_SELECTED_TOPICS_PREF) {
   2069      this._setTopicSelectionSelectedTopicsMetrics();
   2070    } else {
   2071      this._setNewtabPrefMetrics(data, true);
   2072    }
   2073  }
   2074 
   2075  async _setNewtabPrefMetrics(fullPrefName, isChanged) {
   2076    const pref = fullPrefName.slice(ACTIVITY_STREAM_PREF_BRANCH.length);
   2077    if (!Object.hasOwn(NEWTAB_PING_PREFS, pref)) {
   2078      return;
   2079    }
   2080    const metric = NEWTAB_PING_PREFS[pref];
   2081    switch (Services.prefs.getPrefType(fullPrefName)) {
   2082      case Services.prefs.PREF_BOOL:
   2083        metric.set(Services.prefs.getBoolPref(fullPrefName));
   2084        break;
   2085 
   2086      case Services.prefs.PREF_INT:
   2087        metric.set(Services.prefs.getIntPref(fullPrefName));
   2088        break;
   2089    }
   2090    if (isChanged) {
   2091      switch (fullPrefName) {
   2092        case `${ACTIVITY_STREAM_PREF_BRANCH}feeds.topsites`:
   2093        case `${ACTIVITY_STREAM_PREF_BRANCH}${PREF_SHOW_SPONSORED_TOPSITES}`:
   2094          Glean.topsites.prefChanged.record({
   2095            pref_name: fullPrefName,
   2096            new_value: Services.prefs.getBoolPref(fullPrefName),
   2097          });
   2098          break;
   2099      }
   2100    }
   2101  }
   2102 
   2103  _setBlockedSponsorsMetrics() {
   2104    let blocklist;
   2105    try {
   2106      blocklist = JSON.parse(
   2107        Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]")
   2108      );
   2109    } catch (e) {}
   2110    if (blocklist) {
   2111      Glean.newtab.blockedSponsors.set(blocklist);
   2112    }
   2113  }
   2114 
   2115  _setTopicSelectionSelectedTopicsMetrics() {
   2116    let topiclist;
   2117    try {
   2118      topiclist = Services.prefs.getStringPref(
   2119        TOPIC_SELECTION_SELECTED_TOPICS_PREF,
   2120        ""
   2121      );
   2122    } catch (e) {}
   2123    if (topiclist) {
   2124      // Note: Beacuse Glean is expecting a string list, the
   2125      // value of the pref needs to be converted to an array
   2126      topiclist = topiclist.split(",").map(s => s.trim());
   2127      Glean.newtab.selectedTopics.set(topiclist);
   2128    }
   2129  }
   2130 
   2131  uninit() {
   2132    this._stopObservingNewtabPingPrefs();
   2133    this.newtabContentPing.uninit();
   2134    if (this._initialized) {
   2135      Services.obs.removeObserver(
   2136        this.browserOpenNewtabStart,
   2137        "browser-open-newtab-start"
   2138      );
   2139      this._initialized = false;
   2140    }
   2141 
   2142    // TODO: Send any unfinished sessions
   2143  }
   2144 }