tor-browser

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

telemetry.js (16698B)


      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 /**
      6 * This is the telemetry module to report metrics for tools.
      7 *
      8 * Comprehensive documentation is in docs/frontend/telemetry.md
      9 */
     10 
     11 "use strict";
     12 
     13 const {
     14  getNthPathExcluding,
     15 } = require("resource://devtools/shared/platform/stack.js");
     16 const { TelemetryEnvironment } = ChromeUtils.importESModule(
     17  "resource://gre/modules/TelemetryEnvironment.sys.mjs"
     18 );
     19 const WeakMapMap = require("resource://devtools/client/shared/WeakMapMap.js");
     20 
     21 // Object to be shared among all instances.
     22 const PENDING_EVENT_PROPERTIES = new WeakMapMap();
     23 const PENDING_EVENTS = new WeakMapMap();
     24 
     25 /**
     26 * Instantiate a new Telemetry helper class.
     27 *
     28 * @param {object} options [optional]
     29 * @param {boolean} options.useSessionId [optional]
     30 *        If true, this instance will automatically generate a unique "sessionId"
     31 *        and use it to aggregate all records against this unique session.
     32 *        This helps aggregate all data coming from a single toolbox instance for ex.
     33 */
     34 class Telemetry {
     35  constructor({ useSessionId = false } = {}) {
     36    // Note that native telemetry APIs expect a string
     37    this.sessionId = String(
     38      useSessionId ? parseInt(this.msSinceProcessStart(), 10) : -1
     39    );
     40 
     41    // Bind pretty much all functions so that callers do not need to.
     42    this.msSystemNow = this.msSystemNow.bind(this);
     43    this.recordEvent = this.recordEvent.bind(this);
     44    this.preparePendingEvent = this.preparePendingEvent.bind(this);
     45    this.addEventProperty = this.addEventProperty.bind(this);
     46    this.addEventProperties = this.addEventProperties.bind(this);
     47    this.toolOpened = this.toolOpened.bind(this);
     48    this.toolClosed = this.toolClosed.bind(this);
     49  }
     50 
     51  get osNameAndVersion() {
     52    const osInfo = TelemetryEnvironment.currentEnvironment.system.os;
     53 
     54    if (!osInfo) {
     55      return "Unknown OS";
     56    }
     57 
     58    let osVersion = `${osInfo.name} ${osInfo.version}`;
     59 
     60    if (osInfo.windowsBuildNumber) {
     61      osVersion += `.${osInfo.windowsBuildNumber}`;
     62    }
     63 
     64    return osVersion;
     65  }
     66 
     67  /**
     68   * Time since the system wide epoch. This is not a monotonic timer but
     69   * can be used across process boundaries.
     70   */
     71  msSystemNow() {
     72    return Services.telemetry.msSystemNow();
     73  }
     74 
     75  /**
     76   * The number of milliseconds since process start using monotonic
     77   * timestamps (unaffected by system clock changes).
     78   */
     79  msSinceProcessStart() {
     80    return Services.telemetry.msSinceProcessStart();
     81  }
     82 
     83  /**
     84   * Telemetry events often need to make use of a number of properties from
     85   * completely different codepaths. To make this possible we create a
     86   * "pending event" along with an array of property names that we need to wait
     87   * for before sending the event.
     88   *
     89   * As each property is received via addEventProperty() we check if all
     90   * properties have been received. Once they have all been received we send the
     91   * telemetry event.
     92   *
     93   * @param {object} obj
     94   *        The telemetry event or ping is associated with this object, meaning
     95   *        that multiple events or pings for the same histogram may be run
     96   *        concurrently, as long as they are associated with different objects.
     97   * @param {string} method
     98   *        The telemetry event method (describes the type of event that
     99   *        occurred e.g. "open")
    100   * @param {string} object
    101   *        The telemetry event object name (the name of the object the event
    102   *        occurred on) e.g. "tools" or "setting"
    103   * @param {string | null} value
    104   *        The telemetry event value (a user defined value, providing context
    105   *        for the event) e.g. "console"
    106   * @param {Array} expected
    107   *        An array of the properties needed before sending the telemetry
    108   *        event e.g.
    109   *        [
    110   *          "host",
    111   *          "width"
    112   *        ]
    113   */
    114  preparePendingEvent(obj, method, object, value, expected = []) {
    115    const sig = `${method},${object},${value}`;
    116 
    117    if (expected.length === 0) {
    118      throw new Error(
    119        `preparePendingEvent() was called without any expected ` +
    120          `properties.\n` +
    121          `CALLER: ${getCaller()}`
    122      );
    123    }
    124 
    125    const data = {
    126      extra: {},
    127      expected: new Set(expected),
    128    };
    129 
    130    PENDING_EVENTS.set(obj, sig, data);
    131 
    132    const props = PENDING_EVENT_PROPERTIES.get(obj, sig);
    133    if (props) {
    134      for (const [name, val] of Object.entries(props)) {
    135        this.addEventProperty(obj, method, object, value, name, val);
    136      }
    137      PENDING_EVENT_PROPERTIES.delete(obj, sig);
    138    }
    139  }
    140 
    141  /**
    142   * Adds an expected property for either a current or future pending event.
    143   * This means that if preparePendingEvent() is called before or after sending
    144   * the event properties they will automatically added to the event.
    145   *
    146   * @param {object} obj
    147   *        The telemetry event or ping is associated with this object, meaning
    148   *        that multiple events or pings for the same histogram may be run
    149   *        concurrently, as long as they are associated with different objects.
    150   * @param {string} method
    151   *        The telemetry event method (describes the type of event that
    152   *        occurred e.g. "open")
    153   * @param {string} object
    154   *        The telemetry event object name (the name of the object the event
    155   *        occurred on) e.g. "tools" or "setting"
    156   * @param {string | null} value
    157   *        The telemetry event value (a user defined value, providing context
    158   *        for the event) e.g. "console"
    159   * @param {string} pendingPropName
    160   *        The pending property name
    161   * @param {string} pendingPropValue
    162   *        The pending property value
    163   */
    164  addEventProperty(
    165    obj,
    166    method,
    167    object,
    168    value,
    169    pendingPropName,
    170    pendingPropValue
    171  ) {
    172    const sig = `${method},${object},${value}`;
    173    const events = PENDING_EVENTS.get(obj, sig);
    174 
    175    // If the pending event has not been created add the property to the pending
    176    // list.
    177    if (!events) {
    178      const props = PENDING_EVENT_PROPERTIES.get(obj, sig);
    179 
    180      if (props) {
    181        props[pendingPropName] = pendingPropValue;
    182      } else {
    183        PENDING_EVENT_PROPERTIES.set(obj, sig, {
    184          [pendingPropName]: pendingPropValue,
    185        });
    186      }
    187      return;
    188    }
    189 
    190    const { expected, extra } = events;
    191 
    192    if (expected.has(pendingPropName)) {
    193      extra[pendingPropName] = pendingPropValue;
    194 
    195      if (expected.size === Object.keys(extra).length) {
    196        this._sendPendingEvent(obj, method, object, value);
    197      }
    198    } else {
    199      // The property was not expected, warn and bail.
    200      throw new Error(
    201        `An attempt was made to add the unexpected property ` +
    202          `"${pendingPropName}" to a telemetry event with the ` +
    203          `signature "${sig}"\n` +
    204          `CALLER: ${getCaller()}`
    205      );
    206    }
    207  }
    208 
    209  /**
    210   * Adds expected properties for either a current or future pending event.
    211   * This means that if preparePendingEvent() is called before or after sending
    212   * the event properties they will automatically added to the event.
    213   *
    214   * @param {object} obj
    215   *        The telemetry event or ping is associated with this object, meaning
    216   *        that multiple events or pings for the same histogram may be run
    217   *        concurrently, as long as they are associated with different objects.
    218   * @param {string} method
    219   *        The telemetry event method (describes the type of event that
    220   *        occurred e.g. "open")
    221   * @param {string} object
    222   *        The telemetry event object name (the name of the object the event
    223   *        occurred on) e.g. "tools" or "setting"
    224   * @param {string | null} value
    225   *        The telemetry event value (a user defined value, providing context
    226   *        for the event) e.g. "console"
    227   * @param {string} pendingObject
    228   *        An object containing key, value pairs that should be added to the
    229   *        event as properties.
    230   */
    231  addEventProperties(obj, method, object, value, pendingObject) {
    232    for (const [key, val] of Object.entries(pendingObject)) {
    233      this.addEventProperty(obj, method, object, value, key, val);
    234    }
    235  }
    236 
    237  /**
    238   * A private method that is not to be used externally. This method is used to
    239   * prepare a pending telemetry event for sending and then send it via
    240   * recordEvent().
    241   *
    242   * @param {object} obj
    243   *        The telemetry event or ping is associated with this object, meaning
    244   *        that multiple events or pings for the same histogram may be run
    245   *        concurrently, as long as they are associated with different objects.
    246   * @param {string} method
    247   *        The telemetry event method (describes the type of event that
    248   *        occurred e.g. "open")
    249   * @param {string} object
    250   *        The telemetry event object name (the name of the object the event
    251   *        occurred on) e.g. "tools" or "setting"
    252   * @param {string | null} value
    253   *        The telemetry event value (a user defined value, providing context
    254   *        for the event) e.g. "console"
    255   */
    256  _sendPendingEvent(obj, method, object, value) {
    257    const sig = `${method},${object},${value}`;
    258    const { extra } = PENDING_EVENTS.get(obj, sig);
    259 
    260    PENDING_EVENTS.delete(obj, sig);
    261    PENDING_EVENT_PROPERTIES.delete(obj, sig);
    262    this.recordEvent(method, object, value, extra);
    263  }
    264 
    265  /**
    266   * Send a telemetry event.
    267   *
    268   * @param {string} method
    269   *        The telemetry event method (describes the type of event that
    270   *        occurred e.g. "open")
    271   * @param {string} object
    272   *        The telemetry event object name (the name of the object the event
    273   *        occurred on) e.g. "tools" or "setting"
    274   * @param {string | null} [value]
    275   *        Optional telemetry event value (a user defined value, providing
    276   *        context for the event) e.g. "console"
    277   * @param {object} [extra]
    278   *        Optional telemetry event extra object containing the properties that
    279   *        will be sent with the event e.g.
    280   *        {
    281   *          host: "bottom",
    282   *          width: "1024"
    283   *        }
    284   */
    285  recordEvent(method, object, value = null, extra = null) {
    286    // Only string values are allowed so cast all values to strings.
    287    if (extra) {
    288      for (let [name, val] of Object.entries(extra)) {
    289        val = val + "";
    290 
    291        if (val.length > 80) {
    292          const sig = `${method},${object},${value}`;
    293 
    294          dump(
    295            `Warning: The property "${name}" was added to a telemetry ` +
    296              `event with the signature ${sig} but it's value "${val}" is ` +
    297              `longer than the maximum allowed length of 80 characters.\n` +
    298              `The property value has been trimmed to 80 characters before ` +
    299              `sending.\nCALLER: ${getCaller()}`
    300          );
    301 
    302          val = val.substring(0, 80);
    303        }
    304 
    305        extra[name] = val;
    306      }
    307    }
    308    // Automatically flag the record with the session ID
    309    // if the current Telemetry instance relates to a toolbox
    310    // so that data can be aggregated per toolbox instance.
    311    // Note that we also aggregate data per about:debugging instance.
    312    if (!extra) {
    313      extra = {};
    314    }
    315    extra.session_id = this.sessionId;
    316    if (value !== null) {
    317      extra.value = value;
    318    }
    319 
    320    // Using the Glean API directly insteade of doing string manipulations
    321    // would be better. See bug 1921793.
    322    const eventName = `${method}_${object}`.replace(/(_[a-z])/g, c =>
    323      c[1].toUpperCase()
    324    );
    325    Glean.devtoolsMain[eventName]?.record(extra);
    326  }
    327 
    328  /**
    329   * Sends telemetry pings to indicate that a tool has been opened.
    330   *
    331   * @param {string} id
    332   *        The ID of the tool opened.
    333   * @param {object} obj
    334   *        The telemetry event or ping is associated with this object, meaning
    335   *        that multiple events or pings for the same histogram may be run
    336   *        concurrently, as long as they are associated with different objects.
    337   *
    338   * NOTE: This method is designed for tools that send multiple probes on open,
    339   *       one of those probes being a counter and the other a timer. If you
    340   *       only have one probe you should be using another method.
    341   */
    342  toolOpened(id, obj) {
    343    const charts = getChartsFromToolId(id);
    344 
    345    if (!charts) {
    346      return;
    347    }
    348 
    349    if (charts.useTimedEvent) {
    350      this.preparePendingEvent(obj, "tool_timer", id, null, [
    351        "os",
    352        "time_open",
    353      ]);
    354      this.addEventProperty(
    355        obj,
    356        "tool_timer",
    357        id,
    358        null,
    359        "time_open",
    360        this.msSystemNow()
    361      );
    362    }
    363    if (charts.gleanTimingDist) {
    364      if (!obj._timerIDs) {
    365        obj._timerIDs = new Map();
    366      }
    367      if (!obj._timerIDs.has(id)) {
    368        obj._timerIDs.set(id, charts.gleanTimingDist.start());
    369      }
    370    }
    371    if (charts.gleanCounter) {
    372      charts.gleanCounter.add(1);
    373    }
    374  }
    375 
    376  /**
    377   * Sends telemetry pings to indicate that a tool has been closed.
    378   *
    379   * @param {string} id
    380   *        The ID of the tool opened.
    381   * @param {object} obj
    382   *        The telemetry event or ping is associated with this object, meaning
    383   *        that multiple events or pings for the same histogram may be run
    384   *        concurrently, as long as they are associated with different objects.
    385   *
    386   * NOTE: This method is designed for tools that send multiple probes on open,
    387   *       one of those probes being a counter and the other a timer. If you
    388   *       only have one probe you should be using another method.
    389   */
    390  toolClosed(id, obj) {
    391    const charts = getChartsFromToolId(id);
    392 
    393    if (!charts) {
    394      return;
    395    }
    396 
    397    if (charts.useTimedEvent) {
    398      const sig = `tool_timer,${id},null`;
    399      const event = PENDING_EVENTS.get(obj, sig);
    400      const time = this.msSystemNow() - event.extra.time_open;
    401 
    402      this.addEventProperties(obj, "tool_timer", id, null, {
    403        time_open: time,
    404        os: this.osNameAndVersion,
    405      });
    406    }
    407 
    408    if (charts.gleanTimingDist && obj._timerIDs) {
    409      const timerID = obj._timerIDs.get(id);
    410      if (timerID) {
    411        charts.gleanTimingDist.stopAndAccumulate(timerID);
    412        obj._timerIDs.delete(id);
    413      }
    414    }
    415  }
    416 }
    417 
    418 /**
    419 * Returns the telemetry charts for a specific tool.
    420 *
    421 * @param {string} id
    422 *        The ID of the tool that has been opened.
    423 */
    424 // eslint-disable-next-line complexity
    425 function getChartsFromToolId(id) {
    426  if (!id) {
    427    return null;
    428  }
    429 
    430  let useTimedEvent = null;
    431  let gleanCounter = null;
    432  let gleanTimingDist = null;
    433 
    434  if (id === "performance") {
    435    id = "jsprofiler";
    436  }
    437 
    438  switch (id) {
    439    case "aboutdebugging":
    440    case "browserconsole":
    441    case "dom":
    442    case "inspector":
    443    case "jsbrowserdebugger":
    444    case "jsdebugger":
    445    case "jsprofiler":
    446    case "memory":
    447    case "netmonitor":
    448    case "options":
    449    case "responsive":
    450    case "storage":
    451    case "styleeditor":
    452    case "toolbox":
    453    case "webconsole":
    454      gleanTimingDist = Glean.devtools[`${id}TimeActive`];
    455      gleanCounter = Glean.devtools[`${id}OpenedCount`];
    456      break;
    457    case "accessibility":
    458      gleanTimingDist = Glean.devtools.accessibilityTimeActive;
    459      gleanCounter = Glean.devtoolsAccessibility.openedCount;
    460      break;
    461    case "accessibility_picker":
    462      gleanTimingDist = Glean.devtools.accessibilityPickerTimeActive;
    463      gleanCounter = Glean.devtoolsAccessibility.pickerUsedCount;
    464      break;
    465    case "changesview":
    466      gleanTimingDist = Glean.devtools.changesviewTimeActive;
    467      gleanCounter = Glean.devtoolsChangesview.openedCount;
    468      break;
    469    case "animationinspector":
    470    case "compatibilityview":
    471    case "computedview":
    472    case "fontinspector":
    473    case "layoutview":
    474    case "ruleview":
    475      useTimedEvent = true;
    476      gleanTimingDist = Glean.devtools[`${id}TimeActive`];
    477      gleanCounter = Glean.devtools[`${id}OpenedCount`];
    478      break;
    479    case "flexbox_highlighter":
    480      gleanTimingDist = Glean.devtools.flexboxHighlighterTimeActive;
    481      break;
    482    case "grid_highlighter":
    483      gleanTimingDist = Glean.devtools.gridHighlighterTimeActive;
    484      break;
    485    default:
    486      gleanTimingDist = Glean.devtools.customTimeActive;
    487      gleanCounter = Glean.devtools.customOpenedCount;
    488  }
    489 
    490  return {
    491    useTimedEvent,
    492    gleanCounter,
    493    gleanTimingDist,
    494  };
    495 }
    496 
    497 /**
    498 * Displays the first caller and calling line outside of this file in the
    499 * event of an error. This is the line that made the call that produced the
    500 * error.
    501 */
    502 function getCaller() {
    503  return getNthPathExcluding(0, "/telemetry.js");
    504 }
    505 
    506 module.exports = Telemetry;