ats = stats.filter(s => s.maxCount <= s.count); if (hitStats.length) { return hitStats; } } return null; } /** * Called when a urlbar pref changes. * * @param {string} pref * The name of the pref relative to `browser.urlbar`. */ onPrefChanged(pref) { switch (pref) { case "quicksuggest.impressionCaps.stats": if (!this.#updatingStats) { this.logger.debug( "browser.urlbar.quicksuggest.impressionCaps.stats changed" ); this.#loadStats(); } break; } } #init() { this.#loadStats(); // Validate stats against any changes to the impression caps in the config. this._onConfigSet = () => this.#validateStats(); // TODO: If impression caps are ever enabled again, this will need to be // fixed. // lazy.QuickSuggest.jsBackend.emitter.on("config-set", this._onConfigSet); // Periodically record impression counters reset telemetry. this.#setCountersResetInterval(); // On shutdown, record any final impression counters reset telemetry. this._shutdownBlocker = () => this.#resetElapsedCounters(); lazy.AsyncShutdown.profileChangeTeardown.addBlocker( "QuickSuggest: Record impression counters reset telemetry", this._shutdownBlocker ); } #uninit() { // TODO: If impression caps are ever enabled again, this will need to be // fixed. // lazy.QuickSuggest.jsBackend.emitter.off("config-set", this._onConfigSet); this._onConfigSet = null; lazy.clearInterval(this._impressionCountersResetInterval); this._impressionCountersResetInterval = 0; lazy.AsyncShutdown.profileChangeTeardown.removeBlocker( this._shutdownBlocker ); this._shutdownBlocker = null; } /** * Loads and validates impression stats. */ #loadStats() { let json = lazy.UrlbarPrefs.get("quicksuggest.impressionCaps.stats"); if (!json) { this.#stats = {}; } else { try { this.#stats = JSON.parse( json, // Infinity, which is the `intervalSeconds` for the lifetime cap, is // stringified as `null` in the JSON, so convert it back to Infinity. (key, value) => key == "intervalSeconds" && value === null ? Infinity : value ); } catch (error) {} } this.#validateStats(); } /** * Validates impression stats, which includes two things: * * - Type checks stats and discards any that are invalid. We do this because * stats are stored in prefs where anyone can modify them. * - Syncs stats with impression caps so that there is one stats object * corresponding to each impression cap. See the `#stats` comment for info. */ #validateStats() { let { impression_caps } = lazy.QuickSuggest.config; this.logger.debug("Validating impression stats", { impression_caps, currentStats: this.#stats, }); if (!this.#stats || typeof this.#stats != "object") { this.#stats = {}; } for (let [type, cap] of Object.entries(impression_caps || {})) { // Build a map from interval seconds to max counts in the caps. let maxCapCounts = (cap.custom || []).reduce( (map, { interval_s, max_count }) => { map.set(interval_s, max_count); return map; }, new Map() ); if (typeof cap.lifetime == "number") { maxCapCounts.set(Infinity, cap.lifetime); } let stats = this.#stats[type]; if (!Array.isArray(stats)) { stats = []; this.#stats[type] = stats; } // Validate existing stats: // // * Discard stats with invalid properties. // * Collect and remove stats with intervals that aren't in the caps. This // should only happen when caps are changed or removed. // * For stats with intervals that are in the caps: // * Keep track of the max `stat.count` across all stats so we can // update the lifetime stat below. // * Set `stat.maxCount` to the max count in the corresponding cap. let orphanStats = []; let maxCountInStats = 0; for (let i = 0; i < stats.length; ) { let stat = stats[i]; if ( typeof stat.intervalSeconds != "number" || typeof stat.startDateMs != "number" || typeof stat.count != "number" || typeof stat.maxCount != "number" || typeof stat.impressionDateMs != "number" ) { stats.splice(i, 1); } else { maxCountInStats = Math.max(maxCountInStats, stat.count); let maxCount = maxCapCounts.get(stat.intervalSeconds); if (maxCount === undefined) { stats.splice(i, 1); orphanStats.push(stat); } else { stat.maxCount = maxCount; i++; } } } // Create stats for caps that don't already have corresponding stats. for (let [intervalSeconds, maxCount] of maxCapCounts.entries()) { if (!stats.some(s => s.intervalSeconds == intervalSeconds)) { stats.push({ maxCount, intervalSeconds, startDateMs: Date.now(), count: 0, impressionDateMs: 0, }); } } // Merge orphaned stats into other ones if possible. For each orphan, if // its interval is no bigger than an existing stat's interval, then the // orphan's count can contribute to the existing stat's count, so merge // the two. for (let orphan of orphanStats) { for (let stat of stats) { if (orphan.intervalSeconds <= stat.intervalSeconds) { stat.count = Math.max(stat.count, orphan.count); stat.startDateMs = Math.min(stat.startDateMs, orphan.startDateMs); stat.impressionDateMs = Math.max( stat.impressionDateMs, orphan.impressionDateMs ); } } } // If the lifetime stat exists, make its count the max count found above. // This is only necessary when the lifetime cap wasn't present before, but // it doesn't hurt to always do it. let lifetimeStat = stats.find(s => s.intervalSeconds == Infinity); if (lifetimeStat) { lifetimeStat.count = maxCountInStats; } // Sort the stats by interval ascending. This isn't necessary except that // it guarantees an ordering for tests. stats.sort((a, b) => a.intervalSeconds - b.intervalSeconds); } this.logger.debug("Finished validating impression stats", { newStats: this.#stats, }); } /** * Resets the counters of impression stats whose intervals have elapased. */ #resetElapsedCounters() { this.logger.debug("Checking for elapsed impression cap intervals", { currentStats: this.#stats, impression_caps: lazy.QuickSuggest.config.impression_caps, }); let now = Date.now(); for (let [type, stats] of Object.entries(this.#stats)) { for (let stat of stats) { let elapsedMs = now - stat.startDateMs; let intervalMs = 1000 * stat.intervalSeconds; let elapsedIntervalCount = Math.floor(elapsedMs / intervalMs); if (elapsedIntervalCount) { // At least one interval period elapsed for the stat, so reset it. this.logger.debug("Resetting impression counter", { type, stat, elapsedMs, elapsedIntervalCount, intervalSecs: stat.intervalSeconds, }); let newStartDateMs = stat.startDateMs + elapsedIntervalCount * intervalMs; // Reset the stat. stat.startDateMs = newStartDateMs; stat.count = 0; } } } this.logger.debug("Finished checking elapsed impression cap intervals", { newStats: this.#stats, }); } /** * Creates a repeating timer that resets impression counters and records * related telemetry. Since counters are also reset when suggestions are * triggered, the only point of this is to make sure we record reset telemetry * events in a timely manner during periods when suggestions aren't triggered. * * @param {number} ms * The number of milliseconds in the interval. */ #setCountersResetInterval(ms = IMPRESSION_COUNTERS_RESET_INTERVAL_MS) { if (this._impressionCountersResetInterval) { lazy.clearInterval(this._impressionCountersResetInterval); } this._impressionCountersResetInterval = lazy.setInterval( () => this.#resetElapsedCounters(), ms ); } /** * Gets the timestamp of app startup in ms since Unix epoch. This is only * defined as its own method so tests can override it to simulate arbitrary * startups. * * @returns {number} * Startup timestamp in ms since Unix epoch. */ _getStartupDateMs() { return Services.startup.getStartupInfo().process.getTime(); } get _test_stats() { return this.#stats; } _test_reloadStats() { this.#stats = null; this.#loadStats(); } _test_resetElapsedCounters() { this.#resetElapsedCounters(); } _test_setCountersResetInterval(ms) { this.#setCountersResetInterval(ms); } // An object that keeps track of impression stats per sponsored and // non-sponsored suggestion types. It looks like this: // // { sponsored: statsArray, nonsponsored: statsArray } // // The `statsArray` values are arrays of stats objects, one per impression // cap, which look like this: // // { intervalSeconds, startDateMs, count, maxCount, impressionDateMs } // // {number} intervalSeconds // The number of seconds in the corresponding cap's time interval. // {number} startDateMs // The timestamp at which the current interval period started and the // object's `count` was reset to zero. This is a value returned from // `Date.now()`. When the current date/time advances past `startDateMs + // 1000 * intervalSeconds`, a new interval period will start and `count` // will be reset to zero. // {number} count // The number of impressions during the current interval period. // {number} maxCount // The maximum number of impressions allowed during an interval period. // This value is the same as the `max_count` value in the corresponding // cap. It's stored in the stats object for convenience. // {number} impressionDateMs // The timestamp of the most recent impression, i.e., when `count` was // last incremented. // // There are two types of impression caps: interval and lifetime. Interval // caps are periodically reset, and lifetime caps are never reset. For stats // objects corresponding to interval caps, `intervalSeconds` will be the // `interval_s` value of the cap. For stats objects corresponding to lifetime // caps, `intervalSeconds` will be `Infinity`. // // `#stats` is kept in sync with impression caps, and there is a one-to-one // relationship between stats objects and caps. A stats object's corresponding // cap is the one with the same suggestion type (sponsored or non-sponsored) // and interval. See `#validateStats()` for more. // // Impression caps are stored in the Suggest remote settings global config. #stats = {}; // Whether impression stats are currently being updated. #updatingStats = false; } PK