tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 66c4c04e8c047af3c5bb1aac5f01e957bbc6a33f
parent 2f4d2e2a7ba4460268bd8612f0ee7a7a7e80e448
Author: scottdowne <sdowne@mozilla.com>
Date:   Wed, 15 Oct 2025 16:25:32 +0000

Bug 1988555 - Newtab setup attribution feed. r=home-newtab-reviewers,nbarrett

Differential Revision: https://phabricator.services.mozilla.com/D264846

Diffstat:
Mbrowser/extensions/newtab/lib/ActivityStream.sys.mjs | 14++++++++++++++
Abrowser/extensions/newtab/lib/NewTabAttributionFeed.sys.mjs | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/lib/NewTabAttributionService.sys.mjs | 34++++++++++++++++++++++++++++------
Abrowser/extensions/newtab/test/xpcshell/test_NewTabAttributionFeed.js | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/xpcshell/test_NewTabAttributionService.js | 2+-
Mbrowser/extensions/newtab/test/xpcshell/xpcshell.toml | 2++
6 files changed, 380 insertions(+), 7 deletions(-)

diff --git a/browser/extensions/newtab/lib/ActivityStream.sys.mjs b/browser/extensions/newtab/lib/ActivityStream.sys.mjs @@ -27,6 +27,7 @@ ChromeUtils.defineESModuleGetters(lazy, { FaviconFeed: "resource://newtab/lib/FaviconFeed.sys.mjs", HighlightsFeed: "resource://newtab/lib/HighlightsFeed.sys.mjs", ListsFeed: "resource://newtab/lib/Widgets/ListsFeed.sys.mjs", + NewTabAttributionFeed: "resource://newtab/lib/NewTabAttributionFeed.sys.mjs", NewTabInit: "resource://newtab/lib/NewTabInit.sys.mjs", NewTabMessaging: "resource://newtab/lib/NewTabMessaging.sys.mjs", NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", @@ -752,6 +753,13 @@ export const PREFS_CONFIG = new Map([ }, ], [ + "discoverystream.attribution.enabled", + { + title: "Boolean flag to enable newtab attribution", + value: false, + }, + ], + [ "discoverystream.sections.personalization.inferred.user.enabled", { title: "User pref to toggle inferred personalizaton", @@ -1508,6 +1516,12 @@ const FEEDS_DATA = [ value: true, }, { + name: "newtabattributionfeed", + factory: () => new lazy.NewTabAttributionFeed(), + title: "Handles a local DB for story and shortcuts clicks and impressions", + value: true, + }, + { name: "newtabmessaging", factory: () => new lazy.NewTabMessaging(), title: "Handles fetching and triggering ASRouter messages in newtab", diff --git a/browser/extensions/newtab/lib/NewTabAttributionFeed.sys.mjs b/browser/extensions/newtab/lib/NewTabAttributionFeed.sys.mjs @@ -0,0 +1,166 @@ +/* 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/. */ + +const lazy = {}; + +import { actionTypes as at } from "resource://newtab/common/Actions.mjs"; + +ChromeUtils.defineESModuleGetters(lazy, { + NewTabAttributionService: + "resource://newtab/lib/NewTabAttributionService.sys.mjs", +}); + +const PREF_SYSTEM_ATTRIBUTION = "discoverystream.attribution.enabled"; +const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled"; +const PREF_UNIFIED_ADS_TILES_ENABLED = "unifiedAds.tiles.enabled"; +// topsites +const PREF_FEED_TOPSITES = "feeds.topsites"; +const PREF_SYSTEM_TOPSITES = "feeds.system.topsites"; +const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites"; +// spocs +const PREF_FEED_SECTION_TOPSTORIES = "feeds.section.topstories"; +const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories"; +const PREF_SHOW_SPONSORED = "showSponsored"; +const PREF_SYSTEM_SHOW_SPONSORED = "system.showSponsored"; + +/** + * - Writes clicks and impressions to NewTabAttributionService. + * - Cleared when user history is cleared. + */ +export class NewTabAttributionFeed { + constructor() { + this.loaded = false; + } + + isEnabled() { + const { values } = this.store.getState().Prefs; + const systemPref = values[PREF_SYSTEM_ATTRIBUTION]; + const experimentVariable = values.trainhopConfig?.attribution?.enabled; + + // Check all shortcuts prefs + const tilesEnabled = + values[PREF_FEED_TOPSITES] && values[PREF_SYSTEM_TOPSITES]; + + // Check all sponsored shortcuts prefs + const sponsoredTilesEnabled = + values[PREF_SHOW_SPONSORED_TOPSITES] && + values[PREF_UNIFIED_ADS_TILES_ENABLED]; + + // Check all stories prefs + const storiesEnabled = + values[PREF_FEED_SECTION_TOPSTORIES] && values[PREF_SYSTEM_TOPSTORIES]; + + // Check all sponsored stories prefs + const sponsoredStoriesEnabled = + values[PREF_UNIFIED_ADS_SPOCS_ENABLED] && + values[PREF_SYSTEM_SHOW_SPONSORED] && + values[PREF_SHOW_SPONSORED]; + + // Confirm at least one ads section (tiles, spocs) are enabled to allow attribution + return ( + (systemPref || experimentVariable) && + ((tilesEnabled && sponsoredTilesEnabled) || + (storiesEnabled && sponsoredStoriesEnabled)) + ); + } + + async init() { + this.attributionService = new lazy.NewTabAttributionService(); + this.loaded = true; + } + + uninit() { + this.attributionService = null; + this.loaded = false; + } + + async onPlacesHistoryCleared() { + await this.attributionService?.onAttributionReset(); + } + + async onPrefChangedAction(action) { + switch (action.data.name) { + case PREF_SYSTEM_ATTRIBUTION: + case "trainhopConfig": + case PREF_UNIFIED_ADS_SPOCS_ENABLED: + case PREF_UNIFIED_ADS_TILES_ENABLED: + case PREF_FEED_TOPSITES: + case PREF_SYSTEM_TOPSITES: + case PREF_SHOW_SPONSORED_TOPSITES: + case PREF_FEED_SECTION_TOPSTORIES: + case PREF_SYSTEM_TOPSTORIES: + case PREF_SHOW_SPONSORED: + case PREF_SYSTEM_SHOW_SPONSORED: { + const enabled = this.isEnabled(); + + if (enabled && !this.loaded) { + await this.init(); + } else if (!enabled && this.loaded) { + await this.onPlacesHistoryCleared(); + this.uninit(); + } + break; + } + } + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + if (this.isEnabled() && !this.loaded) { + await this.init(); + } + break; + case at.UNINIT: + this.uninit(); + break; + case at.PLACES_HISTORY_CLEARED: + await this.onPlacesHistoryCleared(); + break; + case at.TOP_SITES_SPONSORED_IMPRESSION_STATS: + if (this.loaded && this.isEnabled()) { + const item = action?.data || {}; + if (item.attribution) { + if (item.type === "impression") { + await this.attributionService.onAttributionEvent( + "view", + item.attribution + ); + } else if (item.type === "click") { + await this.attributionService.onAttributionEvent( + "click", + item.attribution + ); + } + } + } + break; + case at.DISCOVERY_STREAM_IMPRESSION_STATS: + if (this.loaded && this.isEnabled()) { + const item = action?.data?.tiles?.[0] || {}; + if (item.attribution) { + await this.attributionService.onAttributionEvent( + "view", + item.attribution + ); + } + } + break; + case at.DISCOVERY_STREAM_USER_EVENT: + if (this.loaded && this.isEnabled()) { + const item = action?.data?.value || {}; + if (item.attribution) { + await this.attributionService.onAttributionEvent( + "click", + item.attribution + ); + } + } + break; + case at.PREF_CHANGED: + await this.onPrefChangedAction(action); + break; + } + } +} diff --git a/browser/extensions/newtab/lib/NewTabAttributionService.sys.mjs b/browser/extensions/newtab/lib/NewTabAttributionService.sys.mjs @@ -126,14 +126,36 @@ export class NewTabAttributionService { } /** - * onAttributionClear + * Resets all partner budgets and clears stored impressions, + * preparing for a new attribution conversion cycle. */ - async onAttributionClear() {} + async onAttributionReset() { + try { + const now = this.#now(); - /** - * onAttributionReset - */ - async onAttributionReset() {} + // Clear impressions so future conversions won't match outdated impressions + const impressionStore = await this.#getImpressionStore(); + await impressionStore.clear(); + + // Reset budgets + const budgetStore = await this.#getBudgetStore(); + const partnerIds = await budgetStore.getAllKeys(); + + for (const partnerId of partnerIds) { + const budget = await budgetStore.get(partnerId); + // Currently clobbers the budget, but will work if any future data is added to DB + const updatedBudget = { + ...budget, + conversions: 0, + nextReset: now + CONVERSION_RESET_MILLI, + }; + + await budgetStore.put(updatedBudget, partnerId); + } + } catch (e) { + console.error(e); + } + } /** * onAttributionConversion checks for eligible Newtab events and submits diff --git a/browser/extensions/newtab/test/xpcshell/test_NewTabAttributionFeed.js b/browser/extensions/newtab/test/xpcshell/test_NewTabAttributionFeed.js @@ -0,0 +1,169 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + actionTypes: "resource://newtab/common/Actions.mjs", + NewTabAttributionFeed: "resource://newtab/lib/NewTabAttributionFeed.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +function makeStore(state) { + return ( + state || { + Prefs: { + values: { + "discoverystream.attribution.enabled": true, + trainhopConfig: { attribution: { enabled: true } }, + "unifiedAds.adsFeed.enabled": true, + "unifiedAds.spocs.enabled": true, + "unifiedAds.tiles.enabled": true, + "feeds.topsites": true, + "feeds.system.topsites": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "system.showSponsored": true, + showSponsored: true, + showSponsoredTopSites: false, + }, + }, + } + ); +} + +add_task(async function test_init() { + const feed = new NewTabAttributionFeed(); + feed.store = { + getState() { + return this.state; + }, + state: makeStore(), + }; + + Assert.ok(!feed.loaded); + + await feed.onAction({ type: actionTypes.INIT }); + Assert.ok(feed.loaded); + Assert.ok(feed.isEnabled()); + + await feed.onAction({ type: actionTypes.UNINIT }); + Assert.ok(!feed.loaded); +}); + +add_task(async function test_events_when_enabled() { + const feed = new NewTabAttributionFeed(); + feed.store = { + getState() { + return this.state; + }, + state: makeStore({ + Prefs: { + values: { + "discoverystream.attribution.enabled": true, + trainhopConfig: { attribution: { enabled: true } }, + + "unifiedAds.adsFeed.enabled": true, + "unifiedAds.spocs.enabled": true, + "unifiedAds.tiles.enabled": true, + "feeds.topsites": true, + "feeds.system.topsites": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "system.showSponsored": true, + showSponsored: true, + showSponsoredTopSites: true, + }, + }, + }), + }; + + await feed.onAction({ type: actionTypes.INIT }); + Assert.ok(feed.loaded); + + const onAttributionEvent = sinon + .stub(feed.attributionService, "onAttributionEvent") + .resolves(); + const onReset = sinon.stub(feed.attributionService, "onAttributionReset"); + + const attribution = { campaignId: "bar", creativeId: "bar" }; + + // top sites impression + await feed.onAction({ + type: actionTypes.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { type: "impression", attribution }, + }); + Assert.equal(onAttributionEvent.callCount, 1); + Assert.deepEqual(onAttributionEvent.firstCall.args, ["view", attribution]); + + // Top Sites click + await feed.onAction({ + type: actionTypes.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { type: "click", attribution }, + }); + + Assert.equal(onAttributionEvent.callCount, 2); + Assert.deepEqual(onAttributionEvent.secondCall.args, ["click", attribution]); + + // DS impression + await feed.onAction({ + type: actionTypes.DISCOVERY_STREAM_IMPRESSION_STATS, + data: { tiles: [{ attribution }] }, + }); + Assert.equal(onAttributionEvent.callCount, 3); + Assert.deepEqual(onAttributionEvent.thirdCall.args, ["view", attribution]); + + // DS click event + await feed.onAction({ + type: actionTypes.DISCOVERY_STREAM_USER_EVENT, + data: { value: { attribution } }, + }); + + Assert.equal(onAttributionEvent.callCount, 4); + Assert.deepEqual(onAttributionEvent.lastCall.args, ["click", attribution]); + + // History cleared + await feed.onAction({ type: actionTypes.PLACES_HISTORY_CLEARED }); + Assert.ok(onReset.calledOnce); +}); + +add_task(async function test_pref_changed_trigger_init() { + const feed = new NewTabAttributionFeed(); + feed.store = { + getState() { + return this.state; + }, + state: makeStore({ + Prefs: { + values: { + "discoverystream.attribution.enabled": false, + trainhopConfig: { attribution: { enabled: false } }, + + "unifiedAds.adsFeed.enabled": true, + "unifiedAds.spocs.enabled": true, + "unifiedAds.tiles.enabled": true, + "feeds.topsites": true, + "feeds.system.topsites": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "system.showSponsored": true, + showSponsored: true, + showSponsoredTopSites: false, + }, + }, + }), + }; + + await feed.onAction({ type: actionTypes.INIT }); + Assert.ok(!feed.loaded); + + // need both to trigger the PREF_CHANGED action and change the value of the pref in the store. + feed.store.state.Prefs.values["discoverystream.attribution.enabled"] = true; + await feed.onAction({ + type: actionTypes.PREF_CHANGED, + data: { name: "discoverystream.attribution.enabled", value: true }, + }); + Assert.ok(feed.isEnabled()); + + Assert.ok(feed.loaded); +}); diff --git a/browser/extensions/newtab/test/xpcshell/test_NewTabAttributionService.js b/browser/extensions/newtab/test/xpcshell/test_NewTabAttributionService.js @@ -574,7 +574,7 @@ add_task(async function testWithRealDAPSender() { const mockServer = new MockServer(); mockServer.start(); - const privateAttribution = new NewTabAttributionService({}); + const privateAttribution = new NewTabAttributionService(); const partnerIdentifier = "partner_identifier_real_dap"; const conversionSettings = { diff --git a/browser/extensions/newtab/test/xpcshell/xpcshell.toml b/browser/extensions/newtab/test/xpcshell/xpcshell.toml @@ -23,6 +23,8 @@ support-files = ["topstories.json"] ["test_LocalInferredRanking.js"] +["test_NewTabAttributionFeed.js"] + ["test_NewTabAttributionService.js"] ["test_NewTabContentPing.js"]