tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }