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:
Chengzhong Wu 2025-01-08 16:56:42 +00:00 committed by GitHub
parent 7b472fdfe7
commit 4f45acef19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 367 additions and 8 deletions

View File

@ -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,
};

View File

@ -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,

View 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,
};

View File

@ -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 = {

View File

@ -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)

View 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();
}
});
});