Bug 1693393 - add telemetry for sponsored TopSites in Urlbar r=dao,harry

Differential Revision: https://phabricator.services.mozilla.com/D105639
This commit is contained in:
Nan Jiang 2021-02-24 16:57:27 +00:00
Родитель df23c6e58c
Коммит 59c6a49f3a
9 изменённых файлов: 527 добавлений и 21 удалений

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

@ -14,6 +14,8 @@ XPCOMUtils.defineLazyModuleGetters(this, {
AppConstants: "resource://gre/modules/AppConstants.jsm",
BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.jsm",
BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm",
CONTEXTUAL_SERVICES_PING_TYPES:
"resource:///modules/PartnerLinkAttribution.jsm",
ExtensionSearchHandler: "resource://gre/modules/ExtensionSearchHandler.jsm",
ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
@ -44,6 +46,9 @@ XPCOMUtils.defineLazyServiceGetter(
const DEFAULT_FORM_HISTORY_NAME = "searchbar-history";
const SEARCH_BUTTON_ID = "urlbar-search-button";
// The scalar category of TopSites click for Contextual Services
const SCALAR_CATEGORY_TOPSITES = "contextual.services.topsites.click";
let getBoundsWithoutFlushing = element =>
element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element);
let px = number => number.toFixed(2) + "px";
@ -967,6 +972,25 @@ class UrlbarInput {
"browser.partnerlink.campaign.topsites"
),
});
if (!this.isPrivate && result.providerName === "UrlbarProviderTopSites") {
// The position is 1-based for telemetry
const position = selIndex + 1;
Services.telemetry.keyedScalarAdd(
SCALAR_CATEGORY_TOPSITES,
`urlbar_${position}`,
1
);
PartnerLinkAttribution.sendContextualServicesPing(
{
position,
source: "urlbar",
tile_id: result.payload.sponsoredTileId || -1,
reporting_url: result.payload.sponsoredClickUrl,
advertiser: result.payload.title.toLocaleLowerCase(),
},
CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_SELECTION
);
}
}
this._loadURL(

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

@ -12,6 +12,9 @@ const { XPCOMUtils } = ChromeUtils.import(
XPCOMUtils.defineLazyModuleGetters(this, {
AboutNewTab: "resource:///modules/AboutNewTab.jsm",
CONTEXTUAL_SERVICES_PING_TYPES:
"resource:///modules/PartnerLinkAttribution.jsm",
PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.jsm",
PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
Services: "resource://gre/modules/Services.jsm",
@ -25,6 +28,9 @@ XPCOMUtils.defineLazyModuleGetters(this, {
TOP_SITES_DEFAULT_ROWS: "resource://activity-stream/common/Reducers.jsm",
});
// The scalar category of TopSites impression for Contextual Services
const SCALAR_CATEGORY_TOPSITES = "contextual.services.topsites.impression";
/**
* This module exports a provider returning the user's newtab Top Sites.
*/
@ -137,33 +143,70 @@ class ProviderTopSites extends UrlbarProvider {
);
sites = sites.slice(0, numTopSites);
sites = sites.map(link => ({
type: link.searchTopSite ? "search" : "url",
url: link.url_urlbar || link.url,
isPinned: !!link.isPinned,
isSponsored: !!link.sponsored_position,
// The newtab page allows the user to set custom site titles, which
// are stored in `label`, so prefer it. Search top sites currently
// don't have titles but `hostname` instead.
title: link.label || link.title || link.hostname || "",
favicon: link.smallFavicon || link.favicon || undefined,
sendAttributionRequest: !!link.sendAttributionRequest,
}));
let sponsoredSites = [];
let index = 1;
sites = sites.map(link => {
let site = {
type: link.searchTopSite ? "search" : "url",
url: link.url_urlbar || link.url,
isPinned: !!link.isPinned,
isSponsored: !!link.sponsored_position,
// The newtab page allows the user to set custom site titles, which
// are stored in `label`, so prefer it. Search top sites currently
// don't have titles but `hostname` instead.
title: link.label || link.title || link.hostname || "",
favicon: link.smallFavicon || link.favicon || undefined,
sendAttributionRequest: !!link.sendAttributionRequest,
};
if (site.isSponsored) {
let {
sponsored_tile_id,
sponsored_impression_url,
sponsored_click_url,
} = link;
site = {
...site,
sponsoredTileId: sponsored_tile_id,
sponsoredImpressionUrl: sponsored_impression_url,
sponsoredClickUrl: sponsored_click_url,
position: index,
};
sponsoredSites.push(site);
}
index++;
return site;
});
// Store Sponsored Top Sites so we can use it in `onEngagement`
if (sponsoredSites.length) {
this.sponsoredSites = sponsoredSites;
}
for (let site of sites) {
switch (site.type) {
case "url": {
let payload = {
title: site.title,
url: site.url,
icon: site.favicon,
isPinned: site.isPinned,
isSponsored: site.isSponsored,
sendAttributionRequest: site.sendAttributionRequest,
};
if (site.isSponsored) {
payload = {
...payload,
sponsoredTileId: site.sponsoredTileId,
sponsoredClickUrl: site.sponsoredClickUrl,
};
}
let result = new UrlbarResult(
UrlbarUtils.RESULT_TYPE.URL,
UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
title: site.title,
url: site.url,
icon: site.favicon,
isPinned: site.isPinned,
isSponsored: site.isSponsored,
sendAttributionRequest: site.sendAttributionRequest,
})
...UrlbarResult.payloadAndSimpleHighlights(
queryContext.tokens,
payload
)
);
let allowTabSwitch =
@ -244,6 +287,48 @@ class ProviderTopSites extends UrlbarProvider {
}
}
}
/**
* Called when the user starts and ends an engagement with the urlbar. We send
* the impression ping for the sponsored TopSites, the impression scalar is
* recorded as well.
*
* Note:
* * No telemetry recording in private browsing mode
* * The impression is only recorded for the "engagement" and "abandonment"
* states
*
* @param {boolean} isPrivate True if the engagement is in a private context.
* @param {string} state The state of the engagement, one of: start,
* engagement, abandonment, discard.
*/
onEngagement(isPrivate, state) {
if (
!isPrivate &&
this.sponsoredSites &&
["engagement", "abandonment"].includes(state)
) {
for (let site of this.sponsoredSites) {
Services.telemetry.keyedScalarAdd(
SCALAR_CATEGORY_TOPSITES,
`urlbar_${site.position}`,
1
);
PartnerLinkAttribution.sendContextualServicesPing(
{
source: "urlbar",
tile_id: site.sponsoredTileId || -1,
position: site.position,
reporting_url: site.sponsoredImpressionUrl,
advertiser: site.title.toLocaleLowerCase(),
},
CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_IMPRESSION
);
}
}
this.sponsoredSites = null;
}
}
var UrlbarProviderTopSites = new ProviderTopSites();

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

@ -1198,6 +1198,12 @@ UrlbarUtils.RESULT_PAYLOAD_SCHEMA = {
sendAttributionRequest: {
type: "boolean",
},
sponsoredClickUrl: {
type: "string",
},
sponsoredTileId: {
type: "number",
},
tags: {
type: "array",
items: {

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

@ -358,6 +358,61 @@ Event Extra
.. _URLBar provider experiments: experiments.html#developing-address-bar-extensions
Custom pings for Contextual Services
------------------------------------
Contextual Services currently has two features running within the Urlbar: TopSites
and QuickSuggest. We send various pings as the `custom pings`_ to record the impressions
and clicks of these two features.
.. _custom pings: https://docs.telemetry.mozilla.org/cookbooks/new_ping.html#sending-a-custom-ping
TopSites Impression
This records an impression when a sponsored TopSite is shown.
- ``context_id``
A UUID representing this user. Note that it's not client_id, nor can it be used to link to a client_id.
- ``tile_id``
A unique identifier for the sponsored TopSite.
- ``source``
The browser location where the impression was displayed.
- ``position``
The placement of the TopSite (1-based).
- ``advertiser``
The Name of the advertiser.
- ``reporting_url``
The reporting URL of the sponsored TopSite, normally pointing to the ad partner's reporting endpoint.
- ``version``
Firefox version.
- ``release_channel``
Firefox release channel.
- ``locale``
User's current locale.
TopSites Click
This records a click ping when a sponsored TopSite is clicked by the user.
- ``context_id``
A UUID representing this user. Note that it's not client_id, nor can it be used to link to a client_id.
- ``tile_id``
A unique identifier for the sponsored TopSite.
- ``source``
The browser location where the click was tirggered.
- ``position``
The placement of the TopSite (1-based).
- ``advertiser``
The Name of the advertiser.
- ``reporting_url``
The reporting URL of the sponsored TopSite, normally pointing to the ad partner's reporting endpoint.
- ``version``
Firefox version.
- ``release_channel``
Firefox release channel.
- ``locale``
User's current locale.
Search probes relevant to the Address Bar
-----------------------------------------
@ -421,6 +476,14 @@ browser.engagement.navigation.*
For ``urlbar`` or ``searchbar``, indicates the user confirmed a search
suggestion.
contextual.services.topsites.*
These keyed scalars instrument the impressions and clicks for sponsored TopSites
in the urlbar.
The key is a combination of the source and the placement of the TopSites link
(1-based) such as 'urlbar_1'. For each key, it records the counter of the
impression or click.
Note that these scalars are shared with the TopSites on the newtab page.
Obsolete probes
---------------

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

@ -270,6 +270,8 @@ tags = search-telemetry
support-files =
urlbarTelemetrySearchSuggestions.sjs
urlbarTelemetrySearchSuggestions.xml
[browser_urlbar_telemetry_sponsored_topsites.js]
tags = search-telemetry
[browser_urlbar_telemetry_tabtosearch.js]
tags = search-telemetry
[browser_urlbar_telemetry_tip.js]

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

@ -0,0 +1,177 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
XPCOMUtils.defineLazyModuleGetters(this, {
CONTEXTUAL_SERVICES_PING_TYPES:
"resource:///modules/PartnerLinkAttribution.jsm",
HttpServer: "resource://testing-common/httpd.js",
PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.jsm",
NewTabUtils: "resource://gre/modules/NewTabUtils.jsm",
});
const EN_US_TOPSITES =
"https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/";
// This is used for "sendAttributionRequest"
var gHttpServer = null;
var gRequests = [];
function submitHandler(request, response) {
gRequests.push(request);
response.setStatusLine(request.httpVersion, 200, "Ok");
}
// Spy for telemetry sender
let spy;
add_task(async function setup() {
sandbox = sinon.createSandbox();
spy = sandbox.spy(
PartnerLinkAttribution._pingCentre,
"sendStructuredIngestionPing"
);
let topsitesAttribution = Services.prefs.getStringPref(
"browser.partnerlink.campaign.topsites"
);
gHttpServer = new HttpServer();
gHttpServer.registerPathHandler(`/cid/${topsitesAttribution}`, submitHandler);
gHttpServer.start(-1);
await SpecialPowers.pushPrefEnv({
set: [
["browser.urlbar.suggest.topsites", true],
["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES],
[
"browser.partnerlink.attributionURL",
`http://localhost:${gHttpServer.identity.primaryPort}/cid/`,
],
],
});
await updateTopSites(
sites => sites && sites.length == EN_US_TOPSITES.split(",").length
);
registerCleanupFunction(async () => {
sandbox.restore();
await gHttpServer.stop();
gHttpServer = null;
});
});
add_task(async function send_impression_and_click() {
await BrowserTestUtils.withNewTab("about:blank", async () => {
let link = {
label: "test_label",
url: "http://example.com/",
sponsored_position: 1,
sendAttributionRequest: true,
sponsored_tile_id: 42,
sponsored_impression_url: "http://impression.test.com/",
sponsored_click_url: "http://click.test.com/",
};
// Pin a sponsored TopSite to set up the test fixture
NewTabUtils.pinnedLinks.pin(link, 0);
await updateTopSites(sites => sites && sites[0] && sites[0].isPinned);
await UrlbarTestUtils.promisePopupOpen(window, () => {
EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {});
});
await UrlbarTestUtils.promiseSearchComplete(window);
// Select the first result and confirm it.
let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
EventUtils.synthesizeKey("KEY_ArrowDown");
let loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt(
result.url,
gBrowser.selectedBrowser
);
EventUtils.synthesizeKey("KEY_Enter");
await loadPromise;
Assert.ok(
spy.calledTwice,
"Should send an impression ping and a click ping"
);
// Validate the impression ping
let [payload, endpoint] = spy.firstCall.args;
Assert.ok(
endpoint.includes(CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_IMPRESSION),
"Should set the endpoint for TopSites impression"
);
Assert.ok(!!payload.context_id, "Should set the context_id");
Assert.equal(payload.advertiser, "test_label", "Should set the advertiser");
Assert.equal(
payload.reporting_url,
"http://impression.test.com/",
"Should set the impression reporting URL"
);
Assert.equal(payload.tile_id, 42, "Should set the tile_id");
Assert.equal(payload.position, 1, "Should set the position");
// Validate the click ping
[payload, endpoint] = spy.secondCall.args;
Assert.ok(
endpoint.includes(CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_SELECTION),
"Should set the endpoint for TopSites click"
);
Assert.ok(!!payload.context_id, "Should set the context_id");
Assert.equal(
payload.reporting_url,
"http://click.test.com/",
"Should set the click reporting URL"
);
Assert.equal(payload.tile_id, 42, "Should set the tile_id");
Assert.equal(payload.position, 1, "Should set the position");
await UrlbarTestUtils.promisePopupClose(window, () => {
gURLBar.blur();
});
NewTabUtils.pinnedLinks.unpin(link);
});
});
add_task(async function zero_ping() {
await BrowserTestUtils.withNewTab("about:blank", async () => {
spy.resetHistory();
// Reload the TopSites
await updateTopSites(
sites => sites && sites.length == EN_US_TOPSITES.split(",").length
);
await UrlbarTestUtils.promisePopupOpen(window, () => {
EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {});
});
await UrlbarTestUtils.promiseSearchComplete(window);
// Select the first result and confirm it.
let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
EventUtils.synthesizeKey("KEY_ArrowDown");
let loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt(
result.url,
gBrowser.selectedBrowser
);
EventUtils.synthesizeKey("KEY_Enter");
await loadPromise;
Assert.ok(
spy.notCalled,
"Should not send any ping if there is no sponsored Top Site"
);
await UrlbarTestUtils.promisePopupClose(window, () => {
gURLBar.blur();
});
});
});

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

@ -6,7 +6,10 @@
Cu.importGlobalProperties(["fetch"]);
var EXPORTED_SYMBOLS = ["PartnerLinkAttribution"];
var EXPORTED_SYMBOLS = [
"PartnerLinkAttribution",
"CONTEXTUAL_SERVICES_PING_TYPES",
];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
@ -15,8 +18,45 @@ const { XPCOMUtils } = ChromeUtils.import(
XPCOMUtils.defineLazyModuleGetters(this, {
Services: "resource://gre/modules/Services.jsm",
Region: "resource://gre/modules/Region.jsm",
PingCentre: "resource:///modules/PingCentre.jsm",
});
XPCOMUtils.defineLazyServiceGetters(this, {
gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
});
// Endpoint base URL for Structured Ingestion
XPCOMUtils.defineLazyPreferenceGetter(
this,
"structuredIngestionEndpointBase",
"browser.newtabpage.activity-stream.telemetry.structuredIngestion.endpoint",
""
);
const NAMESPACE_CONTEXUAL_SERVICES = "contextual-services";
// PingCentre client to send custom pings
XPCOMUtils.defineLazyGetter(this, "pingcentre", () => {
return new PingCentre({ topic: "contextual-services" });
});
// `contextId` is a unique identifier used by Contextual Services
const CONTEXT_ID_PREF = "browser.contextual-services.contextId";
XPCOMUtils.defineLazyGetter(this, "contextId", () => {
let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null);
if (!_contextId) {
_contextId = String(gUUIDGenerator.generateUUID());
Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId);
}
return _contextId;
});
const CONTEXTUAL_SERVICES_PING_TYPES = {
TOPSITES_IMPRESSION: "topsites-impression",
TOPSITES_SELECTION: "topsites-click",
QS_IMPRESSION: "quicksuggest-impression",
QS_SELECTION: "quicksuggest-click",
};
var PartnerLinkAttribution = {
/**
* Sends an attribution request to an anonymizing proxy.
@ -116,6 +156,39 @@ var PartnerLinkAttribution = {
await sendRequest(attributionUrl, "searchurl", strippedTargetUrl);
},
/**
* Sends a Contextual Services ping to the Mozilla data pipeline.
*
* Note:
* * All Contextual Services pings are sent as custom pings
* (https://docs.telemetry.mozilla.org/cookbooks/new_ping.html#sending-a-custom-ping)
*
* * The full event list can be found at https://github.com/mozilla-services/mozilla-pipeline-schemas
* under the "contextual-services" namespace
*
* @param {object} payload
* The ping payload to be sent to the Mozilla Structured Ingestion endpoint
* @param {String} pingType
* The ping type. Must be one of CONTEXTUAL_SERVICES_PING_TYPES
*/
sendContextualServicesPing(payload, pingType) {
if (!Object.values(CONTEXTUAL_SERVICES_PING_TYPES).includes(pingType)) {
Cu.reportError("Invalid Contextual Services ping type");
return;
}
const endpoint = makeEndpointUrl(pingType, "1");
payload.context_id = contextId;
pingcentre.sendStructuredIngestionPing(payload, endpoint);
},
/**
* Gets the underlying PingCentre client, only used for tests.
*/
get _pingCentre() {
return pingcentre;
},
};
async function sendRequest(attributionUrl, source, targetURL) {
@ -131,3 +204,26 @@ function recordTelemetryEvent({ method, objectString, value }) {
Services.telemetry.setEventRecordingEnabled("partner_link", true);
Services.telemetry.recordEvent("partner_link", method, objectString, value);
}
/**
* Makes a new endpoint URL for a ping submission. Note that each submission
* to Structured Ingesttion requires a new endpoint. See more details about
* the specs:
*
* https://docs.telemetry.mozilla.org/concepts/pipeline/http_edge_spec.html?highlight=docId#postput-request
*
* @param {String} pingType
* The ping type. Must be one of CONTEXTUAL_SERVICES_PING_TYPES
* @param {String} version
* The schema version of the ping.
*/
function makeEndpointUrl(pingType, version) {
// Structured Ingestion does not support the UUID generated by gUUIDGenerator.
// Stripping off the leading and trailing braces to make it happy.
const docID = gUUIDGenerator
.generateUUID()
.toString()
.slice(1, -1);
const extension = `${NAMESPACE_CONTEXUAL_SERVICES}/${pingType}/${version}/${docID}`;
return `${structuredIngestionEndpointBase}/${extension}`;
}

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

@ -0,0 +1,52 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
"use strict";
const {
PartnerLinkAttribution,
CONTEXTUAL_SERVICES_PING_TYPES,
} = ChromeUtils.import("resource:///modules/PartnerLinkAttribution.jsm");
const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
const FAKE_PING = { tile_id: 1, position: 1 };
let sandbox;
let stub;
add_task(function setup() {
sandbox = sinon.createSandbox();
stub = sandbox.stub(
PartnerLinkAttribution._pingCentre,
"sendStructuredIngestionPing"
);
stub.returns(200);
registerCleanupFunction(() => {
sandbox.restore();
});
});
add_task(function test_sendContextualService_success() {
for (const type of Object.values(CONTEXTUAL_SERVICES_PING_TYPES)) {
PartnerLinkAttribution.sendContextualServicesPing(FAKE_PING, type);
Assert.ok(stub.calledOnce, `Should send the ping for ${type}`);
const [payload, endpoint] = stub.firstCall.args;
Assert.ok(!!payload.context_id, "Should add context_id to the payload");
Assert.ok(
endpoint.includes(type),
"Should include the ping type in the endpoint URL"
);
stub.resetHistory();
}
});
add_task(function test_rejectUnknownPingType() {
PartnerLinkAttribution.sendContextualServicesPing(FAKE_PING, "unknown-type");
Assert.ok(stub.notCalled, "Should not send the ping with unknown ping type");
});

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

@ -16,3 +16,4 @@ skip-if = toolkit == 'android'
skip-if = os != 'win' # Test of a Windows-specific feature
[test_InstallationTelemetry.js]
skip-if = os != 'win' # Test of a Windows-specific feature
[test_PartnerLinkAttribution.js]