Bug 859058 - Export content of the Network panel as HAR; r=jsantell, r=jlongster
authorJan Odvarko <odvarko@gmail.com>
Wed, 03 Jun 2015 17:31:35 +0200
changeset 247714 e708f2ce973c
parent 247713 fa429d0aec1a
child 247715 507407471469
push id13418
push userkwierso@gmail.com
push dateTue, 09 Jun 2015 18:11:39 +0000
treeherderfx-team@507407471469 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjsantell, jlongster
bugs859058
milestone41.0a1
Bug 859058 - Export content of the Network panel as HAR; r=jsantell, r=jlongster
browser/app/profile/firefox.js
browser/devtools/canvasdebugger/canvasdebugger.js
browser/devtools/netmonitor/har/har-builder.js
browser/devtools/netmonitor/har/har-exporter.js
browser/devtools/netmonitor/har/har-utils.js
browser/devtools/netmonitor/har/moz.build
browser/devtools/netmonitor/har/test/browser.ini
browser/devtools/netmonitor/har/test/browser_net_har_copy_all_as_har.js
browser/devtools/netmonitor/har/test/browser_net_har_post_data.js
browser/devtools/netmonitor/har/test/head.js
browser/devtools/netmonitor/har/test/html_har_post-data-test-page.html
browser/devtools/netmonitor/moz.build
browser/devtools/netmonitor/netmonitor-view.js
browser/devtools/netmonitor/netmonitor.xul
browser/devtools/netmonitor/test/html_har_post-data-test-page.html
browser/locales/en-US/chrome/browser/devtools/har.properties
browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
browser/locales/jar.mn
toolkit/devtools/webconsole/network-helper.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1482,16 +1482,25 @@ pref("devtools.serviceWorkers.testing.en
 pref("devtools.netmonitor.enabled", true);
 
 // The default Network Monitor UI settings
 pref("devtools.netmonitor.panes-network-details-width", 550);
 pref("devtools.netmonitor.panes-network-details-height", 450);
 pref("devtools.netmonitor.statistics", true);
 pref("devtools.netmonitor.filters", "[\"all\"]");
 
+// The default Network monitor HAR export setting
+pref("devtools.netmonitor.har.defaultLogDir", "");
+pref("devtools.netmonitor.har.defaultFileName", "archive");
+pref("devtools.netmonitor.har.jsonp", false);
+pref("devtools.netmonitor.har.jsonpCallback", "");
+pref("devtools.netmonitor.har.includeResponseBodies", true);
+pref("devtools.netmonitor.har.compress", false);
+pref("devtools.netmonitor.har.forceExport", false);
+
 // Enable the Tilt inspector
 pref("devtools.tilt.enabled", true);
 pref("devtools.tilt.intro_transition", true);
 pref("devtools.tilt.outro_transition", true);
 
 // Scratchpad settings
 // - recentFileMax: The maximum number of recently-opened files
 //                  stored. Setting this preference to 0 will not
--- a/browser/devtools/canvasdebugger/canvasdebugger.js
+++ b/browser/devtools/canvasdebugger/canvasdebugger.js
@@ -34,16 +34,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   "resource://gre/modules/FileUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
   "resource://gre/modules/NetUtil.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
   "resource://gre/modules/devtools/DevToolsUtils.jsm");
 
+XPCOMUtils.defineLazyGetter(this, "NetworkHelper", function() {
+  return require("devtools/toolkit/webconsole/network-helper");
+});
+
 // The panel's window global is an EventEmitter firing the following events:
 const EVENTS = {
   // When the UI is reset from tab navigation.
   UI_RESET: "CanvasDebugger:UIReset",
 
   // When all the animation frame snapshots are removed by the user.
   SNAPSHOTS_LIST_CLEARED: "CanvasDebugger:SnapshotsListCleared",
 
@@ -189,36 +193,21 @@ EventEmitter.decorate(this);
 
 /**
  * DOM query helpers.
  */
 let $ = (selector, target = document) => target.querySelector(selector);
 let $all = (selector, target = document) => target.querySelectorAll(selector);
 
 /**
- * Helper for getting an nsIURL instance out of a string.
- */
-function nsIURL(url, store = nsIURL.store) {
-  if (store.has(url)) {
-    return store.get(url);
-  }
-  let uri = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
-  store.set(url, uri);
-  return uri;
-}
-
-// The cache used in the `nsIURL` function.
-nsIURL.store = new Map();
-
-/**
  * Gets the fileName part of a string which happens to be an URL.
  */
 function getFileName(url) {
   try {
-    let { fileName } = nsIURL(url);
+    let { fileName } = NetworkHelper.nsIURL(url);
     return fileName || "/";
   } catch (e) {
     // This doesn't look like a url, or nsIURL can't handle it.
     return "";
   }
 }
 
 /**
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/har-builder.js
@@ -0,0 +1,476 @@
+/* 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/. */
+"use strict";
+
+const { Cu, Ci, Cc } = require("chrome");
+const { defer, all, resolve } = require("sdk/core/promise");
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+const { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
+
+XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
+  return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
+});
+
+XPCOMUtils.defineLazyModuleGetter(this, "ViewHelpers",
+  "resource:///modules/devtools/ViewHelpers.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "L10N", function() {
+  return new ViewHelpers.L10N("chrome://browser/locale/devtools/har.properties");
+});
+
+XPCOMUtils.defineLazyGetter(this, "NetworkHelper", function() {
+  return devtools.require("devtools/toolkit/webconsole/network-helper");
+});
+
+const HAR_VERSION = "1.1";
+
+/**
+ * This object is responsible for building HAR file. See HAR spec:
+ * https://dvcs.w3.org/hg/webperf/raw-file/tip/specs/HAR/Overview.html
+ * http://www.softwareishard.com/blog/har-12-spec/
+ *
+ * @param {Object} options configuration object
+ *
+ * The following options are supported:
+ *
+ * - items {Array}: List of Network requests to be exported. It is possible
+ *   to use directly: NetMonitorView.RequestsMenu.items
+ *
+ * - id {String}: ID of the exported page.
+ *
+ * - title {String}: Title of the exported page.
+ *
+ * - includeResponseBodies {Boolean}: Set to true to include HTTP response
+ *   bodies in the result data structure.
+ */
+var HarBuilder = function(options) {
+  this._options = options;
+  this._pageMap = [];
+}
+
+HarBuilder.prototype = {
+  // Public API
+
+  /**
+   * This is the main method used to build the entire result HAR data.
+   * The process is asynchronous since it can involve additional RDP
+   * communication (e.g. resolving long strings).
+   *
+   * @returns {Promise} A promise that resolves to the HAR object when
+   * the entire build process is done.
+   */
+  build: function() {
+    this.promises = [];
+
+    // Build basic structure for data.
+    let log = this.buildLog();
+
+    // Build entries.
+    let items = this._options.items;
+    for (let i=0; i<items.length; i++) {
+      let file = items[i].attachment;
+      log.entries.push(this.buildEntry(log, file));
+    }
+
+    // Some data needs to be fetched from the backend during the
+    // build process, so wait till all is done.
+    let { resolve, promise } = defer();
+    all(this.promises).then(results => resolve({ log: log }));
+
+    return promise;
+  },
+
+  // Helpers
+
+  buildLog: function() {
+    return {
+      version: HAR_VERSION,
+      creator: {
+        name: appInfo.name,
+        version: appInfo.version
+      },
+      browser: {
+        name: appInfo.name,
+        version: appInfo.version
+      },
+      pages: [],
+      entries: [],
+    }
+  },
+
+  buildPage: function(file) {
+    let page = {};
+
+    // Page start time is set when the first request is processed
+    // (see buildEntry)
+    page.startedDateTime = 0;
+    page.id = "page_" + this._options.id;
+    page.title = this._options.title;
+
+    return page;
+  },
+
+  getPage: function(log, file) {
+    let id = this._options.id;
+    let page = this._pageMap[id];
+    if (page) {
+      return page;
+    }
+
+    this._pageMap[id] = page = this.buildPage(file);
+    log.pages.push(page);
+
+    return page;
+  },
+
+  buildEntry: function(log, file) {
+    let page = this.getPage(log, file);
+
+    let entry = {};
+    entry.pageref = page.id;
+    entry.startedDateTime = dateToJSON(new Date(file.startedMillis));
+    entry.time = file.endedMillis - file.startedMillis;
+
+    entry.request = this.buildRequest(file);
+    entry.response = this.buildResponse(file);
+    entry.cache = this.buildCache(file);
+    entry.timings = file.eventTimings ? file.eventTimings.timings : {};
+
+    if (file.remoteAddress) {
+      entry.serverIPAddress = file.remoteAddress;
+    }
+
+    if (file.remotePort) {
+      entry.connection = file.remotePort + "";
+    }
+
+    // Compute page load start time according to the first request start time.
+    if (!page.startedDateTime) {
+      page.startedDateTime = entry.startedDateTime;
+      page.pageTimings = this.buildPageTimings(page, file);
+    }
+
+    return entry;
+  },
+
+  buildPageTimings: function(page, file) {
+    // Event timing info isn't available
+    let timings = {
+      onContentLoad: -1,
+      onLoad: -1
+    };
+
+    return timings;
+  },
+
+  buildRequest: function(file) {
+    let request = {
+      bodySize: 0
+    };
+
+    request.method = file.method;
+    request.url = file.url;
+    request.httpVersion = file.httpVersion;
+
+    request.headers = this.buildHeaders(file.requestHeaders);
+    request.cookies = this.buildCookies(file.requestCookies);
+
+    request.queryString = NetworkHelper.parseQueryString(
+      NetworkHelper.nsIURL(file.url).query) || [];
+
+    request.postData = this.buildPostData(file);
+
+    request.headersSize = file.requestHeaders.headersSize;
+
+    // Set request body size, but make sure the body is fetched
+    // from the backend.
+    if (file.requestPostData) {
+      this.fetchData(file.requestPostData.postData.text).then(value => {
+        request.bodySize = value.length;
+      });
+    }
+
+    return request;
+  },
+
+  /**
+   * Fetch all header values from the backend (if necessary) and
+   * build the result HAR structure.
+   *
+   * @param {Object} input Request or response header object.
+   */
+  buildHeaders: function(input) {
+    if (!input) {
+      return [];
+    }
+
+    return this.buildNameValuePairs(input.headers);
+  },
+
+  buildCookies: function(input) {
+    if (!input) {
+      return [];
+    }
+
+    return this.buildNameValuePairs(input.cookies);
+  },
+
+  buildNameValuePairs: function(entries) {
+    let result = [];
+
+    // HAR requires headers array to be presented, so always
+    // return at least an empty array.
+    if (!entries) {
+      return result;
+    }
+
+    // Make sure header values are fully fetched from the server.
+    entries.forEach(entry => {
+      this.fetchData(entry.value).then(value => {
+        result.push({
+          name: entry.name,
+          value: value
+        });
+      });
+    })
+
+    return result;
+  },
+
+  buildPostData: function(file) {
+    let postData = {
+      mimeType: findValue(file.requestHeaders.headers, "content-type"),
+      params: [],
+      text: ""
+    };
+
+    if (!file.requestPostData) {
+      return postData;
+    }
+
+    if (file.requestPostData.postDataDiscarded) {
+      postData.comment = L10N.getStr("har.requestBodyNotIncluded");
+      return postData;
+    }
+
+    // Load request body from the backend.
+    this.fetchData(file.requestPostData.postData.text).then(value => {
+      postData.text = value;
+
+      // If we are dealing with URL encoded body, parse parameters.
+      if (isURLEncodedFile(file, value)) {
+        postData.mimeType = "application/x-www-form-urlencoded";
+
+        // Extract form parameters and produce nice HAR array.
+        this._options.view._getFormDataSections(file.requestHeaders,
+          file.requestHeadersFromUploadStream,
+          file.requestPostData).then(formDataSections => {
+            formDataSections.forEach(section => {
+              let paramsArray = NetworkHelper.parseQueryString(section);
+              if (paramsArray) {
+                postData.params = [...postData.params, ...paramsArray];
+              }
+            });
+          });
+      }
+    });
+
+    return postData;
+  },
+
+  buildResponse: function(file) {
+    let response = {
+      status: 0
+    };
+
+    // Arbitrary value if it's aborted to make sure status has a number
+    if (file.status) {
+      response.status = parseInt(file.status);
+    }
+
+    response.statusText = file.statusText || "";
+    response.httpVersion = file.httpVersion;
+
+    response.headers = this.buildHeaders(file.responseHeaders);
+    response.cookies = this.buildCookies(file.responseCookies);
+
+    response.content = this.buildContent(file);
+    response.redirectURL = findValue(file.responseHeaders.headers, "Location");
+    response.headersSize = file.responseHeaders.headersSize;
+    response.bodySize = file.transferredSize || -1;
+
+    return response;
+  },
+
+  buildContent: function(file) {
+    let content = {
+      mimeType: file.mimeType,
+      size: -1
+    };
+
+    if (file.responseContent && file.responseContent.content) {
+      content.size = file.responseContent.content.size;
+    }
+
+    if (!this._options.includeResponseBodies ||
+        file.responseContent.contentDiscarded) {
+      content.comment = L10N.getStr("har.responseBodyNotIncluded");
+      return content;
+    }
+
+    if (file.responseContent) {
+      let text = file.responseContent.content.text;
+      let promise = this.fetchData(text).then(value => {
+        content.text = value;
+      });
+    }
+
+    return content;
+  },
+
+  buildCache: function(file) {
+    let cache = {};
+
+    if (!file.fromCache) {
+      return cache;
+    }
+
+    // There is no such info yet in the Net panel.
+    // cache.beforeRequest = {};
+
+    if (file.cacheEntry) {
+      cache.afterRequest = this.buildCacheEntry(file.cacheEntry);
+    } else {
+      cache.afterRequest = null;
+    }
+
+    return cache;
+  },
+
+  buildCacheEntry: function(cacheEntry) {
+    let cache = {};
+
+    cache.expires = findValue(cacheEntry, "Expires");
+    cache.lastAccess = findValue(cacheEntry, "Last Fetched");
+    cache.eTag = "";
+    cache.hitCount = findValue(cacheEntry, "Fetch Count");
+
+    return cache;
+  },
+
+  getBlockingEndTime: function(file) {
+    if (file.resolveStarted && file.connectStarted) {
+      return file.resolvingTime;
+    }
+
+    if (file.connectStarted) {
+      return file.connectingTime;
+    }
+
+    if (file.sendStarted) {
+      return file.sendingTime;
+    }
+
+    return (file.sendingTime > file.startTime) ?
+      file.sendingTime : file.waitingForTime;
+  },
+
+  // RDP Helpers
+
+  fetchData: function(string) {
+    let promise = this._options.getString(string).then(value => {
+      return value;
+    });
+
+    // Building HAR is asynchronous and not done till all
+    // collected promises are resolved.
+    this.promises.push(promise);
+
+    return promise;
+  }
+}
+
+// Helpers
+
+/**
+ * Returns true if specified request body is URL encoded.
+ */
+function isURLEncodedFile(file, text) {
+  let contentType = "content-type: application/x-www-form-urlencoded"
+  if (text && text.toLowerCase().indexOf(contentType) != -1) {
+    return true;
+  }
+
+  // The header value doesn't have to be always exactly
+  // "application/x-www-form-urlencoded",
+  // there can be even charset specified. So, use indexOf rather than just
+  // "==".
+  let value = findValue(file.requestHeaders.headers, "content-type");
+  if (value && value.indexOf("application/x-www-form-urlencoded") == 0) {
+    return true;
+  }
+
+  return false;
+}
+
+/**
+ * Find specified value within an array of name-value pairs
+ * (used for headers, cookies and cache entries)
+ */
+function findValue(arr, name) {
+  name = name.toLowerCase();
+  let result = arr.find(entry => entry.name.toLowerCase() == name);
+  return result ? result.value : "";
+}
+
+/**
+ * Generate HAR representation of a date.
+ * (YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00)
+ * See also HAR Schema: http://janodvarko.cz/har/viewer/
+ *
+ * Note: it would be great if we could utilize Date.toJSON(), but
+ * it doesn't return proper time zone offset.
+ *
+ * An example:
+ * This helper returns:    2015-05-29T16:10:30.424+02:00
+ * Date.toJSON() returns:  2015-05-29T14:10:30.424Z
+ *
+ * @param date {Date} The date object we want to convert.
+ */
+function dateToJSON(date) {
+  function f(n, c) {
+    if (!c) {
+      c = 2;
+    }
+    let s = new String(n);
+    while (s.length < c) {
+      s = "0" + s;
+    }
+    return s;
+  }
+
+  let result = date.getFullYear() + '-' +
+    f(date.getMonth() + 1) + '-' +
+    f(date.getDate()) + 'T' +
+    f(date.getHours()) + ':' +
+    f(date.getMinutes()) + ':' +
+    f(date.getSeconds()) + '.' +
+    f(date.getMilliseconds(), 3);
+
+  let offset = date.getTimezoneOffset();
+  let positive = offset > 0;
+
+  // Convert to positive number before using Math.floor (see issue 5512)
+  offset = Math.abs(offset);
+  let offsetHours = Math.floor(offset / 60);
+  let offsetMinutes = Math.floor(offset % 60);
+  let prettyOffset = (positive > 0 ? "-" : "+") + f(offsetHours) +
+    ":" + f(offsetMinutes);
+
+  return result + prettyOffset;
+}
+
+// Exports from this module
+exports.HarBuilder = HarBuilder;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/har-exporter.js
@@ -0,0 +1,182 @@
+/* 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/. */
+"use strict";
+
+const { Cu, Cc, Ci } = require("chrome");
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+const { defer, resolve } = require("sdk/core/promise");
+const { HarUtils } = require("./har-utils.js");
+const { HarBuilder } = require("./har-builder.js");
+
+XPCOMUtils.defineLazyGetter(this, "clipboardHelper", function() {
+  return Cc["@mozilla.org/widget/clipboardhelper;1"].
+    getService(Ci.nsIClipboardHelper);
+});
+
+var uid = 1;
+
+/**
+ * This object represents the main public API designed to access
+ * Network export logic. Clients, such as the Network panel itself,
+ * should use this API to export collected HTTP data from the panel.
+ */
+const HarExporter = {
+  // Public API
+
+  /**
+   * Save collected HTTP data from the Network panel into HAR file.
+   *
+   * @param Object options
+   *        Configuration object
+   *
+   * The following options are supported:
+   *
+   * - includeResponseBodies {Boolean}: If set to true, HTTP response bodies
+   *   are also included in the HAR file (can produce significantly bigger
+   *   amount of data).
+   *
+   * - items {Array}: List of Network requests to be exported. It is possible
+   *   to use directly: NetMonitorView.RequestsMenu.items
+   *
+   * - jsonp {Boolean}: If set to true the export format is HARP (support
+   *   for JSONP syntax).
+   *
+   * - jsonpCallback {String}: Default name of JSONP callback (used for
+   *   HARP format).
+   *
+   * - compress {Boolean}: If set to true the final HAR file is zipped.
+   *   This represents great disk-space optimization.
+   *
+   * - defaultFileName {String}: Default name of the target HAR file.
+   *   The default file name supports formatters, see:
+   *   https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleFormat
+   *
+   * - defaultLogDir {String}: Default log directory for automated logs.
+   *
+   * - id {String}: ID of the page (used in the HAR file).
+   *
+   * - title {String}: Title of the page (used in the HAR file).
+   *
+   * - forceExport {Boolean}: The result HAR file is created even if
+   *   there are no HTTP entries.
+   */
+  save: function(options) {
+    // Set default options related to save operation.
+    options.defaultFileName = Services.prefs.getCharPref(
+      "devtools.netmonitor.har.defaultFileName");
+    options.compress = Services.prefs.getBoolPref(
+      "devtools.netmonitor.har.compress");
+
+    // Get target file for exported data. Bail out, if the user
+    // presses cancel.
+    let file = HarUtils.getTargetFile(options.defaultFileName,
+      options.jsonp, options.compress);
+
+    if (!file) {
+      return resolve();
+    }
+
+    return this.fetchHarData(options).then(jsonString => {
+      if (!HarUtils.saveToFile(file, jsonString, options.compress)) {
+        let msg = "Failed to save HAR file at: " + options.defaultFileName;
+        Cu.reportError(msg);
+      }
+      return jsonString;
+    });
+  },
+
+  /**
+   * Copy HAR string into the clipboard.
+   *
+   * @param Object options
+   *        Configuration object, see save() for detailed description.
+   */
+  copy: function(options) {
+    return this.fetchHarData(options).then(jsonString => {
+      clipboardHelper.copyString(jsonString);
+      return jsonString;
+    });
+  },
+
+  // Helpers
+
+  fetchHarData: function(options) {
+    // Generate page ID
+    options.id = options.id || uid++;
+
+    // Set default generic HAR export options.
+    options.jsonp = options.jsonp ||
+      Services.prefs.getBoolPref("devtools.netmonitor.har.jsonp");
+    options.includeResponseBodies = options.includeResponseBodies ||
+      Services.prefs.getBoolPref("devtools.netmonitor.har.includeResponseBodies");
+    options.jsonpCallback = options.jsonpCallback ||
+      Services.prefs.getCharPref( "devtools.netmonitor.har.jsonpCallback");
+    options.forceExport = options.forceExport ||
+      Services.prefs.getBoolPref("devtools.netmonitor.har.forceExport");
+
+    // Build HAR object.
+    return this.buildHarData(options).then(har => {
+      let jsonString = this.stringify(har);
+      if (!jsonString) {
+        return resolve();
+      }
+
+      // If JSONP is wanted, wrap the string in a function call
+      if (options.jsonp) {
+        // This callback name is also used in HAR Viewer by default.
+        // http://www.softwareishard.com/har/viewer/
+        let callbackName = options.jsonpCallback || "onInputData";
+        jsonString = callbackName + "(" + jsonString + ");";
+      }
+
+      return jsonString;
+    }).then(null, function onError(err) {
+      Cu.reportError(err);
+    });
+  },
+
+  /**
+   * Build HAR data object. This object contains all HTTP data
+   * collected by the Network panel. The process is asynchronous
+   * since it can involve additional RDP communication (e.g. resolving
+   * long strings).
+   */
+  buildHarData: function(options) {
+    let deferred = defer();
+
+    try {
+      // Build HAR object from provided data.
+      let builder = new HarBuilder(options);
+      builder.build().then(har => {
+        if (!har.log.entries.length && !options.forceExport) {
+          deferred.resolve();
+        }
+        deferred.resolve(har);
+      });
+    } catch (err) {
+      deferred.reject(err);
+    }
+
+    return deferred.promise;
+  },
+
+  /**
+   * Build JSON string from the HAR data object.
+   */
+  stringify: function(har) {
+    if (!har) {
+      return null;
+    }
+
+    try {
+      return JSON.stringify(har, null, "  ");
+    }
+    catch (err) {
+      Cu.reportError(err);
+    }
+  },
+};
+
+// Exports from this module
+exports.HarExporter = HarExporter;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/har-utils.js
@@ -0,0 +1,185 @@
+/* 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/. */
+"use strict";
+
+const { Cu, Ci, Cc, CC } = require("chrome");
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+
+XPCOMUtils.defineLazyGetter(this, "dirService", function() {
+  return Cc["@mozilla.org/file/directory_service;1"].getService(Ci.nsIProperties);
+});
+
+XPCOMUtils.defineLazyGetter(this, "ZipWriter", function() {
+  return CC("@mozilla.org/zipwriter;1", "nsIZipWriter");
+});
+
+XPCOMUtils.defineLazyGetter(this, "LocalFile", function() {
+  return new CC("@mozilla.org/file/local;1", "nsILocalFile", "initWithPath");
+});
+
+XPCOMUtils.defineLazyGetter(this, "getMostRecentBrowserWindow", function() {
+  return require("sdk/window/utils").getMostRecentBrowserWindow;
+});
+
+const nsIFilePicker = Ci.nsIFilePicker;
+
+const OPEN_FLAGS = {
+  RDONLY: parseInt("0x01"),
+  WRONLY: parseInt("0x02"),
+  CREATE_FILE: parseInt("0x08"),
+  APPEND: parseInt("0x10"),
+  TRUNCATE: parseInt("0x20"),
+  EXCL: parseInt("0x80")
+};
+
+/**
+ * Helper API for HAR export features.
+ */
+var HarUtils = {
+  /**
+   * Open File Save As dialog and let the user pick the proper file
+   * location for generated HAR log.
+   */
+  getTargetFile: function(fileName, jsonp, compress) {
+    let browser = getMostRecentBrowserWindow();
+
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+    fp.init(browser, null, nsIFilePicker.modeSave);
+    fp.appendFilter("HTTP Archive Files", "*.har; *.harp; *.json; *.jsonp; *.zip");
+    fp.appendFilters(nsIFilePicker.filterAll | nsIFilePicker.filterText);
+    fp.filterIndex = 1;
+
+    fp.defaultString = this.getHarFileName(fileName, jsonp, compress);
+
+    let rv = fp.show();
+    if (rv == nsIFilePicker.returnOK || rv == nsIFilePicker.returnReplace) {
+      return fp.file;
+    }
+
+    return null;
+  },
+
+  getHarFileName: function(defaultFileName, jsonp, compress) {
+    let extension = jsonp ? ".harp" : ".har";
+
+    // Read more about toLocaleFormat & format string.
+    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleFormat
+    var now = new Date();
+    var name = now.toLocaleFormat(defaultFileName);
+    name = name.replace(/\:/gm, "-", "");
+    name = name.replace(/\//gm, "_", "");
+
+    let fileName = name + extension;
+
+    // Default file extension is zip if compressing is on.
+    if (compress) {
+      fileName += ".zip";
+    }
+
+    return fileName;
+  },
+
+  /**
+   * Save HAR string into a given file. The file might be compressed
+   * if specified in the options.
+   *
+   * @param {File} file Target file where the HAR string (JSON)
+   * should be stored.
+   * @param {String} jsonString HAR data (JSON or JSONP)
+   * @param {Boolean} compress The result file is zipped if set to true.
+   */
+  saveToFile: function(file, jsonString, compress) {
+    let openFlags = OPEN_FLAGS.WRONLY | OPEN_FLAGS.CREATE_FILE |
+      OPEN_FLAGS.TRUNCATE;
+
+    try {
+      let foStream = Cc["@mozilla.org/network/file-output-stream;1"]
+        .createInstance(Ci.nsIFileOutputStream);
+
+      let permFlags = parseInt("0666", 8);
+      foStream.init(file, openFlags, permFlags, 0);
+
+      let convertor = Cc["@mozilla.org/intl/converter-output-stream;1"]
+        .createInstance(Ci.nsIConverterOutputStream);
+      convertor.init(foStream, "UTF-8", 0, 0);
+
+      // The entire jsonString can be huge so, write the data in chunks.
+      let chunkLength = 1024 * 1024;
+      for (let i=0; i<=jsonString.length; i++) {
+        let data = jsonString.substr(i, chunkLength+1);
+        if (data) {
+          convertor.writeString(data);
+        }
+
+        i = i + chunkLength;
+      }
+
+      // this closes foStream
+      convertor.close();
+    } catch (err) {
+      Cu.reportError(err);
+      return false;
+    }
+
+    // If no compressing then bail out.
+    if (!compress) {
+      return true;
+    }
+
+    // Remember name of the original file, it'll be replaced by a zip file.
+    let originalFilePath = file.path;
+    let originalFileName = file.leafName;
+
+    try {
+      // Rename using unique name (the file is going to be removed).
+      file.moveTo(null, "temp" + (new Date()).getTime() + "temphar");
+
+      // Create compressed file with the original file path name.
+      let zipFile = Cc["@mozilla.org/file/local;1"].
+        createInstance(Ci.nsILocalFile);
+      zipFile.initWithPath(originalFilePath);
+
+      // The file within the zipped file doesn't use .zip extension.
+      let fileName = originalFileName;
+      if (fileName.indexOf(".zip") == fileName.length - 4) {
+        fileName = fileName.substr(0, fileName.indexOf(".zip"));
+      }
+
+      let zip = new ZipWriter();
+      zip.open(zipFile, openFlags);
+      zip.addEntryFile(fileName, Ci.nsIZipWriter.COMPRESSION_DEFAULT,
+        file, false);
+      zip.close();
+
+      // Remove the original file (now zipped).
+      file.remove(true);
+      return true;
+    } catch (err) {
+      Cu.reportError(err);
+
+      // Something went wrong (disk space?) rename the original file back.
+      file.moveTo(null, originalFileName);
+    }
+
+    return false;
+  },
+
+  getLocalDirectory: function(path) {
+    let dir;
+
+    if (!path) {
+      dir = dirService.get("ProfD", Ci.nsILocalFile);
+      dir.append("har");
+      dir.append("logs");
+    } else {
+      dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+      dir.initWithPath(path);
+    }
+
+    return dir;
+  },
+}
+
+// Exports from this module
+exports.HarUtils = HarUtils;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES.devtools.netmonitor.har += [
+    'har-builder.js',
+    'har-exporter.js',
+    'har-utils.js',
+]
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/test/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+  head.js
+  html_har_post-data-test-page.html
+
+[browser_net_har_copy_all_as_har.js]
+[browser_net_har_post_data.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/test/browser_net_har_copy_all_as_har.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Basic tests for exporting Network panel content into HAR format.
+ */
+add_task(function*() {
+  let [ aTab, aDebuggee, aMonitor ] = yield initNetMonitor(SIMPLE_URL);
+
+  info("Starting test... ");
+
+  let { NetMonitorView } = aMonitor.panelWin;
+  let { RequestsMenu } = NetMonitorView;
+
+  RequestsMenu.lazyUpdate = false;
+
+  aDebuggee.location.reload();
+
+  yield waitForNetworkEvents(aMonitor, 1);
+  yield RequestsMenu.copyAllAsHar();
+
+  let jsonString = SpecialPowers.getClipboardData("text/unicode");
+  let har = JSON.parse(jsonString);
+
+  // Check out HAR log
+  isnot(har.log, null, "The HAR log must exist");
+  is(har.log.creator.name, "Firefox", "The creator field must be set");
+  is(har.log.browser.name, "Firefox", "The browser field must be set");
+  is(har.log.pages.length, 1, "There must be one page");
+  is(har.log.entries.length, 1, "There must be one request");
+
+  let entry = har.log.entries[0];
+  is(entry.request.method, "GET", "Check the method");
+  is(entry.request.url, SIMPLE_URL, "Check the URL");
+  is(entry.request.headers.length, 8, "Check number of request headers");
+  is(entry.response.status, 200, "Check response status");
+  is(entry.response.statusText, "OK", "Check response status text");
+  is(entry.response.headers.length, 6, "Check number of response headers");
+  is(entry.response.content.mimeType, "text/html", "Check response content type");
+  isnot(entry.response.content.text, undefined, "Check response body");
+  isnot(entry.timings, undefined, "Check timings");
+
+  teardown(aMonitor).then(finish);
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/test/browser_net_har_post_data.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests for exporting POST data into HAR format.
+ */
+add_task(function*() {
+  let [ aTab, aDebuggee, aMonitor ] = yield initNetMonitor(
+    HAR_EXAMPLE_URL + "html_har_post-data-test-page.html");
+
+  info("Starting test... ");
+
+  let { NetMonitorView } = aMonitor.panelWin;
+  let { RequestsMenu } = NetMonitorView;
+
+  RequestsMenu.lazyUpdate = false;
+
+  // Execute one POST request on the page and wait till its done.
+  aDebuggee.executeTest();
+  yield waitForNetworkEvents(aMonitor, 0, 1);
+
+  // Copy HAR into the clipboard (asynchronous).
+  let jsonString = yield RequestsMenu.copyAllAsHar();
+  let har = JSON.parse(jsonString);
+
+  // Check out the HAR log.
+  isnot(har.log, null, "The HAR log must exist");
+  is(har.log.pages.length, 1, "There must be one page");
+  is(har.log.entries.length, 1, "There must be one request");
+
+  let entry = har.log.entries[0];
+  is(entry.request.postData.mimeType, "application/json; charset=UTF-8",
+    "Check post data content type");
+  is(entry.request.postData.text, "{'first': 'John', 'last': 'Doe'}",
+    "Check post data payload");
+
+  // Clean up
+  teardown(aMonitor).then(finish);
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/test/head.js
@@ -0,0 +1,12 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+let { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+
+// Load the NetMonitor head.js file to share its API.
+let netMonitorHead = "chrome://mochitests/content/browser/browser/devtools/netmonitor/test/head.js";
+Services.scriptloader.loadSubScript(netMonitorHead, this);
+
+// Directory with HAR related test files.
+const HAR_EXAMPLE_URL = "http://example.com/browser/browser/devtools/netmonitor/har/test/";
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/har/test/html_har_post-data-test-page.html
@@ -0,0 +1,33 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+    <meta http-equiv="Pragma" content="no-cache" />
+    <meta http-equiv="Expires" content="0" />
+    <title>Network Monitor Test Page</title>
+  </head>
+
+  <body>
+    <p>HAR POST data test</p>
+
+    <script type="text/javascript">
+      function post(aAddress, aData) {
+        var xhr = new XMLHttpRequest();
+        xhr.open("POST", aAddress, true);
+        xhr.setRequestHeader("Content-Type", "application/json");
+        xhr.send(aData);
+      }
+
+      function executeTest() {
+        var url = "html_har_post-data-test-page.html";
+        var data = "{'first': 'John', 'last': 'Doe'}";
+        post(url, data);
+      }
+    </script>
+  </body>
+
+</html>
--- a/browser/devtools/netmonitor/moz.build
+++ b/browser/devtools/netmonitor/moz.build
@@ -1,10 +1,14 @@
 # vim: set filetype=python:
 # 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/.
 
+DIRS += [
+    'har'
+]
+
 EXTRA_JS_MODULES.devtools.netmonitor += [
     'panel.js'
 ]
 
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
--- a/browser/devtools/netmonitor/netmonitor-view.js
+++ b/browser/devtools/netmonitor/netmonitor-view.js
@@ -1,15 +1,28 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* 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/. */
 "use strict";
 
+const { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+
+XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
+  "resource://gre/modules/devtools/DevToolsUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "HarExporter", function() {
+  return devtools.require("devtools/netmonitor/har/har-exporter.js").HarExporter;
+});
+
+XPCOMUtils.defineLazyGetter(this, "NetworkHelper", function() {
+  return devtools.require("devtools/toolkit/webconsole/network-helper");
+});
+
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const EPSILON = 0.001;
 const SOURCE_SYNTAX_HIGHLIGHT_MAX_FILE_SIZE = 102400; // 100 KB in bytes
 const RESIZE_REFRESH_RATE = 50; // ms
 const REQUESTS_REFRESH_RATE = 50; // ms
 const REQUESTS_HEADERS_SAFE_BOUNDS = 30; // px
 const REQUESTS_TOOLTIP_POSITION = "topcenter bottomleft";
 const REQUESTS_TOOLTIP_IMAGE_MAX_DIM = 400; // px
@@ -566,17 +579,17 @@ RequestsMenuView.prototype = Heritage.ex
     clipboardHelper.copyString(selected.url);
   },
 
   /**
    * Copy the request url query string parameters from the currently selected item.
    */
   copyUrlParams: function() {
     let selected = this.selectedItem.attachment;
-    let params = nsIURL(selected.url).query.split("&");
+    let params = NetworkHelper.nsIURL(selected.url).query.split("&");
     let string = params.join(Services.appinfo.OS === "WINNT" ? "\r\n" : "\n");
     clipboardHelper.copyString(string);
   },
 
   /**
    * Extracts any urlencoded form data sections (e.g. "?foo=bar&baz=42") from a
    * POST request.
    *
@@ -626,17 +639,17 @@ RequestsMenuView.prototype = Heritage.ex
     // Try to extract any form data parameters.
     let formDataSections = yield view._getFormDataSections(
       selected.requestHeaders,
       selected.requestHeadersFromUploadStream,
       selected.requestPostData);
 
     let params = [];
     formDataSections.forEach(section => {
-      let paramsArray = parseQueryString(section);
+      let paramsArray = NetworkHelper.parseQueryString(section);
       if (paramsArray) {
         params = [...params, ...paramsArray];
       }
     });
 
     let string = params
       .map(param => param.name + (param.value ? "=" + param.value : ""))
       .join(Services.appinfo.OS === "WINNT" ? "\r\n" : "\n");
@@ -681,16 +694,44 @@ RequestsMenuView.prototype = Heritage.ex
         data.postDataText = yield gNetwork.getString(postData);
       }
 
       clipboardHelper.copyString(Curl.generateCommand(data));
     });
   },
 
   /**
+   * Copy HAR from the network panel content to the clipboard.
+   */
+  copyAllAsHar: function() {
+    let options = this.getDefaultHarOptions();
+    return HarExporter.copy(options);
+  },
+
+  /**
+   * Save HAR from the network panel content to a file.
+   */
+  saveAllAsHar: function() {
+    let options = this.getDefaultHarOptions();
+    return HarExporter.save(options);
+  },
+
+  getDefaultHarOptions: function() {
+    let form = NetMonitorController._target.form;
+    let title = form.title || form.url;
+
+    return {
+      getString: gNetwork.getString.bind(gNetwork),
+      view: this,
+      items: NetMonitorView.RequestsMenu.items,
+      title: title
+    };
+  },
+
+  /**
    * Copy the raw request headers from the currently selected item.
    */
   copyRequestHeaders: function() {
     let selected = this.selectedItem.attachment;
     let rawHeaders = selected.requestHeaders.rawHeaders.trim();
     if (Services.appinfo.OS !== "WINNT") {
       rawHeaders = rawHeaders.replace(/\r/g, "");
     }
@@ -1516,17 +1557,17 @@ RequestsMenuView.prototype = Heritage.ex
       case "method": {
         let node = $(".requests-menu-method", target);
         node.setAttribute("value", aValue);
         break;
       }
       case "url": {
         let uri;
         try {
-          uri = nsIURL(aValue);
+          uri = NetworkHelper.nsIURL(aValue);
         } catch(e) {
           break; // User input may not make a well-formed url yet.
         }
         let nameWithQuery = this._getUriNameWithQuery(uri);
         let hostPort = this._getUriHostPort(uri);
         let unicodeUrl = NetworkHelper.convertToUnicode(unescape(uri.spec));
 
         let file = $(".requests-menu-file", target);
@@ -1939,17 +1980,17 @@ RequestsMenuView.prototype = Heritage.ex
     let resendElement = $("#request-menu-context-resend");
     resendElement.hidden = !NetMonitorController.supportsCustomRequest ||
       !selectedItem || selectedItem.attachment.isCustom;
 
     let copyUrlElement = $("#request-menu-context-copy-url");
     copyUrlElement.hidden = !selectedItem;
 
     let copyUrlParamsElement = $("#request-menu-context-copy-url-params");
-    copyUrlParamsElement.hidden = !selectedItem || !nsIURL(selectedItem.attachment.url).query;
+    copyUrlParamsElement.hidden = !selectedItem || !NetworkHelper.nsIURL(selectedItem.attachment.url).query;
 
     let copyPostDataElement = $("#request-menu-context-copy-post-data");
     copyPostDataElement.hidden = !selectedItem || !selectedItem.attachment.requestPostData;
 
     let copyAsCurlElement = $("#request-menu-context-copy-as-curl");
     copyAsCurlElement.hidden = !selectedItem || !selectedItem.attachment.responseContent;
 
     let copyRequestHeadersElement = $("#request-menu-context-copy-request-headers");
@@ -1967,16 +2008,22 @@ RequestsMenuView.prototype = Heritage.ex
     let copyImageAsDataUriElement = $("#request-menu-context-copy-image-as-data-uri");
     copyImageAsDataUriElement.hidden = !selectedItem ||
       !selectedItem.attachment.responseContent ||
       !selectedItem.attachment.responseContent.content.mimeType.includes("image/");
 
     let separators = $all(".request-menu-context-separator");
     Array.forEach(separators, separator => separator.hidden = !selectedItem);
 
+    let copyAsHar = $("#request-menu-context-copy-all-as-har");
+    copyAsHar.hidden = !NetMonitorView.RequestsMenu.items.length;
+
+    let saveAsHar = $("#request-menu-context-save-all-as-har");
+    saveAsHar.hidden = !NetMonitorView.RequestsMenu.items.length;
+
     let newTabElement = $("#request-menu-context-newtab");
     newTabElement.hidden = !selectedItem;
   },
 
   /**
    * Checks if the specified unix time is the first one to be known of,
    * and saves it if so.
    *
@@ -2005,25 +2052,25 @@ RequestsMenuView.prototype = Heritage.ex
   /**
    * Helpers for getting details about an nsIURL.
    *
    * @param nsIURL | string aUrl
    * @return string
    */
   _getUriNameWithQuery: function(aUrl) {
     if (!(aUrl instanceof Ci.nsIURL)) {
-      aUrl = nsIURL(aUrl);
+      aUrl = NetworkHelper.nsIURL(aUrl);
     }
     let name = NetworkHelper.convertToUnicode(unescape(aUrl.fileName || aUrl.filePath || "/"));
     let query = NetworkHelper.convertToUnicode(unescape(aUrl.query));
     return name + (query ? "?" + query : "");
   },
   _getUriHostPort: function(aUrl) {
     if (!(aUrl instanceof Ci.nsIURL)) {
-      aUrl = nsIURL(aUrl);
+      aUrl = NetworkHelper.nsIURL(aUrl);
     }
     return NetworkHelper.convertToUnicode(unescape(aUrl.hostPort));
   },
 
   /**
    * Helper for getting an abbreviated string for a mime type.
    *
    * @param string aMimeType
@@ -2249,17 +2296,17 @@ CustomRequestView.prototype = {
 
   /**
    * Update the query string field based on the url.
    *
    * @param object aUrl
    *        The URL to extract query string from.
    */
   updateCustomQuery: function(aUrl) {
-    let paramsArray = parseQueryString(nsIURL(aUrl).query);
+    let paramsArray = NetworkHelper.parseQueryString(NetworkHelper.nsIURL(aUrl).query);
     if (!paramsArray) {
       $("#custom-query").hidden = true;
       return;
     }
     $("#custom-query").hidden = false;
     $("#custom-query-value").value = writeQueryText(paramsArray);
   },
 
@@ -2269,17 +2316,17 @@ CustomRequestView.prototype = {
    * @param object aQueryText
    *        The contents of the query string field.
    */
   updateCustomUrl: function(aQueryText) {
     let params = parseQueryText(aQueryText);
     let queryString = writeQueryString(params);
 
     let url = $("#custom-url-value").value;
-    let oldQuery = nsIURL(url).query;
+    let oldQuery = NetworkHelper.nsIURL(url).query;
     let path = url.replace(oldQuery, queryString);
 
     $("#custom-url-value").value = path;
   }
 }
 
 /**
  * Functions handling the requests details view.
@@ -2682,17 +2729,17 @@ NetworkDetailsView.prototype = {
 
   /**
    * Sets the network request get params shown in this view.
    *
    * @param string aUrl
    *        The request's url.
    */
   _setRequestGetParams: function(aUrl) {
-    let query = nsIURL(aUrl).query;
+    let query = NetworkHelper.nsIURL(aUrl).query;
     if (query) {
       this._addParams(this._paramsQueryString, query);
     }
   },
 
   /**
    * Sets the network request post params shown in this view.
    *
@@ -2753,17 +2800,17 @@ NetworkDetailsView.prototype = {
    * Populates the params container in this view with the specified data.
    *
    * @param string aName
    *        The type of params to populate (get or post).
    * @param string aQueryString
    *        A query string of params (e.g. "?foo=bar&baz=42").
    */
   _addParams: function(aName, aQueryString) {
-    let paramsArray = parseQueryString(aQueryString);
+    let paramsArray = NetworkHelper.parseQueryString(aQueryString);
     if (!paramsArray) {
       return;
     }
     let paramsScope = this._params.addScope(aName);
     paramsScope.expanded = true;
 
     for (let param of paramsArray) {
       let paramVar = paramsScope.addItem(param.name, {}, true);
@@ -2847,17 +2894,17 @@ NetworkDetailsView.prototype = {
       $("#response-content-image-box").setAttribute("align", "center");
       $("#response-content-image-box").setAttribute("pack", "center");
       $("#response-content-image-box").hidden = false;
       $("#response-content-image").src =
         "data:" + mimeType + ";" + encoding + "," + responseBody;
 
       // Immediately display additional information about the image:
       // file name, mime type and encoding.
-      $("#response-content-image-name-value").setAttribute("value", nsIURL(aUrl).fileName);
+      $("#response-content-image-name-value").setAttribute("value", NetworkHelper.nsIURL(aUrl).fileName);
       $("#response-content-image-mime-value").setAttribute("value", mimeType);
       $("#response-content-image-encoding-value").setAttribute("value", encoding);
 
       // Wait for the image to load in order to display the width and height.
       $("#response-content-image").onload = e => {
         // XUL images are majestic so they don't bother storing their dimensions
         // in width and height attributes like the rest of the folk. Hack around
         // this by getting the bounding client rect and subtracting the margins.
@@ -3280,54 +3327,16 @@ PerformanceStatisticsView.prototype = {
 
 /**
  * DOM query helper.
  */
 let $ = (aSelector, aTarget = document) => aTarget.querySelector(aSelector);
 let $all = (aSelector, aTarget = document) => aTarget.querySelectorAll(aSelector);
 
 /**
- * Helper for getting an nsIURL instance out of a string.
- */
-function nsIURL(aUrl, aStore = nsIURL.store) {
-  if (aStore.has(aUrl)) {
-    return aStore.get(aUrl);
-  }
-  let uri = Services.io.newURI(aUrl, null, null).QueryInterface(Ci.nsIURL);
-  aStore.set(aUrl, uri);
-  return uri;
-}
-nsIURL.store = new Map();
-
-/**
- * Parse a url's query string into its components
- *
- * @param string aQueryString
- *        The query part of a url
- * @return array
- *         Array of query params {name, value}
- */
-function parseQueryString(aQueryString) {
-  // Make sure there's at least one param available.
-  // Be careful here, params don't necessarily need to have values, so
-  // no need to verify the existence of a "=".
-  if (!aQueryString) {
-    return;
-  }
-  // Turn the params string into an array containing { name: value } tuples.
-  let paramsArray = aQueryString.replace(/^[?&]/, "").split("&").map(e => {
-    let param = e.split("=");
-    return {
-      name: param[0] ? NetworkHelper.convertToUnicode(unescape(param[0])) : "",
-      value: param[1] ? NetworkHelper.convertToUnicode(unescape(param[1])) : ""
-    }});
-  return paramsArray;
-}
-
-/**
  * Parse text representation of multiple HTTP headers.
  *
  * @param string aText
  *        Text of headers
  * @return array
  *         Array of headers info {name, value}
  */
 function parseHeadersText(aText) {
--- a/browser/devtools/netmonitor/netmonitor.xul
+++ b/browser/devtools/netmonitor/netmonitor.xul
@@ -50,16 +50,26 @@
                 accesskey="&netmonitorUI.context.copyResponseHeaders.accesskey;"
                 oncommand="NetMonitorView.RequestsMenu.copyResponseHeaders();"/>
       <menuitem id="request-menu-context-copy-response"
                 label="&netmonitorUI.context.copyResponse;"
                 accesskey="&netmonitorUI.context.copyResponse.accesskey;"/>
       <menuitem id="request-menu-context-copy-image-as-data-uri"
                 label="&netmonitorUI.context.copyImageAsDataUri;"
                 accesskey="&netmonitorUI.context.copyImageAsDataUri.accesskey;"/>
+      <menuseparator class="request-menu-context-separator"/>
+      <menuitem id="request-menu-context-copy-all-as-har"
+                label="&netmonitorUI.context.copyAllAsHar;"
+                accesskey="&netmonitorUI.context.copyAllAsHar.accesskey;"
+                oncommand="NetMonitorView.RequestsMenu.copyAllAsHar();"/>
+      <menuitem id="request-menu-context-save-all-as-har"
+                label="&netmonitorUI.context.saveAllAsHar;"
+                accesskey="&netmonitorUI.context.saveAllAsHar.accesskey;"
+                oncommand="NetMonitorView.RequestsMenu.saveAllAsHar();"/>
+      <menuseparator class="request-menu-context-separator"/>
       <menuitem id="request-menu-context-resend"
                 label="&netmonitorUI.summary.editAndResend;"
                 accesskey="&netmonitorUI.summary.editAndResend.accesskey;"/>
       <menuseparator class="request-menu-context-separator"/>
       <menuitem id="request-menu-context-newtab"
                 label="&netmonitorUI.context.newTab;"
                 accesskey="&netmonitorUI.context.newTab.accesskey;"/>
       <menuitem id="request-menu-context-perf"
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/test/html_har_post-data-test-page.html
@@ -0,0 +1,33 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+    <meta http-equiv="Pragma" content="no-cache" />
+    <meta http-equiv="Expires" content="0" />
+    <title>Network Monitor Test Page</title>
+  </head>
+
+  <body>
+    <p>HAR POST data test</p>
+
+    <script type="text/javascript">
+      function post(aAddress, aData) {
+        var xhr = new XMLHttpRequest();
+        xhr.open("POST", aAddress, true);
+        xhr.setRequestHeader("Content-Type", "application/json");
+        xhr.send(aData);
+      }
+
+      function executeTest() {
+        var url = "sjs_simple-test-server.sjs";
+        var data = "{'first': 'John', 'last': 'Doe'}";
+        post(url, data);
+      }
+    </script>
+  </body>
+
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/chrome/browser/devtools/har.properties
@@ -0,0 +1,22 @@
+# 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/.
+
+# LOCALIZATION NOTE These strings are used inside the Network Monitor
+# which is available from the Web Developer sub-menu -> 'Network Monitor'.
+# The correct localization of this file might be to keep it in
+# English, or another language commonly spoken among web developers.
+# You want to make that choice consistent across the developer tools.
+# A good criteria is the language in which you'd find the best
+# documentation on web development on the web.
+
+# LOCALIZATION NOTE (har.responseBodyNotIncluded): A label used within
+# HAR file explaining that HTTP response bodies are not includes
+# in exported data.
+har.responseBodyNotIncluded=Response bodies are not included.
+
+# LOCALIZATION NOTE (har.responseBodyNotIncluded): A label used within
+# HAR file explaining that HTTP request bodies are not includes
+# in exported data.
+har.requestBodyNotIncluded=Request bodies are not included.
+
--- a/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
@@ -309,16 +309,32 @@
 <!-- LOCALIZATION NOTE (netmonitorUI.context.copyImageAsDataUri): This is the label displayed
   -  on the context menu that copies the selected image as data uri -->
 <!ENTITY netmonitorUI.context.copyImageAsDataUri "Copy Image as Data URI">
 
 <!-- LOCALIZATION NOTE (netmonitorUI.context.copyImageAsDataUri.accesskey): This is the access key
   -  for the Copy Image As Data URI menu item displayed in the context menu for a request -->
 <!ENTITY netmonitorUI.context.copyImageAsDataUri.accesskey  "I">
 
+<!-- LOCALIZATION NOTE (netmonitorUI.context.copyAllAsHar): This is the label displayed
+  -  on the context menu that copies all as HAR format -->
+<!ENTITY netmonitorUI.context.copyAllAsHar "Copy All As HAR">
+
+<!-- LOCALIZATION NOTE (netmonitorUI.context.copyAllAsHar.accesskey): This is the access key
+  -  for the Copy All As HAR menu item displayed in the context menu for a network panel -->
+<!ENTITY netmonitorUI.context.copyAllAsHar.accesskey "O">
+
+<!-- LOCALIZATION NOTE (netmonitorUI.context.saveAllAsHar): This is the label displayed
+  -  on the context menu that saves all as HAR format -->
+<!ENTITY netmonitorUI.context.saveAllAsHar "Save All As HAR">
+
+<!-- LOCALIZATION NOTE (netmonitorUI.context.saveAllAsHar.accesskey): This is the access key
+  -  for the Save All As HAR menu item displayed in the context menu for a network panel -->
+<!ENTITY netmonitorUI.context.saveAllAsHar.accesskey "H">
+
 <!-- LOCALIZATION NOTE (netmonitorUI.summary.editAndResend): This is the label displayed
   -  on the button in the headers tab that opens a form to edit and resend the currently
      displayed request -->
 <!ENTITY netmonitorUI.summary.editAndResend "Edit and Resend">
 
 <!-- LOCALIZATION NOTE (netmonitorUI.summary.editAndResend.accesskey): This is the access key
   -  for the "Edit and Resend" menu item displayed in the context menu for a request -->
 <!ENTITY netmonitorUI.summary.editAndResend.accesskey "E">
--- a/browser/locales/jar.mn
+++ b/browser/locales/jar.mn
@@ -67,16 +67,17 @@
     locale/browser/devtools/inspector.dtd          (%chrome/browser/devtools/inspector.dtd)
     locale/browser/devtools/timeline.dtd           (%chrome/browser/devtools/timeline.dtd)
     locale/browser/devtools/timeline.properties    (%chrome/browser/devtools/timeline.properties)
     locale/browser/devtools/projecteditor.properties     (%chrome/browser/devtools/projecteditor.properties)
     locale/browser/devtools/eyedropper.properties     (%chrome/browser/devtools/eyedropper.properties)
     locale/browser/devtools/connection-screen.dtd  (%chrome/browser/devtools/connection-screen.dtd)
     locale/browser/devtools/connection-screen.properties (%chrome/browser/devtools/connection-screen.properties)
     locale/browser/devtools/font-inspector.dtd     (%chrome/browser/devtools/font-inspector.dtd)
+    locale/browser/devtools/har.properties         (%chrome/browser/devtools/har.properties)
     locale/browser/devtools/app-manager.dtd        (%chrome/browser/devtools/app-manager.dtd)
     locale/browser/devtools/app-manager.properties (%chrome/browser/devtools/app-manager.properties)
     locale/browser/devtools/webide.dtd             (%chrome/browser/devtools/webide.dtd)
     locale/browser/devtools/webide.properties      (%chrome/browser/devtools/webide.properties)
     locale/browser/lightweightThemes.properties    (%chrome/browser/lightweightThemes.properties)
     locale/browser/loop/loop.properties            (%chrome/browser/loop/loop.properties)
     locale/browser/newTab.dtd                      (%chrome/browser/newTab.dtd)
     locale/browser/newTab.properties               (%chrome/browser/newTab.properties)
--- a/toolkit/devtools/webconsole/network-helper.js
+++ b/toolkit/devtools/webconsole/network-helper.js
@@ -53,16 +53,19 @@
  */
 
 "use strict";
 
 const {components, Cc, Ci, Cu} = require("chrome");
 loader.lazyImporter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm");
 loader.lazyImporter(this, "DevToolsUtils", "resource://gre/modules/devtools/DevToolsUtils.jsm");
 
+// The cache used in the `nsIURL` function.
+const gNSURLStore = new Map();
+
 /**
  * Helper object for networking stuff.
  *
  * Most of the following functions have been taken from the Firebug source. They
  * have been modified to match the Firefox coding rules.
  */
 let NetworkHelper = {
   /**
@@ -738,13 +741,53 @@ let NetworkHelper = {
       if (!isCipher) {
         DevToolsUtils.reportException("NetworkHelper.getReasonsForWeakness",
           "STATE_IS_BROKEN without a known reason. Full state was: " + state);
       }
     }
 
     return reasons;
   },
+
+  /**
+   * Parse a url's query string into its components
+   *
+   * @param string aQueryString
+   *        The query part of a url
+   * @return array
+   *         Array of query params {name, value}
+   */
+  parseQueryString: function(aQueryString) {
+    // Make sure there's at least one param available.
+    // Be careful here, params don't necessarily need to have values, so
+    // no need to verify the existence of a "=".
+    if (!aQueryString) {
+      return;
+    }
+
+    // Turn the params string into an array containing { name: value } tuples.
+    let paramsArray = aQueryString.replace(/^[?&]/, "").split("&").map(e => {
+      let param = e.split("=");
+      return {
+        name: param[0] ? NetworkHelper.convertToUnicode(unescape(param[0])) : "",
+        value: param[1] ? NetworkHelper.convertToUnicode(unescape(param[1])) : ""
+      }});
+
+    return paramsArray;
+  },
+
+  /**
+   * Helper for getting an nsIURL instance out of a string.
+   */
+  nsIURL: function(aUrl, aStore = gNSURLStore) {
+    if (aStore.has(aUrl)) {
+      return aStore.get(aUrl);
+    }
+
+    let uri = Services.io.newURI(aUrl, null, null).QueryInterface(Ci.nsIURL);
+    aStore.set(aUrl, uri);
+    return uri;
+  }
 };
 
 for (let prop of Object.getOwnPropertyNames(NetworkHelper)) {
   exports[prop] = NetworkHelper[prop];
 }