Bug Description
We collect DNS lookup, TCP connection, and TLS handshake duration metrics using Node.js diagnostics channels (source).
However, when the DNS interceptor is used, Undici resolves the original hostname before creating the socket and replaces the connection origin with the selected IP address.
For HTTPS connections, the original hostname is still available through servername, because it is required for TLS SNI.
For plain HTTP connections, however, the original hostname is no longer available at the socket level. Instrumentation based on Node.js's net.client.socket diagnostics channel can only observe the resolved IP address.
This makes it impossible to correctly attribute TCP connection metrics to the original service hostname.
Reproduction
Standalone reproduction script:
const { test } = require("node:test");
const { createServer } = require("node:http");
const { once } = require("node:events");
const diagnosticsChannel = require("node:diagnostics_channel");
const { Agent, fetch, interceptors } = require("undici");
const netClientSocketChannel = diagnosticsChannel.channel("net.client.socket");
const ORIGINAL_HOSTNAME = "localhost";
const PORT = 3000;
const RESOLVED_ADDRESS = "127.0.0.1";
const url = `http://${ORIGINAL_HOSTNAME}:${PORT}`;
// Create dispatcher with dns cache
const dispatcherWithDnsCache = new Agent().compose(
interceptors.dns({
maxTTL: 60_000,
lookup(hostname, options, callback) {
callback(null, [
{
address: RESOLVED_ADDRESS,
family: 4,
ttl: 60_000,
},
]);
},
}),
);
let lookupEmitted = false;
let host;
// Subscribe on diagnostic channel events
netClientSocketChannel.subscribe(({ socket }) => {
socket.once("lookup", (error, address, family, hostname) => {
lookupEmitted = true;
});
socket.once("connect", () => {
host = socket._host;
});
});
test("Host field should be available", { timeout: 60000 }, async (t) => {
// 1. Start a local HTTP server
const server = createServer({}, async (req, res) => {
res.statusCode = 200;
res.setHeader("content-length", 100);
res.end("hello".repeat(20));
});
t.after(() => {
server.closeAllConnections?.();
server.close();
});
server.listen(PORT);
await once(server, "listening");
// 2. Perform the request without dns cache
const response = await fetch(url);
// host should be 'localhost'
t.assert.strictEqual(host, ORIGINAL_HOSTNAME);
// 3. Perform the request with dns cache
const cachedDnsResponse = await fetch(url, { dispatcher: dispatcherWithDnsCache });
// host should be 'localhost'
t.assert.strictEqual(host, ORIGINAL_HOSTNAME);
});
Expected Behavior
Connection instrumentation should have access to both:
- the resolved address used for the TCP connection;
- the original hostname supplied in the request URL.
For example:
{
remoteAddress: '127.0.0.1',
host: 'localhost'
}
This would also make HTTP and HTTPS connection instrumentation consistent.
Actual Behavior
The socket created for the HTTP request contains only the resolved IP address:
{
remoteAddress: '127.0.0.1',
host: null
}
The original hostname is not available from the socket or from the net.client.socket diagnostics event.
As a result, diagnostics-based instrumentation cannot determine which logical hostname or service initiated that connection.
Bug Description
We collect DNS lookup, TCP connection, and TLS handshake duration metrics using Node.js diagnostics channels (source).
However, when the DNS interceptor is used, Undici resolves the original hostname before creating the socket and replaces the connection origin with the selected IP address.
For HTTPS connections, the original hostname is still available through servername, because it is required for TLS SNI.
For plain HTTP connections, however, the original hostname is no longer available at the socket level. Instrumentation based on Node.js's net.client.socket diagnostics channel can only observe the resolved IP address.
This makes it impossible to correctly attribute TCP connection metrics to the original service hostname.
Reproduction
Standalone reproduction script:
Expected Behavior
Connection instrumentation should have access to both:
For example:
{
remoteAddress: '127.0.0.1',
host: 'localhost'
}
This would also make HTTP and HTTPS connection instrumentation consistent.
Actual Behavior
The socket created for the HTTP request contains only the resolved IP address:
{
remoteAddress: '127.0.0.1',
host: null
}
The original hostname is not available from the socket or from the net.client.socket diagnostics event.
As a result, diagnostics-based instrumentation cannot determine which logical hostname or service initiated that connection.