ImpressionCaps.sys.mjs (15234B)
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 { SuggestFeature } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", 11 QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", 12 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 13 clearInterval: "resource://gre/modules/Timer.sys.mjs", 14 setInterval: "resource://gre/modules/Timer.sys.mjs", 15 }); 16 17 const IMPRESSION_COUNTERS_RESET_INTERVAL_MS = 60 * 60 * 1000; // 1 hour 18 19 /** 20 * Impression caps and stats for quick suggest suggestions. 21 */ 22 export class ImpressionCaps extends SuggestFeature { 23 constructor() { 24 super(); 25 lazy.UrlbarPrefs.addObserver(this); 26 } 27 28 get enablingPreferences() { 29 return [ 30 "quickSuggestImpressionCapsSponsoredEnabled", 31 "quickSuggestImpressionCapsNonSponsoredEnabled", 32 ]; 33 } 34 35 enable(enabled) { 36 if (enabled) { 37 this.#init(); 38 } else { 39 this.#uninit(); 40 } 41 } 42 43 /** 44 * Increments the user's impression stats counters for the given type of 45 * suggestion. This should be called only when a suggestion impression is 46 * recorded. 47 * 48 * @param {string} type 49 * The suggestion type, one of: "sponsored", "nonsponsored" 50 */ 51 updateStats(type) { 52 this.logger.debug("Starting impression stats update", { 53 type, 54 currentStats: this.#stats, 55 impression_caps: lazy.QuickSuggest.config.impression_caps, 56 }); 57 58 // Don't bother recording anything if caps are disabled. 59 let isSponsored = type == "sponsored"; 60 if ( 61 (isSponsored && 62 !lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) || 63 (!isSponsored && 64 !lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled")) 65 ) { 66 this.logger.debug("Impression caps disabled, skipping update"); 67 return; 68 } 69 70 // Get the user's impression stats. Since stats are synced from caps, if the 71 // stats don't exist then the caps don't exist, and don't bother recording 72 // anything in that case. 73 let stats = this.#stats[type]; 74 if (!stats) { 75 this.logger.debug("Impression caps undefined, skipping update"); 76 return; 77 } 78 79 // Increment counters. 80 for (let stat of stats) { 81 stat.count++; 82 stat.impressionDateMs = Date.now(); 83 84 // Record a telemetry event for each newly hit cap. 85 if (stat.count == stat.maxCount) { 86 this.logger.debug("Impression cap hit", { type, hitStat: stat }); 87 } 88 } 89 90 // Save the stats. 91 this.#updatingStats = true; 92 try { 93 lazy.UrlbarPrefs.set( 94 "quicksuggest.impressionCaps.stats", 95 JSON.stringify(this.#stats) 96 ); 97 } finally { 98 this.#updatingStats = false; 99 } 100 101 this.logger.debug("Finished impression stats update", { 102 newStats: this.#stats, 103 }); 104 } 105 106 /** 107 * Returns a non-null value if an impression cap has been reached for the 108 * given suggestion type and null otherwise. This method can therefore be used 109 * to tell whether a cap has been reached for a given type. The actual return 110 * value an object describing the impression stats that caused the cap to be 111 * reached. 112 * 113 * @param {string} type 114 * The suggestion type, one of: "sponsored", "nonsponsored" 115 * @returns {object} 116 * An impression stats object or null. 117 */ 118 getHitStats(type) { 119 this.#resetElapsedCounters(); 120 let stats = this.#stats[type]; 121 if (stats) { 122 let hitStats = stats.filter(s => s.maxCount <= s.count); 123 if (hitStats.length) { 124 return hitStats; 125 } 126 } 127 return null; 128 } 129 130 /** 131 * Called when a urlbar pref changes. 132 * 133 * @param {string} pref 134 * The name of the pref relative to `browser.urlbar`. 135 */ 136 onPrefChanged(pref) { 137 switch (pref) { 138 case "quicksuggest.impressionCaps.stats": 139 if (!this.#updatingStats) { 140 this.logger.debug( 141 "browser.urlbar.quicksuggest.impressionCaps.stats changed" 142 ); 143 this.#loadStats(); 144 } 145 break; 146 } 147 } 148 149 #init() { 150 this.#loadStats(); 151 152 // Validate stats against any changes to the impression caps in the config. 153 this._onConfigSet = () => this.#validateStats(); 154 // TODO: If impression caps are ever enabled again, this will need to be 155 // fixed. 156 // lazy.QuickSuggest.jsBackend.emitter.on("config-set", this._onConfigSet); 157 158 // Periodically record impression counters reset telemetry. 159 this.#setCountersResetInterval(); 160 161 // On shutdown, record any final impression counters reset telemetry. 162 this._shutdownBlocker = () => this.#resetElapsedCounters(); 163 lazy.AsyncShutdown.profileChangeTeardown.addBlocker( 164 "QuickSuggest: Record impression counters reset telemetry", 165 this._shutdownBlocker 166 ); 167 } 168 169 #uninit() { 170 // TODO: If impression caps are ever enabled again, this will need to be 171 // fixed. 172 // lazy.QuickSuggest.jsBackend.emitter.off("config-set", this._onConfigSet); 173 this._onConfigSet = null; 174 175 lazy.clearInterval(this._impressionCountersResetInterval); 176 this._impressionCountersResetInterval = 0; 177 178 lazy.AsyncShutdown.profileChangeTeardown.removeBlocker( 179 this._shutdownBlocker 180 ); 181 this._shutdownBlocker = null; 182 } 183 184 /** 185 * Loads and validates impression stats. 186 */ 187 #loadStats() { 188 let json = lazy.UrlbarPrefs.get("quicksuggest.impressionCaps.stats"); 189 if (!json) { 190 this.#stats = {}; 191 } else { 192 try { 193 this.#stats = JSON.parse( 194 json, 195 // Infinity, which is the `intervalSeconds` for the lifetime cap, is 196 // stringified as `null` in the JSON, so convert it back to Infinity. 197 (key, value) => 198 key == "intervalSeconds" && value === null ? Infinity : value 199 ); 200 } catch (error) {} 201 } 202 this.#validateStats(); 203 } 204 205 /** 206 * Validates impression stats, which includes two things: 207 * 208 * - Type checks stats and discards any that are invalid. We do this because 209 * stats are stored in prefs where anyone can modify them. 210 * - Syncs stats with impression caps so that there is one stats object 211 * corresponding to each impression cap. See the `#stats` comment for info. 212 */ 213 #validateStats() { 214 let { impression_caps } = lazy.QuickSuggest.config; 215 216 this.logger.debug("Validating impression stats", { 217 impression_caps, 218 currentStats: this.#stats, 219 }); 220 221 if (!this.#stats || typeof this.#stats != "object") { 222 this.#stats = {}; 223 } 224 225 for (let [type, cap] of Object.entries(impression_caps || {})) { 226 // Build a map from interval seconds to max counts in the caps. 227 let maxCapCounts = (cap.custom || []).reduce( 228 (map, { interval_s, max_count }) => { 229 map.set(interval_s, max_count); 230 return map; 231 }, 232 new Map() 233 ); 234 if (typeof cap.lifetime == "number") { 235 maxCapCounts.set(Infinity, cap.lifetime); 236 } 237 238 let stats = this.#stats[type]; 239 if (!Array.isArray(stats)) { 240 stats = []; 241 this.#stats[type] = stats; 242 } 243 244 // Validate existing stats: 245 // 246 // * Discard stats with invalid properties. 247 // * Collect and remove stats with intervals that aren't in the caps. This 248 // should only happen when caps are changed or removed. 249 // * For stats with intervals that are in the caps: 250 // * Keep track of the max `stat.count` across all stats so we can 251 // update the lifetime stat below. 252 // * Set `stat.maxCount` to the max count in the corresponding cap. 253 let orphanStats = []; 254 let maxCountInStats = 0; 255 for (let i = 0; i < stats.length; ) { 256 let stat = stats[i]; 257 if ( 258 typeof stat.intervalSeconds != "number" || 259 typeof stat.startDateMs != "number" || 260 typeof stat.count != "number" || 261 typeof stat.maxCount != "number" || 262 typeof stat.impressionDateMs != "number" 263 ) { 264 stats.splice(i, 1); 265 } else { 266 maxCountInStats = Math.max(maxCountInStats, stat.count); 267 let maxCount = maxCapCounts.get(stat.intervalSeconds); 268 if (maxCount === undefined) { 269 stats.splice(i, 1); 270 orphanStats.push(stat); 271 } else { 272 stat.maxCount = maxCount; 273 i++; 274 } 275 } 276 } 277 278 // Create stats for caps that don't already have corresponding stats. 279 for (let [intervalSeconds, maxCount] of maxCapCounts.entries()) { 280 if (!stats.some(s => s.intervalSeconds == intervalSeconds)) { 281 stats.push({ 282 maxCount, 283 intervalSeconds, 284 startDateMs: Date.now(), 285 count: 0, 286 impressionDateMs: 0, 287 }); 288 } 289 } 290 291 // Merge orphaned stats into other ones if possible. For each orphan, if 292 // its interval is no bigger than an existing stat's interval, then the 293 // orphan's count can contribute to the existing stat's count, so merge 294 // the two. 295 for (let orphan of orphanStats) { 296 for (let stat of stats) { 297 if (orphan.intervalSeconds <= stat.intervalSeconds) { 298 stat.count = Math.max(stat.count, orphan.count); 299 stat.startDateMs = Math.min(stat.startDateMs, orphan.startDateMs); 300 stat.impressionDateMs = Math.max( 301 stat.impressionDateMs, 302 orphan.impressionDateMs 303 ); 304 } 305 } 306 } 307 308 // If the lifetime stat exists, make its count the max count found above. 309 // This is only necessary when the lifetime cap wasn't present before, but 310 // it doesn't hurt to always do it. 311 let lifetimeStat = stats.find(s => s.intervalSeconds == Infinity); 312 if (lifetimeStat) { 313 lifetimeStat.count = maxCountInStats; 314 } 315 316 // Sort the stats by interval ascending. This isn't necessary except that 317 // it guarantees an ordering for tests. 318 stats.sort((a, b) => a.intervalSeconds - b.intervalSeconds); 319 } 320 321 this.logger.debug("Finished validating impression stats", { 322 newStats: this.#stats, 323 }); 324 } 325 326 /** 327 * Resets the counters of impression stats whose intervals have elapased. 328 */ 329 #resetElapsedCounters() { 330 this.logger.debug("Checking for elapsed impression cap intervals", { 331 currentStats: this.#stats, 332 impression_caps: lazy.QuickSuggest.config.impression_caps, 333 }); 334 335 let now = Date.now(); 336 for (let [type, stats] of Object.entries(this.#stats)) { 337 for (let stat of stats) { 338 let elapsedMs = now - stat.startDateMs; 339 let intervalMs = 1000 * stat.intervalSeconds; 340 let elapsedIntervalCount = Math.floor(elapsedMs / intervalMs); 341 if (elapsedIntervalCount) { 342 // At least one interval period elapsed for the stat, so reset it. 343 this.logger.debug("Resetting impression counter", { 344 type, 345 stat, 346 elapsedMs, 347 elapsedIntervalCount, 348 intervalSecs: stat.intervalSeconds, 349 }); 350 351 let newStartDateMs = 352 stat.startDateMs + elapsedIntervalCount * intervalMs; 353 354 // Reset the stat. 355 stat.startDateMs = newStartDateMs; 356 stat.count = 0; 357 } 358 } 359 } 360 361 this.logger.debug("Finished checking elapsed impression cap intervals", { 362 newStats: this.#stats, 363 }); 364 } 365 366 /** 367 * Creates a repeating timer that resets impression counters and records 368 * related telemetry. Since counters are also reset when suggestions are 369 * triggered, the only point of this is to make sure we record reset telemetry 370 * events in a timely manner during periods when suggestions aren't triggered. 371 * 372 * @param {number} ms 373 * The number of milliseconds in the interval. 374 */ 375 #setCountersResetInterval(ms = IMPRESSION_COUNTERS_RESET_INTERVAL_MS) { 376 if (this._impressionCountersResetInterval) { 377 lazy.clearInterval(this._impressionCountersResetInterval); 378 } 379 this._impressionCountersResetInterval = lazy.setInterval( 380 () => this.#resetElapsedCounters(), 381 ms 382 ); 383 } 384 385 /** 386 * Gets the timestamp of app startup in ms since Unix epoch. This is only 387 * defined as its own method so tests can override it to simulate arbitrary 388 * startups. 389 * 390 * @returns {number} 391 * Startup timestamp in ms since Unix epoch. 392 */ 393 _getStartupDateMs() { 394 return Services.startup.getStartupInfo().process.getTime(); 395 } 396 397 get _test_stats() { 398 return this.#stats; 399 } 400 401 _test_reloadStats() { 402 this.#stats = null; 403 this.#loadStats(); 404 } 405 406 _test_resetElapsedCounters() { 407 this.#resetElapsedCounters(); 408 } 409 410 _test_setCountersResetInterval(ms) { 411 this.#setCountersResetInterval(ms); 412 } 413 414 // An object that keeps track of impression stats per sponsored and 415 // non-sponsored suggestion types. It looks like this: 416 // 417 // { sponsored: statsArray, nonsponsored: statsArray } 418 // 419 // The `statsArray` values are arrays of stats objects, one per impression 420 // cap, which look like this: 421 // 422 // { intervalSeconds, startDateMs, count, maxCount, impressionDateMs } 423 // 424 // {number} intervalSeconds 425 // The number of seconds in the corresponding cap's time interval. 426 // {number} startDateMs 427 // The timestamp at which the current interval period started and the 428 // object's `count` was reset to zero. This is a value returned from 429 // `Date.now()`. When the current date/time advances past `startDateMs + 430 // 1000 * intervalSeconds`, a new interval period will start and `count` 431 // will be reset to zero. 432 // {number} count 433 // The number of impressions during the current interval period. 434 // {number} maxCount 435 // The maximum number of impressions allowed during an interval period. 436 // This value is the same as the `max_count` value in the corresponding 437 // cap. It's stored in the stats object for convenience. 438 // {number} impressionDateMs 439 // The timestamp of the most recent impression, i.e., when `count` was 440 // last incremented. 441 // 442 // There are two types of impression caps: interval and lifetime. Interval 443 // caps are periodically reset, and lifetime caps are never reset. For stats 444 // objects corresponding to interval caps, `intervalSeconds` will be the 445 // `interval_s` value of the cap. For stats objects corresponding to lifetime 446 // caps, `intervalSeconds` will be `Infinity`. 447 // 448 // `#stats` is kept in sync with impression caps, and there is a one-to-one 449 // relationship between stats objects and caps. A stats object's corresponding 450 // cap is the one with the same suggestion type (sponsored or non-sponsored) 451 // and interval. See `#validateStats()` for more. 452 // 453 // Impression caps are stored in the Suggest remote settings global config. 454 #stats = {}; 455 456 // Whether impression stats are currently being updated. 457 #updatingStats = false; 458 }