tor-browser

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

BrowserSearchTelemetry.sys.mjs (13164B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
      9  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     10  SearchSERPTelemetry:
     11    "moz-src:///browser/components/search/SearchSERPTelemetry.sys.mjs",
     12  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
     13  UrlbarSearchUtils:
     14    "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs",
     15 });
     16 
     17 /**
     18 * @import {SearchEngine} from "moz-src:///toolkit/components/search/SearchEngine.sys.mjs"
     19 */
     20 
     21 /**
     22 * This class handles saving search telemetry related to the url bar,
     23 * search bar and other areas as per the sources above.
     24 */
     25 class BrowserSearchTelemetryHandler {
     26  /**
     27   * A map of known search origins. The values of this map should be used for all
     28   * current telemetry, except for sap.deprecatedCounts.
     29   *
     30   * The keys of this map are used in the calling code to recordSearch, and in
     31   * the sap.deprecatedCounts labelled counter (and the mirrored SEARCH_COUNTS
     32   * histogram).
     33   *
     34   * When legacy telemetry stops being reported, we should remove this map, and
     35   * update the callers to use the values directly. We might still want to keep
     36   * a list of valid sources, to help ensure that telemetry reporting is updated
     37   * correctly if new sources are added.
     38   */
     39  KNOWN_SEARCH_SOURCES = new Map([
     40    ["abouthome", "about_home"],
     41    ["contextmenu", "contextmenu"],
     42    ["contextmenu_visual", "contextmenu_visual"],
     43    ["newtab", "about_newtab"],
     44    ["searchbar", "searchbar"],
     45    ["system", "system"],
     46    ["urlbar", "urlbar"],
     47    ["urlbar-handoff", "urlbar_handoff"],
     48    ["urlbar-persisted", "urlbar_persisted"],
     49    ["urlbar-searchmode", "urlbar_searchmode"],
     50    ["webextension", "webextension"],
     51    ["aiwindow_assistant", "aiwindow_assistant"],
     52  ]);
     53 
     54  /**
     55   * Determines if we should record a search for this browser instance.
     56   * Private Browsing mode is normally skipped.
     57   *
     58   * @param {MozBrowser} browser
     59   *   The browser where the search was loaded.
     60   * @returns {boolean}
     61   *   True if the search should be recorded, false otherwise.
     62   */
     63  shouldRecordSearchCount(browser) {
     64    return (
     65      !lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal) ||
     66      !Services.prefs.getBoolPref("browser.engagement.search_counts.pbm", false)
     67    );
     68  }
     69 
     70  /**
     71   * Records the method by which the user selected a result from the searchbar.
     72   *
     73   * @param {Event} event
     74   *        The event that triggered the selection.
     75   * @param {number} index
     76   *        The index that the user chose in the popup, or -1 if there wasn't a
     77   *        selection.
     78   */
     79  recordSearchSuggestionSelectionMethod(event, index) {
     80    // command events are from the one-off context menu. Treat them as clicks.
     81    // Note that we only care about MouseEvent subclasses here when the
     82    // event type is "click", or else the subclasses are associated with
     83    // non-click interactions.
     84    let isClick =
     85      event &&
     86      (ChromeUtils.getClassName(event) == "MouseEvent" ||
     87        event.type == "click" ||
     88        event.type == "command");
     89    let category;
     90    if (isClick) {
     91      category = "click";
     92    } else if (index >= 0) {
     93      category = "enterSelection";
     94    } else {
     95      category = "enter";
     96    }
     97 
     98    Glean.searchbar.selectedResultMethod[category].add(1);
     99  }
    100 
    101  /**
    102   * Records entry into the Urlbar's search mode.
    103   *
    104   * Telemetry records only which search mode is entered and how it was entered.
    105   * It does not record anything pertaining to searches made within search mode.
    106   *
    107   * @param {object} searchMode
    108   *   A search mode object. See UrlbarInput.setSearchMode documentation for
    109   *   details.
    110   */
    111  recordSearchMode(searchMode) {
    112    // Search mode preview is not search mode. Recording it would just create
    113    // noise.
    114    if (searchMode.isPreview) {
    115      return;
    116    }
    117 
    118    let label = lazy.UrlbarSearchUtils.getSearchModeScalarKey(searchMode);
    119    let name = searchMode.entry.replace(/_([a-z])/g, (m, p) => p.toUpperCase());
    120    Glean.urlbarSearchmode[name]?.[label].add(1);
    121  }
    122 
    123  /**
    124   * The main entry point for recording search related Telemetry. This includes
    125   * search counts and engagement measurements.
    126   *
    127   * Telemetry records only search counts per engine and action origin, but
    128   * nothing pertaining to the search contents themselves.
    129   *
    130   * @param {MozBrowser} browser
    131   *        The browser where the search originated.
    132   * @param {nsISearchEngine} engine
    133   *        The engine handling the search.
    134   * @param {string} source
    135   *        Where the search originated from. See KNOWN_SEARCH_SOURCES for allowed
    136   *        values.
    137   * @param {object} [details] Options object.
    138   * @param {boolean} [details.isOneOff=false]
    139   *        true if this event was generated by a one-off search.
    140   * @param {boolean} [details.isSuggestion=false]
    141   *        true if this event was generated by a suggested search.
    142   * @param {boolean} [details.isFormHistory=false]
    143   *        true if this event was generated by a form history result.
    144   * @param {string} [details.alias=null]
    145   *        The search engine alias used in the search, if any.
    146   * @param {string} [details.newtabSessionId=undefined]
    147   *        The newtab session that prompted this search, if any.
    148   * @param {string} [details.searchUrlType=undefined]
    149   *        A `SearchUtils.URL_TYPE` value that indicates the type of search.
    150   *        Defaults to `SearchUtils.URL_TYPE.SEARCH`, a plain old search.
    151   * @throws if source is not in the known sources list.
    152   */
    153  recordSearch(browser, engine, source, details = {}) {
    154    if (engine.clickUrl) {
    155      this.#reportSearchInGlean(engine.clickUrl);
    156    }
    157 
    158    try {
    159      if (!this.shouldRecordSearchCount(browser)) {
    160        return;
    161      }
    162      if (!this.KNOWN_SEARCH_SOURCES.has(source)) {
    163        console.error("Unknown source for search: ", source);
    164        return;
    165      }
    166 
    167      if (source.startsWith("urlbar")) {
    168        Services.prefs.setIntPref(
    169          "browser.urlbar.lastUrlbarSearchSeconds",
    170          Math.round(Date.now() / 1000)
    171        );
    172      }
    173 
    174      if (source != "contextmenu_visual") {
    175        const countIdPrefix = `${engine.telemetryId}.`;
    176        const countIdSource = countIdPrefix + source;
    177 
    178        // NOTE: When removing the sap.deprecatedCounts telemetry, see the note
    179        // above KNOWN_SEARCH_SOURCES.
    180        if (
    181          details.alias &&
    182          engine.isConfigEngine &&
    183          engine.aliases.includes(details.alias)
    184        ) {
    185          // This is a keyword search using a config engine.
    186          // Record the source as "alias", not "urlbar".
    187          Glean.sap.deprecatedCounts[countIdPrefix + "alias"].add();
    188        } else {
    189          Glean.sap.deprecatedCounts[countIdSource].add();
    190        }
    191      }
    192 
    193      // When an engine is overridden by a third party, then we report the
    194      // override and skip reporting the partner code, since we don't have
    195      // a requirement to report the partner code in that case.
    196      let isOverridden = !!engine.overriddenById;
    197 
    198      let searchUrlType =
    199        details.searchUrlType ?? lazy.SearchUtils.URL_TYPE.SEARCH;
    200 
    201      let unwrappedEngine = /** @type {SearchEngine} */ (
    202        engine.wrappedJSObject
    203      );
    204 
    205      // Strict equality is used because we want to only match against the
    206      // empty string and not other values. We would have `engine.partnerCode`
    207      // return `undefined`, but the XPCOM interfaces force us to return an
    208      // empty string.
    209      let reportPartnerCode =
    210        !isOverridden &&
    211        engine.partnerCode !== "" &&
    212        !unwrappedEngine.getURLOfType(searchUrlType)
    213          ?.excludePartnerCodeFromTelemetry;
    214 
    215      Glean.sap.counts.record({
    216        source: this.KNOWN_SEARCH_SOURCES.get(source),
    217        provider_id: engine.isConfigEngine ? engine.id : "other",
    218        provider_name: engine.name,
    219        // If no code is reported, we must returned undefined, Glean will then
    220        // not report the field.
    221        partner_code: reportPartnerCode ? engine.partnerCode : undefined,
    222        overridden_by_third_party: isOverridden.toString(),
    223      });
    224 
    225      // Dispatch the search signal to other handlers.
    226      switch (source) {
    227        case "urlbar":
    228        case "searchbar":
    229        case "urlbar-searchmode":
    230        case "urlbar-persisted":
    231        case "urlbar-handoff":
    232          this._handleSearchAndUrlbar(browser, engine, source, details);
    233          break;
    234        case "abouthome":
    235        case "newtab":
    236          this._recordSearch(browser, engine, source, "enter");
    237          break;
    238        default:
    239          this._recordSearch(browser, engine, source);
    240          break;
    241      }
    242      if (["urlbar-handoff", "abouthome", "newtab"].includes(source)) {
    243        Glean.newtabSearch.issued.record({
    244          newtab_visit_id: details.newtabSessionId,
    245          search_access_point: this.KNOWN_SEARCH_SOURCES.get(source),
    246          telemetry_id: engine.telemetryId,
    247        });
    248        lazy.SearchSERPTelemetry.recordBrowserNewtabSession(
    249          browser,
    250          details.newtabSessionId
    251        );
    252      }
    253    } catch (ex) {
    254      // Catch any errors here, so that search actions are not broken if
    255      // telemetry is broken for some reason.
    256      console.error(ex);
    257    }
    258  }
    259 
    260  /**
    261   * Records visits to a search engine's search form.
    262   *
    263   * @param {nsISearchEngine} engine
    264   *   The engine whose search form is being visited.
    265   * @param {string} source
    266   *   Where the search form was opened from.
    267   *   This can be "urlbar" or "searchbar".
    268   */
    269  recordSearchForm(engine, source) {
    270    Glean.sap.searchFormCounts.record({
    271      source,
    272      provider_id: engine.isConfigEngine ? engine.id : "other",
    273    });
    274  }
    275 
    276  /**
    277   * Records an impression of a search access point.
    278   *
    279   * @param {MozBrowser} browser
    280   *   The browser associated with the SAP.
    281   * @param {nsISearchEngine|null} engine
    282   *   The engine handling the search, or null if this doesn't apply to the SAP
    283   *   (e.g., the engine isn't known or selected yet). The counter's label will
    284   *   be `engine.id` if `engine` is a non-null, app-provided engine. Otherwise
    285   *   the label will be "none".
    286   * @param {string} source
    287   *   The name of the SAP. See `KNOWN_SEARCH_SOURCES` for allowed values.
    288   */
    289  recordSapImpression(browser, engine, source) {
    290    if (!this.shouldRecordSearchCount(browser)) {
    291      return;
    292    }
    293    if (!this.KNOWN_SEARCH_SOURCES.has(source)) {
    294      console.error("Unknown source for SAP impression:", source);
    295      return;
    296    }
    297 
    298    let scalarSource = this.KNOWN_SEARCH_SOURCES.get(source);
    299    let name = scalarSource.replace(/_([a-z])/g, (m, p) => p.toUpperCase());
    300    let label = engine?.isConfigEngine ? engine.id : "none";
    301    Glean.sapImpressionCounts[name][label].add(1);
    302  }
    303 
    304  /**
    305   * This function handles the "urlbar", "urlbar-oneoff", "searchbar" and
    306   * "searchbar-oneoff" sources.
    307   *
    308   * @param {MozBrowser} browser
    309   *   The browser where the search originated.
    310   * @param {nsISearchEngine} engine
    311   *   The engine handling the search.
    312   * @param {string} source
    313   *   Where the search originated from.
    314   * @param {object} details
    315   *   See {@link BrowserSearchTelemetryHandler.recordSearch}
    316   */
    317  _handleSearchAndUrlbar(browser, engine, source, details) {
    318    const isOneOff = !!details.isOneOff;
    319    let action = "enter";
    320    if (isOneOff) {
    321      action = "oneoff";
    322    } else if (details.isFormHistory) {
    323      action = "formhistory";
    324    } else if (details.isSuggestion) {
    325      action = "suggestion";
    326    } else if (details.alias) {
    327      action = "alias";
    328    }
    329 
    330    this._recordSearch(browser, engine, source, action);
    331  }
    332 
    333  _recordSearch(browser, engine, source, action = null) {
    334    let scalarSource = this.KNOWN_SEARCH_SOURCES.get(source);
    335    lazy.SearchSERPTelemetry.recordBrowserSource(browser, scalarSource);
    336 
    337    let label = action ? "search_" + action : "search";
    338    let name = scalarSource.replace(/_([a-z])/g, (m, p) => p.toUpperCase());
    339    Glean.browserEngagementNavigation[name][label].add(1);
    340  }
    341 
    342  /**
    343   * Records the search in Glean for contextual services.
    344   *
    345   * @param {string} reportingUrl
    346   *   The url to be sent to contextual services.
    347   */
    348  async #reportSearchInGlean(reportingUrl) {
    349    let defaultValuesByGleanKey = {
    350      contextId: await lazy.ContextId.request(),
    351    };
    352 
    353    let sendGleanPing = valuesByGleanKey => {
    354      valuesByGleanKey = { ...defaultValuesByGleanKey, ...valuesByGleanKey };
    355      for (let [gleanKey, value] of Object.entries(valuesByGleanKey)) {
    356        let glean = Glean.searchWith[gleanKey];
    357        if (value !== undefined && value !== "") {
    358          glean.set(value);
    359        }
    360      }
    361      GleanPings.searchWith.submit();
    362    };
    363 
    364    sendGleanPing({
    365      reportingUrl,
    366    });
    367  }
    368 }
    369 
    370 export var BrowserSearchTelemetry = new BrowserSearchTelemetryHandler();