Bug 1416024 - Patch 1: Record event for toolbox open/close r?yulia draft
authorMike Ratcliffe <mratcliffe@mozilla.com>
Thu, 22 Mar 2018 16:54:15 +0000
changeset 778610 91a4d5e011948c2d0b965ac5daf01811a89d7c02
parent 778609 82d9b216ea35137afcc69987331880d0c15577b4
push id105531
push userbmo:mratcliffe@mozilla.com
push dateFri, 06 Apr 2018 14:43:25 +0000
reviewersyulia
bugs1416024
milestone61.0a1
Bug 1416024 - Patch 1: Record event for toolbox open/close r?yulia MozReview-Commit-ID: 9YI0Bv6Q8gG
devtools/client/framework/devtools.js
devtools/client/framework/toolbox.js
devtools/client/shared/telemetry.js
devtools/startup/DevToolsShim.jsm
devtools/startup/devtools-startup.js
toolkit/components/telemetry/Events.yaml
--- a/devtools/client/framework/devtools.js
+++ b/devtools/client/framework/devtools.js
@@ -4,23 +4,21 @@
 
 "use strict";
 
 const {Cu} = require("chrome");
 const Services = require("Services");
 
 const {DevToolsShim} = require("chrome://devtools-startup/content/DevToolsShim.jsm");
 
-// Load gDevToolsBrowser toolbox lazily as they need gDevTools to be fully initialized
 loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true);
 loader.lazyRequireGetter(this, "TabTarget", "devtools/client/framework/target", true);
-loader.lazyRequireGetter(this, "Toolbox", "devtools/client/framework/toolbox", true);
 loader.lazyRequireGetter(this, "ToolboxHostManager", "devtools/client/framework/toolbox-host-manager", true);
-loader.lazyRequireGetter(this, "gDevToolsBrowser", "devtools/client/framework/devtools-browser", true);
 loader.lazyRequireGetter(this, "HUDService", "devtools/client/webconsole/hudservice", true);
+loader.lazyRequireGetter(this, "Telemetry", "devtools/client/shared/telemetry");
 loader.lazyImporter(this, "ScratchpadManager", "resource://devtools/client/scratchpad/scratchpad-manager.jsm");
 loader.lazyImporter(this, "BrowserToolboxProcess", "resource://devtools/client/framework/ToolboxProcess.jsm");
 
 loader.lazyRequireGetter(this, "WebExtensionInspectedWindowFront",
       "devtools/shared/fronts/webextension-inspected-window", true);
 
 const {defaultTools: DefaultTools, defaultThemes: DefaultThemes} =
   require("devtools/client/definitions");
@@ -38,16 +36,18 @@ const MAX_ORDINAL = 99;
 function DevTools() {
   this._tools = new Map(); // Map<toolId, tool>
   this._themes = new Map(); // Map<themeId, theme>
   this._toolboxes = new Map(); // Map<target, toolbox>
   // List of toolboxes that are still in process of creation
   this._creatingToolboxes = new Map(); // Map<target, toolbox Promise>
 
   EventEmitter.decorate(this);
+  this._telemetry = new Telemetry();
+  this._telemetry.setEventRecordingEnabled("devtools.main", true);
 
   // Listen for changes to the theme pref.
   this._onThemeChanged = this._onThemeChanged.bind(this);
   addThemeObserver(this._onThemeChanged);
 
   // This is important step in initialization codepath where we are going to
   // start registering all default tools and themes: create menuitems, keys, emit
   // related events.
@@ -476,16 +476,21 @@ DevTools.prototype = {
       toolbox = await toolboxPromise;
       this._creatingToolboxes.delete(target);
 
       if (startTime) {
         this.logToolboxOpenTime(toolbox.currentToolId, startTime);
       }
       this._firstShowToolbox = false;
     }
+
+    let width = Math.ceil(toolbox.win.outerWidth / 50) * 50;
+    this._telemetry.addEventProperty(
+      "devtools.main", "open", "tools", null, "width", width);
+
     return toolbox;
   },
 
   /**
    * Log telemetry related to toolbox opening.
    * Two distinct probes are logged. One for cold startup, when we open the very first
    * toolbox. This one includes devtools framework loading. And a second one for all
    * subsequent toolbox opening, which should all be faster.
@@ -495,20 +500,44 @@ DevTools.prototype = {
    *        The id of the opened tool.
    * @param {Number} startTime
    *        Indicates the time at which the user event related to the toolbox
    *        opening started. This is a `performance.now()` timing.
    */
   logToolboxOpenTime(toolId, startTime) {
     let { performance } = Services.appShell.hiddenDOMWindow;
     let delay = performance.now() - startTime;
+
     let telemetryKey = this._firstShowToolbox ?
       "DEVTOOLS_COLD_TOOLBOX_OPEN_DELAY_MS" : "DEVTOOLS_WARM_TOOLBOX_OPEN_DELAY_MS";
-    let histogram = Services.telemetry.getKeyedHistogramById(telemetryKey);
-    histogram.add(toolId, delay);
+    this._telemetry.logKeyed(telemetryKey, toolId, delay);
+
+    this._telemetry.addEventProperty(
+      "devtools.main", "open", "tools", null, "first_panel",
+      this.makeToolIdHumanReadable(toolId));
+  },
+
+  makeToolIdHumanReadable(toolId) {
+    if (/^[0-9a-fA-F]{40}_temporary-addon/.test(toolId)) {
+      return "temporary-addon";
+    }
+
+    let matches = toolId.match(
+      /^_([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})_/
+    );
+    if (matches && matches.length === 2) {
+      return matches[1];
+    }
+
+    matches = toolId.match(/^_?(.*)-\d+-\d+-devtools-panel$/);
+    if (matches && matches.length === 2) {
+      return matches[1];
+    }
+
+    return toolId;
   },
 
   async createToolbox(target, toolId, hostType, hostOptions) {
     let manager = new ToolboxHostManager(target, hostType, hostOptions);
 
     let toolbox = await manager.create(toolId);
 
     this._toolboxes.set(target, toolbox);
--- a/devtools/client/framework/toolbox.js
+++ b/devtools/client/framework/toolbox.js
@@ -540,16 +540,21 @@ Toolbox.prototype = {
 
       await this.selectTool(this._defaultToolId);
 
       // Wait until the original tool is selected so that the split
       // console input will receive focus.
       let splitConsolePromise = promise.resolve();
       if (Services.prefs.getBoolPref(SPLITCONSOLE_ENABLED_PREF)) {
         splitConsolePromise = this.openSplitConsole();
+        this._telemetry.addEventProperty(
+          "devtools.main", "open", "tools", null, "splitconsole", true);
+      } else {
+        this._telemetry.addEventProperty(
+          "devtools.main", "open", "tools", null, "splitconsole", false);
       }
 
       await promise.all([
         splitConsolePromise,
         buttonsPromise,
         framesPromise
       ]);
 
@@ -695,27 +700,46 @@ Toolbox.prototype = {
       case Toolbox.HostType.BOTTOM: return 0;
       case Toolbox.HostType.SIDE: return 1;
       case Toolbox.HostType.WINDOW: return 2;
       case Toolbox.HostType.CUSTOM: return 3;
       default: return 9;
     }
   },
 
+  // Return HostType string for telemetry
+  _getTelemetryHostString: function() {
+    switch (this.hostType) {
+      case Toolbox.HostType.BOTTOM: return "bottom";
+      case Toolbox.HostType.SIDE: return "side";
+      case Toolbox.HostType.WINDOW: return "window";
+      case Toolbox.HostType.CUSTOM: return "other";
+      default: return "bottom";
+    }
+  },
+
   _pingTelemetry: function() {
     this._telemetry.toolOpened("toolbox");
 
     this._telemetry.logOncePerBrowserVersion(SCREENSIZE_HISTOGRAM,
                                              system.getScreenDimensions());
     this._telemetry.log(HOST_HISTOGRAM, this._getTelemetryHostId());
 
     // Log current theme. The question we want to answer is:
     // "What proportion of users use which themes?"
     let currentTheme = Services.prefs.getCharPref("devtools.theme");
     this._telemetry.logKeyedScalar(CURRENT_THEME_SCALAR, currentTheme, 1);
+
+    this._telemetry.preparePendingEvent(
+      "devtools.main", "open", "tools", null,
+      ["entrypoint", "first_panel", "host", "splitconsole", "width"]
+    );
+    this._telemetry.addEventProperty(
+      "devtools.main", "open", "tools", null, "host", this._getTelemetryHostString()
+    );
   },
 
   /**
    * Create a simple object to store the state of a toolbox button. The checked state of
    * a button can be updated arbitrarily outside of the scope of the toolbar and its
    * controllers. In order to simplify this interaction this object emits an
    * "updatechecked" event any time the isChecked value is updated, allowing any consuming
    * components to listen and respond to updates.
--- a/devtools/client/shared/telemetry.js
+++ b/devtools/client/shared/telemetry.js
@@ -8,25 +8,33 @@
  * Comprehensive documentation is in docs/frontend/telemetry.md
  */
 
 "use strict";
 
 const Services = require("Services");
 const TOOLS_OPENED_PREF = "devtools.telemetry.tools.opened.version";
 
+// Object to be shared among all instances.
+const PENDING_EVENTS = new Map();
+const PENDING_EVENT_PROPERTIES = new Map();
+
 class Telemetry {
   constructor() {
     // Bind pretty much all functions so that callers do not need to.
     this.toolOpened = this.toolOpened.bind(this);
     this.toolClosed = this.toolClosed.bind(this);
     this.log = this.log.bind(this);
     this.logScalar = this.logScalar.bind(this);
     this.logKeyedScalar = this.logKeyedScalar.bind(this);
     this.logOncePerBrowserVersion = this.logOncePerBrowserVersion.bind(this);
+    this.recordEvent = this.recordEvent.bind(this);
+    this.setEventRecordingEnabled = this.setEventRecordingEnabled.bind(this);
+    this.preparePendingEvent = this.preparePendingEvent.bind(this);
+    this.addEventProperty = this.addEventProperty.bind(this);
     this.destroy = this.destroy.bind(this);
 
     this._timers = new Map();
   }
 
   get histograms() {
     return {
       toolbox: {
@@ -285,20 +293,20 @@ class Telemetry {
    *         Value to store.
    */
   logScalar(scalarId, value) {
     if (!scalarId) {
       return;
     }
 
     try {
-      if (isNaN(value)) {
-        dump(`Warning: An attempt was made to write a non-numeric value ` +
-             `${value} to the ${scalarId} scalar. Only numeric values are ` +
-             `allowed.`);
+      if (isNaN(value) && typeof value !== "boolean") {
+        dump(`Warning: An attempt was made to write a non-numeric and ` +
+             `non-boolean value ${value} to the ${scalarId} scalar. Only ` +
+             `numeric and boolean values are allowed.`);
 
         return;
       }
       Services.telemetry.scalarSet(scalarId, value);
     } catch (e) {
       dump(`Warning: An attempt was made to write to the ${scalarId} ` +
            `scalar, which is not defined in Scalars.yaml\n`);
     }
@@ -379,16 +387,199 @@ class Telemetry {
         lastVersionHistogramUpdated !== currentVersion) {
       latestObj[perUserHistogram] = currentVersion;
       latest = JSON.stringify(latestObj);
       Services.prefs.setCharPref(TOOLS_OPENED_PREF, latest);
       this.log(perUserHistogram, value);
     }
   }
 
+  /**
+   * Event telemetry is disabled by default. Use this method to enable it for
+   * a particular category.
+   *
+   * @param {String} category
+   *        The telemetry event category e.g. "devtools.main"
+   * @param {Boolean} enabled
+   *        Enabled: true or false.
+   */
+  setEventRecordingEnabled(category, enabled) {
+    return Services.telemetry.setEventRecordingEnabled(category, enabled);
+  }
+
+  /**
+   * Telemetry events often need to make use of a number of properties from
+   * completely different codepaths. To make this possible we create a
+   * "pending event" along with an array of property names that we need to wait
+   * for before sending the event.
+   *
+   * As each property is received via addEventProperty() we check if all
+   * properties have been received. Once they have all been received we send the
+   * telemetry event.
+   *
+   * @param {String} category
+   *        The telemetry event category (a group name for events and helps to
+   *        avoid name conflicts) e.g. "devtools.main"
+   * @param {String} method
+   *        The telemetry event method (describes the type of event that
+   *        occurred e.g. "open")
+   * @param {String} object
+   *        The telemetry event object name (the name of the object the event
+   *        occurred on) e.g. "tools" or "setting"
+   * @param {String|null} value
+   *        The telemetry event value (a user defined value, providing context
+   *        for the event) e.g. "console"
+   * @param {Array} expected
+   *        An array of the properties needed before sending the telemetry
+   *        event e.g.
+   *        [
+   *          "host",
+   *          "width"
+   *        ]
+   */
+  preparePendingEvent(category, method, object, value, expected = []) {
+    const sig = `${category},${method},${object},${value}`;
+
+    if (expected.length === 0) {
+      throw new Error(`preparePendingEvent() was called without any expected ` +
+                      `properties.`);
+    }
+
+    PENDING_EVENTS.set(sig, {
+      extra: {},
+      expected: new Set(expected)
+    });
+
+    const props = PENDING_EVENT_PROPERTIES.get(sig);
+    if (props) {
+      for (let [name, val] of Object.entries(props)) {
+        this.addEventProperty(category, method, object, value, name, val);
+      }
+      PENDING_EVENT_PROPERTIES.delete(sig);
+    }
+  }
+
+  /**
+   * Adds an expected property for either a current or future pending event.
+   * This means that if preparePendingEvent() is called before or after sending
+   * the event properties they will automatically added to the event.
+   *
+   * @param {String} category
+   *        The telemetry event category (a group name for events and helps to
+   *        avoid name conflicts) e.g. "devtools.main"
+   * @param {String} method
+   *        The telemetry event method (describes the type of event that
+   *        occurred e.g. "open")
+   * @param {String} object
+   *        The telemetry event object name (the name of the object the event
+   *        occurred on) e.g. "tools" or "setting"
+   * @param {String|null} value
+   *        The telemetry event value (a user defined value, providing context
+   *        for the event) e.g. "console"
+   * @param {String} pendingPropName
+   *        The pending property name
+   * @param {String} pendingPropValue
+   *        The pending property value
+   */
+  addEventProperty(category, method, object, value, pendingPropName, pendingPropValue) {
+    const sig = `${category},${method},${object},${value}`;
+
+    // If the pending event has not been created add the property to the pending
+    // list.
+    if (!PENDING_EVENTS.has(sig)) {
+      PENDING_EVENT_PROPERTIES.set(sig, {
+        [pendingPropName]: pendingPropValue
+      });
+      return;
+    }
+
+    const { expected, extra } = PENDING_EVENTS.get(sig);
+
+    if (expected.has(pendingPropName)) {
+      extra[pendingPropName] = pendingPropValue;
+
+      if (expected.size === Object.keys(extra).length) {
+        this._sendPendingEvent(category, method, object, value);
+      }
+    } else {
+      // The property was not expected, warn and bail.
+      throw new Error(`An attempt was made to add the unexpected property ` +
+                      `"${pendingPropName}" to a telemetry event with the ` +
+                      `signature "${sig}"\n`);
+    }
+  }
+
+  /**
+   * Send a telemetry event.
+   *
+   * @param {String} category
+   *        The telemetry event category (a group name for events and helps to
+   *        avoid name conflicts) e.g. "devtools.main"
+   * @param {String} method
+   *        The telemetry event method (describes the type of event that
+   *        occurred e.g. "open")
+   * @param {String} object
+   *        The telemetry event object name (the name of the object the event
+   *        occurred on) e.g. "tools" or "setting"
+   * @param {String|null} value
+   *        The telemetry event value (a user defined value, providing context
+   *        for the event) e.g. "console"
+   * @param {Object} extra
+   *        The telemetry event extra object containing the properties that will
+   *        be sent with the event e.g.
+   *        {
+   *          host: "bottom",
+   *          width: "1024"
+   *        }
+   */
+  recordEvent(category, method, object, value, extra) {
+    // Only string values are allowed so cast all values to strings.
+    for (let [name, val] of Object.entries(extra)) {
+      val = val + "";
+      extra[name] = val;
+
+      if (val.length > 80) {
+        const sig = `${category},${method},${object},${value}`;
+
+        throw new Error(`The property "${name}" was added to a telemetry ` +
+                        `event with the signature ${sig} but it's value ` +
+                        `"${val}" is longer than the maximum allowed length ` +
+                        `of 80 characters\n`);
+      }
+    }
+    Services.telemetry.recordEvent(category, method, object, value, extra);
+  }
+
+  /**
+   * A private method that is not to be used externally. This method is used to
+   * prepare a pending telemetry event for sending and then send it via
+   * recordEvent().
+   *
+   * @param {String} category
+   *        The telemetry event category (a group name for events and helps to
+   *        avoid name conflicts) e.g. "devtools.main"
+   * @param {String} method
+   *        The telemetry event method (describes the type of event that
+   *        occurred e.g. "open")
+   * @param {String} object
+   *        The telemetry event object name (the name of the object the event
+   *        occurred on) e.g. "tools" or "setting"
+   * @param {String|null} value
+   *        The telemetry event value (a user defined value, providing context
+   *        for the event) e.g. "console"
+   */
+  _sendPendingEvent(category, method, object, value) {
+    const sig = `${category},${method},${object},${value}`;
+    const { extra } = PENDING_EVENTS.get(sig);
+
+    PENDING_EVENTS.delete(sig);
+    PENDING_EVENT_PROPERTIES.delete(sig);
+    this.recordEvent(category, method, object, value, extra);
+  }
+
   destroy() {
     for (let histogramId of this._timers.keys()) {
       this.stopTimer(histogramId);
     }
   }
 }
 
 module.exports = Telemetry;
--- a/devtools/startup/DevToolsShim.jsm
+++ b/devtools/startup/DevToolsShim.jsm
@@ -8,16 +8,25 @@ const { Services } = ChromeUtils.import(
 
 const { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm", {});
 XPCOMUtils.defineLazyGetter(this, "DevtoolsStartup", () => {
   return Cc["@mozilla.org/devtools/startup-clh;1"]
             .getService(Ci.nsICommandLineHandler)
             .wrappedJSObject;
 });
 
+// We don't want to spend time initializing the full loader here so we create
+// our own lazy require.
+XPCOMUtils.defineLazyGetter(this, "Telemetry", function() {
+  const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm", {});
+  const Telemetry = require("devtools/client/shared/telemetry");
+
+  return Telemetry;
+});
+
 const DEVTOOLS_ENABLED_PREF = "devtools.enabled";
 const DEVTOOLS_POLICY_DISABLED_PREF = "devtools.policy.disabled";
 
 this.EXPORTED_SYMBOLS = [
   "DevToolsShim",
 ];
 
 function removeItem(array, callback) {
@@ -34,16 +43,24 @@ function removeItem(array, callback) {
  * It can be used to start listening to devtools events before DevTools are ready. As soon
  * as DevTools are enabled, the DevToolsShim will forward all the requests received until
  * then to the real DevTools instance.
  */
 this.DevToolsShim = {
   _gDevTools: null,
   listeners: [],
 
+  get telemetry() {
+    if (!this._telemetry) {
+      this._telemetry = new Telemetry();
+      this._telemetry.setEventRecordingEnabled("devtools.main", true);
+    }
+    return this._telemetry;
+  },
+
   /**
    * Returns true if DevTools are enabled for the current profile. If devtools are not
    * enabled, initializing DevTools will open the onboarding page. Some entry points
    * should no-op in this case.
    */
   isEnabled: function() {
     let enabled = Services.prefs.getBoolPref(DEVTOOLS_ENABLED_PREF);
     return enabled && !this.isDisabledByPolicy();
@@ -208,16 +225,22 @@ this.DevToolsShim = {
    *        optional, if provided should be a valid entry point for DEVTOOLS_ENTRY_POINT
    *        in toolkit/components/telemetry/Histograms.json
    */
   initDevTools: function(reason) {
     if (!this.isEnabled()) {
       throw new Error("DevTools are not enabled and can not be initialized.");
     }
 
+    if (reason) {
+      this.telemetry.addEventProperty(
+        "devtools.main", "open", "tools", null, "entrypoint", reason
+      );
+    }
+
     if (!this.isInitialized()) {
       DevtoolsStartup.initDevTools(reason);
     }
   },
 };
 
 /**
  * Compatibility layer for webextensions.
--- a/devtools/startup/devtools-startup.js
+++ b/devtools/startup/devtools-startup.js
@@ -30,25 +30,35 @@ const kDebuggerPrefs = [
 // startup.
 const TOOLBAR_VISIBLE_PREF = "devtools.toolbar.visible";
 
 const DEVTOOLS_ENABLED_PREF = "devtools.enabled";
 
 const DEVTOOLS_POLICY_DISABLED_PREF = "devtools.policy.disabled";
 
 const { XPCOMUtils } = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm", {});
+
 ChromeUtils.defineModuleGetter(this, "Services",
                                "resource://gre/modules/Services.jsm");
 ChromeUtils.defineModuleGetter(this, "AppConstants",
                                "resource://gre/modules/AppConstants.jsm");
 ChromeUtils.defineModuleGetter(this, "CustomizableUI",
                                "resource:///modules/CustomizableUI.jsm");
 ChromeUtils.defineModuleGetter(this, "CustomizableWidgets",
                                "resource:///modules/CustomizableWidgets.jsm");
 
+// We don't want to spend time initializing the full loader here so we create
+// our own lazy require.
+XPCOMUtils.defineLazyGetter(this, "Telemetry", function() {
+  const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm", {});
+  const Telemetry = require("devtools/client/shared/telemetry");
+
+  return Telemetry;
+});
+
 XPCOMUtils.defineLazyGetter(this, "StartupBundle", function() {
   const url = "chrome://devtools-startup/locale/startup.properties";
   return Services.strings.createBundle(url);
 });
 
 XPCOMUtils.defineLazyGetter(this, "KeyShortcutsBundle", function() {
   const url = "chrome://devtools-startup/locale/key-shortcuts.properties";
   return Services.strings.createBundle(url);
@@ -184,16 +194,24 @@ DevToolsStartup.prototype = {
 
   /**
    * Boolean flag to check if the devtools initialization was already sent to telemetry.
    * We only want to record one devtools entry point per Firefox run, but we are not
    * interested in all the entry points (e.g. devtools.toolbar.visible).
    */
   recorded: false,
 
+  get telemetry() {
+    if (!this._telemetry) {
+      this._telemetry = new Telemetry();
+      this._telemetry.setEventRecordingEnabled("devtools.main", true);
+    }
+    return this._telemetry;
+  },
+
   /**
    * Flag that indicates if the developer toggle was already added to customizableUI.
    */
   developerToggleCreated: false,
 
   isDisabledByPolicy: function() {
     return Services.prefs.getBoolPref(DEVTOOLS_POLICY_DISABLED_PREF, false);
   },
@@ -311,17 +329,17 @@ DevToolsStartup.prototype = {
   pingOnboardingTelemetry() {
     // Only ping telemetry once per profile.
     let alreadyLoggedPref = "devtools.onboarding.telemetry.logged";
     if (Services.prefs.getBoolPref(alreadyLoggedPref)) {
       return;
     }
 
     let scalarId = "devtools.onboarding.is_devtools_user";
-    Services.telemetry.scalarSet(scalarId, this.isDevToolsUser());
+    this.telemetry.logScalar(scalarId, this.isDevToolsUser());
     Services.prefs.setBoolPref(alreadyLoggedPref, true);
   },
 
   /**
    * Register listeners to all possible entry points for Developer Tools.
    * But instead of implementing the actual actions, defer to DevTools codebase.
    * In most cases, it only needs to call this.initDevTools which handles the rest.
    * We do that to prevent loading any DevTools module until the user intent to use them.
@@ -821,28 +839,37 @@ DevToolsStartup.prototype = {
     }
 
     if (cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_AUTO) {
       cmdLine.preventDefault = true;
     }
   },
 
   sendEntryPointTelemetry(reason) {
-    if (reason && !this.recorded) {
-      // Only save the first call for each firefox run as next call
-      // won't necessarely start the tool. For example key shortcuts may
-      // only change the currently selected tool.
-      try {
-        Services.telemetry.getHistogramById("DEVTOOLS_ENTRY_POINT")
-                          .add(reason);
-      } catch (e) {
-        dump("DevTools telemetry entry point failed: " + e + "\n");
-      }
-      this.recorded = true;
+    if (!reason) {
+      return;
+    }
+
+    this.telemetry.addEventProperty(
+      "devtools.main", "open", "tools", null, "entrypoint", reason
+    );
+
+    if (this.recorded) {
+      return;
     }
+
+    // Only save the first call for each firefox run as next call
+    // won't necessarely start the tool. For example key shortcuts may
+    // only change the currently selected tool.
+    try {
+      this.telemetry.log("DEVTOOLS_ENTRY_POINT", reason);
+    } catch (e) {
+      dump("DevTools telemetry entry point failed: " + e + "\n");
+    }
+    this.recorded = true;
   },
 
   // Used by tests and the toolbox to register the same key shortcuts in toolboxes loaded
   // in a window window.
   get KeyShortcuts() {
     return KeyShortcuts;
   },
   get wrappedJSObject() {
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -156,8 +156,24 @@ telemetry.test.second:
     objects: ["object1", "object2", "object3"]
     bug_numbers: [1286606]
     notification_emails: ["telemetry-client-dev@mozilla.com"]
     record_in_processes: ["main"]
     description: This is a test entry for Telemetry.
     expiry_version: never
     extra_keys:
       key1: This is just a test description.
+
+devtools.main:
+  open:
+    objects: ["tools"]
+    bug_numbers: [1416024]
+    notification_emails: ["dev-developer-tools@lists.mozilla.org", "hkirschner@mozilla.com"]
+    record_in_processes: ["main"]
+    description: User opens devtools toolbox.
+    release_channel_collection: opt-out
+    expiry_version: never
+    extra_keys:
+      entrypoint: How was the toolbox opened? CommandLine, ContextMenu, DeveloperToolbar, HamburgerMenu, KeyShortcut, SessionRestore or SystemMenu
+      first_panel: The name of the first panel opened.
+      host: "Toolbox host (positioning): bottom, side, window or other."
+      splitconsole: Indicates whether the split console was open.
+      width: Toolbox width rounded up to the nearest 50px.