NewTabContentPing.sys.mjs (12116B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", 11 PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs", 12 }); 13 14 XPCOMUtils.defineLazyPreferenceGetter( 15 lazy, 16 "MAX_SUBMISSION_DELAY_PREF_VALUE", 17 "browser.newtabpage.activity-stream.telemetry.privatePing.maxSubmissionDelayMs", 18 5000 19 ); 20 21 const EVENT_STATS_KEY = "event_stats"; 22 const CACHE_KEY = "newtab_content_event_stats"; 23 24 const CLICK_EVENT_ID = "click"; 25 26 const EVENT_STATS_DAILY_PERIOD_MS = 60 * 60 * 24 * 1000; 27 const EVENT_STATS_WEEKLY_PERIOD_MS = 7 * 60 * 60 * 24 * 1000; 28 29 const MAX_UINT32 = 0xffffffff; 30 31 export class NewTabContentPing { 32 #eventBuffer = []; 33 #deferredTask = null; 34 #lastDelaySelection = 0; 35 #maxDailyEvents = 0; 36 #maxDailyClickEvents = 0; 37 #maxWeeklyClickEvents = 0; 38 #curInstanceEventsSent = 0; // Used for tests 39 40 constructor() { 41 this.#maxDailyEvents = 0; 42 this.#maxDailyClickEvents = 0; 43 this.#maxWeeklyClickEvents = 0; 44 this.cache = this.PersistentCache(CACHE_KEY, true); 45 } 46 47 /** 48 * Set the maximum number of events to send in a 24 hour period 49 * 50 * @param {int} maxEvents 51 */ 52 setMaxEventsPerDay(maxEvents) { 53 this.#maxDailyEvents = maxEvents || 0; 54 } 55 56 /** 57 * Set the maximum number of events to send in a 24 hour period 58 * 59 * @param {int} maxEvents 60 */ 61 setMaxClickEventsPerDay(maxEvents) { 62 this.#maxDailyClickEvents = maxEvents || 0; 63 } 64 65 /** 66 * Set the maximum number of events to send in a 24 hour period 67 * 68 * @param {int} maxEvents 69 */ 70 setMaxClickEventsPerWeek(maxEvents) { 71 this.#maxWeeklyClickEvents = maxEvents || 0; 72 } 73 74 /** 75 * Adds a event recording for Glean.newtabContent to the internal buffer. 76 * The event will be recorded when the ping is sent. 77 * 78 * @param {string} name 79 * The name of the event to record. 80 * @param {object} data 81 * The extra data being recorded with the event. 82 */ 83 recordEvent(name, data) { 84 this.#eventBuffer.push([name, this.sanitizeEventData(data)]); 85 } 86 87 /** 88 * Schedules the sending of the newtab-content ping at some randomly selected 89 * point in the future. 90 * 91 * @param {object} privateMetrics 92 * The metrics to send along with the ping when it is sent, keyed on the 93 * name of the metric. 94 */ 95 scheduleSubmission(privateMetrics) { 96 for (let metric of Object.keys(privateMetrics)) { 97 try { 98 Glean.newtabContent[metric].set(privateMetrics[metric]); 99 } catch (e) { 100 console.error(e); 101 } 102 } 103 104 if (!this.#deferredTask) { 105 this.#lastDelaySelection = this.#generateRandomSubmissionDelayMs(); 106 this.#deferredTask = new lazy.DeferredTask(async () => { 107 await this.#flushEventsAndSubmit(); 108 }, this.#lastDelaySelection); 109 this.#deferredTask.arm(); 110 } 111 } 112 113 /** 114 * Disarms any pre-existing scheduled newtab-content pings and clears the 115 * event buffer. 116 */ 117 uninit() { 118 this.#deferredTask?.disarm(); 119 this.#eventBuffer = []; 120 } 121 122 /** 123 * Resets the impression stats object of the Newtab_content ping and returns it. 124 */ 125 async resetDailyStats(eventStats = {}) { 126 const stats = { 127 ...eventStats, 128 dailyCount: 0, 129 lastUpdatedDaily: this.Date().now(), 130 dailyClickCount: 0, 131 }; 132 await this.cache.set(EVENT_STATS_KEY, stats); 133 return stats; 134 } 135 136 async resetWeeklyStats(eventStats = {}) { 137 const stats = { 138 ...eventStats, 139 lastUpdatedWeekly: this.Date().now(), 140 weeklyClickCount: 0, 141 }; 142 await this.cache.set(EVENT_STATS_KEY, stats); 143 return stats; 144 } 145 146 /** 147 * Resets all stats for testing purposes. 148 */ 149 async test_only_resetAllStats() { 150 let eventStats = await this.resetDailyStats(); 151 await this.resetWeeklyStats(eventStats); 152 } 153 154 /** 155 * Randomly shuffles the elements of an array in place using the Fisher–Yates algorithm. 156 * 157 * @param {Array} array - The array to shuffle. This array will be modified. 158 * @returns {Array} The same array instance, shuffled randomly. 159 */ 160 static shuffleArray(array) { 161 for (let i = array.length - 1; i > 0; i--) { 162 const j = Math.floor(Math.random() * (i + 1)); 163 const temp = array[i]; 164 array[i] = array[j]; 165 array[j] = temp; 166 } 167 return array; 168 } 169 /** 170 * Called by the DeferredTask when the randomly selected delay has elapsed 171 * after calling scheduleSubmission. 172 */ 173 async #flushEventsAndSubmit() { 174 const isOrganicClickEvent = (event, data) => { 175 return event === CLICK_EVENT_ID && !data.is_sponsored; 176 }; 177 178 this.#deferredTask = null; 179 180 // See if we have no event stats or the stats period has cycled 181 let eventStats = await this.cache.get(EVENT_STATS_KEY, {}); 182 183 if ( 184 !eventStats?.lastUpdatedDaily || 185 !( 186 this.Date().now() - eventStats.lastUpdatedDaily < 187 EVENT_STATS_DAILY_PERIOD_MS 188 ) 189 ) { 190 eventStats = await this.resetDailyStats(eventStats); 191 } 192 193 if ( 194 !eventStats?.lastUpdatedWeekly || 195 !( 196 this.Date().now() - eventStats.lastUpdatedWeekly < 197 EVENT_STATS_WEEKLY_PERIOD_MS 198 ) 199 ) { 200 eventStats = await this.resetWeeklyStats(eventStats); 201 } 202 203 let events = this.#eventBuffer; 204 this.#eventBuffer = []; 205 if (this.#maxDailyEvents > 0) { 206 if (eventStats?.dailyCount >= this.#maxDailyEvents) { 207 // Drop all events. Don't send 208 return; 209 } 210 } 211 let clickEvents = events.filter(([eventName, data]) => 212 isOrganicClickEvent(eventName, data) 213 ); 214 let numOriginalClickEvents = clickEvents.length; 215 // Check if we need to cap organic click events 216 if ( 217 numOriginalClickEvents > 0 && 218 (this.#maxDailyClickEvents > 0 || this.#maxWeeklyClickEvents > 0) 219 ) { 220 if (this.#maxDailyClickEvents > 0) { 221 clickEvents = clickEvents.slice( 222 0, 223 Math.max(0, this.#maxDailyClickEvents - eventStats?.dailyClickCount) 224 ); 225 } 226 if (this.#maxWeeklyClickEvents > 0) { 227 clickEvents = clickEvents.slice( 228 0, 229 Math.max(0, this.#maxWeeklyClickEvents - eventStats?.weeklyClickCount) 230 ); 231 } 232 events = events 233 .filter(([eventName, data]) => !isOrganicClickEvent(eventName, data)) 234 .concat(clickEvents); 235 } 236 237 eventStats.dailyCount += events.length; 238 eventStats.weeklyClickCount += clickEvents.length; 239 eventStats.dailyClickCount += clickEvents.length; 240 241 await this.cache.set(EVENT_STATS_KEY, eventStats); 242 243 for (let [eventName, data] of NewTabContentPing.shuffleArray(events)) { 244 try { 245 Glean.newtabContent[eventName].record(data); 246 } catch (e) { 247 console.error(e); 248 } 249 } 250 GleanPings.newtabContent.submit(); 251 this.#curInstanceEventsSent += events.length; 252 } 253 254 /** 255 * Returns number of events sent through Glean in this instance of the class. 256 */ 257 get testOnlyCurInstanceEventCount() { 258 return this.#curInstanceEventsSent; 259 } 260 261 /** 262 * Removes fields from an event that can be linked to a user in any way, in 263 * order to preserve anonymity of the newtab_content ping. This is just to 264 * ensure we don't accidentally send these if copying information between 265 * the newtab ping and the newtab-content ping. 266 * 267 * @param {object} eventDataDict 268 * The Glean event data that would be passed to a `record` method. 269 * @returns {object} 270 * The sanitized event data. 271 */ 272 sanitizeEventData(eventDataDict) { 273 const { 274 // eslint-disable-next-line no-unused-vars 275 tile_id, 276 // eslint-disable-next-line no-unused-vars 277 newtab_visit_id, 278 // eslint-disable-next-line no-unused-vars 279 matches_selected_topic, 280 // eslint-disable-next-line no-unused-vars 281 recommended_at, 282 // eslint-disable-next-line no-unused-vars 283 received_rank, 284 // eslint-disable-next-line no-unused-vars 285 event_source, 286 // eslint-disable-next-line no-unused-vars 287 recommendation_id, 288 // eslint-disable-next-line no-unused-vars 289 layout_name, 290 ...result 291 } = eventDataDict; 292 return result; 293 } 294 295 /** 296 * Generate a random delay to submit the ping from the point of 297 * scheduling. This uses a cryptographically secure mechanism for 298 * generating the random delay and returns it in millseconds. 299 * 300 * @returns {number} 301 * A random number between 1000 and the max new content ping submission 302 * delay pref. 303 */ 304 #generateRandomSubmissionDelayMs() { 305 const MIN_SUBMISSION_DELAY = 1000; 306 307 if (lazy.MAX_SUBMISSION_DELAY_PREF_VALUE <= MIN_SUBMISSION_DELAY) { 308 // Somehow we got configured with a maximum delay less than the minimum... 309 // Let's fallback to 5000 then. 310 console.error( 311 "Can not have a newtab-content maximum submission delay less" + 312 ` than 1000: ${lazy.MAX_SUBMISSION_DELAY_PREF_VALUE}` 313 ); 314 } 315 const MAX_SUBMISSION_DELAY = 316 lazy.MAX_SUBMISSION_DELAY_PREF_VALUE > MIN_SUBMISSION_DELAY 317 ? lazy.MAX_SUBMISSION_DELAY_PREF_VALUE 318 : 5000; 319 320 const RANGE = MAX_SUBMISSION_DELAY - MIN_SUBMISSION_DELAY + 1; 321 const selection = NewTabContentPing.secureRandIntInRange(RANGE); 322 return MIN_SUBMISSION_DELAY + (selection % RANGE); 323 } 324 325 /** 326 * Returns a secure random number between 0 and range 327 * 328 * @param {int} range Integer value range 329 * @returns {int} Random value between 0 and range non-inclusive 330 */ 331 static secureRandIntInRange(range) { 332 // To ensure a uniform distribution, we discard values that could introduce 333 // modulo bias. We divide the 2^32 range into equal-sized "buckets" and only 334 // accept random values that fall entirely within one of these buckets. 335 // This ensures each possible output in the target range is equally likely. 336 337 const BUCKET_SIZE = Math.floor(MAX_UINT32 / range); 338 const MAX_ACCEPTABLE = BUCKET_SIZE * range; 339 340 let selection; 341 let randomValues = new Uint32Array(1); 342 do { 343 crypto.getRandomValues(randomValues); 344 [selection] = randomValues; 345 } while (selection >= MAX_ACCEPTABLE); 346 return selection % range; 347 } 348 349 /** 350 * Returns true or false with a certain proability specified 351 * 352 * @param {number} prob Probability 353 * @returns {boolean} Random boolean result of probability prob. A higher prob 354 * increases the chance of true being returned. 355 */ 356 static decideWithProbability(prob) { 357 if (prob <= 0) { 358 return false; 359 } 360 if (prob >= 1) { 361 return true; 362 } 363 const randomValues = new Uint32Array(1); 364 crypto.getRandomValues(randomValues); 365 const random = randomValues[0] / MAX_UINT32; 366 return random < prob; 367 } 368 369 /** 370 * This is a test-only function that will disarm the DeferredTask from sending 371 * the newtab-content ping, and instead send it manually. The originally 372 * selected submission delay is returned. 373 * 374 * This function is a no-op when not running in test automation. 375 * 376 * @returns {number} 377 * The originally selected random delay for submitting the newtab-content 378 * ping. 379 * @throws {Error} 380 * Function throws an exception if this is called when no submission has been scheduled yet. 381 */ 382 async testOnlyForceFlush() { 383 if (!Cu.isInAutomation) { 384 return 0; 385 } 386 387 if (this.#deferredTask) { 388 this.#deferredTask.disarm(); 389 this.#deferredTask = null; 390 await this.#flushEventsAndSubmit(); 391 return this.#lastDelaySelection; 392 } 393 throw new Error("No submission was scheduled."); 394 } 395 } 396 397 /** 398 * Creating a thin wrapper around PersistentCache, and Date. 399 * This makes it easier for us to write automated tests 400 */ 401 NewTabContentPing.prototype.PersistentCache = (...args) => { 402 return new lazy.PersistentCache(...args); 403 }; 404 405 NewTabContentPing.prototype.Date = () => { 406 return Date; 407 };