toolkit/components/telemetry/TelemetryEventPing.jsm
author Chris H-C <chutten@mozilla.com>
Mon, 14 May 2018 09:57:30 -0400
changeset 477871 0abaa980b795b7553aa9f1fdb8ad5f1eb0f06d14
child 477872 602dd78381af870c37a513f972fc096234098051
permissions -rw-r--r--
bug 1460595 - Implement the 'event' ping r=Dexter,janerik a=lizzard MozReview-Commit-ID: HQAk8jmouyI

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/*
 * This module sends Telemetry Events periodically:
 * https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/event-ping.html
 */

"use strict";

var EXPORTED_SYMBOLS = [
  "TelemetryEventPing",
];

ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm", this);

XPCOMUtils.defineLazyModuleGetters(this, {
  TelemetrySession: "resource://gre/modules/TelemetrySession.jsm",
  TelemetryController: "resource://gre/modules/TelemetryController.jsm",
  Log: "resource://gre/modules/Log.jsm",
});

XPCOMUtils.defineLazyServiceGetters(this, {
  Telemetry: ["@mozilla.org/base/telemetry;1", "nsITelemetry"],
});

ChromeUtils.defineModuleGetter(this, "setTimeout", "resource://gre/modules/Timer.jsm");
ChromeUtils.defineModuleGetter(this, "clearTimeout", "resource://gre/modules/Timer.jsm");
ChromeUtils.defineModuleGetter(this, "TelemetryUtils", "resource://gre/modules/TelemetryUtils.jsm");
ChromeUtils.defineModuleGetter(this, "Services", "resource://gre/modules/Services.jsm");

const Utils = TelemetryUtils;

const MS_IN_A_MINUTE = 60 * 1000;

const DEFAULT_EVENT_LIMIT = 1000;
const DEFAULT_MIN_FREQUENCY_MS = 60 * MS_IN_A_MINUTE;
const DEFAULT_MAX_FREQUENCY_MS = 10 * MS_IN_A_MINUTE;

const LOGGER_NAME = "Toolkit.Telemetry";
const LOGGER_PREFIX = "TelemetryEventPing::";

const EVENT_LIMIT_REACHED_TOPIC = "event-telemetry-storage-limit-reached";
const PROFILE_BEFORE_CHANGE_TOPIC = "profile-before-change";

var Policy = {
  setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
  clearTimeout: (id) => clearTimeout(id),
  sendPing: (type, payload, options) => TelemetryController.submitExternalPing(type, payload, options),
};

var TelemetryEventPing = {
  Reason: Object.freeze({
    PERIODIC: "periodic", // Sent the ping containing events from the past periodic interval (default one hour).
    MAX: "max",           // Sent the ping containing the maximum number (default 1000) of event records, earlier than the periodic interval.
    SHUTDOWN: "shutdown", // Recorded data was sent on shutdown.
  }),

  EVENT_PING_TYPE: "event",

  _logger: null,

  _testing: false,

  // So that if we quickly reach the max limit we can immediately send.
  _lastSendTime: -DEFAULT_MIN_FREQUENCY_MS,

  get dataset() {
    return Telemetry.canRecordPrereleaseData ? Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN
                                             : Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTOUT;
  },

  startup() {
    Services.obs.addObserver(this, EVENT_LIMIT_REACHED_TOPIC);
    Services.obs.addObserver(this, PROFILE_BEFORE_CHANGE_TOPIC);

    XPCOMUtils.defineLazyPreferenceGetter(this, "maxEventsPerPing",
                                          Utils.Preferences.EventPingEventLimit,
                                          DEFAULT_EVENT_LIMIT);
    XPCOMUtils.defineLazyPreferenceGetter(this, "maxFrequency",
                                          Utils.Preferences.EventPingMaximumFrequency,
                                          DEFAULT_MAX_FREQUENCY_MS);
    XPCOMUtils.defineLazyPreferenceGetter(this, "minFrequency",
                                          Utils.Preferences.EventPingMinimumFrequency,
                                          DEFAULT_MIN_FREQUENCY_MS);

    this._startTimer();
  },

  shutdown() {
    // removeObserver may throw, which could interrupt shutdown.
    try {
      Services.obs.removeObserver(this, EVENT_LIMIT_REACHED_TOPIC);
      Services.obs.removeObserver(this, PROFILE_BEFORE_CHANGE_TOPIC);
    } catch (ex) {}

    this._clearTimer();
  },

  observe(aSubject, aTopic, aData) {
    switch (aTopic) {
      case EVENT_LIMIT_REACHED_TOPIC:
        this._log.trace("event limit reached");
        let now = Utils.monotonicNow();
        if ((now - this._lastSendTime) < this.maxFrequency) {
          this._log.trace("can't submit ping immediately as it's too soon");
          this._startTimer(this.maxFrequency - this._lastSendTime,
                           this.Reason.MAX,
                           true /* discardLeftovers*/);
        } else {
          this._log.trace("submitting ping immediately");
          this._submitPing(this.Reason.MAX);
        }
        break;
      case PROFILE_BEFORE_CHANGE_TOPIC:
        this._log.trace("profile before change");
        this._submitPing(this.Reason.SHUTDOWN, true /* discardLeftovers */);
        break;
    }
  },

  _startTimer(delay = this.minFrequency, reason = this.Reason.PERIODIC, discardLeftovers = false) {
    this._clearTimer();
    this._timeoutId =
      Policy.setTimeout(() => TelemetryEventPing._submitPing(reason, discardLeftovers), delay);
  },

  _clearTimer() {
    if (this._timeoutId) {
      Policy.clearTimeout(this._timeoutId);
      this._timeoutId = null;
    }
  },

  /**
   * Submits an "event" ping and restarts the timer for the next interval.
   *
   * @param {String} reason The reason we're sending the ping. One of TelemetryEventPing.Reason.
   * @param {bool} discardLeftovers Whether to discard event records left over from a previous ping.
   */
  _submitPing(reason, discardLeftovers = false) {
    this._log.trace("_submitPing");

    if (reason !== this.Reason.SHUTDOWN) {
      this._startTimer();
    }

    let snapshot = Telemetry.snapshotEvents(this.dataset,
                                            true /* clear */,
                                            this.maxEventsPerPing);

    if (!this._testing) {
      for (let process of Object.keys(snapshot)) {
        snapshot[process] = snapshot[process].filter(([, category]) => !category.startsWith("telemetry.test"));
      }
    }

    let eventCount = Object.values(snapshot).reduce((acc, val) => acc + val.length, 0);
    if (eventCount === 0) {
      // Don't send a ping if we haven't any events.
      this._log.trace("not sending event ping due to lack of events");
      return;
    }

    // The reason doesn't matter as it will just be echo'd back.
    let sessionMeta = TelemetrySession.getMetadata(reason);

    let processStartTimestamp = Math.round((Date.now() - TelemetryUtils.monotonicNow()) / MS_IN_A_MINUTE) * MS_IN_A_MINUTE;

    let payload = {
      reason,
      processStartTimestamp,
      sessionId: sessionMeta.sessionId,
      subsessionId: sessionMeta.subsessionId,
      lostEventsCount: 0,
      events: snapshot,
    };

    if (discardLeftovers) {
      // Any leftovers must be discarded, the count submitted in the ping.
      // This can happen on shutdown or if our max was reached before faster
      // than our maxFrequency.
      let leftovers = Telemetry.snapshotEvents(this.dataset,
                                               true /* clear */);
      let leftoverCount = Object.values(leftovers).reduce((acc, val) => acc + val.length, 0);
      payload.lostEventsCount = leftoverCount;
    }

    const options = {
      addClientId: true,
      addEnvironment: true,
      usePingSender: reason == this.Reason.SHUTDOWN,
    };

    this._lastSendTime = Utils.monotonicNow();
    Policy.sendPing(this.EVENT_PING_TYPE, payload, options);
  },

  /**
   * Test-only, restore to initial state.
   */
  testReset() {
    this._lastSendTime = -DEFAULT_MIN_FREQUENCY_MS;
    this._clearTimer();
    this._testing = true;
  },

  get _log() {
    if (!this._logger) {
      this._logger = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX + "::");
    }

    return this._logger;
  },
};