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 247934 e708f2ce973c
parent 247933 fa429d0aec1a
child 247935 507407471469
push id28887
push userkwierso@gmail.com
push dateThu, 11 Jun 2015 01:10:53 +0000
treeherdermozilla-central@1fd19d8fc936 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjsantell, jlongster
bugs859058
milestone41.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 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];
 }