Skip to content

Original hostname is lost for HTTP connections when using the DNS interceptor #5573

Description

@kholstinin

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions