summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs')
-rw-r--r--browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs954
1 files changed, 954 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs
new file mode 100644
index 0000000000..202e51c9e5
--- /dev/null
+++ b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs
@@ -0,0 +1,954 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ UrlbarProvider,
+ UrlbarUtils,
+} from "resource:///modules/UrlbarUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+ MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+});
+
+// `contextId` is a unique identifier used by Contextual Services
+const CONTEXT_ID_PREF = "browser.contextual-services.contextId";
+ChromeUtils.defineLazyGetter(lazy, "contextId", () => {
+ let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null);
+ if (!_contextId) {
+ _contextId = String(Services.uuid.generateUUID());
+ Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId);
+ }
+ return _contextId;
+});
+
+// Used for suggestions that don't otherwise have a score.
+const DEFAULT_SUGGESTION_SCORE = 0.2;
+
+const TELEMETRY_PREFIX = "contextual.services.quicksuggest";
+
+const TELEMETRY_SCALARS = {
+ BLOCK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.block_dynamic_wikipedia`,
+ BLOCK_NONSPONSORED: `${TELEMETRY_PREFIX}.block_nonsponsored`,
+ BLOCK_SPONSORED: `${TELEMETRY_PREFIX}.block_sponsored`,
+ CLICK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.click_dynamic_wikipedia`,
+ CLICK_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.click_nav_notmatched`,
+ CLICK_NAV_SHOWN_HEURISTIC: `${TELEMETRY_PREFIX}.click_nav_shown_heuristic`,
+ CLICK_NAV_SHOWN_NAV: `${TELEMETRY_PREFIX}.click_nav_shown_nav`,
+ CLICK_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.click_nav_superceded`,
+ CLICK_NONSPONSORED: `${TELEMETRY_PREFIX}.click_nonsponsored`,
+ CLICK_SPONSORED: `${TELEMETRY_PREFIX}.click_sponsored`,
+ HELP_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.help_dynamic_wikipedia`,
+ HELP_NONSPONSORED: `${TELEMETRY_PREFIX}.help_nonsponsored`,
+ HELP_SPONSORED: `${TELEMETRY_PREFIX}.help_sponsored`,
+ IMPRESSION_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.impression_dynamic_wikipedia`,
+ IMPRESSION_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.impression_nav_notmatched`,
+ IMPRESSION_NAV_SHOWN: `${TELEMETRY_PREFIX}.impression_nav_shown`,
+ IMPRESSION_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.impression_nav_superceded`,
+ IMPRESSION_NONSPONSORED: `${TELEMETRY_PREFIX}.impression_nonsponsored`,
+ IMPRESSION_SPONSORED: `${TELEMETRY_PREFIX}.impression_sponsored`,
+};
+
+/**
+ * A provider that returns a suggested url to the user based on what
+ * they have currently typed so they can navigate directly.
+ */
+class ProviderQuickSuggest extends UrlbarProvider {
+ /**
+ * Returns the name of this provider.
+ *
+ * @returns {string} the name of this provider.
+ */
+ get name() {
+ return "UrlbarProviderQuickSuggest";
+ }
+
+ /**
+ * The type of the provider.
+ *
+ * @returns {UrlbarUtils.PROVIDER_TYPE}
+ */
+ get type() {
+ return UrlbarUtils.PROVIDER_TYPE.NETWORK;
+ }
+
+ /**
+ * @returns {number}
+ * The default score for suggestions that don't otherwise have one. All
+ * suggestions require scores so they can be ranked. Scores are numeric
+ * values in the range [0, 1].
+ */
+ get DEFAULT_SUGGESTION_SCORE() {
+ return DEFAULT_SUGGESTION_SCORE;
+ }
+
+ /**
+ * @returns {object} An object mapping from mnemonics to scalar names.
+ */
+ get TELEMETRY_SCALARS() {
+ return { ...TELEMETRY_SCALARS };
+ }
+
+ /**
+ * Whether this provider should be invoked for the given context.
+ * If this method returns false, the providers manager won't start a query
+ * with this provider, to save on resources.
+ *
+ * @param {UrlbarQueryContext} queryContext The query context object
+ * @returns {boolean} Whether this provider should be invoked for the search.
+ */
+ isActive(queryContext) {
+ this.#resultFromLastQuery = null;
+
+ // If the sources don't include search or the user used a restriction
+ // character other than search, don't allow any suggestions.
+ if (
+ !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) ||
+ (queryContext.restrictSource &&
+ queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH)
+ ) {
+ return false;
+ }
+
+ if (
+ !lazy.UrlbarPrefs.get("quickSuggestEnabled") ||
+ queryContext.isPrivate ||
+ queryContext.searchMode
+ ) {
+ return false;
+ }
+
+ // Trim only the start of the search string because a trailing space can
+ // affect the suggestions.
+ let trimmedSearchString = queryContext.searchString.trimStart();
+ if (!trimmedSearchString) {
+ return false;
+ }
+ this._trimmedSearchString = trimmedSearchString;
+
+ return true;
+ }
+
+ /**
+ * Starts querying. Extended classes should return a Promise resolved when the
+ * provider is done searching AND returning results.
+ *
+ * @param {UrlbarQueryContext} queryContext The query context object
+ * @param {Function} addCallback Callback invoked by the provider to add a new
+ * result. A UrlbarResult should be passed to it.
+ * @returns {Promise}
+ */
+ async startQuery(queryContext, addCallback) {
+ let instance = this.queryInstance;
+ let searchString = this._trimmedSearchString;
+
+ // There are two sources for quick suggest: the current remote settings
+ // backend (either JS or Rust) and Merino.
+ let promises = [];
+ let { backend } = lazy.QuickSuggest;
+ if (backend?.isEnabled) {
+ promises.push(backend.query(searchString));
+ }
+ if (
+ lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled") &&
+ queryContext.allowRemoteResults()
+ ) {
+ promises.push(this._fetchMerinoSuggestions(queryContext, searchString));
+ }
+
+ // Wait for both sources to finish before adding a suggestion.
+ let values = await Promise.all(promises);
+ if (instance != this.queryInstance) {
+ return;
+ }
+
+ let suggestions = values.flat();
+
+ // Ensure all suggestions have a `score` by falling back to the default
+ // score as necessary. If `quickSuggestScoreMap` is defined, override scores
+ // with the values it defines. It maps telemetry types to scores.
+ let scoreMap = lazy.UrlbarPrefs.get("quickSuggestScoreMap");
+ for (let suggestion of suggestions) {
+ if (isNaN(suggestion.score)) {
+ suggestion.score = DEFAULT_SUGGESTION_SCORE;
+ }
+ if (scoreMap) {
+ let telemetryType = this.#getSuggestionTelemetryType(suggestion);
+ if (scoreMap.hasOwnProperty(telemetryType)) {
+ let score = parseFloat(scoreMap[telemetryType]);
+ if (!isNaN(score)) {
+ suggestion.score = score;
+ }
+ }
+ }
+ }
+
+ suggestions.sort((a, b) => b.score - a.score);
+
+ // All suggestions should have the following keys at this point. They are
+ // required for looking up the features that manage them.
+ let requiredKeys = ["source", "provider"];
+
+ // Add a result for the first suggestion that can be shown.
+ for (let suggestion of suggestions) {
+ for (let key of requiredKeys) {
+ if (!suggestion[key]) {
+ this.logger.error(
+ `Suggestion is missing required key '${key}': ` +
+ JSON.stringify(suggestion)
+ );
+ continue;
+ }
+ }
+
+ let canAdd = await this._canAddSuggestion(suggestion);
+ if (instance != this.queryInstance) {
+ return;
+ }
+
+ let result;
+ if (
+ canAdd &&
+ (result = await this.#makeResult(queryContext, suggestion))
+ ) {
+ this.#resultFromLastQuery = result;
+ addCallback(this, result);
+ return;
+ }
+ }
+ }
+
+ onEngagement(state, queryContext, details, controller) {
+ // Ignore engagements on other results that didn't end the session.
+ if (details.result?.providerName != this.name && details.isSessionOngoing) {
+ return;
+ }
+
+ // Reset the Merino session ID when a session ends. By design for the user's
+ // privacy, we don't keep it around between engagements.
+ if (state != "start" && !details.isSessionOngoing) {
+ this.#merino?.resetSession();
+ }
+
+ // Impression and clicked telemetry are both recorded on engagement. We
+ // define "impression" to mean a quick suggest result was present in the
+ // view when any result was picked.
+ if (state == "engagement" && queryContext) {
+ // Get the result that's visible in the view. `details.result` is the
+ // engaged result, if any; if it's from this provider, then that's the
+ // visible result. Otherwise fall back to #getVisibleResultFromLastQuery.
+ let { result } = details;
+ if (result?.providerName != this.name) {
+ result = this.#getVisibleResultFromLastQuery(controller.view);
+ }
+
+ this.#recordEngagement(queryContext, result, details);
+ }
+
+ if (details.result?.providerName == this.name) {
+ let feature = this.#getFeatureByResult(details.result);
+ if (feature?.handleCommand) {
+ feature.handleCommand(
+ controller.view,
+ details.result,
+ details.selType,
+ this._trimmedSearchString
+ );
+ } else if (details.selType == "dismiss") {
+ // Handle dismissals.
+ this.#dismissResult(controller, details.result);
+ }
+ }
+
+ this.#resultFromLastQuery = null;
+ }
+
+ /**
+ * This is called only for dynamic result types, when the urlbar view updates
+ * the view of one of the results of the provider. It should return an object
+ * describing the view update.
+ *
+ * @param {UrlbarResult} result The result whose view will be updated.
+ * @returns {object} An object describing the view update.
+ */
+ getViewUpdate(result) {
+ return this.#getFeatureByResult(result)?.getViewUpdate?.(result);
+ }
+
+ getResultCommands(result) {
+ return this.#getFeatureByResult(result)?.getResultCommands?.(result);
+ }
+
+ /**
+ * Gets the `BaseFeature` instance that implements suggestions for a source
+ * and provider name. The source and provider name can be supplied from either
+ * a suggestion object or the payload of a `UrlbarResult` object.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {string} options.source
+ * The suggestion source, one of: "remote-settings", "merino", "rust"
+ * @param {string} options.provider
+ * This value depends on `source`. The possible values per source are:
+ *
+ * remote-settings:
+ * The name of the `BaseFeature` instance (`feature.name`) that manages
+ * the suggestion type
+ * merino:
+ * The name of the Merino provider that serves the suggestion type
+ * rust:
+ * The name of the suggestion type as defined in `suggest.udl`
+ * @returns {BaseFeature}
+ * The feature instance or null if no feature was found.
+ */
+ #getFeature({ source, provider }) {
+ switch (source) {
+ case "remote-settings":
+ return lazy.QuickSuggest.getFeature(provider);
+ case "merino":
+ return lazy.QuickSuggest.getFeatureByMerinoProvider(provider);
+ case "rust":
+ return lazy.QuickSuggest.getFeatureByRustSuggestionType(provider);
+ }
+ return null;
+ }
+
+ #getFeatureByResult(result) {
+ return this.#getFeature(result.payload);
+ }
+
+ /**
+ * Returns the telemetry type for a suggestion. A telemetry type uniquely
+ * identifies a type of suggestion as well as the kind of `UrlbarResult`
+ * instances created from it.
+ *
+ * @param {object} suggestion
+ * A suggestion from remote settings or Merino.
+ * @returns {string}
+ * The telemetry type. If the suggestion type is managed by a `BaseFeature`
+ * instance, the telemetry type is retrieved from it. Otherwise the
+ * suggestion type is assumed to come from Merino, and `suggestion.provider`
+ * (the Merino provider name) is returned.
+ */
+ #getSuggestionTelemetryType(suggestion) {
+ let feature = this.#getFeature(suggestion);
+ if (feature) {
+ return feature.getSuggestionTelemetryType(suggestion);
+ }
+ return suggestion.provider;
+ }
+
+ async #makeResult(queryContext, suggestion) {
+ let result;
+ let feature = this.#getFeature(suggestion);
+ if (!feature) {
+ result = this.#makeDefaultResult(queryContext, suggestion);
+ } else {
+ result = await feature.makeResult(
+ queryContext,
+ suggestion,
+ this._trimmedSearchString
+ );
+ if (!result) {
+ // Feature might return null, if the feature is disabled and so on.
+ return null;
+ }
+ }
+
+ // `source` will be one of: "remote-settings", "merino", "rust".
+ // `provider` depends on `source`. See `#getFeature()` for possible values.
+ result.payload.source = suggestion.source;
+ result.payload.provider = suggestion.provider;
+ result.payload.telemetryType = this.#getSuggestionTelemetryType(suggestion);
+
+ // Handle icons here so each feature doesn't have to do it, but use `||=` to
+ // let them do it if they need to.
+ result.payload.icon ||= suggestion.icon;
+ result.payload.iconBlob ||= suggestion.icon_blob;
+
+ // Set the appropriate suggested index and related properties unless the
+ // feature did it already.
+ if (!result.hasSuggestedIndex) {
+ if (suggestion.is_top_pick) {
+ result.isBestMatch = true;
+ result.isRichSuggestion = true;
+ result.richSuggestionIconSize ||= 52;
+ result.suggestedIndex = 1;
+ } else if (
+ suggestion.is_sponsored &&
+ lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")
+ ) {
+ result.isBestMatch = true;
+ result.suggestedIndex = 1;
+ } else if (
+ !isNaN(suggestion.position) &&
+ lazy.UrlbarPrefs.get("quickSuggestAllowPositionInSuggestions")
+ ) {
+ result.suggestedIndex = suggestion.position;
+ } else {
+ result.isSuggestedIndexRelativeToGroup = true;
+ result.suggestedIndex = lazy.UrlbarPrefs.get(
+ suggestion.is_sponsored
+ ? "quickSuggestSponsoredIndex"
+ : "quickSuggestNonSponsoredIndex"
+ );
+ }
+ }
+
+ return result;
+ }
+
+ #makeDefaultResult(queryContext, suggestion) {
+ let payload = {
+ url: suggestion.url,
+ isSponsored: suggestion.is_sponsored,
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ };
+
+ if (suggestion.full_keyword) {
+ payload.title = suggestion.title;
+ payload.qsSuggestion = [
+ suggestion.full_keyword,
+ UrlbarUtils.HIGHLIGHT.SUGGESTED,
+ ];
+ } else {
+ payload.title = [suggestion.title, UrlbarUtils.HIGHLIGHT.TYPED];
+ payload.shouldShowUrl = true;
+ }
+
+ return new lazy.UrlbarResult(
+ UrlbarUtils.RESULT_TYPE.URL,
+ UrlbarUtils.RESULT_SOURCE.SEARCH,
+ ...lazy.UrlbarResult.payloadAndSimpleHighlights(
+ queryContext.tokens,
+ payload
+ )
+ );
+ }
+
+ #getVisibleResultFromLastQuery(view) {
+ let result = this.#resultFromLastQuery;
+
+ if (
+ result?.rowIndex >= 0 &&
+ view?.visibleResults?.[result.rowIndex] == result
+ ) {
+ // The result was visible.
+ return result;
+ }
+
+ // Find a visible result. Quick suggest results typically appear last in the
+ // view, so do a reverse search.
+ return view?.visibleResults?.findLast(r => r.providerName == this.name);
+ }
+
+ #dismissResult(controller, result) {
+ if (!result.payload.isBlockable) {
+ this.logger.info("Dismissals disabled, ignoring dismissal");
+ return;
+ }
+
+ this.logger.info("Dismissing result: " + JSON.stringify(result));
+ lazy.QuickSuggest.blockedSuggestions.add(
+ // adM results have `originalUrl`, which contains timestamp templates.
+ result.payload.originalUrl ?? result.payload.url
+ );
+ controller.removeResult(result);
+ }
+
+ /**
+ * Records engagement telemetry. This should be called only at the end of an
+ * engagement when a quick suggest result is present or when a quick suggest
+ * result is dismissed.
+ *
+ * @param {UrlbarQueryContext} queryContext
+ * The query context.
+ * @param {UrlbarResult} result
+ * The quick suggest result that was present (and possibly picked) at the
+ * end of the engagement or that was dismissed. Null if no quick suggest
+ * result was present.
+ * @param {object} details
+ * The `details` object that was passed to `onEngagement()`. It must look
+ * like this: `{ selType, selIndex }`
+ */
+ #recordEngagement(queryContext, result, details) {
+ let resultSelType = "";
+ let resultClicked = false;
+ if (result && details.result == result) {
+ resultSelType = details.selType;
+ resultClicked =
+ details.element?.tagName != "menuitem" &&
+ !details.element?.classList.contains("urlbarView-button") &&
+ details.selType != "dismiss";
+ }
+
+ if (result) {
+ // Update impression stats.
+ lazy.QuickSuggest.impressionCaps.updateStats(
+ result.payload.isSponsored ? "sponsored" : "nonsponsored"
+ );
+
+ // Record engagement scalars, event, and pings.
+ this.#recordEngagementScalars({ result, resultSelType, resultClicked });
+ this.#recordEngagementEvent({ result, resultSelType, resultClicked });
+ if (!queryContext.isPrivate) {
+ this.#recordEngagementPings({ result, resultSelType, resultClicked });
+ }
+ }
+
+ // Navigational suggestions telemetry requires special handling and does not
+ // depend on a result being visible.
+ if (
+ lazy.UrlbarPrefs.get("recordNavigationalSuggestionTelemetry") &&
+ queryContext.heuristicResult
+ ) {
+ this.#recordNavSuggestionTelemetry({
+ queryContext,
+ result,
+ resultSelType,
+ resultClicked,
+ details,
+ });
+ }
+ }
+
+ /**
+ * Helper for engagement telemetry that records engagement scalars.
+ *
+ * @param {object} options
+ * Options object
+ * @param {UrlbarResult} options.result
+ * The quick suggest result related to the engagement. Must not be null.
+ * @param {string} options.resultSelType
+ * If an element in the result's row was clicked, this should be its
+ * `selType`. Otherwise it should be an empty string.
+ * @param {boolean} options.resultClicked
+ * True if the main part of the result's row was clicked; false if a button
+ * like help or dismiss was clicked or if no part of the row was clicked.
+ */
+ #recordEngagementScalars({ result, resultSelType, resultClicked }) {
+ // Navigational suggestion scalars are handled separately.
+ if (result.payload.telemetryType == "top_picks") {
+ return;
+ }
+
+ // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the
+ // 0-based `result.rowIndex`.
+ let telemetryResultIndex = result.rowIndex + 1;
+
+ let scalars = [];
+ switch (result.payload.telemetryType) {
+ case "adm_nonsponsored":
+ scalars.push(TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED);
+ if (resultClicked) {
+ scalars.push(TELEMETRY_SCALARS.CLICK_NONSPONSORED);
+ } else {
+ switch (resultSelType) {
+ case "help":
+ scalars.push(TELEMETRY_SCALARS.HELP_NONSPONSORED);
+ break;
+ case "dismiss":
+ scalars.push(TELEMETRY_SCALARS.BLOCK_NONSPONSORED);
+ break;
+ }
+ }
+ break;
+ case "adm_sponsored":
+ scalars.push(TELEMETRY_SCALARS.IMPRESSION_SPONSORED);
+ if (resultClicked) {
+ scalars.push(TELEMETRY_SCALARS.CLICK_SPONSORED);
+ } else {
+ switch (resultSelType) {
+ case "help":
+ scalars.push(TELEMETRY_SCALARS.HELP_SPONSORED);
+ break;
+ case "dismiss":
+ scalars.push(TELEMETRY_SCALARS.BLOCK_SPONSORED);
+ break;
+ }
+ }
+ break;
+ case "wikipedia":
+ scalars.push(TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA);
+ if (resultClicked) {
+ scalars.push(TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA);
+ } else {
+ switch (resultSelType) {
+ case "help":
+ scalars.push(TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA);
+ break;
+ case "dismiss":
+ scalars.push(TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA);
+ break;
+ }
+ }
+ break;
+ }
+
+ for (let scalar of scalars) {
+ Services.telemetry.keyedScalarAdd(scalar, telemetryResultIndex, 1);
+ }
+ }
+
+ /**
+ * Helper for engagement telemetry that records the legacy engagement event.
+ *
+ * @param {object} options
+ * Options object
+ * @param {UrlbarResult} options.result
+ * The quick suggest result related to the engagement. Must not be null.
+ * @param {string} options.resultSelType
+ * If an element in the result's row was clicked, this should be its
+ * `selType`. Otherwise it should be an empty string.
+ * @param {boolean} options.resultClicked
+ * True if the main part of the result's row was clicked; false if a button
+ * like help or dismiss was clicked or if no part of the row was clicked.
+ */
+ #recordEngagementEvent({ result, resultSelType, resultClicked }) {
+ let eventType;
+ if (resultClicked) {
+ eventType = "click";
+ } else if (!resultSelType) {
+ eventType = "impression_only";
+ } else {
+ switch (resultSelType) {
+ case "dismiss":
+ eventType = "block";
+ break;
+ case "help":
+ eventType = "help";
+ break;
+ default:
+ eventType = "other";
+ break;
+ }
+ }
+
+ let suggestion_type;
+ switch (result.payload.telemetryType) {
+ case "adm_nonsponsored":
+ suggestion_type = "nonsponsored";
+ break;
+ case "adm_sponsored":
+ suggestion_type = "sponsored";
+ break;
+ case "top_picks":
+ suggestion_type = "navigational";
+ break;
+ case "wikipedia":
+ suggestion_type = "dynamic-wikipedia";
+ break;
+ default:
+ suggestion_type = result.payload.telemetryType;
+ break;
+ }
+
+ Services.telemetry.recordEvent(
+ lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ "engagement",
+ eventType,
+ "",
+ {
+ suggestion_type,
+ match_type: result.isBestMatch ? "best-match" : "firefox-suggest",
+ // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based
+ position: String(result.rowIndex + 1),
+ source: result.payload.source,
+ }
+ );
+ }
+
+ /**
+ * Helper for engagement telemetry that records custom contextual services
+ * pings.
+ *
+ * @param {object} options
+ * Options object
+ * @param {UrlbarResult} options.result
+ * The quick suggest result related to the engagement. Must not be null.
+ * @param {string} options.resultSelType
+ * If an element in the result's row was clicked, this should be its
+ * `selType`. Otherwise it should be an empty string.
+ * @param {boolean} options.resultClicked
+ * True if the main part of the result's row was clicked; false if a button
+ * like help or dismiss was clicked or if no part of the row was clicked.
+ */
+ #recordEngagementPings({ result, resultSelType, resultClicked }) {
+ if (
+ result.payload.telemetryType != "adm_sponsored" &&
+ result.payload.telemetryType != "adm_nonsponsored"
+ ) {
+ return;
+ }
+
+ // Contextual services ping paylod
+ let payload = {
+ match_type: result.isBestMatch ? "best-match" : "firefox-suggest",
+ // Always use lowercase to make the reporting consistent
+ advertiser: result.payload.sponsoredAdvertiser.toLocaleLowerCase(),
+ block_id: result.payload.sponsoredBlockId,
+ improve_suggest_experience_checked: lazy.UrlbarPrefs.get(
+ "quicksuggest.dataCollection.enabled"
+ ),
+ // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based
+ position: result.rowIndex + 1,
+ suggested_index: result.suggestedIndex,
+ suggested_index_relative_to_group:
+ !!result.isSuggestedIndexRelativeToGroup,
+ request_id: result.payload.requestId,
+ source: result.payload.source,
+ };
+
+ // Glean ping key -> value
+ let defaultValuesByGleanKey = {
+ matchType: payload.match_type,
+ advertiser: payload.advertiser,
+ blockId: payload.block_id,
+ improveSuggestExperience: payload.improve_suggest_experience_checked,
+ position: payload.position,
+ suggestedIndex: payload.suggested_index.toString(),
+ suggestedIndexRelativeToGroup: payload.suggested_index_relative_to_group,
+ requestId: payload.request_id,
+ source: payload.source,
+ contextId: lazy.contextId,
+ };
+
+ let sendGleanPing = valuesByGleanKey => {
+ valuesByGleanKey = { ...defaultValuesByGleanKey, ...valuesByGleanKey };
+ for (let [gleanKey, value] of Object.entries(valuesByGleanKey)) {
+ let glean = Glean.quickSuggest[gleanKey];
+ if (value !== undefined && value !== "") {
+ glean.set(value);
+ }
+ }
+ GleanPings.quickSuggest.submit();
+ };
+
+ // impression
+ sendGleanPing({
+ pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ isClicked: resultClicked,
+ reportingUrl: result.payload.sponsoredImpressionUrl,
+ });
+
+ // click
+ if (resultClicked) {
+ sendGleanPing({
+ pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
+ reportingUrl: result.payload.sponsoredClickUrl,
+ });
+ }
+
+ // dismiss
+ if (resultSelType == "dismiss") {
+ sendGleanPing({
+ pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ iabCategory: result.payload.sponsoredIabCategory,
+ });
+ }
+ }
+
+ /**
+ * Helper for engagement telemetry that records telemetry specific to
+ * navigational suggestions.
+ *
+ * @param {object} options
+ * Options object
+ * @param {UrlbarQueryContext} options.queryContext
+ * The query context.
+ * @param {UrlbarResult} options.result
+ * The quick suggest result related to the engagement, or null if no result
+ * was present.
+ * @param {string} options.resultSelType
+ * If an element in the result's row was clicked, this should be its
+ * `selType`. Otherwise it should be an empty string.
+ * @param {boolean} options.resultClicked
+ * True if the main part of the result's row was clicked; false if a button
+ * like help or dismiss was clicked or if no part of the row was clicked.
+ * @param {object} options.details
+ * The `details` object that was passed to `onEngagement()`. It must look
+ * like this: `{ selType, selIndex }`
+ */
+ #recordNavSuggestionTelemetry({
+ queryContext,
+ result,
+ resultSelType,
+ resultClicked,
+ details,
+ }) {
+ let scalars = [];
+ let heuristicClicked =
+ details.selIndex == 0 && queryContext.heuristicResult;
+
+ if (result?.payload.telemetryType == "top_picks") {
+ // nav suggestion shown
+ scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN);
+ if (resultClicked) {
+ scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_NAV);
+ } else if (heuristicClicked) {
+ scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_HEURISTIC);
+ }
+ } else if (
+ this.#resultFromLastQuery?.payload.telemetryType == "top_picks" &&
+ this.#resultFromLastQuery?.payload.dupedHeuristic
+ ) {
+ // nav suggestion duped heuristic
+ scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED);
+ if (heuristicClicked) {
+ scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SUPERCEDED);
+ }
+ } else {
+ // nav suggestion not matched or otherwise not shown
+ scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED);
+ if (heuristicClicked) {
+ scalars.push(TELEMETRY_SCALARS.CLICK_NAV_NOTMATCHED);
+ }
+ }
+
+ let heuristicType = UrlbarUtils.searchEngagementTelemetryType(
+ queryContext.heuristicResult
+ );
+ for (let scalar of scalars) {
+ Services.telemetry.keyedScalarAdd(scalar, heuristicType, 1);
+ }
+ }
+
+ /**
+ * Cancels the current query.
+ *
+ * @param {UrlbarQueryContext} queryContext
+ * The query context.
+ */
+ cancelQuery(queryContext) {
+ // Cancel the Rust query.
+ let backend = lazy.QuickSuggest.getFeature("SuggestBackendRust");
+ if (backend?.isEnabled) {
+ backend.cancelQuery();
+ }
+
+ // Cancel the Merino timeout timer so it doesn't fire and record a timeout.
+ // If it's already canceled or has fired, this is a no-op.
+ this.#merino?.cancelTimeoutTimer();
+
+ // Don't abort the Merino fetch if one is ongoing. By design we allow
+ // fetches to finish so we can record their latency.
+ }
+
+ /**
+ * Fetches Merino suggestions.
+ *
+ * @param {UrlbarQueryContext} queryContext
+ * The query context.
+ * @param {string} searchString
+ * The search string.
+ * @returns {Array}
+ * The Merino suggestions or null if there's an error or unexpected
+ * response.
+ */
+ async _fetchMerinoSuggestions(queryContext, searchString) {
+ if (!this.#merino) {
+ this.#merino = new lazy.MerinoClient(this.name);
+ }
+
+ let providers;
+ if (
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") &&
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") &&
+ !lazy.UrlbarPrefs.get("merinoProviders")
+ ) {
+ // Data collection is enabled but suggestions are not. Use an empty list
+ // of providers to tell Merino not to fetch any suggestions.
+ providers = [];
+ }
+
+ let suggestions = await this.#merino.fetch({
+ providers,
+ query: searchString,
+ });
+
+ return suggestions;
+ }
+
+ /**
+ * Returns whether a given suggestion can be added for a query, assuming the
+ * provider itself should be active.
+ *
+ * @param {object} suggestion
+ * The suggestion to check.
+ * @returns {boolean}
+ * Whether the suggestion can be added.
+ */
+ async _canAddSuggestion(suggestion) {
+ this.logger.info("Checking if suggestion can be added");
+ this.logger.debug(JSON.stringify({ suggestion }));
+
+ // Return false if suggestions are disabled.
+ if (
+ (suggestion.is_sponsored &&
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) ||
+ (!suggestion.is_sponsored &&
+ !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored"))
+ ) {
+ this.logger.info("Suggestions disabled, not adding suggestion");
+ return false;
+ }
+
+ // Return false if an impression cap has been hit.
+ if (
+ (suggestion.is_sponsored &&
+ lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) ||
+ (!suggestion.is_sponsored &&
+ lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled"))
+ ) {
+ let type = suggestion.is_sponsored ? "sponsored" : "nonsponsored";
+ let hitStats = lazy.QuickSuggest.impressionCaps.getHitStats(type);
+ if (hitStats) {
+ this.logger.info("Impression cap(s) hit, not adding suggestion");
+ this.logger.debug(JSON.stringify({ type, hitStats }));
+ return false;
+ }
+ }
+
+ // Return false if the suggestion is blocked based on its URL. Suggestions
+ // from the JS backend define a single `url` property. Suggestions from the
+ // Rust backend are more complicated: Sponsored suggestions define `rawUrl`,
+ // which may contain timestamp templates, while non-sponsored suggestions
+ // define only `url`. Blocking should always be based on URLs with timestamp
+ // templates, where applicable, so check `rawUrl` and then `url`, in that
+ // order.
+ let { blockedSuggestions } = lazy.QuickSuggest;
+ if (await blockedSuggestions.has(suggestion.rawUrl ?? suggestion.url)) {
+ this.logger.info("Suggestion blocked, not adding suggestion");
+ return false;
+ }
+
+ this.logger.info("Suggestion can be added");
+ return true;
+ }
+
+ get _test_merino() {
+ return this.#merino;
+ }
+
+ // The result we added during the most recent query.
+ #resultFromLastQuery = null;
+
+ // The Merino client.
+ #merino = null;
+}
+
+export var UrlbarProviderQuickSuggest = new ProviderQuickSuggest();