fune/browser/components/urlbar/private/AdmWikipedia.sys.mjs
Drew Willcoxon d4a37e5b65 Bug 1860791 - Fix dismissals and telemetry for Rust Wikipedia Suggest results. r=daisuke
Wikipedia suggestions from Rust don't have an `advertiser` property, unlike
suggestions from the JS backend [1]. That causes an exception to be thrown when
`UrlbarProviderQuickSuggest` tries to convert the advertister to lower case for
telemetry. The result is that telemetry isn't properly recorded for Rust
Wikipedia suggestions, and anything that happens after telemetry is supposed to
be recorded doesn't actually happen, which means dismissals aren't handled.

I fixed this by adding `advertiser` to Rust Wikipedia suggestions, and I
modified the telemetry tests for sponsored and nonsponsored suggestions so they
also run with Rust enabled. This tests the code path where the error was thrown.

Running the telemetry tests with Rust enabled revealed some more problems that I
also fixed:

* Rust Wikipedia suggestions also don't have `iab_category`, so I added that to
  them too.
* `_assertGleanPing()` in head.js wasn't checking a few properties, so I added
  them. Now it checks them all.
* URLs in the Glean ping are declared with type `url`, which means our tests
  need to use valid URLs in their dummy data. The mock Merino server and some
  related tests are using invalid URL values like `"url"`, so I fixed that.

[1] The suggestions data in remote settings does include an `advertiser`, so
it's possible to consider this a bug in the Rust implementation, but IMO it
makes sense for nonsponsored suggestions to lack an advertiser, and it's always
set to `"Wikipedia"` anyway.

Differential Revision: https://phabricator.services.mozilla.com/D191795
2023-10-25 01:09:49 +00:00

285 lines
8.8 KiB
JavaScript

/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
});
const NONSPONSORED_IAB_CATEGORIES = new Set(["5 - Education"]);
/**
* A feature that manages sponsored adM and non-sponsored Wikpedia (sometimes
* called "expanded Wikipedia") suggestions in remote settings.
*/
export class AdmWikipedia extends BaseFeature {
constructor() {
super();
this.#suggestionsMap = new lazy.SuggestionsMap();
}
get shouldEnable() {
return (
lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") ||
lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")
);
}
get enablingPreferences() {
return [
"suggest.quicksuggest.nonsponsored",
"suggest.quicksuggest.sponsored",
];
}
get merinoProvider() {
return "adm";
}
get rustSuggestionTypes() {
return ["Amp", "Wikipedia"];
}
getSuggestionTelemetryType(suggestion) {
return suggestion.is_sponsored ? "adm_sponsored" : "adm_nonsponsored";
}
enable(enabled) {
if (enabled) {
lazy.QuickSuggest.jsBackend.register(this);
} else {
lazy.QuickSuggest.jsBackend.unregister(this);
}
}
async queryRemoteSettings(searchString) {
let suggestions = this.#suggestionsMap.get(searchString);
if (!suggestions) {
return [];
}
// Start each icon fetch at the same time and wait for them all to finish.
let icons = await Promise.all(
suggestions.map(({ icon }) => this.#fetchIcon(icon))
);
return suggestions.map(suggestion => ({
full_keyword: this.#getFullKeyword(searchString, suggestion.keywords),
title: suggestion.title,
url: suggestion.url,
click_url: suggestion.click_url,
impression_url: suggestion.impression_url,
block_id: suggestion.id,
advertiser: suggestion.advertiser,
iab_category: suggestion.iab_category,
is_sponsored: !NONSPONSORED_IAB_CATEGORIES.has(suggestion.iab_category),
score: suggestion.score,
position: suggestion.position,
icon: icons.shift(),
}));
}
async onRemoteSettingsSync(rs) {
let dataType = lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsDataType");
this.logger.debug("Loading remote settings with type: " + dataType);
let [data] = await Promise.all([
rs.get({ filters: { type: dataType } }),
rs
.get({ filters: { type: "icon" } })
.then(icons =>
Promise.all(icons.map(i => rs.attachments.downloadToDisk(i)))
),
]);
if (!this.isEnabled) {
return;
}
let suggestionsMap = new lazy.SuggestionsMap();
this.logger.debug(`Got data with ${data.length} records`);
for (let record of data) {
let { buffer } = await rs.attachments.download(record);
if (!this.isEnabled) {
return;
}
let results = JSON.parse(new TextDecoder("utf-8").decode(buffer));
this.logger.debug(`Adding ${results.length} results`);
await suggestionsMap.add(results);
if (!this.isEnabled) {
return;
}
}
this.#suggestionsMap = suggestionsMap;
}
makeResult(queryContext, suggestion, searchString) {
if (suggestion.source == "rust") {
// The Rust backend uses camelCase instead of snake_case, and it excludes
// some properties in non-sponsored suggestions that we expect, so convert
// the Rust suggestion to a suggestion object we expect here on desktop.
let desktopSuggestion = {
title: suggestion.title,
url: suggestion.url,
is_sponsored: suggestion.is_sponsored,
full_keyword: suggestion.fullKeyword,
};
if (suggestion.is_sponsored) {
desktopSuggestion.impression_url = suggestion.impressionUrl;
desktopSuggestion.click_url = suggestion.clickUrl;
desktopSuggestion.block_id = suggestion.blockId;
desktopSuggestion.advertiser = suggestion.advertiser;
desktopSuggestion.iab_category = suggestion.iabCategory;
} else {
desktopSuggestion.advertiser = "Wikipedia";
desktopSuggestion.iab_category = "5 - Education";
}
suggestion = desktopSuggestion;
}
// Replace the suggestion's template substrings, but first save the original
// URL before its timestamp template is replaced.
let originalUrl = suggestion.url;
lazy.QuickSuggest.replaceSuggestionTemplates(suggestion);
let payload = {
originalUrl,
url: suggestion.url,
title: suggestion.title,
qsSuggestion: [
suggestion.full_keyword,
lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED,
],
isSponsored: suggestion.is_sponsored,
requestId: suggestion.request_id,
urlTimestampIndex: suggestion.urlTimestampIndex,
sponsoredImpressionUrl: suggestion.impression_url,
sponsoredClickUrl: suggestion.click_url,
sponsoredBlockId: suggestion.block_id,
sponsoredAdvertiser: suggestion.advertiser,
sponsoredIabCategory: suggestion.iab_category,
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",
},
};
let result = new lazy.UrlbarResult(
lazy.UrlbarUtils.RESULT_TYPE.URL,
lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
...lazy.UrlbarResult.payloadAndSimpleHighlights(
queryContext.tokens,
payload
)
);
if (suggestion.is_sponsored) {
if (!lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")) {
result.richSuggestionIconSize = 16;
}
result.payload.descriptionL10n = {
id: "urlbar-result-action-sponsored",
};
result.isRichSuggestion = true;
}
return result;
}
/**
* Gets the "full keyword" (i.e., suggestion) for a query from a list of
* keywords. The suggestions data doesn't include full keywords, so we make
* our own based on the result's keyword phrases and a particular query. We
* use two heuristics:
*
* (1) Find the first keyword phrase that has more words than the query. Use
* its first `queryWords.length` words as the full keyword. e.g., if the
* query is "moz" and `keywords` is ["moz", "mozi", "mozil", "mozill",
* "mozilla", "mozilla firefox"], pick "mozilla firefox", pop off the
* "firefox" and use "mozilla" as the full keyword.
* (2) If there isn't any keyword phrase with more words, then pick the
* longest phrase. e.g., pick "mozilla" in the previous example (assuming
* the "mozilla firefox" phrase isn't there). That might be the query
* itself.
*
* @param {string} query
* The query string.
* @param {Array} keywords
* An array of suggestion keywords.
* @returns {string}
* The full keyword.
*/
#getFullKeyword(query, keywords) {
let longerPhrase;
let trimmedQuery = query.toLocaleLowerCase().trim();
let queryWords = trimmedQuery.split(" ");
for (let phrase of keywords) {
if (phrase.startsWith(query)) {
let trimmedPhrase = phrase.trim();
let phraseWords = trimmedPhrase.split(" ");
// As an exception to (1), if the query ends with a space, then look for
// phrases with one more word so that the suggestion includes a word
// following the space.
let extra = query.endsWith(" ") ? 1 : 0;
let len = queryWords.length + extra;
if (len < phraseWords.length) {
// We found a phrase with more words.
return phraseWords.slice(0, len).join(" ");
}
if (
query.length < phrase.length &&
(!longerPhrase || longerPhrase.length < trimmedPhrase.length)
) {
// We found a longer phrase with the same number of words.
longerPhrase = trimmedPhrase;
}
}
}
return longerPhrase || trimmedQuery;
}
/**
* Fetch the icon from RemoteSettings attachments.
*
* @param {string} path
* The icon's remote settings path.
*/
async #fetchIcon(path) {
if (!path) {
return null;
}
let { rs } = lazy.QuickSuggest.jsBackend;
if (!rs) {
return null;
}
let record = (
await rs.get({
filters: { id: `icon-${path}` },
})
).pop();
if (!record) {
return null;
}
return rs.attachments.downloadToDisk(record);
}
#suggestionsMap;
}