Bug 568634 - Update networking log entries with subsequent http transactions, r=sdwilsh, a=blocking2.0, beta5

This commit is contained in:
Julian Viereck 2010-08-13 12:13:32 +02:00
parent 49a6a5e90e
commit 701e4402ad
6 changed files with 657 additions and 28 deletions

View File

@ -63,6 +63,12 @@ XPCOMUtils.defineLazyServiceGetter(this, "sss",
"@mozilla.org/content/style-sheet-service;1",
"nsIStyleSheetService");
XPCOMUtils.defineLazyGetter(this, "NetUtil", function () {
var obj = {};
Cu.import("resource://gre/modules/NetUtil.jsm", obj);
return obj.NetUtil;
});
XPCOMUtils.defineLazyGetter(this, "PropertyPanel", function () {
var obj = {};
try {
@ -107,6 +113,143 @@ const ERRORS = { LOG_MESSAGE_MISSING_ARGS:
LOG_OUTPUT_FAILED: "Log Failure: Could not append messageNode to outputNode",
};
/**
* Implements the nsIStreamListener and nsIRequestObserver interface. Used
* within the HS_httpObserverFactory function to get the response body of
* requests.
*
* The code is mostly based on code listings from:
*
* http://www.softwareishard.com/blog/firebug/
* nsitraceablechannel-intercept-http-traffic/
*
* @param object aHttpActivity
* HttpActivity object associated with this request (see
* HS_httpObserverFactory). As the response is done, the response header,
* body and status is stored on aHttpActivity.
*/
function ResponseListener(aHttpActivity) {
this.receivedData = "";
this.httpActivity = aHttpActivity;
}
ResponseListener.prototype =
{
/**
* The original listener for this request.
*/
originalListener: null,
/**
* The HttpActivity object associated with this response.
*/
httpActivity: null,
/**
* Stores the received data as a string.
*/
receivedData: null,
/**
* Sets the httpActivity object's response header if it isn't set already.
*
* @param nsIRequest aRequest
*/
setResponseHeader: function RL_setResponseHeader(aRequest)
{
let httpActivity = this.httpActivity;
// Check if the header isn't set yet.
if (!httpActivity.response.header) {
httpActivity.response.header = {};
if (aRequest instanceof Ci.nsIHttpChannel) {
aRequest.visitResponseHeaders({
visitHeader: function(aName, aValue) {
httpActivity.response.header[aName] = aValue;
}
});
}
}
},
/**
* See documention at
* https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsIStreamListener
*
* Grabs a copy of the original data and passes it on to the original listener.
*
* @param nsIRequest aRequest
* @param nsISupports aContext
* @param nsIInputStream aInputStream
* @param unsigned long aOffset
* @param unsigned long aCount
*/
onDataAvailable: function RL_onDataAvailable(aRequest, aContext, aInputStream,
aOffset, aCount)
{
this.setResponseHeader(aRequest);
let StorageStream = Components.Constructor("@mozilla.org/storagestream;1",
"nsIStorageStream",
"init");
let BinaryOutputStream = Components.Constructor("@mozilla.org/binaryoutputstream;1",
"nsIBinaryOutputStream",
"setOutputStream");
storageStream = new StorageStream(8192, aCount, null);
binaryOutputStream = new BinaryOutputStream(storageStream.getOutputStream(0));
let data = NetUtil.readInputStreamToString(aInputStream, aCount);
this.receivedData += data;
binaryOutputStream.writeBytes(data, aCount);
this.originalListener.onDataAvailable(aRequest, aContext,
storageStream.newInputStream(0), aOffset, aCount);
},
/**
* See documentation at
* https://developer.mozilla.org/En/NsIRequestObserver
*
* @param nsIRequest aRequest
* @param nsISupports aContext
*/
onStartRequest: function RL_onStartRequest(aRequest, aContext)
{
this.originalListener.onStartRequest(aRequest, aContext);
},
/**
* See documentation at
* https://developer.mozilla.org/En/NsIRequestObserver
*
* If aRequest is an nsIHttpChannel then the response header is stored on the
* httpActivity object. Also, the response body is set on the httpActivity
* object and the HUDService.lastFinishedRequestCallback is called if there
* is one.
*
* @param nsIRequest aRequest
* @param nsISupports aContext
* @param nsresult aStatusCode
*/
onStopRequest: function RL_onStopRequest(aRequest, aContext, aStatusCode)
{
this.originalListener.onStopRequest(aRequest, aContext, aStatusCode);
this.setResponseHeader(aRequest);
this.httpActivity.response.body = this.receivedData;
if (HUDService.lastFinishedRequestCallback) {
HUDService.lastFinishedRequestCallback(this.httpActivity);
}
this.httpActivity = null;
},
QueryInterface: XPCOMUtils.generateQI([
Ci.nsIStreamListener,
Ci.nsISupports
])
}
/**
* Helper object for networking stuff.
*
@ -169,6 +312,109 @@ const ERRORS = { LOG_MESSAGE_MISSING_ARGS:
*/
var NetworkHelper =
{
/**
* Converts aText with a given aCharset to unicode.
*
* @param string aText
* Text to convert.
* @param string aCharset
* Charset to convert the text to.
* @returns string
* Converted text.
*/
convertToUnicode: function NH_convertToUnicode(aText, aCharset)
{
let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
createInstance(Ci.nsIScriptableUnicodeConverter);
conv.charset = aCharset || "UTF-8";
return conv.ConvertToUnicode(aText);
},
/**
* Reads all available bytes from aStream and converts them to aCharset.
*
* @param nsIInputStream aStream
* @param string aCharset
* @returns string
* UTF-16 encoded string based on the content of aStream and aCharset.
*/
readAndConvertFromStream: function NH_readAndConvertFromStream(aStream, aCharset)
{
let text = null;
try {
text = NetUtil.readInputStreamToString(aStream, aStream.available())
return this.convertToUnicode(text, aCharset);
}
catch (err) {
return text;
}
},
/**
* Reads the posted text from aRequest.
*
* @param nsIHttpChannel aRequest
* @param nsIDOMNode aBrowser
* @returns string or null
* Returns the posted string if it was possible to read from aRequest
* otherwise null.
*/
readPostTextFromRequest: function NH_readPostTextFromRequest(aRequest, aBrowser)
{
if (aRequest instanceof Ci.nsIUploadChannel) {
let iStream = aRequest.uploadStream;
let isSeekableStream = false;
if (iStream instanceof Ci.nsISeekableStream) {
isSeekableStream = true;
}
let prevOffset;
if (isSeekableStream) {
prevOffset = iStream.tell();
iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
}
// Read data from the stream.
let charset = aBrowser.contentWindow.document.characterSet;
let text = this.readAndConvertFromStream(iStream, charset);
// Seek locks the file, so seek to the beginning only if necko hasn't
// read it yet, since necko doesn't seek to 0 before reading (at lest
// not till 459384 is fixed).
if (isSeekableStream && prevOffset == 0) {
iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
}
return text;
}
return null;
},
/**
* Reads the posted text from the page's cache.
*
* @param nsIDOMNode aBrowser
* @returns string or null
* Returns the posted string if it was possible to read from aBrowser
* otherwise null.
*/
readPostTextFromPage: function NH_readPostTextFromPage(aBrowser)
{
let webNav = aBrowser.webNavigation;
if (webNav instanceof Ci.nsIWebPageDescriptor) {
let descriptor = webNav.currentDescriptor;
if (descriptor instanceof Ci.nsISHEntry && descriptor.postData &&
descriptor instanceof Ci.nsISeekableStream) {
descriptor.seek(NS_SEEK_SET, 0);
let charset = browser.contentWindow.document.characterSet;
return this.readAndConvertFromStream(descriptor, charset);
}
}
return null;
},
/**
* Gets the nsIDOMWindow that is associated with aRequest.
*
@ -1109,6 +1355,17 @@ HUD_SERVICE.prototype =
return win;
},
/**
* Requests that haven't finished yet.
*/
openRequests: {},
/**
* Assign a function to this property to listen for finished httpRequests.
* Used by unit tests.
*/
lastFinishedRequestCallback: null,
/**
* Begin observing HTTP traffic that we care about,
* namely traffic that originates inside any context that a Heads Up Display
@ -1123,13 +1380,15 @@ HUD_SERVICE.prototype =
function (aChannel, aActivityType, aActivitySubtype,
aTimestamp, aExtraSizeData, aExtraStringData)
{
var loadGroup;
if (aActivityType ==
activityDistributor.ACTIVITY_TYPE_HTTP_TRANSACTION) {
activityDistributor.ACTIVITY_TYPE_HTTP_TRANSACTION ||
aActivityType ==
activityDistributor.ACTIVITY_TYPE_SOCKET_TRANSPORT) {
aChannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
var transCodes = this.httpTransactionCodes;
let transCodes = this.httpTransactionCodes;
let hudId;
if (aActivitySubtype ==
activityDistributor.ACTIVITY_SUBTYPE_REQUEST_HEADER ) {
@ -1140,29 +1399,158 @@ HUD_SERVICE.prototype =
}
// Try to get the hudId that is associated to the window.
let hudId = self.getHudIdByWindow(win);
hudId = self.getHudIdByWindow(win);
if (!hudId) {
return;
}
var httpActivity = {
// The httpActivity object will hold all information concerning
// this request and later response.
let httpActivity = {
id: self.sequenceId(),
hudId: hudId,
url: aChannel.URI.spec,
method: aChannel.requestMethod,
channel: aChannel,
type: aActivityType,
subType: aActivitySubtype,
timestamp: aTimestamp,
extraSizeData: aExtraSizeData,
extraStringData: aExtraStringData,
stage: transCodes[aActivitySubtype],
hudId: hudId
request: {
header: { }
},
response: {
header: null
},
timing: {
"REQUEST_HEADER": aTimestamp
}
};
// create a unique ID to track this transaction and be able to
// update the logged node with subsequent http transactions
httpActivity.httpId = self.sequenceId();
// Add a new output entry.
let loggedNode =
self.logActivity("network", aChannel.URI, httpActivity);
self.httpTransactions[aChannel] =
new Number(httpActivity.httpId);
// In some cases loggedNode can be undefined (e.g. if an image was
// requested). Don't continue in such a case.
if (!loggedNode) {
return;
}
// Add listener for the response body.
let newListener = new ResponseListener(httpActivity);
aChannel.QueryInterface(Ci.nsITraceableChannel);
newListener.originalListener = aChannel.setNewListener(newListener);
httpActivity.response.listener = newListener;
// Copy the request header data.
aChannel.visitRequestHeaders({
visitHeader: function(aName, aValue) {
httpActivity.request.header[aName] = aValue;
}
});
// Store the loggedNode and the httpActivity object for later reuse.
httpActivity.messageObject = loggedNode;
self.openRequests[httpActivity.id] = httpActivity;
}
else {
// Iterate over all currently ongoing requests. If aChannel can't
// be found within them, then exit this function.
let httpActivity = null;
for each (var item in self.openRequests) {
if (item.channel !== aChannel) {
continue;
}
httpActivity = item;
break;
}
if (!httpActivity) {
return;
}
let msgObject;
let data, textNode;
// Store the time information for this activity subtype.
httpActivity.timing[transCodes[aActivitySubtype]] = aTimestamp;
switch (aActivitySubtype) {
case activityDistributor.ACTIVITY_SUBTYPE_REQUEST_BODY_SENT:
let gBrowser = HUDService.currentContext().gBrowser;
let sentBody = NetworkHelper.readPostTextFromRequest(
aChannel, gBrowser);
if (!sentBody) {
// If the request URL is the same as the current page url, then
// we can try to get the posted text from the page directly.
// This is necessary as otherwise the
// NetworkHelper.readPostTextFromPage
// function is called for image requests as well but these
// are not web pages and as such don't store the posted text
// in the cache of the webpage.
if (httpActivity.url == gBrowser.contentWindow.location.href) {
sentBody = NetworkHelper.readPostTextFromPage(gBrowser);
}
if (!sentBody) {
sentBody = "";
}
}
httpActivity.request.body = sentBody;
break;
case activityDistributor.ACTIVITY_SUBTYPE_RESPONSE_HEADER:
msgObject = httpActivity.messageObject;
// aExtraStringData contains the response header. The first line
// contains the response status (e.g. HTTP/1.1 200 OK).
//
// Note: The response header is not saved here. Calling the
// aChannel.visitResponseHeaders at this point sometimes
// causes an NS_ERROR_NOT_AVAILABLE exception. Therefore,
// the response header and response body is stored on the
// httpActivity object within the RL_onStopRequest function.
httpActivity.response.status =
aExtraStringData.split(/\r\n|\n|\r/)[0];
// Remove the textNode from the messageNode and add a new one
// that contains the respond http status.
textNode = msgObject.messageNode.firstChild;
textNode.parentNode.removeChild(textNode);
data = [ httpActivity.url,
httpActivity.response.status ];
msgObject.messageNode.appendChild(
msgObject.textFactory(
msgObject.prefix +
self.getFormatStr("networkUrlWithStatus", data)));
break;
case activityDistributor.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE:
msgObject = httpActivity.messageObject;
let timing = httpActivity.timing;
let requestDuration =
Math.round((timing.RESPONSE_COMPLETE -
timing.REQUEST_HEADER) / 1000);
// Remove the textNode from the messageNode and add a new one
// that contains the request duration.
textNode = msgObject.messageNode.firstChild;
textNode.parentNode.removeChild(textNode);
data = [ httpActivity.url,
httpActivity.response.status,
requestDuration ];
msgObject.messageNode.appendChild(
msgObject.textFactory(
msgObject.prefix +
self.getFormatStr("networkUrlWithStatusAndDuration", data)));
delete self.openRequests[item.id];
break;
}
}
}
},
@ -1174,16 +1562,19 @@ HUD_SERVICE.prototype =
0x5004: "RESPONSE_HEADER",
0x5005: "RESPONSE_COMPLETE",
0x5006: "TRANSACTION_CLOSE",
0x804b0003: "STATUS_RESOLVING",
0x804b0007: "STATUS_CONNECTING_TO",
0x804b0004: "STATUS_CONNECTED_TO",
0x804b0005: "STATUS_SENDING_TO",
0x804b000a: "STATUS_WAITING_FOR",
0x804b0006: "STATUS_RECEIVING_FROM"
}
};
activityDistributor.addObserver(httpObserver);
},
// keep tracked of trasactions where the request header was logged
// update logged transactions thereafter.
httpTransactions: {},
/**
* Logs network activity
*
@ -1211,13 +1602,20 @@ HUD_SERVICE.prototype =
};
var msgType = this.getStr("typeNetwork");
var msg = msgType + " " +
aActivityObject.channel.requestMethod +
aActivityObject.method +
" " +
aURI.spec;
aActivityObject.url;
message.message = msg;
var messageObject =
this.messageFactory(message, aType, outputNode, aActivityObject);
this.messageFactory(message, aType, outputNode, aActivityObject);
var timestampedMessage = messageObject.timestampedMessage;
var urlIdx = timestampedMessage.indexOf(aActivityObject.url);
messageObject.prefix = timestampedMessage.substring(0, urlIdx);
this.logMessage(messageObject.messageObject, outputNode, messageObject.messageNode);
return messageObject;
}
catch (ex) {
Cu.reportError(ex);
@ -1307,7 +1705,7 @@ HUD_SERVICE.prototype =
var displayNode, outputNode, hudId;
if (aType == "network") {
var result = this.logNetActivity(aType, aURI, aActivityObject);
return this.logNetActivity(aType, aURI, aActivityObject);
}
else if (aType == "console-listener") {
this.logConsoleActivity(aURI, aActivityObject);
@ -3145,9 +3543,9 @@ LogMessage.prototype = {
this.messageNode = this.xulElementFactory("label");
var ts = ConsoleUtils.timestamp();
var timestampedMessage = ConsoleUtils.timestampString(ts) + ": " +
this.timestampedMessage = ConsoleUtils.timestampString(ts) + ": " +
this.message.message;
var messageTxtNode = this.textFactory(timestampedMessage);
var messageTxtNode = this.textFactory(this.timestampedMessage);
this.messageNode.appendChild(messageTxtNode);

View File

@ -45,11 +45,13 @@ include $(topsrcdir)/config/rules.mk
_BROWSER_TEST_FILES = \
browser_HUDServiceTestsAll.js \
browser_webconsole_netlogging.js \
$(NULL)
_BROWSER_TEST_PAGES = \
test-console.html \
test-network.html \
test-network-request.html \
test-mutation.html \
testscript.js \
test-filter.html \

View File

@ -0,0 +1,170 @@
/* vim:set ts=2 sw=2 sts=2 et: */
/* ***** BEGIN LICENSE BLOCK *****
* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*
* Contributor(s):
* Julian Viereck <jviereck@mozilla.com>
*
* ***** END LICENSE BLOCK ***** */
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/HUDService.jsm");
const TEST_NETWORK_REQUEST_URI = "http://example.com/browser/toolkit/components/console/hudservice/tests/browser/test-network-request.html";
const TEST_DATA_JSON_CONTENT =
'{ id: "test JSON data", myArray: [ "foo", "bar", "baz", "biff" ] }';
var hud;
var hudId;
function testOpenWebConsole()
{
HUDService.activateHUDForContext(gBrowser.selectedTab);
is(HUDService.displaysIndex().length, 1, "WebConsole was opened");
hudId = HUDService.displaysIndex()[0];
hud = HUDService.hudWeakReferences[hudId].get();
testNetworkLogging();
}
function finishTest() {
hud = null;
hudId = null;
let tab = gBrowser.selectedTab;
HUDService.deactivateHUDForContext(tab);
executeSoon(function() {
gBrowser.removeCurrentTab();
finish();
});
}
function testNetworkLogging()
{
var lastFinishedRequest = null;
HUDService.lastFinishedRequestCallback =
function requestDoneCallback(aHttpRequest)
{
lastFinishedRequest = aHttpRequest;
}
let browser = gBrowser.selectedBrowser;
let loggingGen;
// This generator function is used to step through the individual, async tests.
function loggingGeneratorFunc() {
browser.addEventListener("load", function onLoad () {
browser.removeEventListener("load", onLoad, true);
loggingGen.next();
}, true);
content.location = TEST_NETWORK_REQUEST_URI;
yield;
// Check if page load was logged correctly.
let httpActivity = lastFinishedRequest;
isnot(httpActivity, null, "Page load was logged");
is(httpActivity.url, TEST_NETWORK_REQUEST_URI,
"Logged network entry is page load");
is(httpActivity.method, "GET", "Method is correct");
is(httpActivity.request.body, undefined, "No request body sent");
// TODO: Figure out why the following test is failing on linux (bug 588533).
//
// If not linux, then run the test. On Linux it always fails.
if (navigator.platform.indexOf("Linux") != 0) {
ok(httpActivity.response.body.indexOf("<!DOCTYPE HTML>") == 0,
"Response body's beginning is okay");
}
// Start xhr-get test.
browser.contentWindow.wrappedJSObject.testXhrGet(loggingGen);
yield;
// Use executeSoon here as the xhr callback calls loggingGen.next() before
// the network observer detected that the request is completly done and the
// HUDService.lastFinishedRequest is set. executeSoon solves that problem.
executeSoon(function() {
// Check if xhr-get test was successful.
httpActivity = lastFinishedRequest;
isnot(httpActivity, null, "testXhrGet() was logged");
is(httpActivity.method, "GET", "Method is correct");
is(httpActivity.request.body, undefined, "No request body was sent");
is(httpActivity.response.body, TEST_DATA_JSON_CONTENT,
"Response is correct");
lastFinishedRequest = null;
loggingGen.next();
});
yield;
// Start xhr-post test.
browser.contentWindow.wrappedJSObject.testXhrPost(loggingGen);
yield;
executeSoon(function() {
// Check if xhr-post test was successful.
httpActivity = lastFinishedRequest;
isnot(httpActivity, null, "testXhrPost() was logged");
is(httpActivity.method, "POST", "Method is correct");
is(httpActivity.request.body, "Hello world!",
"Request body was logged");
is(httpActivity.response.body, TEST_DATA_JSON_CONTENT,
"Response is correct");
lastFinishedRequest = null
loggingGen.next();
});
yield;
// Start submit-form test. As the form is submitted, the page is loaded
// again. Bind to the DOMContentLoaded event to catch when this is done.
browser.addEventListener("load", function onLoad () {
browser.removeEventListener("load", onLoad, true);
loggingGen.next();
}, true);
browser.contentWindow.wrappedJSObject.testSubmitForm();
yield;
// Check if submitting the form was logged successful.
httpActivity = lastFinishedRequest;
isnot(httpActivity, null, "testSubmitForm() was logged");
is(httpActivity.method, "POST", "Method is correct");
isnot(httpActivity.request.body.indexOf(
"Content-Type: application/x-www-form-urlencoded"), -1,
"Content-Type is correct");
isnot(httpActivity.request.body.indexOf(
"Content-Length: 20"), -1, "Content-length is correct");
isnot(httpActivity.request.body.indexOf(
"name=foo+bar&age=144"), -1, "Form data is correct");
ok(httpActivity.response.body.indexOf("<!DOCTYPE HTML>") == 0,
"Response body's beginning is okay");
lastFinishedRequest = null
// All tests are done. Shutdown.
browser = null;
lastFinishedRequest = null;
HUDService.lastFinishedRequestCallback = null;
finishTest();
}
loggingGen = loggingGeneratorFunc();
loggingGen.next();
}
function test()
{
waitForExplicitFinish();
gBrowser.selectedTab = gBrowser.addTab();
gBrowser.selectedBrowser.addEventListener("load", function() {
gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true);
waitForFocus(testOpenWebConsole, content);
}, true);
content.location = "data:text/html,WebConsole network logging tests";
}

View File

@ -1 +1 @@
{ id: "test JSON data", myArray: [ "foo", "bar", "baz", "biff" ] }
{ id: "test JSON data", myArray: [ "foo", "bar", "baz", "biff" ] }

View File

@ -0,0 +1,38 @@
<!DOCTYPE HTML>
<html dir="ltr" xml:lang="en-US" lang="en-US"><head>
<title>Console HTTP test page</title>
<script type="text/javascript">
function makeXhr(aMethod, aUrl, aRequestBody, aTestGenerator) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.open(aMethod, aUrl, true);
xmlhttp.onreadystatechange = function (aEvt) {
if (xmlhttp.readyState == 4) {
aTestGenerator.next();
}
};
xmlhttp.send(aRequestBody);
}
function testXhrGet(aTestGenerator) {
makeXhr('get', 'test-data.json', null, aTestGenerator);
}
function testXhrPost(aTestGenerator) {
makeXhr('post', 'test-data.json', "Hello world!", aTestGenerator);
}
function testSubmitForm() {
document.getElementsByTagName("form")[0].submit();
}
</script>
</head>
<body>
<h1>Heads Up Display HTTP Logging Testpage</h1>
<h2>This page is used to test the HTTP logging.</h2>
<form action="http://example.com/browser/toolkit/components/console/hudservice/tests/browser/test-network-request.html" method="post">
<input name="name" type="text" value="foo bar"><br>
<input name="age" type="text" value="144"><br>
</form>
</body>
</html>

View File

@ -63,3 +63,24 @@ jsPropertyTitle=Object Inspector
jsPropertyInspectTitle=Inspect: %S
copyCmd.label=Copy
copyCmd.accesskey=C
# LOCALIZATION NOTE (networkUrlWithStatus):
#
# When the HTTP request is started only the URL of the request is printed to the
# WebConsole. As the response status of the HTTP request arrives, the URL string
# is replaced by this string (the response status can look like `HTTP/1.1 200 OK`).
# The bracket is not closed to mark that this request is not done by now. As the
# request is finished (the HTTP connection is closed) this string is replaced
# by `networkUrlWithStatusAndDuration` which has a closing the braket.
#
# %1$S = URL of network request
# %2$S = response status code from the server (e.g. `HTTP/1.1 200 OK`)
networkUrlWithStatus=%1$S [%2$S
# LOCALIZATION NOTE (networkUrlWithStatusAndDuration):
#
# When the HTTP request is finished (the HTTP connection is closed) this string
# replaces the former `networkUrlWithStatus` string in the WebConsole.
#
# %1$S = URL of network request
# %2$S = response status code from the server (e.g. `HTTP/1.1 200 OK`)
# %3$S = duration for the complete network request in milliseconds
networkUrlWithStatusAndDuration=%1$S [%2$S %3$Sms]