commit bded0c98fcda0c32179a869d06c8a04027b1993f
parent 8edaac307780ea3c4eeaa9627ff26518013ed862
Author: Rolf Rando <rrando@mozilla.com>
Date: Thu, 4 Dec 2025 00:38:32 +0000
Bug 2003680 - Add support for daily and weekly click impression caps, DP on click items r=mconley
Note we are also deprecating support for KRR randomization on impressions. We have a new pref that applies to clicks.
We are retaining daily impression cap support, and adding support for daily click caps as well as weekly click caps. All caps can be combined in various ways.
Differential Revision: https://phabricator.services.mozilla.com/D274876
Diffstat:
3 files changed, 287 insertions(+), 29 deletions(-)
diff --git a/browser/extensions/newtab/lib/NewTabContentPing.sys.mjs b/browser/extensions/newtab/lib/NewTabContentPing.sys.mjs
@@ -21,7 +21,11 @@ XPCOMUtils.defineLazyPreferenceGetter(
const EVENT_STATS_KEY = "event_stats";
const CACHE_KEY = "newtab_content_event_stats";
-const EVENT_STATS_PERIOD_MS = 60 * 60 * 24 * 1000;
+const CLICK_EVENT_ID = "click";
+
+const EVENT_STATS_DAILY_PERIOD_MS = 60 * 60 * 24 * 1000;
+const EVENT_STATS_WEEKLY_PERIOD_MS = 7 * 60 * 60 * 24 * 1000;
+
const MAX_UINT32 = 0xffffffff;
export class NewTabContentPing {
@@ -29,10 +33,14 @@ export class NewTabContentPing {
#deferredTask = null;
#lastDelaySelection = 0;
#maxDailyEvents = 0;
+ #maxDailyClickEvents = 0;
+ #maxWeeklyClickEvents = 0;
#curInstanceEventsSent = 0; // Used for tests
constructor() {
this.#maxDailyEvents = 0;
+ this.#maxDailyClickEvents = 0;
+ this.#maxWeeklyClickEvents = 0;
this.cache = this.PersistentCache(CACHE_KEY, true);
}
@@ -46,6 +54,24 @@ export class NewTabContentPing {
}
/**
+ * Set the maximum number of events to send in a 24 hour period
+ *
+ * @param {int} maxEvents
+ */
+ setMaxClickEventsPerDay(maxEvents) {
+ this.#maxDailyClickEvents = maxEvents || 0;
+ }
+
+ /**
+ * Set the maximum number of events to send in a 24 hour period
+ *
+ * @param {int} maxEvents
+ */
+ setMaxClickEventsPerWeek(maxEvents) {
+ this.#maxWeeklyClickEvents = maxEvents || 0;
+ }
+
+ /**
* Adds a event recording for Glean.newtabContent to the internal buffer.
* The event will be recorded when the ping is sent.
*
@@ -96,13 +122,33 @@ export class NewTabContentPing {
/**
* Resets the impression stats object of the Newtab_content ping and returns it.
*/
- async resetStats() {
- const eventStats = {
- count: 0,
- lastUpdated: this.Date().now(),
+ async resetDailyStats(eventStats = {}) {
+ const stats = {
+ ...eventStats,
+ dailyCount: 0,
+ lastUpdatedDaily: this.Date().now(),
+ dailyClickCount: 0,
};
- await this.cache.set(EVENT_STATS_KEY, eventStats);
- return eventStats;
+ await this.cache.set(EVENT_STATS_KEY, stats);
+ return stats;
+ }
+
+ async resetWeeklyStats(eventStats = {}) {
+ const stats = {
+ ...eventStats,
+ lastUpdatedWeekly: this.Date().now(),
+ weeklyClickCount: 0,
+ };
+ await this.cache.set(EVENT_STATS_KEY, stats);
+ return stats;
+ }
+
+ /**
+ * Resets all stats for testing purposes.
+ */
+ async test_only_resetAllStats() {
+ let eventStats = await this.resetDailyStats();
+ await this.resetWeeklyStats(eventStats);
}
/**
@@ -125,26 +171,73 @@ export class NewTabContentPing {
* after calling scheduleSubmission.
*/
async #flushEventsAndSubmit() {
+ const isOrganicClickEvent = (event, data) => {
+ return event === CLICK_EVENT_ID && !data.is_sponsored;
+ };
+
this.#deferredTask = null;
// See if we have no event stats or the stats period has cycled
let eventStats = await this.cache.get(EVENT_STATS_KEY, {});
+
if (
- !eventStats?.lastUpdated ||
- !(this.Date().now() - eventStats.lastUpdated < EVENT_STATS_PERIOD_MS)
+ !eventStats?.lastUpdatedDaily ||
+ !(
+ this.Date().now() - eventStats.lastUpdatedDaily <
+ EVENT_STATS_DAILY_PERIOD_MS
+ )
) {
- eventStats = await this.resetStats();
+ eventStats = await this.resetDailyStats(eventStats);
+ }
+
+ if (
+ !eventStats?.lastUpdatedWeekly ||
+ !(
+ this.Date().now() - eventStats.lastUpdatedWeekly <
+ EVENT_STATS_WEEKLY_PERIOD_MS
+ )
+ ) {
+ eventStats = await this.resetWeeklyStats(eventStats);
}
let events = this.#eventBuffer;
this.#eventBuffer = [];
if (this.#maxDailyEvents > 0) {
- if (eventStats?.count >= this.#maxDailyEvents) {
- // Drop the events. Don't send.
+ if (eventStats?.dailyCount >= this.#maxDailyEvents) {
+ // Drop all events. Don't send
return;
}
}
- eventStats.count += events.length;
+ let clickEvents = events.filter(([eventName, data]) =>
+ isOrganicClickEvent(eventName, data)
+ );
+ let numOriginalClickEvents = clickEvents.length;
+ // Check if we need to cap organic click events
+ if (
+ numOriginalClickEvents > 0 &&
+ (this.#maxDailyClickEvents > 0 || this.#maxWeeklyClickEvents > 0)
+ ) {
+ if (this.#maxDailyClickEvents > 0) {
+ clickEvents = clickEvents.slice(
+ 0,
+ Math.max(0, this.#maxDailyClickEvents - eventStats?.dailyClickCount)
+ );
+ }
+ if (this.#maxWeeklyClickEvents > 0) {
+ clickEvents = clickEvents.slice(
+ 0,
+ Math.max(0, this.#maxWeeklyClickEvents - eventStats?.weeklyClickCount)
+ );
+ }
+ events = events
+ .filter(([eventName, data]) => !isOrganicClickEvent(eventName, data))
+ .concat(clickEvents);
+ }
+
+ eventStats.dailyCount += events.length;
+ eventStats.weeklyClickCount += clickEvents.length;
+ eventStats.dailyClickCount += clickEvents.length;
+
await this.cache.set(EVENT_STATS_KEY, eventStats);
for (let [eventName, data] of NewTabContentPing.shuffleArray(events)) {
diff --git a/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs b/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs
@@ -91,14 +91,17 @@ const TOP_STORIES_SECTION_NAME = "top_stories_section";
trainhopConfig.newtabPrivatePing.dailyEventCap
Maximum newtab_content events that can be sent in 24 hour period.
*/
-const TRAINHOP_PREF_RANDOM_CONTENT_PROBABILITY_MICRO =
- "randomContentProbabilityEpsilonMicro";
+const TRAINHOP_PREF_RANDOM_CLICK_PROBABILITY_MICRO =
+ "randomContentClickProbabilityEpsilonMicro";
/**
* Maximum newtab_content events that can be sent in 24 hour period.
*/
const TRAINHOP_PREF_DAILY_EVENT_CAP = "dailyEventCap";
+const TRAINHOP_PREF_DAILY_CLICK_EVENT_CAP = "dailyClickEventCap";
+const TRAINHOP_PREF_WEEKLY_CLICK_EVENT_CAP = "weeklyClickEventCap";
+
// This is a mapping table between the user preferences and its encoding code
export const USER_PREFS_ENCODING = {
showSearch: 1 << 0,
@@ -825,7 +828,7 @@ export class TelemetryFeed {
}
const { n } = this._privateRandomContentTelemetryProbablityValues;
if (!n || n < 10) {
- // None or very view articles. We're in an intermediate or errorstate.
+ // None or very view articles. We're in an intermediate or error state.
return item;
}
const cache_key = `probability_${epsilon}_${n}`; // Lookup of probability for a item size
@@ -1267,14 +1270,20 @@ export class TelemetryFeed {
this._privateRandomContentTelemetryProbablityValues = {
epsilon:
(prefs?.trainhopConfig?.newtabPrivatePing?.[
- TRAINHOP_PREF_RANDOM_CONTENT_PROBABILITY_MICRO
+ TRAINHOP_PREF_RANDOM_CLICK_PROBABILITY_MICRO
] || 0) / 1e6,
};
- const impressionCap =
- prefs?.trainhopConfig?.newtabPrivatePing?.[
- TRAINHOP_PREF_DAILY_EVENT_CAP
- ] || 0;
+ const privatePingConfig = prefs?.trainhopConfig?.newtabPrivatePing || {};
+ // Set the daily cap for content pings
+ const impressionCap = privatePingConfig[TRAINHOP_PREF_DAILY_EVENT_CAP] || 0;
this.newtabContentPing.setMaxEventsPerDay(impressionCap);
+ const clickDailyCap =
+ privatePingConfig[TRAINHOP_PREF_DAILY_CLICK_EVENT_CAP] || 0;
+ this.newtabContentPing.setMaxClickEventsPerDay(clickDailyCap);
+ const weeklyClickCap =
+ privatePingConfig[TRAINHOP_PREF_WEEKLY_CLICK_EVENT_CAP] || 0;
+ this.newtabContentPing.setMaxClickEventsPerWeek(weeklyClickCap);
+
// When we have a coarse interest vector we want to make sure there isn't
// anything additionaly identifable as a unique identifier. Therefore,
// when interest vectors are used we reduce our context profile somewhat.
@@ -1948,10 +1957,7 @@ export class TelemetryFeed {
newtab_visit_id: session.session_id,
});
if (this.privatePingEnabled) {
- this.newtabContentPing.recordEvent(
- "impression",
- this.randomizeOrganicContentEvent(gleanData)
- );
+ this.newtabContentPing.recordEvent("impression", gleanData);
}
if (tile.shim) {
diff --git a/browser/extensions/newtab/test/xpcshell/test_NewTabContentPing.js b/browser/extensions/newtab/test/xpcshell/test_NewTabContentPing.js
@@ -24,7 +24,7 @@ add_setup(() => {
*/
add_task(async function test_recordEvent_sanitizes_and_buffers() {
let ping = new NewTabContentPing();
- ping.resetStats();
+ ping.test_only_resetAllStats();
// These fields are expected to be stripped before they get recorded in the
// event.
@@ -106,14 +106,14 @@ add_task(async function test_recordEvent_sanitizes_and_buffers() {
});
/**
- * Tests that the recordEvent caps the maximum number of events posted to a maxiumum
+ * Tests that the recordEvent caps the maximum number of events
*/
-add_task(async function test_recordEvent_caps_events() {
+add_task(async function test_recordEventDaily_caps_events() {
const MAX_EVENTS = 2;
let ping = new NewTabContentPing();
ping.setMaxEventsPerDay(MAX_EVENTS);
- ping.resetStats();
+ ping.test_only_resetAllStats();
// These fields are expected to survive the sanitization.
let expectedFields = {
@@ -209,6 +209,165 @@ add_task(async function test_recordEvent_caps_events() {
sandbox.restore();
});
+/**
+ * Tests that the recordEvent caps the maximum number of click events
+ */
+add_task(async function test_recordEventDaily_caps_click_events() {
+ const MAX_DAILY_CLICK_EVENTS = 2;
+ const MAX_WEEKLY_CLICK_EVENTS = 10;
+
+ let ping = new NewTabContentPing();
+ ping.setMaxClickEventsPerDay(MAX_DAILY_CLICK_EVENTS);
+ ping.setMaxClickEventsPerWeek(MAX_WEEKLY_CLICK_EVENTS);
+ ping.test_only_resetAllStats();
+
+ // These fields are expected to survive the sanitization.
+ let expectedFields = {
+ section: "business",
+ corpus_item_id: "7fc404a1-74ec-450b-8eef-4f52b45ec510",
+ topic: "business",
+ };
+
+ for (let i = 0; i < MAX_DAILY_CLICK_EVENTS * 2; i++) {
+ ping.recordEvent("impression", {
+ ...expectedFields,
+ });
+ }
+ for (let i = 0; i < MAX_DAILY_CLICK_EVENTS; i++) {
+ ping.recordEvent("click", {
+ ...expectedFields,
+ });
+ }
+
+ let extraMetrics = {
+ utcOffset: "1",
+ experimentBranch: "some-branch",
+ };
+
+ ping.scheduleSubmission(extraMetrics);
+
+ await GleanPings.newtabContent.testSubmission(
+ () => {
+ // Test Callback
+ let [clickEvent] = Glean.newtabContent.click.testGetValue();
+ Assert.ok(clickEvent, "Found click event.");
+ let [impression] = Glean.newtabContent.impression.testGetValue();
+ Assert.ok(impression, "Found impression event.");
+ },
+ async () => {
+ // Submit Callback
+ await ping.testOnlyForceFlush();
+ }
+ );
+
+ const curTotalEvents = MAX_DAILY_CLICK_EVENTS * 3;
+ Assert.equal(
+ ping.testOnlyCurInstanceEventCount,
+ curTotalEvents,
+ "Expected number of events sent"
+ );
+
+ ping.recordEvent("click", {
+ ...expectedFields,
+ });
+ ping.scheduleSubmission(extraMetrics);
+ await ping.testOnlyForceFlush();
+
+ Assert.equal(
+ ping.testOnlyCurInstanceEventCount,
+ curTotalEvents,
+ "Click cap enforced, no new events sent"
+ );
+
+ ping = new NewTabContentPing();
+ ping.setMaxClickEventsPerDay(MAX_DAILY_CLICK_EVENTS);
+
+ Assert.equal(ping.testOnlyCurInstanceEventCount, 0, "Event count reset");
+
+ ping.recordEvent("click", {
+ ...expectedFields,
+ });
+ ping.scheduleSubmission(extraMetrics);
+ await ping.testOnlyForceFlush();
+
+ Assert.equal(
+ ping.testOnlyCurInstanceEventCount,
+ 0,
+ "No new click events after re-creating NewTabContentPing class"
+ );
+
+ // Some time has passed
+ let sandbox = sinon.createSandbox();
+
+ sandbox.stub(NewTabContentPing.prototype, "Date").returns({
+ now: () => Date.now() + 3600 * 25 * 1000, // 25 hours in future
+ });
+
+ ping.scheduleSubmission(extraMetrics);
+
+ ping.recordEvent("click", {
+ ...expectedFields,
+ });
+
+ await GleanPings.newtabContent.testSubmission(
+ () => {
+ // Test Callback
+ let [click] = Glean.newtabContent.click.testGetValue();
+ Assert.ok(click, "Found click event.");
+ },
+ async () => {
+ // Submit Callback
+ await ping.testOnlyForceFlush();
+ }
+ );
+ Assert.equal(ping.testOnlyCurInstanceEventCount, 1, "Event sending restored");
+
+ let cur_events_sent = ping.testOnlyCurInstanceEventCount;
+ for (let i = 0; i < MAX_WEEKLY_CLICK_EVENTS; i++) {
+ ping.recordEvent("click", {
+ ...expectedFields,
+ });
+ }
+ ping.scheduleSubmission(extraMetrics);
+ await ping.testOnlyForceFlush();
+ // Assert that less than MAX_WEEKLY_CLICK_EVENTS events were sent using a difference check (less) to avoid timing issues
+ Assert.less(
+ ping.testOnlyCurInstanceEventCount - cur_events_sent,
+ MAX_WEEKLY_CLICK_EVENTS,
+ "Weekly click cap enforced, no new events sent"
+ );
+ // Assert that at least 1 item was sent before we reached the cap
+ Assert.greater(
+ ping.testOnlyCurInstanceEventCount - cur_events_sent,
+ 0,
+ "At least one event was sent before reaching the weekly cap"
+ );
+
+ sandbox.restore();
+
+ // Some more time has passed - weekly reset
+ sandbox = sinon.createSandbox();
+ sandbox.stub(NewTabContentPing.prototype, "Date").returns({
+ now: () => Date.now() + 3600 * 24 * 1000 * 8, // 8 days in the future
+ });
+
+ cur_events_sent = ping.testOnlyCurInstanceEventCount;
+ for (let i = 0; i < MAX_WEEKLY_CLICK_EVENTS; i++) {
+ ping.recordEvent("click", {
+ ...expectedFields,
+ });
+ }
+ ping.scheduleSubmission(extraMetrics);
+ await ping.testOnlyForceFlush();
+ Assert.equal(
+ ping.testOnlyCurInstanceEventCount - cur_events_sent,
+ MAX_DAILY_CLICK_EVENTS, // Only daily cap should apply after weekly reset
+ "Event sending restored after weekly reset"
+ );
+
+ sandbox.restore();
+});
+
add_task(function test_decideWithProbability() {
Assert.equal(NewTabContentPing.decideWithProbability(-0.1), false);
Assert.equal(NewTabContentPing.decideWithProbability(1.1), true);