Bug 1656220 - Implement recording attributions for search engines. r=dao

Differential Revision: https://phabricator.services.mozilla.com/D87501
This commit is contained in:
Mark Banner 2020-08-20 12:58:23 +00:00
Родитель 87e0ce2ae4
Коммит 0542586f06
16 изменённых файлов: 517 добавлений и 35 удалений

Просмотреть файл

@ -253,6 +253,7 @@ let ContentSearch = {
}
win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey, {
selection: data.selection,
url: submission.uri,
});
},

Просмотреть файл

@ -1313,7 +1313,8 @@ pref("prompts.tab_modal.enabled", true);
pref("prompts.defaultModalType", 3);
pref("browser.topsites.useRemoteSetting", false);
pref("browser.topsites.attributionURL", "");
pref("browser.partnerlink.attributionURL", "https://topsites.mozilla.io/cid/amzn_2020_a1");
// Whether to show tab level system prompts opened via nsIPrompt(Service) as
// SubDialogs in the TabDialogBox (true) or as TabModalPrompt in the

Просмотреть файл

@ -4346,7 +4346,7 @@ const BrowserSearch = {
csp,
});
return engine;
return { engine, url: submission.uri };
},
/**
@ -4356,7 +4356,7 @@ const BrowserSearch = {
* BrowserSearch.loadSearch for the preferred API.
*/
async loadSearchFromContext(terms, usePrivate, triggeringPrincipal, csp) {
let engine = await BrowserSearch._loadSearch(
let { engine, url } = await BrowserSearch._loadSearch(
terms,
usePrivate && !PrivateBrowsingUtils.isWindowPrivate(window)
? "window"
@ -4369,7 +4369,7 @@ const BrowserSearch = {
csp
);
if (engine) {
BrowserSearch.recordSearchInTelemetry(engine, "contextmenu");
BrowserSearch.recordSearchInTelemetry(engine, "contextmenu", { url });
}
},
@ -4377,7 +4377,7 @@ const BrowserSearch = {
* Perform a search initiated from the command line.
*/
async loadSearchFromCommandLine(terms, usePrivate, triggeringPrincipal, csp) {
let engine = await BrowserSearch._loadSearch(
let { engine, url } = await BrowserSearch._loadSearch(
terms,
"current",
usePrivate,
@ -4386,7 +4386,7 @@ const BrowserSearch = {
csp
);
if (engine) {
BrowserSearch.recordSearchInTelemetry(engine, "system");
BrowserSearch.recordSearchInTelemetry(engine, "system", { url });
}
},

Просмотреть файл

@ -117,7 +117,8 @@ this.search = class extends ExtensionAPI {
BrowserUsageTelemetry.recordSearch(
tabbrowser,
engine,
"webextension"
"webextension",
{ url: submission.uri }
);
},
},

Просмотреть файл

@ -421,6 +421,7 @@
isOneOff: aOneOff,
isSuggestion: !aOneOff && telemetrySearchDetails,
selection: telemetrySearchDetails,
url: submission.uri,
};
BrowserSearch.recordSearchInTelemetry(engine, "searchbar", details);
// null parameter below specifies HTML response for search

Просмотреть файл

@ -474,7 +474,7 @@ class UrlbarInput {
selectedOneOff.engine,
searchString
);
this._recordSearch(selectedOneOff.engine, event);
this._recordSearch(selectedOneOff.engine, event, { url });
UrlbarUtils.addToFormHistory(
this,
@ -776,6 +776,7 @@ class UrlbarInput {
isSuggestion: !!result.payload.suggestion,
isFormHistory: result.source == UrlbarUtils.RESULT_SOURCE.HISTORY,
alias: result.payload.keyword,
url,
};
const engine = Services.search.getEngineByName(result.payload.engine);
this._recordSearch(engine, event, actionDetails);
@ -1825,6 +1826,8 @@ class UrlbarInput {
* True if this query was initiated via a search alias.
* @param {boolean} searchActionDetails.isFormHistory
* True if this query was initiated from a form history result.
* @param {string} searchActionDetails.url
* The url this query was triggered with.
*/
_recordSearch(engine, event, searchActionDetails = {}) {
const isOneOff = this.view.oneOffSearchButtons.maybeRecordTelemetry(event);

Просмотреть файл

@ -23,6 +23,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
CustomizableUI: "resource:///modules/CustomizableUI.jsm",
OS: "resource://gre/modules/osfile.jsm",
PageActions: "resource:///modules/PageActions.jsm",
PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
SearchTelemetry: "resource:///modules/SearchTelemetry.jsm",
Services: "resource://gre/modules/Services.jsm",
@ -604,7 +605,11 @@ let BrowserUsageTelemetry = {
this._handleSearchAction(engine, source, details);
},
_recordSearch(engine, source, action = null) {
_recordSearch(engine, url, source, action = null) {
PartnerLinkAttribution.makeSearchEngineRequest(engine, url).catch(
Cu.reportError
);
let scalarKey = action ? "search_" + action : "search";
Services.telemetry.keyedScalarAdd(
"browser.engagement.navigation." + source,
@ -626,15 +631,15 @@ let BrowserUsageTelemetry = {
this._handleSearchAndUrlbar(engine, source, details);
break;
case "abouthome":
this._recordSearch(engine, "about_home", "enter");
this._recordSearch(engine, details.url, "about_home", "enter");
break;
case "newtab":
this._recordSearch(engine, "about_newtab", "enter");
this._recordSearch(engine, details.url, "about_newtab", "enter");
break;
case "contextmenu":
case "system":
case "webextension":
this._recordSearch(engine, source);
this._recordSearch(engine, details.url, source);
break;
}
},
@ -665,27 +670,27 @@ let BrowserUsageTelemetry = {
}
// If that's a legit one-off search signal, record it using the relative key.
this._recordSearch(engine, sourceName, "oneoff");
this._recordSearch(engine, details.url, sourceName, "oneoff");
return;
}
// The search was not a one-off. It was a search with the default search engine.
if (details.isFormHistory) {
// It came from a form history result.
this._recordSearch(engine, sourceName, "formhistory");
this._recordSearch(engine, details.url, sourceName, "formhistory");
return;
} else if (details.isSuggestion) {
// It came from a suggested search, so count it as such.
this._recordSearch(engine, sourceName, "suggestion");
this._recordSearch(engine, details.url, sourceName, "suggestion");
return;
} else if (details.alias) {
// This one came from a search that used an alias.
this._recordSearch(engine, sourceName, "alias");
this._recordSearch(engine, details.url, sourceName, "alias");
return;
}
// The search signal was generated by typing something and pressing enter.
this._recordSearch(engine, sourceName, "enter");
this._recordSearch(engine, details.url, sourceName, "enter");
},
/**

Просмотреть файл

@ -8,17 +8,14 @@ Cu.importGlobalProperties(["fetch"]);
var EXPORTED_SYMBOLS = ["PartnerLinkAttribution"];
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Region",
"resource://gre/modules/Region.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
Services: "resource://gre/modules/Services.jsm",
Region: "resource://gre/modules/Region.jsm",
});
var PartnerLinkAttribution = {
async makeRequest({ targetURL, source }) {
@ -34,7 +31,7 @@ var PartnerLinkAttribution = {
const attributionUrl = Services.prefs.getStringPref(
Services.prefs.getBoolPref("browser.topsites.useRemoteSetting")
? "browser.topsites.attributionURL"
? "browser.partnerlink.attributionURL"
: `browser.newtabpage.searchTileOverride.${partner}.attributionURL`,
""
);
@ -42,15 +39,67 @@ var PartnerLinkAttribution = {
record("attribution", "abort");
return;
}
const request = new Request(attributionUrl);
request.headers.set("X-Region", Region.home);
request.headers.set("X-Source", source);
request.headers.set("X-Target-URL", targetURL);
const response = await fetch(request);
record("attribution", response.ok ? "success" : "failure");
let result = await sendRequest(attributionUrl, source, targetURL);
record("attribution", result ? "success" : "failure");
},
/**
* Makes a request to the attribution URL for a search engine search.
*
* @param {nsISearchEngine} engine
* The search engine to save the attribution for.
* @param {nsIURI} targetUrl
* The target URL to filter and include in the attribution.
*/
async makeSearchEngineRequest(engine, targetUrl) {
if (!engine.sendAttributionRequest) {
return;
}
let searchUrlQueryParamName = engine.searchUrlQueryParamName;
if (!searchUrlQueryParamName) {
Cu.reportError("makeSearchEngineRequest can't find search terms key");
return;
}
let url = targetUrl;
if (typeof url == "string") {
url = Services.io.newURI(url);
}
let targetParams = new URLSearchParams(url.query);
if (!targetParams.has(searchUrlQueryParamName)) {
Cu.reportError(
"makeSearchEngineRequest can't remove target search terms"
);
return;
}
const attributionUrl = Services.prefs.getStringPref(
"browser.partnerlink.attributionURL",
""
);
targetParams.delete(searchUrlQueryParamName);
let strippedTargetUrl = `${url.prePath}${url.filePath}`;
let newParams = targetParams.toString();
if (newParams) {
strippedTargetUrl += "?" + newParams;
}
await sendRequest(attributionUrl, "searchurl", strippedTargetUrl);
},
};
async function sendRequest(attributionUrl, source, targetURL) {
const request = new Request(attributionUrl);
request.headers.set("X-Region", Region.home);
request.headers.set("X-Source", source);
request.headers.set("X-Target-URL", targetURL);
const response = await fetch(request);
return response.ok;
}
function recordTelemetryEvent(method, objectString, value, extra) {
Services.telemetry.setEventRecordingEnabled("partner_link", true);
Services.telemetry.recordEvent(

Просмотреть файл

@ -15,10 +15,15 @@ support-files =
!/browser/components/search/test/browser/testEngine.xml
!/browser/components/search/test/browser/testEngine_diacritics.xml
testEngine_chromeicon.xml
skip-if = (debug && os == "linux" && bits == 64 && os_version == "18.04") # Bug 1649755
skip-if = (debug && os == "linux" && bits == 64 && os_version == "18.04") # Bug 1649755
[browser_EveryWindow.js]
[browser_LiveBookmarkMigrator.js]
[browser_PageActions.js]
[browser_PartnerLinkAttribution.js]
support-files =
search-engines/basic/manifest.json
search-engines/simple/manifest.json
search-engines/engines.json
[browser_PermissionUI.js]
[browser_PermissionUI_prompts.js]
[browser_preloading_tab_moving.js]

Просмотреть файл

@ -0,0 +1,286 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
/**
* This file tests urlbar telemetry with search related actions.
*/
"use strict";
const SCALAR_URLBAR = "browser.engagement.navigation.urlbar";
// The preference to enable suggestions in the urlbar.
const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches";
// The name of the search engine used to generate suggestions.
const SUGGESTION_ENGINE_NAME =
"browser_UsageTelemetry usageTelemetrySearchSuggestions.xml";
XPCOMUtils.defineLazyModuleGetters(this, {
CustomizableUITestUtils:
"resource://testing-common/CustomizableUITestUtils.jsm",
Region: "resource://gre/modules/Region.jsm",
SearchTelemetry: "resource:///modules/SearchTelemetry.jsm",
SearchTestUtils: "resource://testing-common/SearchTestUtils.jsm",
UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm",
HttpServer: "resource://testing-common/httpd.js",
});
let gCUITestUtils = new CustomizableUITestUtils(window);
SearchTestUtils.init(Assert, registerCleanupFunction);
var gHttpServer = null;
var gRequests = [];
function submitHandler(request, response) {
gRequests.push(request);
response.setStatusLine(request.httpVersion, 200, "Ok");
}
add_task(async function setup() {
// Ensure the initial init is complete.
await Services.search.init();
// Clear history so that history added by previous tests doesn't mess up this
// test when it selects results in the urlbar.
await PlacesUtils.history.clear();
let searchExtensions = getChromeDir(getResolvedURI(gTestPath));
searchExtensions.append("search-engines");
await SearchTestUtils.useMochitestEngines(searchExtensions);
SearchTestUtils.useMockIdleService();
let response = await fetch(`resource://search-extensions/engines.json`);
let json = await response.json();
await SearchTestUtils.updateRemoteSettingsConfig(json.data);
gHttpServer = new HttpServer();
gHttpServer.registerPathHandler("/", submitHandler);
gHttpServer.start(-1);
await SpecialPowers.pushPrefEnv({
set: [
// Enable search suggestions in the urlbar.
[SUGGEST_URLBAR_PREF, true],
// Clear historical search suggestions to avoid interference from previous
// tests.
["browser.urlbar.maxHistoricalSearchSuggestions", 0],
// Use the default matching bucket configuration.
["browser.urlbar.matchBuckets", "general:5,suggestion:4"],
//
[
"browser.partnerlink.attributionURL",
`http://localhost:${gHttpServer.identity.primaryPort}/`,
],
],
});
await gCUITestUtils.addSearchBar();
// Make sure to restore the engine once we're done.
registerCleanupFunction(async function() {
await SearchTestUtils.updateRemoteSettingsConfig();
await gHttpServer.stop();
gHttpServer = null;
await PlacesUtils.history.clear();
gCUITestUtils.removeSearchBar();
});
});
function searchInAwesomebar(value, win = window) {
return UrlbarTestUtils.promiseAutocompleteResultPopup({
window: win,
waitForFocus,
value,
fireInputEvent: true,
});
}
add_task(async function test_simpleQuery_no_attribution() {
await Services.search.setDefault(
Services.search.getEngineByName("Simple Engine")
);
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:blank"
);
info("Simulate entering a simple search.");
let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
"https://example.com/?sourceId=Mozilla-search&search=simple+query",
tab
);
await searchInAwesomebar("simple query");
EventUtils.synthesizeKey("KEY_Enter");
await promiseLoad;
await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
Assert.equal(gRequests.length, 0, "Should not have submitted an attribution");
BrowserTestUtils.removeTab(tab);
await Services.search.setDefault(Services.search.getEngineByName("basic"));
});
async function checkAttributionRecorded(actionFn, cleanupFn) {
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"data:text/plain;charset=utf8,simple%20query"
);
let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
"https://mochi.test:8888/browser/browser/components/search/test/browser/?search=simple+query&foo=1",
tab
);
await actionFn(tab);
await promiseLoad;
await BrowserTestUtils.waitForCondition(
() => gRequests.length == 1,
"Should have received an attribution submission"
);
Assert.equal(
gRequests[0].getHeader("x-region"),
Region.home,
"Should have set the region correctly"
);
Assert.equal(
gRequests[0].getHeader("X-Source"),
"searchurl",
"Should have set the source correctly"
);
Assert.equal(
gRequests[0].getHeader("X-Target-url"),
"https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1",
"Should have set the target url correctly and stripped the search terms"
);
if (cleanupFn) {
await cleanupFn();
}
BrowserTestUtils.removeTab(tab);
gRequests = [];
}
add_task(async function test_urlbar() {
await checkAttributionRecorded(async tab => {
await searchInAwesomebar("simple query");
EventUtils.synthesizeKey("KEY_Enter");
});
});
add_task(async function test_searchbar() {
await checkAttributionRecorded(async tab => {
let sb = BrowserSearch.searchBar;
// Write the search query in the searchbar.
sb.focus();
sb.value = "simple query";
sb.textbox.controller.startSearch("simple query");
// Wait for the popup to show.
await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown");
// And then for the search to complete.
await BrowserTestUtils.waitForCondition(
() =>
sb.textbox.controller.searchStatus >=
Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH,
"The search in the searchbar must complete."
);
EventUtils.synthesizeKey("KEY_Enter");
});
});
add_task(async function test_context_menu() {
let contextMenu;
await checkAttributionRecorded(
async tab => {
info("Select all the text in the page.");
await SpecialPowers.spawn(tab.linkedBrowser, [""], async function() {
return new Promise(resolve => {
content.document.addEventListener(
"selectionchange",
() => resolve(),
{
once: true,
}
);
content.document
.getSelection()
.selectAllChildren(content.document.body);
});
});
info("Open the context menu.");
contextMenu = document.getElementById("contentAreaContextMenu");
let popupPromise = BrowserTestUtils.waitForEvent(
contextMenu,
"popupshown"
);
BrowserTestUtils.synthesizeMouseAtCenter(
"body",
{ type: "contextmenu", button: 2 },
gBrowser.selectedBrowser
);
await popupPromise;
info("Click on search.");
let searchItem = contextMenu.getElementsByAttribute(
"id",
"context-searchselect"
)[0];
searchItem.click();
},
() => {
contextMenu.hidePopup();
BrowserTestUtils.removeTab(gBrowser.selectedTab);
}
);
});
add_task(async function test_about_newtab() {
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:newtab",
false
);
await SpecialPowers.spawn(tab.linkedBrowser, [], async function() {
await ContentTaskUtils.waitForCondition(() => !content.document.hidden);
});
info("Trigger a simple serch, just text + enter.");
let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt(
"https://mochi.test:8888/browser/browser/components/search/test/browser/?search=simple+query&foo=1",
tab
);
await typeInSearchField(
tab.linkedBrowser,
"simple query",
"newtab-search-text"
);
await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser);
await promiseLoad;
await BrowserTestUtils.waitForCondition(
() => gRequests.length == 1,
"Should have received an attribution submission"
);
Assert.equal(
gRequests[0].getHeader("x-region"),
Region.home,
"Should have set the region correctly"
);
Assert.equal(
gRequests[0].getHeader("X-Source"),
"searchurl",
"Should have set the source correctly"
);
Assert.equal(
gRequests[0].getHeader("X-Target-url"),
"https://mochi.test:8888/browser/browser/components/search/test/browser/?foo=1",
"Should have set the target url correctly and stripped the search terms"
);
BrowserTestUtils.removeTab(tab);
gRequests = [];
});

Просмотреть файл

@ -0,0 +1,19 @@
{
"name": "basic",
"manifest_version": 2,
"version": "1.0",
"description": "basic",
"applications": {
"gecko": {
"id": "basic@search.mozilla.org"
}
},
"hidden": true,
"chrome_settings_overrides": {
"search_provider": {
"name": "basic",
"search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1",
"suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}"
}
}
}

Просмотреть файл

@ -0,0 +1,24 @@
{
"data": [
{
"webExtension": {
"id":"basic@search.mozilla.org"
},
"telemetryId": "telemetry",
"appliesTo": [{
"included": { "everywhere": true },
"default": "yes",
"sendAttributionRequest": true
}]
},
{
"webExtension": {
"id":"simple@search.mozilla.org"
},
"appliesTo": [{
"included": { "everywhere": true },
"default": "yes"
}]
}
]
}

Просмотреть файл

@ -0,0 +1,29 @@
{
"name": "Simple Engine",
"manifest_version": 2,
"version": "1.0",
"description": "Simple engine with a different name from the WebExtension id prefix",
"applications": {
"gecko": {
"id": "simple@search.mozilla.org"
}
},
"hidden": true,
"chrome_settings_overrides": {
"search_provider": {
"name": "Simple Engine",
"search_url": "https://example.com",
"params": [
{
"name": "sourceId",
"value": "Mozilla-search"
},
{
"name": "search",
"value": "{searchTerms}"
}
],
"suggest_url": "https://example.com?search={searchTerms}"
}
}
}

Просмотреть файл

@ -622,6 +622,9 @@ class SearchEngine {
_definedAliases = [];
// The urls associated with this engine.
_urls = [];
// The query parameter name of the search url, cached in memory to avoid
// repeated look-ups.
_searchUrlQueryParamName = null;
/**
* Constructor.
@ -1506,6 +1509,32 @@ class SearchEngine {
return url.getSubmission(submissionData, this, purpose);
}
get searchUrlQueryParamName() {
if (this._searchUrlQueryParamName != null) {
return this._searchUrlQueryParamName;
}
let submission = this.getSubmission(
"{searchTerms}",
SearchUtils.URL_TYPE.SEARCH
);
if (submission.postData) {
Cu.reportError("searchUrlQueryParamName can't handle POST urls.");
return (this._searchUrlQueryParamName = "");
}
let queryParams = new URLSearchParams(submission.uri.query);
let searchUrlQueryParamName = "";
for (let [key, value] of queryParams) {
if (value == "{searchTerms}") {
searchUrlQueryParamName = key;
}
}
return (this._searchUrlQueryParamName = searchUrlQueryParamName);
}
// from nsISearchEngine
supportsResponseType(type) {
return this._getURLOfType(type) != null;

Просмотреть файл

@ -50,6 +50,15 @@ interface nsISearchEngine : nsISupports
[optional] in AString responseType,
[optional] in AString purpose);
/**
* Returns the name of the parameter used for the search terms for a submission
* URL of type `SearchUtils.URL_TYPE.SEARCH`.
*
* @returns A string which is the name of the parameter, or empty string
* if no parameter cannot be found or is not supported (e.g. POST).
*/
readonly attribute AString searchUrlQueryParamName;
/**
* Determines whether the engine can return responses in the given
* MIME type. Returns true if the engine spec has a URL with the

Просмотреть файл

@ -108,6 +108,22 @@ var SearchTestUtils = Object.freeze({
}
},
async useMochitestEngines(testDir) {
// Replace the path we load search engines from with
// the path to our test data.
let resProt = Services.io
.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
let originalSubstitution = resProt.getSubstitution("search-extensions");
resProt.setSubstitution(
"search-extensions",
Services.io.newURI("file://" + testDir.path)
);
gTestGlobals.registerCleanupFunction(() => {
resProt.setSubstitution("search-extensions", originalSubstitution);
});
},
/**
* Convert a list of engine configurations into engine objects.
*
@ -284,10 +300,14 @@ var SearchTestUtils = Object.freeze({
/**
* Simulates an update to the RemoteSettings configuration.
*
* @param {object} config
* @param {object} [config]
* The new configuration.
*/
async updateRemoteSettingsConfig(config) {
if (!config) {
let settings = RemoteSettings(SearchUtils.SETTINGS_KEY);
config = await settings.get();
}
const reloadObserved = SearchTestUtils.promiseSearchNotification(
"engines-reloaded"
);