inspector: add undici http tracking support
Add basic undici http tracking support via inspector protocol. This allows tracking `fetch` calls with an inspector. PR-URL: https://github.com/nodejs/node/pull/56488 Refs: https://github.com/nodejs/node/issues/53946 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com> Reviewed-By: Ethan Arrowood <ethan@arrowood.dev> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
parent
7b472fdfe7
commit
4f45acef19
@ -8,6 +8,28 @@ const {
|
||||
const { now } = require('internal/perf/utils');
|
||||
const kInspectorRequestId = Symbol('kInspectorRequestId');
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-ResourceType
|
||||
const kResourceType = {
|
||||
Document: 'Document',
|
||||
Stylesheet: 'Stylesheet',
|
||||
Image: 'Image',
|
||||
Media: 'Media',
|
||||
Font: 'Font',
|
||||
Script: 'Script',
|
||||
TextTrack: 'TextTrack',
|
||||
XHR: 'XHR',
|
||||
Fetch: 'Fetch',
|
||||
Prefetch: 'Prefetch',
|
||||
EventSource: 'EventSource',
|
||||
WebSocket: 'WebSocket',
|
||||
Manifest: 'Manifest',
|
||||
SignedExchange: 'SignedExchange',
|
||||
Ping: 'Ping',
|
||||
CSPViolationReport: 'CSPViolationReport',
|
||||
Preflight: 'Preflight',
|
||||
Other: 'Other',
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a monotonically increasing time in seconds since an arbitrary point in the past.
|
||||
* @returns {number}
|
||||
@ -26,6 +48,7 @@ function getNextRequestId() {
|
||||
|
||||
module.exports = {
|
||||
kInspectorRequestId,
|
||||
kResourceType,
|
||||
getMonotonicTime,
|
||||
getNextRequestId,
|
||||
};
|
||||
|
@ -10,13 +10,13 @@ const {
|
||||
|
||||
const {
|
||||
kInspectorRequestId,
|
||||
kResourceType,
|
||||
getMonotonicTime,
|
||||
getNextRequestId,
|
||||
} = require('internal/inspector/network');
|
||||
const dc = require('diagnostics_channel');
|
||||
const { Network } = require('inspector');
|
||||
|
||||
const kResourceType = 'Other';
|
||||
const kRequestUrl = Symbol('kRequestUrl');
|
||||
|
||||
// Convert a Headers object (Map<string, number | string | string[]>) to a plain object (Map<string, string>)
|
||||
@ -79,7 +79,7 @@ function onClientRequestError({ request, error }) {
|
||||
Network.loadingFailed({
|
||||
requestId: request[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
type: kResourceType,
|
||||
type: kResourceType.Other,
|
||||
errorText: error.message,
|
||||
});
|
||||
}
|
||||
@ -96,7 +96,7 @@ function onClientResponseFinish({ request, response }) {
|
||||
Network.responseReceived({
|
||||
requestId: request[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
type: kResourceType,
|
||||
type: kResourceType.Other,
|
||||
response: {
|
||||
url: request[kRequestUrl],
|
||||
status: response.statusCode,
|
||||
|
141
lib/internal/inspector/network_undici.js
Normal file
141
lib/internal/inspector/network_undici.js
Normal file
@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
DateNow,
|
||||
} = primordials;
|
||||
|
||||
const {
|
||||
kInspectorRequestId,
|
||||
kResourceType,
|
||||
getMonotonicTime,
|
||||
getNextRequestId,
|
||||
} = require('internal/inspector/network');
|
||||
const dc = require('diagnostics_channel');
|
||||
const { Network } = require('inspector');
|
||||
|
||||
// Convert an undici request headers array to a plain object (Map<string, string>)
|
||||
function requestHeadersArrayToDictionary(headers) {
|
||||
const dict = {};
|
||||
for (let idx = 0; idx < headers.length; idx += 2) {
|
||||
const key = `${headers[idx]}`;
|
||||
const value = `${headers[idx + 1]}`;
|
||||
dict[key] = value;
|
||||
}
|
||||
return dict;
|
||||
};
|
||||
|
||||
// Convert an undici response headers array to a plain object (Map<string, string>)
|
||||
function responseHeadersArrayToDictionary(headers) {
|
||||
const dict = {};
|
||||
for (let idx = 0; idx < headers.length; idx += 2) {
|
||||
const key = `${headers[idx]}`;
|
||||
const value = `${headers[idx + 1]}`;
|
||||
const prevValue = dict[key];
|
||||
|
||||
if (typeof prevValue === 'string') {
|
||||
// ChromeDevTools frontend treats 'set-cookie' as a special case
|
||||
// https://github.com/ChromeDevTools/devtools-frontend/blob/4275917f84266ef40613db3c1784a25f902ea74e/front_end/core/sdk/NetworkRequest.ts#L1368
|
||||
if (key.toLowerCase() === 'set-cookie') dict[key] = `${prevValue}\n${value}`;
|
||||
else dict[key] = `${prevValue}, ${value}`;
|
||||
} else {
|
||||
dict[key] = value;
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
};
|
||||
|
||||
/**
|
||||
* When a client request starts, emit Network.requestWillBeSent event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestWillBeSent
|
||||
* @param {{ request: undici.Request }} event
|
||||
*/
|
||||
function onClientRequestStart({ request }) {
|
||||
const url = `${request.origin}${request.path}`;
|
||||
request[kInspectorRequestId] = getNextRequestId();
|
||||
Network.requestWillBeSent({
|
||||
requestId: request[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
wallTime: DateNow(),
|
||||
request: {
|
||||
url,
|
||||
method: request.method,
|
||||
headers: requestHeadersArrayToDictionary(request.headers),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When a client request errors, emit Network.loadingFailed event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFailed
|
||||
* @param {{ request: undici.Request, error: any }} event
|
||||
*/
|
||||
function onClientRequestError({ request, error }) {
|
||||
if (typeof request[kInspectorRequestId] !== 'string') {
|
||||
return;
|
||||
}
|
||||
Network.loadingFailed({
|
||||
requestId: request[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
// TODO(legendecas): distinguish between `undici.request` and `undici.fetch`.
|
||||
type: kResourceType.Fetch,
|
||||
errorText: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When response headers are received, emit Network.responseReceived event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived
|
||||
* @param {{ request: undici.Request, response: undici.Response }} event
|
||||
*/
|
||||
function onClientResponseHeaders({ request, response }) {
|
||||
if (typeof request[kInspectorRequestId] !== 'string') {
|
||||
return;
|
||||
}
|
||||
const url = `${request.origin}${request.path}`;
|
||||
Network.responseReceived({
|
||||
requestId: request[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
// TODO(legendecas): distinguish between `undici.request` and `undici.fetch`.
|
||||
type: kResourceType.Fetch,
|
||||
response: {
|
||||
url,
|
||||
status: response.statusCode,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeadersArrayToDictionary(response.headers),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When a response is completed, emit Network.loadingFinished event.
|
||||
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFinished
|
||||
* @param {{ request: undici.Request, response: undici.Response }} event
|
||||
*/
|
||||
function onClientResponseFinish({ request }) {
|
||||
if (typeof request[kInspectorRequestId] !== 'string') {
|
||||
return;
|
||||
}
|
||||
Network.loadingFinished({
|
||||
requestId: request[kInspectorRequestId],
|
||||
timestamp: getMonotonicTime(),
|
||||
});
|
||||
}
|
||||
|
||||
function enable() {
|
||||
dc.subscribe('undici:request:create', onClientRequestStart);
|
||||
dc.subscribe('undici:request:error', onClientRequestError);
|
||||
dc.subscribe('undici:request:headers', onClientResponseHeaders);
|
||||
dc.subscribe('undici:request:trailers', onClientResponseFinish);
|
||||
}
|
||||
|
||||
function disable() {
|
||||
dc.subscribe('undici:request:create', onClientRequestStart);
|
||||
dc.subscribe('undici:request:error', onClientRequestError);
|
||||
dc.subscribe('undici:request:headers', onClientResponseHeaders);
|
||||
dc.subscribe('undici:request:trailers', onClientResponseFinish);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enable,
|
||||
disable,
|
||||
};
|
@ -2,14 +2,12 @@
|
||||
|
||||
function enable() {
|
||||
require('internal/inspector/network_http').enable();
|
||||
// TODO: add undici request/websocket tracking.
|
||||
// https://github.com/nodejs/node/issues/53946
|
||||
require('internal/inspector/network_undici').enable();
|
||||
}
|
||||
|
||||
function disable() {
|
||||
require('internal/inspector/network_http').disable();
|
||||
// TODO: add undici request/websocket tracking.
|
||||
// https://github.com/nodejs/node/issues/53946
|
||||
require('internal/inspector/network_undici').disable();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
@ -120,7 +120,8 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const {
|
||||
#if !HAVE_INSPECTOR
|
||||
"inspector", "inspector/promises", "internal/util/inspector",
|
||||
"internal/inspector/network", "internal/inspector/network_http",
|
||||
"internal/inspector_async_hook", "internal/inspector_network_tracking",
|
||||
"internal/inspector/network_undici", "internal/inspector_async_hook",
|
||||
"internal/inspector_network_tracking",
|
||||
#endif // !HAVE_INSPECTOR
|
||||
|
||||
#if !NODE_USE_V8_PLATFORM || !defined(NODE_HAVE_I18N_SUPPORT)
|
||||
|
196
test/parallel/test-inspector-network-fetch.js
Normal file
196
test/parallel/test-inspector-network-fetch.js
Normal file
@ -0,0 +1,196 @@
|
||||
// Flags: --inspect=0 --experimental-network-inspection
|
||||
'use strict';
|
||||
const common = require('../common');
|
||||
|
||||
common.skipIfInspectorDisabled();
|
||||
|
||||
const assert = require('node:assert');
|
||||
const { addresses } = require('../common/internet');
|
||||
const fixtures = require('../common/fixtures');
|
||||
const http = require('node:http');
|
||||
const https = require('node:https');
|
||||
const inspector = require('node:inspector/promises');
|
||||
|
||||
// Disable certificate validation for the global fetch.
|
||||
const undici = require('../../deps/undici/src/index.js');
|
||||
undici.setGlobalDispatcher(new undici.Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
}));
|
||||
|
||||
const session = new inspector.Session();
|
||||
session.connect();
|
||||
|
||||
const requestHeaders = [
|
||||
['accept-language', 'en-US'],
|
||||
['cookie', 'k1=v1'],
|
||||
['cookie', 'k2=v2'],
|
||||
['age', 1000],
|
||||
['x-header1', 'value1'],
|
||||
['x-header1', 'value2'],
|
||||
];
|
||||
|
||||
const setResponseHeaders = (res) => {
|
||||
res.setHeader('server', 'node');
|
||||
res.setHeader('etag', 12345);
|
||||
res.setHeader('Set-Cookie', ['key1=value1', 'key2=value2']);
|
||||
res.setHeader('x-header2', ['value1', 'value2']);
|
||||
};
|
||||
|
||||
const handleRequest = (req, res) => {
|
||||
const path = req.url;
|
||||
switch (path) {
|
||||
case '/hello-world':
|
||||
setResponseHeaders(res);
|
||||
res.writeHead(200);
|
||||
res.end('hello world\n');
|
||||
break;
|
||||
default:
|
||||
assert(false, `Unexpected path: ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
const httpServer = http.createServer(handleRequest);
|
||||
|
||||
const httpsServer = https.createServer({
|
||||
key: fixtures.readKey('agent1-key.pem'),
|
||||
cert: fixtures.readKey('agent1-cert.pem')
|
||||
}, handleRequest);
|
||||
|
||||
const terminate = () => {
|
||||
session.disconnect();
|
||||
httpServer.close();
|
||||
httpsServer.close();
|
||||
inspector.close();
|
||||
};
|
||||
|
||||
const testHttpGet = () => new Promise((resolve, reject) => {
|
||||
session.on('Network.requestWillBeSent', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(params.request.url, `http://127.0.0.1:${httpServer.address().port}/hello-world`);
|
||||
assert.strictEqual(params.request.method, 'GET');
|
||||
assert.strictEqual(typeof params.request.headers, 'object');
|
||||
assert.strictEqual(params.request.headers['accept-language'], 'en-US');
|
||||
assert.strictEqual(params.request.headers.cookie, 'k1=v1; k2=v2');
|
||||
assert.strictEqual(params.request.headers.age, '1000');
|
||||
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(typeof params.wallTime, 'number');
|
||||
}));
|
||||
session.on('Network.responseReceived', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(params.type, 'Fetch');
|
||||
assert.strictEqual(params.response.status, 200);
|
||||
assert.strictEqual(params.response.statusText, 'OK');
|
||||
assert.strictEqual(params.response.url, `http://127.0.0.1:${httpServer.address().port}/hello-world`);
|
||||
assert.strictEqual(typeof params.response.headers, 'object');
|
||||
assert.strictEqual(params.response.headers.server, 'node');
|
||||
assert.strictEqual(params.response.headers.etag, '12345');
|
||||
assert.strictEqual(params.response.headers['Set-Cookie'], 'key1=value1\nkey2=value2');
|
||||
assert.strictEqual(params.response.headers['x-header2'], 'value1, value2');
|
||||
}));
|
||||
session.on('Network.loadingFinished', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
resolve();
|
||||
}));
|
||||
|
||||
fetch(`http://127.0.0.1:${httpServer.address().port}/hello-world`, {
|
||||
headers: requestHeaders,
|
||||
}).then(common.mustCall());
|
||||
});
|
||||
|
||||
const testHttpsGet = () => new Promise((resolve, reject) => {
|
||||
session.on('Network.requestWillBeSent', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(params.request.url, `https://127.0.0.1:${httpsServer.address().port}/hello-world`);
|
||||
assert.strictEqual(params.request.method, 'GET');
|
||||
assert.strictEqual(typeof params.request.headers, 'object');
|
||||
assert.strictEqual(params.request.headers['accept-language'], 'en-US');
|
||||
assert.strictEqual(params.request.headers.cookie, 'k1=v1; k2=v2');
|
||||
assert.strictEqual(params.request.headers.age, '1000');
|
||||
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(typeof params.wallTime, 'number');
|
||||
}));
|
||||
session.on('Network.responseReceived', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(params.type, 'Fetch');
|
||||
assert.strictEqual(params.response.status, 200);
|
||||
assert.strictEqual(params.response.statusText, 'OK');
|
||||
assert.strictEqual(params.response.url, `https://127.0.0.1:${httpsServer.address().port}/hello-world`);
|
||||
assert.strictEqual(typeof params.response.headers, 'object');
|
||||
assert.strictEqual(params.response.headers.server, 'node');
|
||||
assert.strictEqual(params.response.headers.etag, '12345');
|
||||
assert.strictEqual(params.response.headers['Set-Cookie'], 'key1=value1\nkey2=value2');
|
||||
assert.strictEqual(params.response.headers['x-header2'], 'value1, value2');
|
||||
}));
|
||||
session.on('Network.loadingFinished', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
resolve();
|
||||
}));
|
||||
|
||||
fetch(`https://127.0.0.1:${httpsServer.address().port}/hello-world`, {
|
||||
headers: requestHeaders,
|
||||
}).then(common.mustCall());
|
||||
});
|
||||
|
||||
const testHttpError = () => new Promise((resolve, reject) => {
|
||||
session.on('Network.requestWillBeSent', common.mustCall());
|
||||
session.on('Network.loadingFailed', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(params.type, 'Fetch');
|
||||
assert.strictEqual(typeof params.errorText, 'string');
|
||||
resolve();
|
||||
}));
|
||||
session.on('Network.responseReceived', common.mustNotCall());
|
||||
session.on('Network.loadingFinished', common.mustNotCall());
|
||||
|
||||
fetch(`http://${addresses.INVALID_HOST}`).catch(common.mustCall());
|
||||
});
|
||||
|
||||
|
||||
const testHttpsError = () => new Promise((resolve, reject) => {
|
||||
session.on('Network.requestWillBeSent', common.mustCall());
|
||||
session.on('Network.loadingFailed', common.mustCall(({ params }) => {
|
||||
assert.ok(params.requestId.startsWith('node-network-event-'));
|
||||
assert.strictEqual(typeof params.timestamp, 'number');
|
||||
assert.strictEqual(params.type, 'Fetch');
|
||||
assert.strictEqual(typeof params.errorText, 'string');
|
||||
resolve();
|
||||
}));
|
||||
session.on('Network.responseReceived', common.mustNotCall());
|
||||
session.on('Network.loadingFinished', common.mustNotCall());
|
||||
|
||||
fetch(`https://${addresses.INVALID_HOST}`).catch(common.mustCall());
|
||||
});
|
||||
|
||||
const testNetworkInspection = async () => {
|
||||
await testHttpGet();
|
||||
session.removeAllListeners();
|
||||
await testHttpsGet();
|
||||
session.removeAllListeners();
|
||||
await testHttpError();
|
||||
session.removeAllListeners();
|
||||
await testHttpsError();
|
||||
session.removeAllListeners();
|
||||
};
|
||||
|
||||
httpServer.listen(0, () => {
|
||||
httpsServer.listen(0, async () => {
|
||||
try {
|
||||
await session.post('Network.enable');
|
||||
await testNetworkInspection();
|
||||
await session.post('Network.disable');
|
||||
} catch (e) {
|
||||
assert.fail(e);
|
||||
} finally {
|
||||
terminate();
|
||||
}
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user