Bug 1834795 - Implement Share-of-Voice for sponsored tiles r=thecount,ttran

Differential Revision: https://phabricator.services.mozilla.com/D179149
This commit is contained in:
Nan Jiang 2023-05-29 20:44:25 +00:00
parent 745fb89c08
commit da167427f9
7 changed files with 350 additions and 35 deletions

View File

@ -1533,6 +1533,13 @@ pref("browser.topsites.useRemoteSetting", true);
pref("browser.topsites.contile.enabled", true);
pref("browser.topsites.contile.endpoint", "https://contile.services.mozilla.com/v1/tiles");
// Whether to enable the Share-of-Voice feature for Sponsored Topsites via Contile.
#if defined(EARLY_BETA_OR_EARLIER)
pref("browser.topsites.contile.sov.enabled", true);
#else
pref("browser.topsites.contile.sov.enabled", false);
#endif
// The base URL for the Quick Suggest anonymizing proxy. To make a request to
// the proxy, include a campaign ID in the path.
pref("browser.partnerlink.attributionURL", "https://topsites.services.mozilla.com/cid/");

View File

@ -78,7 +78,9 @@ export class ImpressionStats extends React.PureComponent {
tile_id: card.id,
source: "newtab",
advertiser: card.advertiser,
position: card.pos + 1, // positions are 1-based for telemetry
// Keep the 0-based position, can be adjusted by the telemetry
// sender if necessary.
position: card.pos,
},
})
);

View File

@ -7249,8 +7249,9 @@ class ImpressionStats_ImpressionStats extends (external_React_default()).PureCom
tile_id: card.id,
source: "newtab",
advertiser: card.advertiser,
position: card.pos + 1 // positions are 1-based for telemetry
// Keep the 0-based position, can be adjusted by the telemetry
// sender if necessary.
position: card.pos
}
}));
}

View File

@ -54,6 +54,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
Region: "resource://gre/modules/Region.sys.mjs",
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs",
Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
});
ChromeUtils.defineModuleGetter(
lazy,
@ -68,6 +69,17 @@ XPCOMUtils.defineLazyGetter(lazy, "log", () => {
return new Logger("TopSitesFeed");
});
// `contextId` is a unique identifier used by Contextual Services
const CONTEXT_ID_PREF = "browser.contextual-services.contextId";
XPCOMUtils.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;
});
const DEFAULT_SITES_PREF = "default.sites";
const SHOWN_ON_NEWTAB_PREF = "feeds.topsites";
const DEFAULT_TOP_SITES = [];
@ -93,6 +105,8 @@ const NIMBUS_VARIABLE_MAX_SPONSORED = "topSitesMaxSponsored";
//considered for Top Sites.
const NIMBUS_VARIABLE_ADDITIONAL_TILES =
"topSitesUseAdditionalTilesFromContile";
// Nimbus variable to enable the SOV feature for sponsored tiles.
const NIMBUS_VARIABLE_CONTILE_SOV_ENABLED = "topSitesContileSovEnabled";
// Search experiment stuff
const FILTER_DEFAULT_SEARCH_PREF = "improvesearch.noDefaultSearchTile";
@ -123,6 +137,14 @@ const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles";
const CONTILE_CACHE_VALID_FOR_PREF = "browser.topsites.contile.cacheValidFor";
const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch";
// Partners of sponsored tiles.
const SPONSORED_TILE_PARTNER_AMP = "amp";
const SPONSORED_TILE_PARTNER_MOZ_SALES = "moz-sales";
const SPONSORED_TILE_PARTNERS = new Set([
SPONSORED_TILE_PARTNER_AMP,
SPONSORED_TILE_PARTNER_MOZ_SALES,
]);
function getShortURLForCurrentSearch() {
const url = shortURL({ url: Services.search.defaultEngine.searchForm });
return url;
@ -133,12 +155,18 @@ class ContileIntegration {
this._topSitesFeed = topSitesFeed;
this._lastPeriodicUpdate = 0;
this._sites = [];
// The Share-of-Voice object managed by Shepherd and sent via Contile.
this._sov = null;
}
get sites() {
return this._sites;
}
get sov() {
return this._sov;
}
periodicUpdate() {
let now = Date.now();
if (now - this._lastPeriodicUpdate >= CONTILE_UPDATE_INTERVAL) {
@ -266,6 +294,10 @@ class ContileIntegration {
return false;
}
const body = await response.json();
if (body?.sov) {
this._sov = JSON.parse(atob(body.sov));
}
if (body?.tiles && Array.isArray(body.tiles)) {
const useAdditionalTiles = lazy.NimbusFeatures.newtab.getVariable(
NIMBUS_VARIABLE_ADDITIONAL_TILES
@ -458,6 +490,7 @@ class TopSitesFeed {
sponsored_click_url: site.click_url,
sponsored_impression_url: site.impression_url,
sponsored_tile_id: site.id,
partner: SPONSORED_TILE_PARTNER_AMP,
};
if (site.image_url && site.image_size >= MIN_FAVICON_SIZE) {
// Only use the image from Contile if it's hi-res, otherwise, fallback
@ -751,7 +784,13 @@ class TopSitesFeed {
return false;
}
insertDiscoveryStreamSpocs(sponsored) {
/**
* Fetch topsites spocs from the DiscoveryStream feed.
*
* @returns {Array} An array of sponsored tile objects.
*/
fetchDiscoveryStreamSpocs() {
let sponsored = [];
const { DiscoveryStream } = this.store.getState();
if (DiscoveryStream) {
const discoveryStreamSpocs =
@ -814,11 +853,13 @@ class TopSitesFeed {
sponsored_position: positionIndex + 1,
// This is used for topsites deduping.
hostname: shortURL({ url: spoc.url }),
partner: SPONSORED_TILE_PARTNER_MOZ_SALES,
};
sponsored.push(link);
}
}
}
return sponsored;
}
// eslint-disable-next-line max-statements
@ -850,15 +891,8 @@ class TopSitesFeed {
}
// Get defaults.
let date = new Date();
let pad = number => number.toString().padStart(2, "0");
let yyyymmddhh =
String(date.getFullYear()) +
pad(date.getMonth() + 1) +
pad(date.getDate()) +
pad(date.getHours());
let contileSponsored = [];
let notBlockedDefaultSites = [];
let sponsored = [];
for (let link of DEFAULT_TOP_SITES) {
// For sponsored Yandex links, default filtering is reversed: we only
// show them if Yandex is the default search engine.
@ -877,24 +911,6 @@ class TopSitesFeed {
) {
continue;
}
// Process %YYYYMMDDHH% tag in the URL.
let url_end;
let url_start;
if (this._useRemoteSetting) {
[url_start, url_end] = link.url.split("%YYYYMMDDHH%");
}
if (typeof url_end === "string") {
link = {
...link,
// Save original URL without %YYYYMMDDHH% replaced so it can be
// blocked properly.
original_url: link.url,
url: url_start + yyyymmddhh + url_end,
};
if (link.url_urlbar) {
link.url_urlbar = link.url_urlbar.replace("%YYYYMMDDHH%", yyyymmddhh);
}
}
// If we've previously blocked a search shortcut, remove the default top site
// that matches the hostname
const searchProvider = getSearchProvider(shortURL(link));
@ -908,7 +924,7 @@ class TopSitesFeed {
if (!prefValues[SHOW_SPONSORED_PREF]) {
continue;
}
sponsored[link.sponsored_position - 1] = link;
contileSponsored[link.sponsored_position - 1] = link;
// Unpin search shortcut if present for the sponsored link to be shown
// instead.
@ -922,7 +938,12 @@ class TopSitesFeed {
}
}
this.insertDiscoveryStreamSpocs(sponsored);
const discoverySponsored = this.fetchDiscoveryStreamSpocs();
const sponsored = await this._mergeSponsoredLinks({
[SPONSORED_TILE_PARTNER_AMP]: contileSponsored,
[SPONSORED_TILE_PARTNER_MOZ_SALES]: discoverySponsored,
});
this._maybeCapSponsoredLinks(sponsored);
@ -1061,6 +1082,73 @@ class TopSitesFeed {
}
}
/**
* Merge sponsored links from all the partners using SOV if present.
* For each tile position, the user is assigned to one partner via stable sampling.
* If the chosen partner doesn't have a tile to serve, another tile from a different
* partner is used as the replacement.
*
* @param {Object} sponsoredLinks An object with sponsored links from all the partners.
* @returns {Array} An array of merged sponsored links.
*/
async _mergeSponsoredLinks(sponsoredLinks) {
if (
!this._contile.sov ||
!lazy.NimbusFeatures.pocketNewtab.getVariable(
NIMBUS_VARIABLE_CONTILE_SOV_ENABLED
)
) {
return Object.values(sponsoredLinks).flat();
}
const sampleInput = `${lazy.contextId}-${this._contile.sov.name}`;
let sponsored = [];
for (const allocation of this._contile.sov.allocations) {
let link = null;
let chosenPartner = null;
const ratios = allocation.allocation.map(alloc => alloc.percentage);
if (ratios.length) {
const index = await lazy.Sampling.ratioSample(sampleInput, ratios);
chosenPartner = allocation.allocation[index].partner;
// Unknown partners are allowed so that new parters can be added to Shepherd
// sooner without waiting for client changes.
link = sponsoredLinks[chosenPartner]?.shift();
}
if (!link) {
// If the chosen partner doesn't have a tile for this postion, choose any
// one from another group. For simplicity, we do _not_ do resampling here
// against the remaining partners.
for (const partner of SPONSORED_TILE_PARTNERS) {
if (
partner === chosenPartner ||
sponsoredLinks[partner].length === 0
) {
continue;
}
link = sponsoredLinks[partner].shift();
break;
}
if (!link) {
// No more links to be added across all the partners, just return.
return sponsored;
}
}
// Update the position fields. Note that postion is also 1-based in SOV.
link.sponsored_position = allocation.position;
if (link.pos !== undefined) {
// Pocket `pos` is 0-based.
link.pos = allocation.position - 1;
}
sponsored.push(link);
}
return sponsored;
}
/**
* Attach TippyTop icon to the given search shortcut
*

View File

@ -177,7 +177,7 @@ describe("<ImpressionStats>", () => {
tile_id: 1,
source: "newtab",
advertiser: "test advertiser",
position: 2,
position: 1,
});
});
it("should send an impression when the wrapped item transiting from invisible to visible", () => {

View File

@ -62,6 +62,7 @@ describe("Top Sites Feed", () => {
let fakePageThumbs;
let fetchStub;
let fakeNimbusFeatures;
let fakeSampling;
beforeEach(() => {
globals = new GlobalOverrider();
@ -122,6 +123,9 @@ describe("Top Sites Feed", () => {
getVariable: sinon.stub(),
},
};
fakeSampling = {
ratioSample: sinon.stub(),
};
globals.set({
PageThumbs: fakePageThumbs,
NewTabUtils: fakeNewTabUtils,
@ -130,6 +134,7 @@ describe("Top Sites Feed", () => {
LinksCache,
FilterAdult: filterAdultStub,
Screenshots: fakeScreenshot,
Sampling: fakeSampling,
});
sandbox.spy(global.XPCOMUtils, "defineLazyGetter");
FAKE_GLOBAL_PREFS.set("default.sites", "https://foo.com/");
@ -186,13 +191,18 @@ describe("Top Sites Feed", () => {
}
describe("#constructor", () => {
it("should defineLazyGetter for log and _currentSearchHostname", () => {
assert.calledTwice(global.XPCOMUtils.defineLazyGetter);
it("should defineLazyGetter for log, contextId, and _currentSearchHostname", () => {
assert.calledThrice(global.XPCOMUtils.defineLazyGetter);
let spyCall = global.XPCOMUtils.defineLazyGetter.getCall(0);
assert.ok(spyCall.calledWith(sinon.match.any, "log", sinon.match.func));
spyCall = global.XPCOMUtils.defineLazyGetter.getCall(1);
assert.ok(
spyCall.calledWith(sinon.match.any, "contextId", sinon.match.func)
);
spyCall = global.XPCOMUtils.defineLazyGetter.getCall(2);
assert.ok(
spyCall.calledWith(feed, "_currentSearchHostname", sinon.match.func)
);
@ -2187,6 +2197,73 @@ describe("Top Sites Feed", () => {
assert.equal(feed._contile.sites.length, 2);
});
it("should fetch SOV (Share-of-Voice) settings from Contile", async () => {
const sov = {
name: "SOV-20230518215316",
allocations: [
{
position: 1,
allocation: [
{
partner: "foo",
percentage: 100,
},
{
partner: "bar",
percentage: 0,
},
],
},
{
position: 2,
allocation: [
{
partner: "foo",
percentage: 80,
},
{
partner: "bar",
percentage: 20,
},
],
},
],
};
fetchStub.resolves({
ok: true,
status: 200,
headers: new Map([
["cache-control", "private, max-age=859, stale-if-error=10463"],
]),
json: () =>
Promise.resolve({
sov: btoa(JSON.stringify(sov)),
tiles: [
{
url: "https://www.test.com",
image_url: "images/test-com.png",
click_url: "https://www.test-click.com",
impression_url: "https://www.test-impression.com",
name: "test",
},
{
url: "https://www.test1.com",
image_url: "images/test1-com.png",
click_url: "https://www.test1-click.com",
impression_url: "https://www.test1-impression.com",
name: "test1",
},
],
}),
});
const fetched = await feed._contile._fetchSites();
assert.ok(fetched);
assert.deepEqual(feed._contile.sov, sov);
assert.equal(feed._contile.sites.length, 2);
});
it("should not fetch from Contile if it's not enabled", async () => {
fakeNimbusFeatures.newtab.getVariable.reset();
fakeNimbusFeatures.newtab.getVariable.returns(false);
@ -2564,6 +2641,140 @@ describe("Top Sites Feed", () => {
});
});
describe("#_mergeSponsoredLinks", () => {
let fakeSponsoredLinks;
let sov;
beforeEach(() => {
fakeSponsoredLinks = {
amp: [
{
url: "https://www.test.com",
image_url: "images/test-com.png",
click_url: "https://www.test-click.com",
impression_url: "https://www.test-impression.com",
name: "test",
partner: "amp",
sponsored_position: 1,
},
{
url: "https://www.test1.com",
image_url: "images/test1-com.png",
click_url: "https://www.test1-click.com",
impression_url: "https://www.test1-impression.com",
name: "test1",
partner: "amp",
sponsored_position: 2,
},
],
"moz-sales": [
{
url: "https://foo.com",
image_url: "images/foo-com.png",
click_url: "https://www.foo-click.com",
impression_url: "https://www.foo-impression.com",
name: "foo",
partner: "moz-sales",
pos: 2,
},
],
};
sov = {
name: "SOV-20230518215316",
allocations: [
{
position: 1,
allocation: [
{
partner: "amp",
percentage: 100,
},
{
partner: "moz-sales",
percentage: 0,
},
],
},
{
position: 2,
allocation: [
{
partner: "amp",
percentage: 80,
},
{
partner: "moz-sales",
percentage: 20,
},
],
},
],
};
});
afterEach(() => {
sandbox.restore();
});
it("should join sponsored links if the sov object is absent", async () => {
sandbox.stub(feed._contile, "sov").get(() => null);
const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat());
});
it("should join sponosred links if the SOV Nimbus variable is disabled", async () => {
fakeNimbusFeatures.pocketNewtab.getVariable.returns(false);
const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat());
});
it("should pick sponsored links based on sov configurations", async () => {
sandbox.stub(feed._contile, "sov").get(() => sov);
fakeNimbusFeatures.pocketNewtab.getVariable.returns(true);
global.Sampling.ratioSample.onCall(0).resolves(0);
global.Sampling.ratioSample.onCall(1).resolves(1);
const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
assert.equal(sponsored.length, 2);
assert.equal(sponsored[0].partner, "amp");
assert.equal(sponsored[0].sponsored_position, 1);
assert.equal(sponsored[1].partner, "moz-sales");
assert.equal(sponsored[1].sponsored_position, 2);
assert.equal(sponsored[1].pos, 1);
});
it("should fall back to other partners if the chosen partner does not have any links", async () => {
sandbox.stub(feed._contile, "sov").get(() => sov);
fakeNimbusFeatures.pocketNewtab.getVariable.returns(true);
global.Sampling.ratioSample.onCall(0).resolves(0);
global.Sampling.ratioSample.onCall(1).resolves(0);
fakeSponsoredLinks.amp = [];
const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
assert.equal(sponsored.length, 1);
assert.equal(sponsored[0].partner, "moz-sales");
assert.equal(sponsored[0].sponsored_position, 1);
assert.equal(sponsored[0].pos, 0);
});
it("should return an empty array if none of the partners have links", async () => {
sandbox.stub(feed._contile, "sov").get(() => sov);
fakeNimbusFeatures.pocketNewtab.getVariable.returns(true);
global.Sampling.ratioSample.onCall(0).resolves(0);
global.Sampling.ratioSample.onCall(1).resolves(0);
fakeSponsoredLinks.amp = [];
fakeSponsoredLinks["moz-sales"] = [];
const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
assert.equal(sponsored.length, 0);
});
});
describe("#_readDefaults", () => {
beforeEach(() => {
// Turn on sponsored TopSites for testing

View File

@ -680,6 +680,12 @@ pocketNewtab:
# Defined under `pocketNewtab` as it needs to be used along with other variables
type: int
description: The maximum number of sponsored Top Sites to be displayed
topSitesContileSovEnabled:
# Defined under `pocketNewtab` as it needs to be used along with other variables
description: Enable the Share-of-Voice feature for Sponsored Topsites.
type: boolean
fallbackPref: >-
browser.topsites.contile.sov.enabled
saveToPocket:
description: The save to Pocket feature