tor-browser

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

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 };