diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 9e31d6e3bda8..953a24689ce0 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1562,6 +1562,10 @@ pref("devtools.webconsole.filter.secwarn", true); pref("devtools.webconsole.filter.serviceworkers", false); pref("devtools.webconsole.filter.sharedworkers", false); pref("devtools.webconsole.filter.windowlessworkers", false); +pref("devtools.webconsole.filter.servererror", false); +pref("devtools.webconsole.filter.serverwarn", false); +pref("devtools.webconsole.filter.serverinfo", false); +pref("devtools.webconsole.filter.serverlog", false); // Remember the Browser Console filters pref("devtools.browserconsole.filter.network", true); @@ -1583,6 +1587,10 @@ pref("devtools.browserconsole.filter.secwarn", true); pref("devtools.browserconsole.filter.serviceworkers", true); pref("devtools.browserconsole.filter.sharedworkers", true); pref("devtools.browserconsole.filter.windowlessworkers", true); +pref("devtools.browserconsole.filter.servererror", false); +pref("devtools.browserconsole.filter.serverwarn", false); +pref("devtools.browserconsole.filter.serverinfo", false); +pref("devtools.browserconsole.filter.serverlog", false); // Text size in the Web Console. Use 0 for the system default size. pref("devtools.webconsole.fontSize", 0); diff --git a/browser/devtools/webconsole/console-output.js b/browser/devtools/webconsole/console-output.js index b3803a9b14f0..1180f770a8f8 100644 --- a/browser/devtools/webconsole/console-output.js +++ b/browser/devtools/webconsole/console-output.js @@ -44,6 +44,7 @@ const COMPAT = { INPUT: 4, OUTPUT: 5, SECURITY: 6, + SERVER: 7, }, // The possible message severities. @@ -68,11 +69,12 @@ const COMPAT = { [ null, null, null, null, ], // Input [ null, null, null, null, ], // Output [ "secerror", "secwarn", null, null, ], // Security + [ "servererror", "serverwarn", "serverinfo", "serverlog", ], // Server Logging ], // The fragment of a CSS class name that identifies each category. CATEGORY_CLASS_FRAGMENTS: [ "network", "cssparser", "exception", "console", - "input", "output", "security" ], + "input", "output", "security", "server" ], // The fragment of a CSS class name that identifies each severity. SEVERITY_CLASS_FRAGMENTS: [ "error", "warn", "info", "log" ], @@ -1300,7 +1302,7 @@ Messages.ConsoleGeneric = function(packet) let options = { className: "cm-s-mozilla", timestamp: packet.timeStamp, - category: "webdev", + category: packet.category || "webdev", severity: CONSOLE_API_LEVELS_TO_SEVERITIES[packet.level], prefix: packet.prefix, private: packet.private, @@ -1571,7 +1573,7 @@ Messages.ConsoleTrace = function(packet) let options = { className: "cm-s-mozilla", timestamp: packet.timeStamp, - category: "webdev", + category: packet.category || "webdev", severity: CONSOLE_API_LEVELS_TO_SEVERITIES[packet.level], private: packet.private, filterDuplicates: true, @@ -1708,7 +1710,7 @@ Messages.ConsoleTable = function(packet) let options = { className: "cm-s-mozilla", timestamp: packet.timeStamp, - category: "webdev", + category: packet.category || "webdev", severity: CONSOLE_API_LEVELS_TO_SEVERITIES[packet.level], private: packet.private, filterDuplicates: false, diff --git a/browser/devtools/webconsole/test/browser.ini b/browser/devtools/webconsole/test/browser.ini index 4e5c4dda1655..a37e2ac3458e 100644 --- a/browser/devtools/webconsole/test/browser.ini +++ b/browser/devtools/webconsole/test/browser.ini @@ -73,6 +73,7 @@ support-files = test-console-count-external-file.js test-console-extras.html test-console-replaced-api.html + test-console-server-logging.sjs test-console.html test-console-workers.html test-console-table.html @@ -172,6 +173,7 @@ skip-if = buildapp == 'mulet' skip-if = e10s # Bug 1042253 - webconsole tests disabled with e10s [browser_console_private_browsing.js] skip-if = buildapp == 'mulet' || e10s # Bug 1042253 - webconsole e10s tests +[browser_console_server_logging.js] [browser_console_variables_view.js] skip-if = e10s # Bug 1042253 - webconsole tests disabled with e10s [browser_console_variables_view_filter.js] diff --git a/browser/devtools/webconsole/test/browser_console_server_logging.js b/browser/devtools/webconsole/test/browser_console_server_logging.js new file mode 100644 index 000000000000..715a9baf38ce --- /dev/null +++ b/browser/devtools/webconsole/test/browser_console_server_logging.js @@ -0,0 +1,34 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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"; + +// Check that server log appears in the console panel - bug 1168872 +let test = asyncTest(function* () { + const PREF = "devtools.webconsole.filter.serverlog"; + const TEST_URI = "http://example.com/browser/browser/devtools/webconsole/test/test-console-server-logging.sjs"; + + Services.prefs.setBoolPref(PREF, true); + registerCleanupFunction(() => Services.prefs.clearUserPref(PREF)); + + yield loadTab(TEST_URI); + + let hud = yield openConsole(); + + BrowserReload(); + + // Note that the test is also checking out the (printf like) + // formatters and encoding of UTF8 characters (see the one at the end). + let text = "values: string Object { a: 10 } 123 1.12 \u2713"; + + yield waitForMessages({ + webconsole: hud, + messages: [{ + text: text, + category: CATEGORY_SERVER, + severity: SEVERITY_LOG, + }], + }) +}); diff --git a/browser/devtools/webconsole/test/browser_webconsole_bug_601667_filter_buttons.js b/browser/devtools/webconsole/test/browser_webconsole_bug_601667_filter_buttons.js index 09423bb22e36..cdc07114f6e8 100644 --- a/browser/devtools/webconsole/test/browser_webconsole_bug_601667_filter_buttons.js +++ b/browser/devtools/webconsole/test/browser_webconsole_bug_601667_filter_buttons.js @@ -27,12 +27,14 @@ function testFilterButtons() { testMenuFilterButton("js"); testMenuFilterButton("logging"); testMenuFilterButton("security"); + testMenuFilterButton("server"); testIsolateFilterButton("net"); testIsolateFilterButton("css"); testIsolateFilterButton("js"); testIsolateFilterButton("logging"); testIsolateFilterButton("security"); + testIsolateFilterButton("server"); } function testMenuFilterButton(category) { diff --git a/browser/devtools/webconsole/test/head.js b/browser/devtools/webconsole/test/head.js index f5d64f829ef6..015cd7f3737c 100644 --- a/browser/devtools/webconsole/test/head.js +++ b/browser/devtools/webconsole/test/head.js @@ -28,6 +28,7 @@ const CATEGORY_WEBDEV = 3; const CATEGORY_INPUT = 4; const CATEGORY_OUTPUT = 5; const CATEGORY_SECURITY = 6; +const CATEGORY_SERVER = 7; // The possible message severities. const SEVERITY_ERROR = 0; diff --git a/browser/devtools/webconsole/test/test-console-server-logging.sjs b/browser/devtools/webconsole/test/test-console-server-logging.sjs new file mode 100644 index 000000000000..167a6e39a311 --- /dev/null +++ b/browser/devtools/webconsole/test/test-console-server-logging.sjs @@ -0,0 +1,32 @@ +/* + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) +{ + var page = "" + + "" + + "

hello world!

" + + ""; + + var data = { + "version": "4.1.0", + "columns": ["log", "backtrace", "type"], + "rows": [[ + ["values: %s %o %i %f %s","string",{"a":10,"___class_name":"Object"},123,1.12, "\u2713"], + "C:\\src\\www\\serverlogging\\test7.php:4:1", + "" + ]] + }; + + // Put log into headers. + var value = b64EncodeUnicode(JSON.stringify(data)); + response.setHeader("X-ChromeLogger-Data", value, false); + + response.write(page); +} + +function b64EncodeUnicode(str) { + return btoa(unescape(encodeURIComponent(str))); +} \ No newline at end of file diff --git a/browser/devtools/webconsole/webconsole.js b/browser/devtools/webconsole/webconsole.js index 6d058292fe59..6f8de55a9591 100644 --- a/browser/devtools/webconsole/webconsole.js +++ b/browser/devtools/webconsole/webconsole.js @@ -34,6 +34,7 @@ loader.lazyImporter(this, "VariablesView", "resource:///modules/devtools/Variabl loader.lazyImporter(this, "VariablesViewController", "resource:///modules/devtools/VariablesViewController.jsm"); loader.lazyImporter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm"); loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm"); +loader.lazyGetter(this, "Timers", () => require("sdk/timers")); const STRINGS_URI = "chrome://browser/locale/devtools/webconsole.properties"; let l10n = new WebConsoleUtils.l10n(STRINGS_URI); @@ -76,6 +77,7 @@ const CATEGORY_WEBDEV = 3; const CATEGORY_INPUT = 4; // always on const CATEGORY_OUTPUT = 5; // always on const CATEGORY_SECURITY = 6; +const CATEGORY_SERVER = 7; // The possible message severities. As before, we start at zero so we can use // these as indexes into MESSAGE_PREFERENCE_KEYS. @@ -93,6 +95,7 @@ const CATEGORY_CLASS_FRAGMENTS = [ "input", "output", "security", + "server", ]; // The fragment of a CSS class name that identifies each severity. @@ -109,14 +112,15 @@ const SEVERITY_CLASS_FRAGMENTS = [ // Most of these rather idiosyncratic names are historical and predate the // division of message type into "category" and "severity". const MESSAGE_PREFERENCE_KEYS = [ -// Error Warning Info Log - [ "network", "netwarn", "netxhr", "networkinfo", ], // Network - [ "csserror", "cssparser", null, "csslog", ], // CSS - [ "exception", "jswarn", null, "jslog", ], // JS - [ "error", "warn", "info", "log", ], // Web Developer - [ null, null, null, null, ], // Input - [ null, null, null, null, ], // Output - [ "secerror", "secwarn", null, null, ], // Security +// Error Warning Info Log + [ "network", "netwarn", "netxhr", "networkinfo", ], // Network + [ "csserror", "cssparser", null, "csslog", ], // CSS + [ "exception", "jswarn", null, "jslog", ], // JS + [ "error", "warn", "info", "log", ], // Web Developer + [ null, null, null, null, ], // Input + [ null, null, null, null, ], // Output + [ "secerror", "secwarn", null, null, ], // Security + [ "servererror", "serverwarn", "serverinfo", "serverlog", ], // Server Logging ]; // A mapping from the console API log event levels to the Web Console @@ -217,6 +221,7 @@ function WebConsoleFrame(aWebConsoleOwner) this._onPanelSelected = this._onPanelSelected.bind(this); this._flushMessageQueue = this._flushMessageQueue.bind(this); this._onToolboxPrefChanged = this._onToolboxPrefChanged.bind(this); + this._onUpdateListeners = this._onUpdateListeners.bind(this); this._outputTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); this._outputTimerInitialized = false; @@ -652,10 +657,12 @@ WebConsoleFrame.prototype = { let prefs = ["network", "networkinfo", "csserror", "cssparser", "csslog", "exception", "jswarn", "jslog", "error", "info", "warn", "log", "secerror", "secwarn", "netwarn", "netxhr", "sharedworkers", - "serviceworkers", "windowlessworkers"]; + "serviceworkers", "windowlessworkers", "servererror", + "serverwarn", "serverinfo", "serverlog"]; + for (let pref of prefs) { - this.filterPrefs[pref] = Services.prefs - .getBoolPref(this._filterPrefsPrefix + pref); + this.filterPrefs[pref] = Services.prefs.getBoolPref( + this._filterPrefsPrefix + pref); } }, @@ -666,7 +673,6 @@ WebConsoleFrame.prototype = { * @param function [aCallback=null] * Optional function to invoke when the listener has been * added/removed. - * */ _updateReflowActivityListener: function WCF__updateReflowActivityListener(aCallback) @@ -681,6 +687,38 @@ WebConsoleFrame.prototype = { } }, + /** + * Attach / detach server logging listener depending on the filter + * preferences. If the user isn't interested in the server logs at + * all the listener is not registered. + * + * @param function [aCallback=null] + * Optional function to invoke when the listener has been + * added/removed. + */ + _updateServerLoggingListener: + function WCF__updateServerLoggingListener(aCallback) + { + if (!this.webConsoleClient) { + return; + } + + let startListener = false; + let prefs = ["servererror", "serverwarn", "serverinfo", "serverlog"]; + for (let i = 0; i < prefs.length; i++) { + if (this.filterPrefs[prefs[i]]) { + startListener = true; + break; + } + } + + if (startListener) { + this.webConsoleClient.startListeners(["ServerLogging"], aCallback); + } else { + this.webConsoleClient.stopListeners(["ServerLogging"], aCallback); + } + }, + /** * Sets the events for the filter input field. * @private @@ -753,6 +791,9 @@ WebConsoleFrame.prototype = { let logging = this.document.querySelector("toolbarbutton[category=logging]"); logging.removeAttribute("accesskey"); + + let serverLogging = this.document.querySelector("toolbarbutton[category=server]"); + serverLogging.removeAttribute("accesskey"); } }, @@ -970,8 +1011,15 @@ WebConsoleFrame.prototype = { { this.filterPrefs[aToggleType] = aState; this.adjustVisibilityForMessageType(aToggleType, aState); + Services.prefs.setBoolPref(this._filterPrefsPrefix + aToggleType, aState); - this._updateReflowActivityListener(); + + if (this._updateListenersTimeout) { + Timers.clearTimeout(this._updateListenersTimeout); + } + + this._updateListenersTimeout = Timers.setTimeout( + this._onUpdateListeners, 200); }, /** @@ -985,6 +1033,15 @@ WebConsoleFrame.prototype = { return this.filterPrefs[aToggleType]; }, + /** + * Called when a logging filter changes. Allows to stop/start + * listeners according to the current filter state. + */ + _onUpdateListeners: function() { + this._updateReflowActivityListener(); + this._updateServerLoggingListener(); + }, + /** * Check that the passed string matches the filter arguments. * @@ -4982,6 +5039,7 @@ function WebConsoleConnectionProxy(aWebConsole, aTarget) this._onNetworkEventUpdate = this._onNetworkEventUpdate.bind(this); this._onFileActivity = this._onFileActivity.bind(this); this._onReflowActivity = this._onReflowActivity.bind(this); + this._onServerLogCall = this._onServerLogCall.bind(this); this._onTabNavigated = this._onTabNavigated.bind(this); this._onAttachConsole = this._onAttachConsole.bind(this); this._onCachedMessages = this._onCachedMessages.bind(this); @@ -5087,6 +5145,7 @@ WebConsoleConnectionProxy.prototype = { client.addListener("consoleAPICall", this._onConsoleAPICall); client.addListener("fileActivity", this._onFileActivity); client.addListener("reflowActivity", this._onReflowActivity); + client.addListener("serverLogCall", this._onServerLogCall); client.addListener("lastPrivateContextExited", this._onLastPrivateContextExited); this.target.on("will-navigate", this._onTabNavigated); this.target.on("navigate", this._onTabNavigated); @@ -5155,7 +5214,7 @@ WebConsoleConnectionProxy.prototype = { let msgs = ["PageError", "ConsoleAPI"]; this.webConsoleClient.getCachedMessages(msgs, this._onCachedMessages); - this.owner._updateReflowActivityListener(); + this.owner._onUpdateListeners(); }, /** @@ -5304,6 +5363,23 @@ WebConsoleConnectionProxy.prototype = { } }, + /** + * The "serverLogCall" message type handler. We redirect any message to + * the UI for displaying. + * + * @private + * @param string aType + * Message type. + * @param object aPacket + * The message received from the server. + */ + _onServerLogCall: function WCCP__onServerLogCall(aType, aPacket) + { + if (this.owner && aPacket.from == this._consoleActor) { + this.owner.handleConsoleAPICall(aPacket.message); + } + }, + /** * The "lastPrivateContextExited" message type handler. When this message is * received the Web Console UI is cleared. @@ -5378,6 +5454,7 @@ WebConsoleConnectionProxy.prototype = { this.client.removeListener("consoleAPICall", this._onConsoleAPICall); this.client.removeListener("fileActivity", this._onFileActivity); this.client.removeListener("reflowActivity", this._onReflowActivity); + this.client.removeListener("serverLogCall", this._onServerLogCall); this.client.removeListener("lastPrivateContextExited", this._onLastPrivateContextExited); this.webConsoleClient.off("networkEvent", this._onNetworkEvent); this.webConsoleClient.off("networkEventUpdate", this._onNetworkEventUpdate); @@ -5474,6 +5551,9 @@ ConsoleContextMenu.prototype = { case CATEGORY_WEBDEV: selection.add("webdev"); break; + case CATEGORY_SERVER: + selection.add("server"); + break; } } diff --git a/browser/devtools/webconsole/webconsole.xul b/browser/devtools/webconsole/webconsole.xul index 79f1c96e9bc3..cd255e8ecfde 100644 --- a/browser/devtools/webconsole/webconsole.xul +++ b/browser/devtools/webconsole/webconsole.xul @@ -172,11 +172,27 @@ function goUpdateConsoleCommands() { autocheck="false" prefKey="windowlessworkers"/> + + + + + + + + + tabindex="9"/> diff --git a/browser/locales/en-US/chrome/browser/devtools/webConsole.dtd b/browser/locales/en-US/chrome/browser/devtools/webConsole.dtd index b6469146dc9c..4e60b892a5f5 100644 --- a/browser/locales/en-US/chrome/browser/devtools/webConsole.dtd +++ b/browser/locales/en-US/chrome/browser/devtools/webConsole.dtd @@ -76,6 +76,17 @@ + + + + + + + + + diff --git a/browser/themes/shared/devtools/webconsole.inc.css b/browser/themes/shared/devtools/webconsole.inc.css index a066d6264927..beb13ab1e16e 100644 --- a/browser/themes/shared/devtools/webconsole.inc.css +++ b/browser/themes/shared/devtools/webconsole.inc.css @@ -319,18 +319,32 @@ a { } .message[category=console][severity=error] > .icon::before, -.message[category=output][severity=error] > .icon::before { +.message[category=output][severity=error] > .icon::before, +.message[category=server][severity=error] > .icon::before { background-position: -12px -36px; } -.message[category=console][severity=warn] > .icon::before { +.message[category=console][severity=warn] > .icon::before, +.message[category=server][severity=warn] > .icon::before { background-position: -24px -36px; } -.message[category=console][severity=info] > .icon::before { +.message[category=console][severity=info] > .icon::before, +.message[category=server][severity=info] > .icon::before { background-position: -36px -36px; } +/* Server Logging Styles */ + +.webconsole-filter-button[category="server"] > .toolbarbutton-menubutton-button:before { + background-image: linear-gradient(rgb(144, 176, 144), rgb(99, 151, 99)); + border-color: rgb(76, 143, 76); +} + +.message[category=server] > .indent { + -moz-border-end: solid #90B090 6px; +} + /* Input and output styles */ .message[category=input] > .indent, .message[category=output] > .indent { diff --git a/toolkit/devtools/server/actors/webconsole.js b/toolkit/devtools/server/actors/webconsole.js index fd48484386a2..ce9d10e12dda 100644 --- a/toolkit/devtools/server/actors/webconsole.js +++ b/toolkit/devtools/server/actors/webconsole.js @@ -31,6 +31,10 @@ XPCOMUtils.defineLazyGetter(this, "ConsoleProgressListener", () => { XPCOMUtils.defineLazyGetter(this, "events", () => { return require("sdk/event/core"); }); +XPCOMUtils.defineLazyGetter(this, "ServerLoggingListener", () => { + return require("devtools/toolkit/webconsole/server-logger") + .ServerLoggingListener; +}); for (let name of ["WebConsoleUtils", "ConsoleServiceListener", "ConsoleAPIListener", "addWebConsoleCommands", "JSPropertyProvider", @@ -354,14 +358,22 @@ WebConsoleActor.prototype = this.consoleReflowListener.destroy(); this.consoleReflowListener = null; } - events.off(this.parentActor, "changed-toplevel-document", this._onChangedToplevelDocument); + if (this.serverLoggingListener) { + this.serverLoggingListener.destroy(); + this.serverLoggingListener = null; + } + + events.off(this.parentActor, "changed-toplevel-document", + this._onChangedToplevelDocument); + this.conn.removeActorPool(this._actorPool); + if (this.parentActor.isRootActor) { Services.obs.removeObserver(this._onObserverNotification, "last-pb-context-exited"); } - this._actorPool = null; + this._actorPool = null; this._webConsoleCommandsCache = null; this._lastConsoleInputEvaluation = null; this._evalWindow = null; @@ -604,6 +616,13 @@ WebConsoleActor.prototype = } startedListeners.push(listener); break; + case "ServerLogging": + if (!this.serverLoggingListener) { + this.serverLoggingListener = + new ServerLoggingListener(this.window, this); + } + startedListeners.push(listener); + break; } } @@ -634,7 +653,7 @@ WebConsoleActor.prototype = // listeners. let toDetach = aRequest.listeners || ["PageError", "ConsoleAPI", "NetworkActivity", - "FileActivity"]; + "FileActivity", "ServerLogging"]; while (toDetach.length > 0) { let listener = toDetach.shift(); @@ -675,6 +694,13 @@ WebConsoleActor.prototype = } stoppedListeners.push(listener); break; + case "ServerLogging": + if (this.serverLoggingListener) { + this.serverLoggingListener.destroy(); + this.serverLoggingListener = null; + } + stoppedListeners.push(listener); + break; } } @@ -1427,6 +1453,40 @@ WebConsoleActor.prototype = this.conn.send(packet); }, + /** + * Handler for server logging. This method forwards log events to the + * remote Web Console client. + * + * @see ServerLoggingListener + * @param object aMessage + * The console API call on the server we need to send to the remote client. + */ + onServerLogCall: function WCA_onServerLogCall(aMessage) + { + // Clone all data into the content scope (that's where + // passed arguments comes from). + let msg = Cu.cloneInto(aMessage, this.window); + + // All arguments within the message need to be converted into + // debuggees to properly send it to the client side. + // Use the default target: this.window as the global object + // since that's the correct scope for data in the message. + // The 'false' argument passed into prepareConsoleMessageForRemote() + // ensures that makeDebuggeeValue uses content debuggee. + // See also: + // * makeDebuggeeValue() + // * prepareConsoleMessageForRemote() + msg = this.prepareConsoleMessageForRemote(msg, false); + + let packet = { + from: this.actorID, + type: "serverLogCall", + message: msg, + }; + + this.conn.send(packet); + }, + ////////////////// // End of event handlers for various listeners. ////////////////// @@ -1437,11 +1497,14 @@ WebConsoleActor.prototype = * * @param object aMessage * The original message received from console-api-log-event. + * @param boolean aUseObjectGlobal + * If |true| the object global is determined and added as a debuggee, + * otherwise |this.window| is used when makeDebuggeeValue() is invoked. * @return object * The object that can be sent to the remote client. */ prepareConsoleMessageForRemote: - function WCA_prepareConsoleMessageForRemote(aMessage) + function WCA_prepareConsoleMessageForRemote(aMessage, aUseObjectGlobal = true) { let result = WebConsoleUtils.cloneObject(aMessage); @@ -1454,7 +1517,7 @@ WebConsoleActor.prototype = delete result.consoleID; result.arguments = Array.map(aMessage.arguments || [], (aObj) => { - let dbgObj = this.makeDebuggeeValue(aObj, true); + let dbgObj = this.makeDebuggeeValue(aObj, aUseObjectGlobal); return this.createValueGrip(dbgObj); }); @@ -1462,6 +1525,8 @@ WebConsoleActor.prototype = return this.createValueGrip(aString); }); + result.category = aMessage.category || "webdev"; + return result; }, diff --git a/toolkit/devtools/webconsole/moz.build b/toolkit/devtools/webconsole/moz.build index 621a69399b25..e9f493d615a2 100644 --- a/toolkit/devtools/webconsole/moz.build +++ b/toolkit/devtools/webconsole/moz.build @@ -12,5 +12,7 @@ EXTRA_JS_MODULES.devtools.toolkit.webconsole += [ 'client.js', 'network-helper.js', 'network-monitor.js', + 'server-logger-monitor.js', + 'server-logger.js', 'utils.js', ] diff --git a/toolkit/devtools/webconsole/server-logger-monitor.js b/toolkit/devtools/webconsole/server-logger-monitor.js new file mode 100644 index 000000000000..4134fb7e18b3 --- /dev/null +++ b/toolkit/devtools/webconsole/server-logger-monitor.js @@ -0,0 +1,211 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set 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 {Ci} = require("chrome"); +const Services = require("Services"); + +const {DebuggerServer} = require("devtools/server/main"); +const {makeInfallible} = require("devtools/toolkit/DevToolsUtils"); + +loader.lazyGetter(this, "NetworkHelper", () => require("devtools/toolkit/webconsole/network-helper")); + +// Helper tracer. Should be generic sharable by other modules (bug 1171927) +const trace = { + log: function(...args) { + } +} + +const acceptableHeaders = ["x-chromelogger-data"]; + +/** + * This object represents HTTP events observer. It's intended to be + * used in e10s enabled browser only. + * + * Since child processes can't register HTTP event observer they use + * this module to do the observing in the parent process. This monitor + * is loaded through DebuggerServerConnection.setupInParent() that is executed + * from within the child process. The execution is done by {@ServerLoggingListener}. + * The monitor listens to HTTP events and forwards it into the right child process. + * + * Read more about the architecture: + * https://github.com/mozilla/gecko-dev/blob/fx-team/toolkit/devtools/server/docs/actor-e10s-handling.md + */ +var ServerLoggerMonitor = { + // Initialization + + initialize: function() { + this.onChildMessage = this.onChildMessage.bind(this); + this.onDisconnectChild = this.onDisconnectChild.bind(this); + this.onExamineResponse = this.onExamineResponse.bind(this); + + // Set of tracked message managers. + this.messageManagers = new Set(); + + // Set of registered child frames (loggers). + this.targets = new Set(); + }, + + // Parent Child Relationship + + attach: makeInfallible(function({mm, prefix}) { + let size = this.messageManagers.size; + + trace.log("ServerLoggerMonitor.attach; ", size, arguments); + + if (this.messageManagers.has(mm)) { + return; + } + + this.messageManagers.add(mm); + + // Start listening for messages from the {@ServerLogger} actor + // living in the child process. + mm.addMessageListener("debug:server-logger", this.onChildMessage); + + // Listen to the disconnection message to clean-up. + DebuggerServer.once("disconnected-from-child:" + prefix, + this.onDisconnectChild); + }), + + detach: function(mm) { + let size = this.messageManagers.size; + + trace.log("ServerLoggerMonitor.detach; ", size); + + // Unregister message listeners + mm.removeMessageListener("debug:server-logger", this.onChildMessage); + }, + + onDisconnectChild: function(event, mm) { + let size = this.messageManagers.size; + + trace.log("ServerLoggerMonitor.onDisconnectChild; ", + size, arguments); + + if (!this.messageManagers.has(mm)) { + return; + } + + this.detach(mm); + + this.messageManagers.delete(mm); + }, + + // Child Message Handling + + onChildMessage: function(msg) { + let method = msg.data.method; + + trace.log("ServerLoggerMonitor.onChildMessage; ", method, msg); + + switch (method) { + case "attachChild": + return this.onAttachChild(msg); + case "detachChild": + return this.onDetachChild(msg); + default: + trace.log("Unknown method name: ", method); + } + }, + + onAttachChild: function(event) { + let target = event.target; + let size = this.targets.size; + + trace.log("ServerLoggerMonitor.onAttachChild; size: ", size, target); + + // If this is the first child attached, register global HTTP observer. + if (!size) { + trace.log("ServerLoggerMonitor.onAttatchChild; Add HTTP Observer"); + Services.obs.addObserver(this.onExamineResponse, + "http-on-examine-response", false); + } + + // Collect child loggers. The frame element where the + // window/document lives. + this.targets.add(target); + }, + + onDetachChild: function(event) { + let target = event.target; + this.targets.delete(target); + + let size = this.targets.size; + trace.log("ServerLoggerMonitor.onDetachChild; size: ", size, target); + + // If this is the last child process attached, unregister + // the global HTTP observer. + if (!size) { + trace.log("ServerLoggerMonitor.onDetachChild; Remove HTTP Observer"); + Services.obs.removeObserver(this.onExamineResponse, + "http-on-examine-response", false); + } + }, + + // HTTP Observer + + onExamineResponse: makeInfallible(function(subject, topic) { + let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel); + + trace.log("ServerLoggerMonitor.onExamineResponse; ", httpChannel.name, + this.targets); + + // Ignore requests from chrome or add-on code when we are monitoring + // content. + if (!httpChannel.loadInfo && + httpChannel.loadInfo.loadingDocument === null && + httpChannel.loadInfo.loadingPrincipal === Services.scriptSecurityManager.getSystemPrincipal()) { + return; + } + + let requestFrame = NetworkHelper.getTopFrameForRequest(httpChannel); + if (!requestFrame) { + return; + } + + // Ignore requests from parent frames that aren't registered. + if (!this.targets.has(requestFrame)) { + return; + } + + let headers = []; + + httpChannel.visitResponseHeaders((header, value) => { + header = header.toLowerCase(); + if (acceptableHeaders.indexOf(header) !== -1) { + headers.push({header: header, value: value}); + } + }); + + if (!headers.length) { + return; + } + + let { messageManager } = requestFrame; + messageManager.sendAsyncMessage("debug:server-logger", { + method: "examineHeaders", + headers: headers, + }); + + trace.log("ServerLoggerMonitor.onExamineResponse; headers ", + headers.length, ", ", headers); + }), +}; + +/** + * Executed automatically by the framework. + */ +function setupParentProcess(event) { + ServerLoggerMonitor.attach(event); +} + +// Monitor initialization. +ServerLoggerMonitor.initialize(); + +// Exports from this module +exports.setupParentProcess = setupParentProcess; diff --git a/toolkit/devtools/webconsole/server-logger.js b/toolkit/devtools/webconsole/server-logger.js new file mode 100644 index 000000000000..edf2def8b3f5 --- /dev/null +++ b/toolkit/devtools/webconsole/server-logger.js @@ -0,0 +1,531 @@ +/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */ +/* vim: set 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 {Cu, Ci} = require("chrome"); +const {Class} = require("sdk/core/heritage"); +const Services = require("Services"); + +const {DebuggerServer} = require("devtools/server/main"); +const DevToolsUtils = require("devtools/toolkit/DevToolsUtils"); + +Cu.importGlobalProperties(["atob"]); + +loader.lazyGetter(this, "NetworkHelper", () => require("devtools/toolkit/webconsole/network-helper")); + +// Helper tracer. Should be generic sharable by other modules (bug 1171927) +const trace = { + log: function(...args) { + } +} + +// Constants +const makeInfallible = DevToolsUtils.makeInfallible; +const acceptableHeaders = ["x-chromelogger-data"]; + +/** + * The listener is responsible for detecting server side logs + * within HTTP headers and sending them to the client. + * + * The logic is based on "http-on-examine-response" event that is + * sent when a response from the server is received. Consequently HTTP + * headers are parsed to find server side logs. + * + * A listeners for "http-on-examine-response" is registered when + * the listener starts and removed when destroy is executed. + */ +var ServerLoggingListener = Class({ + /** + * Initialization of the listener. The main step during the initialization + * process is registering a listener for "http-on-examine-response" event. + * + * @param {Object} win (nsIDOMWindow): + * filter network requests by the associated window object. + * If null (i.e. in the browser context) log everything + * @param {Object} owner + * The {@WebConsoleActor} instance + */ + initialize: function(win, owner) { + trace.log("ServerLoggingListener.initialize; ", owner.actorID, + ", child process: ", DebuggerServer.isInChildProcess); + + this.owner = owner; + this.window = win; + + this.onExamineResponse = this.onExamineResponse.bind(this); + this.onExamineHeaders = this.onExamineHeaders.bind(this); + this.onParentMessage = this.onParentMessage.bind(this); + + this.attach(); + }, + + /** + * The destroy is called by the parent WebConsoleActor actor. + */ + destroy: function() { + trace.log("ServerLoggingListener.destroy; ", this.owner.actorID, + ", child process: ", DebuggerServer.isInChildProcess); + + this.detach(); + }, + + /** + * The main responsibility of this method is registering a listener for + * "http-on-examine-response" events. + */ + attach: makeInfallible(function() { + trace.log("ServerLoggingListener.attach; child process: ", + DebuggerServer.isInChildProcess); + + // Setup the child <-> parent communication if this actor module + // is running in a child process. If e10s is disabled (this actor + // running in the same process as everything else) register observer + // listener just like in good old pre e10s days. + if (DebuggerServer.isInChildProcess) { + this.attachParentProcess(); + } else { + Services.obs.addObserver(this.onExamineResponse, + "http-on-examine-response", false); + } + }), + + /** + * Remove the "http-on-examine-response" listener. + */ + detach: makeInfallible(function() { + trace.log("ServerLoggingListener.detach; ", this.owner.actorID); + + if (DebuggerServer.isInChildProcess) { + this.detachParentProcess(); + } else { + Services.obs.removeObserver(this.onExamineResponse, + "http-on-examine-response", false); + } + }), + + // Parent Child Relationship + + attachParentProcess: function() { + trace.log("ServerLoggingListener.attachParentProcess;"); + + this.owner.conn.setupInParent({ + module: "devtools/toolkit/webconsole/server-logger-monitor", + setupParent: "setupParentProcess" + }); + + let mm = this.owner.conn.parentMessageManager; + let { addMessageListener, sendSyncMessage } = mm; + + // It isn't possible to register HTTP-* event observer inside + // a child process (in case of e10s), so listen for messages + // coming from the {@ServerLoggerMonitor} that lives inside + // the parent process. + addMessageListener("debug:server-logger", this.onParentMessage); + + // Attach to the {@ServerLoggerMonitor} object to subscribe events. + sendSyncMessage("debug:server-logger", { + method: "attachChild" + }); + }, + + detachParentProcess: makeInfallible(function() { + trace.log("ServerLoggingListener.detachParentProcess;"); + + let mm = this.owner.conn.parentMessageManager; + let { removeMessageListener, sendSyncMessage } = mm; + + sendSyncMessage("debug:server-logger", { + method: "detachChild", + }); + + removeMessageListener("debug:server-logger", this.onParentMessage); + }), + + onParentMessage: makeInfallible(function(msg) { + if (!msg.data) { + return; + } + + let method = msg.data.method; + trace.log("ServerLogger.onParentMessage; ", method, msg.data); + + switch (method) { + case "examineHeaders": + return this.onExamineHeaders(msg); + default: + trace.log("Unknown method name: ", method); + } + }), + + // HTTP Observer + + onExamineHeaders: function(event) { + let headers = event.data.headers; + + trace.log("ServerLoggingListener.onExamineHeaders;", headers); + + let parsedMessages = []; + + for (let item of headers) { + let header = item.header; + let value = item.value; + + let messages = this.parse(header, value); + if (messages) { + parsedMessages.push(...messages); + } + } + + if (!parsedMessages.length) { + return; + } + + for (let message of parsedMessages) { + this.sendMessage(message); + } + }, + + onExamineResponse: makeInfallible(function(subject) { + let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel); + + trace.log("ServerLoggingListener.onExamineResponse; ", httpChannel.name, + ", ", this.owner.actorID, httpChannel); + + if (!this._matchRequest(httpChannel)) { + trace.log("ServerLoggerMonitor.onExamineResponse; No matching request!"); + return; + } + + let headers = []; + + httpChannel.visitResponseHeaders((header, value) => { + header = header.toLowerCase(); + if (acceptableHeaders.indexOf(header) !== -1) { + headers.push({header: header, value: value}); + } + }); + + this.onExamineHeaders({ + data: { + headers: headers, + } + }); + }), + + /** + * Check if a given network request should be logged by this network monitor + * instance based on the current filters. + * + * @private + * @param nsIHttpChannel aChannel + * Request to check. + * @return boolean + * True if the network request should be logged, false otherwise. + */ + _matchRequest: function(aChannel) { + trace.log("_matchRequest ", this.window, ", ", this.topFrame); + + // Log everything if the window is null (it's null in the browser context) + if (!this.window) { + return true; + } + + // Ignore requests from chrome or add-on code when we are monitoring + // content. + if (!aChannel.loadInfo && + aChannel.loadInfo.loadingDocument === null && + aChannel.loadInfo.loadingPrincipal === Services.scriptSecurityManager.getSystemPrincipal()) { + return false; + } + + // Since frames support, this.window may not be the top level content + // frame, so that we can't only compare with win.top. + let win = NetworkHelper.getWindowForRequest(aChannel); + while(win) { + if (win == this.window) { + return true; + } + if (win.parent == win) { + break; + } + win = win.parent; + } + + return false; + }, + + // Server Logs + + /** + * Search through HTTP headers to catch all server side logs. + * Learn more about the data structure: + * https://craig.is/writing/chrome-logger/techspecs + */ + parse: function(header, value) { + let data; + + try { + let result = decodeURIComponent(escape(atob(value))); + data = JSON.parse(result); + } catch (err) { + Cu.reportError("Failed to parse HTTP log data! " + err); + return; + } + + let parsedMessage = []; + let columnMap = this.getColumnMap(data); + + trace.log("ServerLoggingListener.parse; ColumnMap", columnMap); + trace.log("ServerLoggingListener.parse; data", data); + + let lastLocation; + + for (let row of data.rows) { + let backtrace = row[columnMap.get("backtrace")]; + let rawLogs = row[columnMap.get("log")]; + let type = row[columnMap.get("type")] || "log"; + + // Old version of the protocol includes a label. + // If this is the old version do some converting. + if (data.columns.indexOf("label") != -1) { + let label = row[columnMap.get("label")]; + let showLabel = label && typeof label === "string"; + + rawLogs = [rawLogs]; + + if (showLabel) { + rawLogs.unshift(label); + } + } + + // If multiple logs come from the same line only the first log + // has info about the backtrace. So, remember the last valid + // location and use it for those that not set. + let location = this.parseBacktrace(backtrace); + if (location) { + lastLocation = location; + } else { + location = lastLocation; + } + + parsedMessage.push({ + logs: rawLogs, + location: location, + type: type + }); + } + + return parsedMessage; + }, + + parseBacktrace: function(backtrace) { + if (!backtrace) { + return null; + } + + let result = backtrace.match(/\s*(\d+)$/); + if (!result || result.length < 2) { + return backtrace; + } + + return { + url: backtrace.slice(0, -result[0].length), + line: result[1] + }; + }, + + getColumnMap: function(data) { + let columnMap = new Map(); + let columnName; + + for (let key in data.columns) { + columnName = data.columns[key]; + columnMap.set(columnName, key); + } + + return columnMap; + }, + + sendMessage: function(msg) { + trace.log("ServerLoggingListener.sendMessage; message", msg); + + let formatted = format(msg); + trace.log("ServerLoggingListener.sendMessage; formatted", formatted); + + let win = this.window; + let innerID = win ? getInnerId(win) : null; + let location = msg.location; + + let message = { + category: "server", + innerID: innerID, + level: msg.type, + filename: location ? location.url : null, + lineNumber: location ? location.line : null, + columnNumber: 0, + private: false, + timeStamp: Date.now(), + arguments: formatted ? formatted.logs : null, + styles: formatted ? formatted.styles : null, + }; + + // Make sure to set the group name. + if (msg.type == "group" && formatted && formatted.logs) { + message.groupName = formatted ? formatted.logs[0] : null; + } + + // A message for console.table() method (passed in as the first + // argument) isn't supported. But, it's passed in by some server + // side libraries that implement console.* API - let's just remove it. + let args = message.arguments; + if (msg.type == "table" && args) { + if (typeof args[0] == "string") { + args.shift(); + } + } + + trace.log("ServerLoggingListener.sendMessage; raw: ", + msg.logs.join(", "), message); + + this.owner.onServerLogCall(message); + }, +}); + +// Helpers + +/** + * Parse printf-like specifiers ("%f", "%d", ...) and + * format the logs according to them. + */ +function format(msg) { + if (!msg.logs || !msg.logs[0]) { + return; + } + + // Initialize the styles array (used for the "%c" specifier). + msg.styles = []; + + // Remove and get the first log (in which the specifiers are). + let firstString = msg.logs.shift(); + // Contains all the strings split by the specifiers + // (i.e. "a %f b" => ["a ", " b"]). + let splitLog = []; + // All the specifiers present in the first string. + let specifiers = []; + let specifierIndex = -1; + let splitLogRegExp = /(.*?)(%[oOcsdif]|$)/g; + let splitLogRegExpRes; + + // Get the strings before the specifiers (or the last chunk before the end + // of the string). + while ((splitLogRegExpRes = splitLogRegExp.exec(firstString)) !== null) { + let [_, log, specifier] = splitLogRegExpRes; + + // We can add an empty string if there is a specifier after (which + // means we haven't reached the end of the string). This empty string is + // necessary when rebuilding the logs after the formatting (we should ensure + // to alternate a log + a specifier to replace to make this loop work). + // + // Example: "%ctest" => first iteration: log = "", specifier = "%c". + // => second iteration: log = "test", specifier = "". + if (log || specifier) { + splitLog.push(log); + } + + // Break now if there is no specifier anymore + // (means that we have reached the end of the string). + if (!specifier) { + break; + } + + specifiers.push(specifier); + } + + // This array represents the string of the log, in which the specifiers + // are replaced. It alternates strings and objects (%o;%O). + let rebuiltLogArray = []; + let concatString = ""; + let pushConcatString = () => { + if (concatString) { + rebuiltLogArray.push(concatString); + } + concatString = ""; + }; + + // Merge the split first string and the values associated to the specifiers. + splitLog.forEach((string, index) => { + // Concatenate the string in any case. + concatString += string; + if (specifiers.length === 0) { + return; + } + + let argument = msg.logs.shift(); + switch (specifiers[index]) { + case "%i": + case "%d": + // Parse into integer. + argument |= 0; + concatString += argument; + break; + case "%f": + // Parse into float. + argument =+ argument; + concatString += argument; + break; + case "%o": + case "%O": + // Push the concatenated string and reinitialize concatString. + pushConcatString(); + // Push the object. + rebuiltLogArray.push(argument); + break; + case "%s": + concatString += argument; + break; + case "%c": + pushConcatString(); + for (let j = msg.styles.length; j < rebuiltLogArray.length; j++) { + msg.styles.push(null); + } + msg.styles.push(argument); + break; + default: + // Should never happen. + return; + } + }); + + if (concatString) { + rebuiltLogArray.push(concatString); + } + + // Append the rest of arguments that don't have corresponding + // specifiers to the message logs. + msg.logs = rebuiltLogArray.concat(msg.logs); + + // Remove special ___class_name property that isn't supported + // by the current implementation. This property represents object class + // allowing custom rendering in the console panel. + for (let log of msg.logs) { + if (typeof log == "object") { + delete log.___class_name; + } + } + + return msg; +} + +// These helper are cloned from SDK to avoid loading to +// much SDK modules just because of two functions. +function getInnerId(win) { + return win.QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; +}; + +// Exports from this module +exports.ServerLoggingListener = ServerLoggingListener;