Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"MAX_SUBMISSION_DELAY_PREF_VALUE",
"browser.newtabpage.activity-stream.telemetry.privatePing.maxSubmissionDelayMs",
5000
);
const EVENT_STATS_KEY = "event_stats";
const CACHE_KEY = "newtab_content_event_stats";
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 {
#eventBuffer = [];
#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);
}
/**
* Set the maximum number of events to send in a 24 hour period
*
* @param {int} maxEvents
*/
setMaxEventsPerDay(maxEvents) {
this.#maxDailyEvents = maxEvents || 0;
}
/**
* 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.
*
* @param {string} name
* The name of the event to record.
* @param {object} data
* The extra data being recorded with the event.
*/
recordEvent(name, data) {
this.#eventBuffer.push([name, this.sanitizeEventData(data)]);
}
/**
* Schedules the sending of the newtab-content ping at some randomly selected
* point in the future.
*
* @param {object} privateMetrics
* The metrics to send along with the ping when it is sent, keyed on the
* name of the metric.
*/
scheduleSubmission(privateMetrics) {
for (let metric of Object.keys(privateMetrics)) {
try {
Glean.newtabContent[metric].set(privateMetrics[metric]);
} catch (e) {
console.error(e);
}
}
if (!this.#deferredTask) {
this.#lastDelaySelection = this.#generateRandomSubmissionDelayMs();
this.#deferredTask = new lazy.DeferredTask(async () => {
await this.#flushEventsAndSubmit();
}, this.#lastDelaySelection);
this.#deferredTask.arm();
}
}
/**
* Disarms any pre-existing scheduled newtab-content pings and clears the
* event buffer.
*/
uninit() {
this.#deferredTask?.disarm();
this.#eventBuffer = [];
}
/**
* Resets the impression stats object of the Newtab_content ping and returns it.
*/
async resetDailyStats(eventStats = {}) {
const stats = {
...eventStats,
dailyCount: 0,
lastUpdatedDaily: this.Date().now(),
dailyClickCount: 0,
};
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);
}
/**
* Randomly shuffles the elements of an array in place using the Fisher–Yates algorithm.
*
* @param {Array} array - The array to shuffle. This array will be modified.
* @returns {Array} The same array instance, shuffled randomly.
*/
static shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = array[i];
array[i] = array[j];
array[j] = temp;
}
return array;
}
/**
* Called by the DeferredTask when the randomly selected delay has elapsed
* 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?.lastUpdatedDaily ||
!(
this.Date().now() - eventStats.lastUpdatedDaily <
EVENT_STATS_DAILY_PERIOD_MS
)
) {
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?.dailyCount >= this.#maxDailyEvents) {
// Drop all events. Don't send
return;
}
}
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)) {
try {
Glean.newtabContent[eventName].record(data);
} catch (e) {
console.error(e);
}
}
GleanPings.newtabContent.submit();
this.#curInstanceEventsSent += events.length;
}
/**
* Returns number of events sent through Glean in this instance of the class.
*/
get testOnlyCurInstanceEventCount() {
return this.#curInstanceEventsSent;
}
/**
* Removes fields from an event that can be linked to a user in any way, in
* order to preserve anonymity of the newtab_content ping. This is just to
* ensure we don't accidentally send these if copying information between
* the newtab ping and the newtab-content ping.
*
* @param {object} eventDataDict
* The Glean event data that would be passed to a `record` method.
* @returns {object}
* The sanitized event data.
*/
sanitizeEventData(eventDataDict) {
const {
// eslint-disable-next-line no-unused-vars
tile_id,
// eslint-disable-next-line no-unused-vars
newtab_visit_id,
// eslint-disable-next-line no-unused-vars
matches_selected_topic,
// eslint-disable-next-line no-unused-vars
recommended_at,
// eslint-disable-next-line no-unused-vars
received_rank,
// eslint-disable-next-line no-unused-vars
event_source,
// eslint-disable-next-line no-unused-vars
recommendation_id,
// eslint-disable-next-line no-unused-vars
layout_name,
...result
} = eventDataDict;
return result;
}
/**
* Generate a random delay to submit the ping from the point of
* scheduling. This uses a cryptographically secure mechanism for
* generating the random delay and returns it in millseconds.
*
* @returns {number}
* A random number between 1000 and the max new content ping submission
* delay pref.
*/
#generateRandomSubmissionDelayMs() {
const MIN_SUBMISSION_DELAY = 1000;
if (lazy.MAX_SUBMISSION_DELAY_PREF_VALUE <= MIN_SUBMISSION_DELAY) {
// Somehow we got configured with a maximum delay less than the minimum...
// Let's fallback to 5000 then.
console.error(
"Can not have a newtab-content maximum submission delay less" +
` than 1000: ${lazy.MAX_SUBMISSION_DELAY_PREF_VALUE}`
);
}
const MAX_SUBMISSION_DELAY =
lazy.MAX_SUBMISSION_DELAY_PREF_VALUE > MIN_SUBMISSION_DELAY
? lazy.MAX_SUBMISSION_DELAY_PREF_VALUE
: 5000;
const RANGE = MAX_SUBMISSION_DELAY - MIN_SUBMISSION_DELAY + 1;
const selection = NewTabContentPing.secureRandIntInRange(RANGE);
return MIN_SUBMISSION_DELAY + (selection % RANGE);
}
/**
* Returns a secure random number between 0 and range
*
* @param {int} range Integer value range
* @returns {int} Random value between 0 and range non-inclusive
*/
static secureRandIntInRange(range) {
// To ensure a uniform distribution, we discard values that could introduce
// modulo bias. We divide the 2^32 range into equal-sized "buckets" and only
// accept random values that fall entirely within one of these buckets.
// This ensures each possible output in the target range is equally likely.
const BUCKET_SIZE = Math.floor(MAX_UINT32 / range);
const MAX_ACCEPTABLE = BUCKET_SIZE * range;
let selection;
let randomValues = new Uint32Array(1);
do {
crypto.getRandomValues(randomValues);
[selection] = randomValues;
} while (selection >= MAX_ACCEPTABLE);
return selection % range;
}
/**
* Returns true or false with a certain proability specified
*
* @param {number} prob Probability
* @returns {boolean} Random boolean result of probability prob. A higher prob
* increases the chance of true being returned.
*/
static decideWithProbability(prob) {
if (prob <= 0) {
return false;
}
if (prob >= 1) {
return true;
}
const randomValues = new Uint32Array(1);
crypto.getRandomValues(randomValues);
const random = randomValues[0] / MAX_UINT32;
return random < prob;
}
/**
* This is a test-only function that will disarm the DeferredTask from sending
* the newtab-content ping, and instead send it manually. The originally
* selected submission delay is returned.
*
* This function is a no-op when not running in test automation.
*
* @returns {number}
* The originally selected random delay for submitting the newtab-content
* ping.
* @throws {Error}
* Function throws an exception if this is called when no submission has been scheduled yet.
*/
async testOnlyForceFlush() {
if (!Cu.isInAutomation) {
return 0;
}
if (this.#deferredTask) {
this.#deferredTask.disarm();
this.#deferredTask = null;
await this.#flushEventsAndSubmit();
return this.#lastDelaySelection;
}
throw new Error("No submission was scheduled.");
}
}
/**
* Creating a thin wrapper around PersistentCache, and Date.
* This makes it easier for us to write automated tests
*/
NewTabContentPing.prototype.PersistentCache = (...args) => {
return new lazy.PersistentCache(...args);
};
NewTabContentPing.prototype.Date = () => {
return Date;
};