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:
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"]