Bug 1134073 - Part 2: Show network request cause and stacktrace in netmonitor UI. r=ochameau
☠☠ backed out by ab5e81678aaa ☠ ☠
authorJarda Snajdr <jsnajdr@gmail.com>
Fri, 03 Jun 2016 16:26:35 +0200
changeset 341379 572ebec612e811adc0333a3b486bc25b680957bb
parent 341378 f12a69ba912259a1e1aaf26cf5f116a8c5948a7e
child 341380 a43d99734390d98a6430d2e0900222651546456e
push id1183
push userraliiev@mozilla.com
push dateMon, 05 Sep 2016 20:01:49 +0000
treeherdermozilla-release@3148731bed45 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersochameau
bugs1134073
milestone49.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1134073 - Part 2: Show network request cause and stacktrace in netmonitor UI. r=ochameau
devtools/client/locales/en-US/netmonitor.dtd
devtools/client/netmonitor/netmonitor-controller.js
devtools/client/netmonitor/netmonitor-view.js
devtools/client/netmonitor/netmonitor.xul
devtools/client/netmonitor/panel.js
devtools/client/themes/netmonitor.css
--- a/devtools/client/locales/en-US/netmonitor.dtd
+++ b/devtools/client/locales/en-US/netmonitor.dtd
@@ -34,16 +34,20 @@
 <!-- LOCALIZATION NOTE (netmonitorUI.toolbar.file): This is the label displayed
   -  in the network table toolbar, above the "file" column. -->
 <!ENTITY netmonitorUI.toolbar.file        "File">
 
 <!-- LOCALIZATION NOTE (netmonitorUI.toolbar.domain): This is the label displayed
   -  in the network table toolbar, above the "domain" column. -->
 <!ENTITY netmonitorUI.toolbar.domain      "Domain">
 
+<!-- LOCALIZATION NOTE (netmonitorUI.toolbar.cause): This is the label displayed
+  -  in the network table toolbar, above the "cause" column. -->
+<!ENTITY netmonitorUI.toolbar.cause        "Cause">
+
 <!-- LOCALIZATION NOTE (netmonitorUI.toolbar.type): This is the label displayed
   -  in the network table toolbar, above the "type" column. -->
 <!ENTITY netmonitorUI.toolbar.type        "Type">
 
 <!-- LOCALIZATION NOTE (netmonitorUI.toolbar.transferred): This is the label displayed
   -  in the network table toolbar, above the "transferred" column, which is the
   -  compressed / encoded size. -->
 <!ENTITY netmonitorUI.toolbar.transferred "Transferred">
--- a/devtools/client/netmonitor/netmonitor-controller.js
+++ b/devtools/client/netmonitor/netmonitor-controller.js
@@ -428,16 +428,23 @@ var NetMonitorController = {
 
   /**
    * Getter that tells if the server can do network performance statistics.
    * @type boolean
    */
   get supportsPerfStats() {
     return this.tabClient &&
            (this.tabClient.traits.reconfigure || !this._target.isApp);
+  },
+
+  /**
+   * Open a given source in Debugger
+   */
+  viewSourceInDebugger(sourceURL, sourceLine) {
+    return this._toolbox.viewSourceInDebugger(sourceURL, sourceLine);
   }
 };
 
 /**
  * Functions handling target-related lifetime events.
  */
 function TargetEventsHandler() {
   this._onTabNavigated = this._onTabNavigated.bind(this);
@@ -624,22 +631,24 @@ NetworkEventsHandler.prototype = {
    * @param object networkInfo
    *        The network request information.
    */
   _onNetworkEvent: function (type, networkInfo) {
     let { actor,
       startedDateTime,
       request: { method, url },
       isXHR,
+      cause,
       fromCache,
       fromServiceWorker
     } = networkInfo;
 
     NetMonitorView.RequestsMenu.addRequest(
-      actor, startedDateTime, method, url, isXHR, fromCache, fromServiceWorker
+      actor, startedDateTime, method, url, isXHR, cause, fromCache,
+        fromServiceWorker
     );
     window.emit(EVENTS.NETWORK_EVENT, actor);
   },
 
   /**
    * The "networkEventUpdate" message type handler.
    *
    * @param string type
--- a/devtools/client/netmonitor/netmonitor-view.js
+++ b/devtools/client/netmonitor/netmonitor-view.js
@@ -25,17 +25,19 @@ const {LocalizationHelper} = require("de
 const {PrefsHelper} = require("devtools/client/shared/prefs");
 const {ViewHelpers, Heritage, WidgetMethods, setNamedTimeout} =
   require("devtools/client/shared/widgets/view-helpers");
 
 /**
  * Localization convenience methods.
  */
 const NET_STRINGS_URI = "chrome://devtools/locale/netmonitor.properties";
+const WEBCONSOLE_STRINGS_URI = "chrome://devtools/locale/webconsole.properties";
 var L10N = new LocalizationHelper(NET_STRINGS_URI);
+const WEBCONSOLE_L10N = new LocalizationHelper(WEBCONSOLE_STRINGS_URI);
 
 // ms
 const WDA_DEFAULT_VERIFY_INTERVAL = 50;
 
 // Use longer timeout during testing as the tests need this process to succeed
 // and two seconds is quite short on slow debug builds. The timeout here should
 // be at least equal to the general mochitest timeout of 45 seconds so that this
 // never gets hit during testing.
@@ -56,16 +58,18 @@ const HTML_NS = "http://www.w3.org/1999/
 const EPSILON = 0.001;
 // 100 KB in bytes
 const SOURCE_SYNTAX_HIGHLIGHT_MAX_FILE_SIZE = 102400;
 // ms
 const RESIZE_REFRESH_RATE = 50;
 // ms
 const REQUESTS_REFRESH_RATE = 50;
 const REQUESTS_TOOLTIP_POSITION = "topcenter bottomleft";
+// tooltip show/hide delay in ms
+const REQUESTS_TOOLTIP_TOGGLE_DELAY = 500;
 // px
 const REQUESTS_TOOLTIP_IMAGE_MAX_DIM = 400;
 // px
 const REQUESTS_WATERFALL_SAFE_BOUNDS = 90;
 // ms
 const REQUESTS_WATERFALL_HEADER_TICKS_MULTIPLE = 5;
 // px
 const REQUESTS_WATERFALL_HEADER_TICKS_SPACING_MIN = 60;
@@ -97,16 +101,41 @@ const CONTENT_MIME_TYPE_MAPPINGS = {
   "/xml": Editor.modes.html,
   "/atom": Editor.modes.html,
   "/soap": Editor.modes.html,
   "/vnd.mpeg.dash.mpd": Editor.modes.html,
   "/rdf": Editor.modes.css,
   "/rss": Editor.modes.css,
   "/css": Editor.modes.css
 };
+const LOAD_CAUSE_STRINGS = {
+  [Ci.nsIContentPolicy.TYPE_INVALID]: "invalid",
+  [Ci.nsIContentPolicy.TYPE_OTHER]: "other",
+  [Ci.nsIContentPolicy.TYPE_SCRIPT]: "script",
+  [Ci.nsIContentPolicy.TYPE_IMAGE]: "img",
+  [Ci.nsIContentPolicy.TYPE_STYLESHEET]: "stylesheet",
+  [Ci.nsIContentPolicy.TYPE_OBJECT]: "object",
+  [Ci.nsIContentPolicy.TYPE_DOCUMENT]: "document",
+  [Ci.nsIContentPolicy.TYPE_SUBDOCUMENT]: "subdocument",
+  [Ci.nsIContentPolicy.TYPE_REFRESH]: "refresh",
+  [Ci.nsIContentPolicy.TYPE_XBL]: "xbl",
+  [Ci.nsIContentPolicy.TYPE_PING]: "ping",
+  [Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST]: "xhr",
+  [Ci.nsIContentPolicy.TYPE_OBJECT_SUBREQUEST]: "objectSubdoc",
+  [Ci.nsIContentPolicy.TYPE_DTD]: "dtd",
+  [Ci.nsIContentPolicy.TYPE_FONT]: "font",
+  [Ci.nsIContentPolicy.TYPE_MEDIA]: "media",
+  [Ci.nsIContentPolicy.TYPE_WEBSOCKET]: "websocket",
+  [Ci.nsIContentPolicy.TYPE_CSP_REPORT]: "csp",
+  [Ci.nsIContentPolicy.TYPE_XSLT]: "xslt",
+  [Ci.nsIContentPolicy.TYPE_BEACON]: "beacon",
+  [Ci.nsIContentPolicy.TYPE_FETCH]: "fetch",
+  [Ci.nsIContentPolicy.TYPE_IMAGESET]: "imageset",
+  [Ci.nsIContentPolicy.TYPE_WEB_MANIFEST]: "webManifest"
+};
 const DEFAULT_EDITOR_CONFIG = {
   mode: Editor.modes.text,
   readOnly: true,
   lineNumbers: true
 };
 const GENERIC_VARIABLES_VIEW_SETTINGS = {
   lazyEmpty: true,
   // ms
@@ -426,16 +455,30 @@ RequestsMenuView.prototype = Heritage.ex
 
     this.widget = new SideMenuWidget($("#requests-menu-contents"));
     this._splitter = $("#network-inspector-view-splitter");
     this._summary = $("#requests-menu-network-summary-button");
     this._summary.setAttribute("label", L10N.getStr("networkMenu.empty"));
     this.userInputTimer = Cc["@mozilla.org/timer;1"]
       .createInstance(Ci.nsITimer);
 
+    // Create a tooltip for the newly appended network request item.
+    this.tooltip = new Tooltip(document, {
+      closeOnEvents: [{
+        emitter: $("#requests-menu-contents"),
+        event: "scroll",
+        useCapture: true
+      }]
+    });
+    this.tooltip.startTogglingOnHover(this.widget, this._onHover, {
+      toggleDelay: REQUESTS_TOOLTIP_TOGGLE_DELAY,
+      interactive: true
+    });
+    this.tooltip.defaultPosition = REQUESTS_TOOLTIP_POSITION;
+
     Prefs.filters.forEach(type => this.filterOn(type));
     this.sortContents(this._byTiming);
 
     this.allowFocusOnRightClick = true;
     this.maintainSelectionVisible = true;
 
     this.widget.addEventListener("select", this._onSelect, false);
     this.widget.addEventListener("swap", this._onSwap, false);
@@ -632,25 +675,30 @@ RequestsMenuView.prototype = Heritage.ex
    *        A string representation of when the request was started, which
    *        can be parsed by Date (for example "2012-09-17T19:50:03.699Z").
    * @param string method
    *        Specifies the request method (e.g. "GET", "POST", etc.)
    * @param string url
    *        Specifies the request's url.
    * @param boolean isXHR
    *        True if this request was initiated via XHR.
+   * @param object cause
+   *        Specifies the request's cause. Has the following properties:
+   *        - type: nsContentPolicyType constant
+   *        - loadingDocumentUri: URI of the request origin
+   *        - stacktrace: JS stacktrace of the request
    * @param boolean fromCache
    *        Indicates if the result came from the browser cache
    * @param boolean fromServiceWorker
    *        Indicates if the request has been intercepted by a Service Worker
    */
-  addRequest: function (id, startedDateTime, method, url, isXHR, fromCache,
-    fromServiceWorker) {
-    this._addQueue.push([id, startedDateTime, method, url, isXHR, fromCache,
-      fromServiceWorker]);
+  addRequest: function (id, startedDateTime, method, url, isXHR, cause,
+    fromCache, fromServiceWorker) {
+    this._addQueue.push([id, startedDateTime, method, url, isXHR, cause,
+      fromCache, fromServiceWorker]);
 
     // Lazy updating is disabled in some tests.
     if (!this.lazyUpdate) {
       return void this._flushRequests();
     }
 
     this._flushRequestsTask.arm();
     return undefined;
@@ -880,17 +928,18 @@ RequestsMenuView.prototype = Heritage.ex
   /**
    * Create a new custom request form populated with the data from
    * the currently selected request.
    */
   cloneSelectedRequest: function () {
     let selected = this.selectedItem.attachment;
 
     // Create the element node for the network request item.
-    let menuView = this._createMenuView(selected.method, selected.url);
+    let menuView = this._createMenuView(selected.method, selected.url,
+      selected.cause);
 
     // Append a network request item to this container.
     let newItem = this.push([menuView], {
       attachment: Object.create(selected, {
         isCustom: { value: true }
       })
     });
 
@@ -1447,29 +1496,16 @@ RequestsMenuView.prototype = Heritage.ex
       } else {
         requestTarget.setAttribute("odd", "");
         requestTarget.removeAttribute("even");
       }
     }
   },
 
   /**
-   * Refreshes the toggling anchor for the specified item's tooltip.
-   *
-   * @param object item
-   *        The network request item in this container.
-   */
-  refreshTooltip: function (item) {
-    let tooltip = item.attachment.tooltip;
-    tooltip.hide();
-    tooltip.startTogglingOnHover(item.target, this._onHover);
-    tooltip.defaultPosition = REQUESTS_TOOLTIP_POSITION;
-  },
-
-  /**
    * Attaches security icon click listener for the given request menu item.
    *
    * @param object item
    *        The network request item to attach the listener to.
    */
   attachSecurityIconClickListener: function ({ target }) {
     let icon = $(".requests-security-state-icon", target);
     icon.addEventListener("click", this._onSecurityIconClick);
@@ -1505,52 +1541,42 @@ RequestsMenuView.prototype = Heritage.ex
     // Prevent displaying any updates received after the target closed.
     if (NetMonitorView._isDestroyed) {
       return;
     }
 
     let widget = NetMonitorView.RequestsMenu.widget;
     let isScrolledToBottom = widget.isScrolledToBottom();
 
-    for (let [id, startedDateTime, method, url, isXHR, fromCache,
+    for (let [id, startedDateTime, method, url, isXHR, cause, fromCache,
       fromServiceWorker] of this._addQueue) {
       // Convert the received date/time string to a unix timestamp.
       let unixTime = Date.parse(startedDateTime);
 
       // Create the element node for the network request item.
-      let menuView = this._createMenuView(method, url);
+      let menuView = this._createMenuView(method, url, cause);
 
       // Remember the first and last event boundaries.
       this._registerFirstRequestStart(unixTime);
       this._registerLastRequestEnd(unixTime);
 
       // Append a network request item to this container.
       let requestItem = this.push([menuView, id], {
         attachment: {
           startedDeltaMillis: unixTime - this._firstRequestStartedMillis,
           startedMillis: unixTime,
           method: method,
           url: url,
           isXHR: isXHR,
+          cause: cause,
           fromCache: fromCache,
           fromServiceWorker: fromServiceWorker
         }
       });
 
-      // Create a tooltip for the newly appended network request item.
-      requestItem.attachment.tooltip = new Tooltip(document, {
-        closeOnEvents: [{
-          emitter: $("#requests-menu-contents"),
-          event: "scroll",
-          useCapture: true
-        }]
-      });
-
-      this.refreshTooltip(requestItem);
-
       if (id == this._preferredItemId) {
         this.selectedItem = requestItem;
       }
 
       window.emit(EVENTS.REQUEST_ADDED, id);
     }
 
     if (isScrolledToBottom && this._addQueue.length) {
@@ -1749,31 +1775,36 @@ RequestsMenuView.prototype = Heritage.ex
 
   /**
    * Customization function for creating an item's UI.
    *
    * @param string method
    *        Specifies the request method (e.g. "GET", "POST", etc.)
    * @param string url
    *        Specifies the request's url.
+   * @param object cause
+   *        Specifies the request's cause. Has two properties:
+   *        - type: nsContentPolicyType constant
+   *        - uri: URI of the request origin
    * @return nsIDOMNode
    *         The network request view.
    */
-  _createMenuView: function (method, url) {
+  _createMenuView: function (method, url, cause) {
     let template = $("#requests-menu-item-template");
     let fragment = document.createDocumentFragment();
 
-    this.updateMenuView(template, "method", method);
-    this.updateMenuView(template, "url", url);
-
     // Flatten the DOM by removing one redundant box (the template container).
     for (let node of template.childNodes) {
       fragment.appendChild(node.cloneNode(true));
     }
 
+    this.updateMenuView(fragment, "method", method);
+    this.updateMenuView(fragment, "url", url);
+    this.updateMenuView(fragment, "cause", cause);
+
     return fragment;
   },
 
   /**
    * Get a human-readable string from a number of bytes, with the B, KB, MB, or
    * GB value. Note that the transition between abbreviations is by 1000 rather
    * than 1024 in order to keep the displayed digits smaller as "1016 KB" is
    * more awkward than 0.99 MB"
@@ -1895,16 +1926,30 @@ RequestsMenuView.prototype = Heritage.ex
         codeNode.setAttribute("value", value.status);
         break;
       }
       case "statusText": {
         let node = $(".requests-menu-status", target);
         node.setAttribute("tooltiptext", value);
         break;
       }
+      case "cause": {
+        let labelNode = $(".requests-menu-cause-label", target);
+        let text = LOAD_CAUSE_STRINGS[value.type] || "unknown";
+        labelNode.setAttribute("value", text);
+        if (value.loadingDocumentUri) {
+          labelNode.setAttribute("tooltiptext", value.loadingDocumentUri);
+        }
+
+        let stackNode = $(".requests-menu-cause-stack", target);
+        if (value.stacktrace && value.stacktrace.length > 0) {
+          stackNode.removeAttribute("hidden");
+        }
+        break;
+      }
       case "contentSize": {
         let node = $(".requests-menu-size", target);
 
         let text = this.getFormattedSize(value);
 
         node.setAttribute("value", text);
         node.setAttribute("tooltiptext", text);
         break;
@@ -2225,21 +2270,16 @@ RequestsMenuView.prototype = Heritage.ex
     }
   },
 
   /**
    * The swap listener for this container.
    * Called when two items switch places, when the contents are sorted.
    */
   _onSwap: function ({ detail: [firstItem, secondItem] }) {
-    // Sorting will create new anchor nodes for all the swapped request items
-    // in this container, so it's necessary to refresh the Tooltip instances.
-    this.refreshTooltip(firstItem);
-    this.refreshTooltip(secondItem);
-
     // Reattach click listener to the security icons
     this.attachSecurityIconClickListener(firstItem);
     this.attachSecurityIconClickListener(secondItem);
   },
 
   /**
    * The predicate used when deciding whether a popup should be shown
    * over a request item or not.
@@ -2247,36 +2287,100 @@ RequestsMenuView.prototype = Heritage.ex
    * @param nsIDOMNode target
    *        The element node currently being hovered.
    * @param object tooltip
    *        The current tooltip instance.
    * @return {Promise}
    */
   _onHover: Task.async(function* (target, tooltip) {
     let requestItem = this.getItemForElement(target);
-    if (!requestItem || !requestItem.attachment.responseContent) {
+    if (!requestItem) {
       return false;
     }
 
     let hovered = requestItem.attachment;
-    let { mimeType, text, encoding } = hovered.responseContent.content;
-
-    if (mimeType && mimeType.includes("image/") && (
-      target.classList.contains("requests-menu-icon") ||
-      target.classList.contains("requests-menu-file"))) {
-      let string = yield gNetwork.getString(text);
-      let anchor = $(".requests-menu-icon", requestItem.target);
-      let src = formDataURI(mimeType, encoding, string);
-
-      tooltip.setImageContent(src, {
-        maxDim: REQUESTS_TOOLTIP_IMAGE_MAX_DIM
-      });
-      return anchor;
+    if (hovered.responseContent && target.closest(".requests-menu-icon-and-file")) {
+      return this._setTooltipImageContent(tooltip, requestItem);
+    } else if (hovered.cause && target.closest(".requests-menu-cause-stack")) {
+      return this._setTooltipStackTraceContent(tooltip, requestItem);
+    }
+
+    return false;
+  }),
+
+  _setTooltipImageContent: Task.async(function* (tooltip, requestItem) {
+    let { mimeType, text, encoding } = requestItem.attachment.responseContent.content;
+
+    if (!mimeType || !mimeType.includes("image/")) {
+      return false;
+    }
+
+    let string = yield gNetwork.getString(text);
+    let anchor = $(".requests-menu-icon", requestItem.target);
+    let src = formDataURI(mimeType, encoding, string);
+
+    tooltip.setImageContent(src, {
+      maxDim: REQUESTS_TOOLTIP_IMAGE_MAX_DIM
+    });
+
+    return anchor;
+  }),
+
+  _setTooltipStackTraceContent: Task.async(function* (tooltip, requestItem) {
+    let {stacktrace} = requestItem.attachment.cause;
+
+    if (!stacktrace || stacktrace.length == 0) {
+      return false;
     }
-    return false;
+
+    let doc = tooltip.doc;
+    let el = doc.createElement("vbox");
+    el.className = "requests-menu-stack-trace";
+
+    for (let f of stacktrace) {
+      let { functionName, filename, lineNumber, columnNumber } = f;
+
+      let frameEl = doc.createElement("hbox");
+      frameEl.className = "requests-menu-stack-frame devtools-monospace";
+
+      let funcEl = doc.createElement("label");
+      funcEl.className = "requests-menu-stack-frame-function-name";
+      funcEl.setAttribute("value",
+        functionName || WEBCONSOLE_L10N.getStr("stacktrace.anonymousFunction"));
+      frameEl.appendChild(funcEl);
+
+      let fileEl = doc.createElement("label");
+      fileEl.className = "requests-menu-stack-frame-file-name";
+      // Parse a stack frame in format "url -> url"
+      let sourceUrl = filename.split(" -> ").pop();
+      fileEl.setAttribute("value", sourceUrl);
+      fileEl.setAttribute("tooltiptext", sourceUrl);
+      fileEl.setAttribute("crop", "start");
+      frameEl.appendChild(fileEl);
+
+      let lineEl = doc.createElement("label");
+      lineEl.className = "requests-menu-stack-frame-line";
+      lineEl.setAttribute("value", `:${lineNumber}:${columnNumber}`);
+      frameEl.appendChild(lineEl);
+
+      frameEl.addEventListener("click", () => {
+        // avoid an ugly visual artefact when the view is switched to debugger and the
+        // tooltip is hidden only after a delay - the tooltip is moved outside the browser
+        // window.
+        tooltip.hide();
+        NetMonitorController.viewSourceInDebugger(filename, lineNumber);
+      }, false);
+
+      el.appendChild(frameEl);
+    }
+
+    tooltip.content = el;
+    tooltip.panel.setAttribute("wide", "");
+
+    return true;
   }),
 
   /**
    * A handler that opens the security tab in the details view if secure or
    * broken security indicator is clicked.
    */
   _onSecurityIconClick: function (e) {
     let state = this.selectedItem.attachment.securityState;
--- a/devtools/client/netmonitor/netmonitor.xul
+++ b/devtools/client/netmonitor/netmonitor.xul
@@ -216,16 +216,27 @@
                 <button id="requests-menu-domain-button"
                         class="requests-menu-header-button requests-menu-security-and-domain"
                         data-key="domain"
                         label="&netmonitorUI.toolbar.domain;"
                         crop="end"
                         flex="1">
                 </button>
               </hbox>
+              <hbox id="requests-menu-cause-header-box"
+                    class="requests-menu-header requests-menu-cause"
+                    align="center">
+                <button id="requests-menu-cause-button"
+                        class="requests-menu-header-button requests-menu-cause"
+                        data-key="cause"
+                        label="&netmonitorUI.toolbar.cause;"
+                        crop="end"
+                        flex="1">
+                </button>
+              </hbox>
               <hbox id="requests-menu-type-header-box"
                     class="requests-menu-header requests-menu-type"
                     align="center">
                 <button id="requests-menu-type-button"
                         class="requests-menu-header-button requests-menu-type"
                         data-key="type"
                         label="&netmonitorUI.toolbar.type;"
                         crop="end"
@@ -318,16 +329,20 @@
               </hbox>
               <hbox class="requests-menu-subitem requests-menu-security-and-domain"
                     align="center">
                 <image class="requests-security-state-icon" />
                 <label class="plain requests-menu-domain"
                        crop="end"
                        flex="1"/>
               </hbox>
+              <hbox class="requests-menu-subitem requests-menu-cause" align="center">
+                <label class="requests-menu-cause-stack" value="JS" hidden="true"/>
+                <label class="plain requests-menu-cause-label" flex="1" crop="end"/>
+              </hbox>
               <label class="plain requests-menu-subitem requests-menu-type"
                      crop="end"/>
               <label class="plain requests-menu-subitem requests-menu-transferred"
                      crop="end"/>
               <label class="plain requests-menu-subitem requests-menu-size"
                      crop="end"/>
               <hbox class="requests-menu-subitem requests-menu-waterfall"
                     align="center"
--- a/devtools/client/netmonitor/panel.js
+++ b/devtools/client/netmonitor/panel.js
@@ -11,16 +11,17 @@ const { Task } = require("devtools/share
 
 function NetMonitorPanel(iframeWindow, toolbox) {
   this.panelWin = iframeWindow;
   this._toolbox = toolbox;
 
   this._view = this.panelWin.NetMonitorView;
   this._controller = this.panelWin.NetMonitorController;
   this._controller._target = this.target;
+  this._controller._toolbox = this._toolbox;
 
   EventEmitter.decorate(this);
 }
 
 exports.NetMonitorPanel = NetMonitorPanel;
 
 NetMonitorPanel.prototype = {
   /**
--- a/devtools/client/themes/netmonitor.css
+++ b/devtools/client/themes/netmonitor.css
@@ -241,16 +241,35 @@
 
 .requests-menu-type,
 .requests-menu-size {
   max-width: 6em;
   text-align: center;
   width: 8vw;
 }
 
+.requests-menu-cause {
+  max-width: 8em;
+  width: 8vw;
+}
+
+.requests-menu-cause-stack {
+  background-color: var(--theme-body-color-alt);
+  color: var(--theme-body-background);
+  font-size: 8px;
+  font-weight: bold;
+  line-height: 10px;
+  border-radius: 3px;
+  padding: 0 2px;
+  margin: 0;
+  margin-inline-end: 3px;
+  -moz-user-select: none;
+  cursor: pointer;
+}
+
 .requests-menu-transferred {
   max-width: 8em;
   text-align: center;
   width: 8vw;
 }
 
 /* Network requests table: status codes */
 
@@ -671,16 +690,50 @@
   background-color: var(--theme-selection-background-semitransparent);
 }
 
 .requests-menu-filter-button:not(:active)[checked] {
   background-color: var(--theme-selection-background);
   color: var(--theme-selection-color);
 }
 
+/* Requests menu stacktrace tooltip */
+.requests-menu-stack-trace {
+  max-height: 400px;
+  width: 586px;
+  overflow-y: auto;
+}
+
+.requests-menu-stack-frame {
+  color: var(--theme-body-color-alt);
+  cursor: pointer;
+  display: flex;
+}
+
+.requests-menu-stack-frame:hover {
+  background-color: var(--theme-selection-background-semitransparent);
+}
+
+.requests-menu-stack-frame-function-name {
+  color: var(--theme-highlight-blue);
+  cursor: inherit;
+  flex-grow: 1;
+}
+
+.requests-menu-stack-frame-file-name {
+  cursor: inherit;
+  margin-inline-end: 0;
+}
+
+.requests-menu-stack-frame-line {
+  color: var(--theme-highlight-orange);
+  cursor: inherit;
+  margin-inline-start: 0;
+}
+
 /* Performance analysis buttons */
 
 #requests-menu-network-summary-button {
   background: none;
   box-shadow: none;
   border-color: transparent;
   list-style-image: url(images/profiler-stopwatch.svg);
   padding-inline-end: 0;