@opentelemetry/instrumentation-http throws an unhandled TypeError out of a patched http.request() / https.request() call when options.host is present but not a string, even though Node itself accepts those exact options and completes the request normally.
Because getOutgoingRequestAttributes() is called outside any safeExecuteInTheMiddle() in _outgoingRequestFunction, the error propagates synchronously into application code. An instrumented process crashes where an uninstrumented one succeeds.
Node's ClientRequest derives the target from options.hostname first and only falls back to options.host (host is then overwritten internally). So whenever hostname is set, any value in host is inert as far as Node is concerned — a stale URL object, a config object, or a number sits there harmlessly and is never read. The instrumentation reads optionsParsed.host first and calls .indexOf() on it.
This reproduces with pure OpenTelemetry — no distribution, no framework, no HTTP client library, just node:http and HttpInstrumentation.
Steps to Reproduce
Two files plus the package.json below. Module type is commonjs; no bundler and no TypeScript involved. otel.js is given under "OpenTelemetry Setup Code".
app.js:
// plain node:http, no HTTP client library involved
'use strict';
const http = require('http');
const server = http.createServer((req, res) => res.end('ok'));
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
const endpoint = new URL(`http://127.0.0.1:${port}/some/path`);
// Valid options: `hostname` is a string, so Node resolves the target from it and
// never reads `host`. `host` below is a URL object -- inert as far as Node cares.
const options = {
hostname: endpoint.hostname,
port: endpoint.port,
path: endpoint.pathname,
method: 'GET',
host: endpoint, // non-string; ignored by Node, read by the instrumentation
};
try {
const req = http.request(options, (res) => {
res.resume();
res.on('end', () => {
console.log('request succeeded:', res.statusCode);
server.close();
});
});
req.on('error', (err) => {
console.log('request error:', err.message);
server.close();
});
req.end();
} catch (err) {
console.log('http.request() threw synchronously:');
console.log(err.stack);
server.close();
}
});
Run it twice:
npm install
node app.js # uninstrumented -> succeeds
node -r ./otel.js app.js # instrumented -> throws
Expected Result
Both runs succeed. The instrumented run additionally produces a client span whose url.full is derived from hostname, mirroring how Node resolves the target. Instrumentation must not reject input that an uninstrumented client accepts.
$ node app.js
request succeeded: 200
Actual Result
The instrumented run never makes the request — http.request() throws synchronously:
$ node -r ./otel.js app.js
http.request() threw synchronously:
TypeError: host.indexOf is not a function
Full stack under "Relevant log output" below.
Additional Details
Root cause. utils.ts assumes host is a string:
let host =
reqUrlObject.host || reqUrlObject.hostname || headers.host || 'localhost';
if ((host as string).indexOf(':') === -1 && port && …) {
The as string cast is doing real work — ParsedRequestOptions.host is string | undefined at the type level, but the object reaching this function is whatever the caller passed to http.request(), unvalidated. getRequestInfo() copies it through verbatim via Object.assign({ protocol: … }, options).
A second instance of the same assumption is in extractHostnameAndPort():
const matches = requestOptions.host?.match(/^([^:/ ]+)(:\d{1,5})?/) || null;
That line is reached first, but it early-returns whenever hostname && port are both truthy — and for https that is always the case, because _setDefaultOptions() sets port = options.port || 443. So https requests sail past it into getAbsoluteUrl(). With plain http and no explicit port, this line fails instead with requestOptions.host.match is not a function.
Which values fail. Any truthy host without an .indexOf method. host: <URL object>, host: { … } and host: 1234 all throw identically; host: [hostname] and host: new String(hostname) happen to survive because both have .indexOf.
Affected versions. Every published version I checked: 0.50.0, 0.54.2, 0.55.0, 0.57.0, 0.200.0, 0.203.0, 0.205.0, 0.207.0, 0.208.0, 0.209.0, 0.210.0, 0.215.0, 0.219.0, 0.220.0, 0.221.0. Not a regression — these lines are unchanged since at least 0.50.0.
Real-world impact. Encountered in production in an AWS Lambda function: a request-options builder assigned a URL object to host alongside a valid hostname string, and most invocations began failing. The code had worked for as long as it existed; enabling instrumentation crashed it.
Proposed fix. Skip non-string candidates rather than coercing them — this matches what Node does with host/hostname, and avoids String(urlObject) yielding a full URL where a host is expected:
- let host =
- reqUrlObject.host || reqUrlObject.hostname || headers.host || 'localhost';
+ let host =
+ (typeof reqUrlObject.host === 'string' && reqUrlObject.host) ||
+ (typeof reqUrlObject.hostname === 'string' && reqUrlObject.hostname) ||
+ (typeof headers.host === 'string' && headers.host) ||
+ 'localhost';
- const matches = requestOptions.host?.match(/^([^:/ ]+)(:\d{1,5})?/) || null;
+ const matches =
+ (typeof requestOptions.host === 'string'
+ ? requestOptions.host.match(/^([^:/ ]+)(:\d{1,5})?/)
+ : null) || null;
I applied both to 0.221.0 locally and checked the emitted url.full. All three failing shapes then produce the correct attribute, and the already-working shapes are unchanged:
=== 0.221.0 + guards === === 0.221.0 as published ===
host: <URL object> url.full ok host: <URL object> THREW
host: <plain object> url.full ok host: <plain object> THREW
host: <number> url.full ok host: <number> THREW
host: <string> url.full ok host: <string> url.full ok
no host at all url.full ok no host at all url.full ok
(url.full is http://127.0.0.1:<port>/some/path in every passing case.)
Happy to open a PR with these two guards plus tests covering the shape family.
Suggestion beyond the immediate fix. It may be worth wrapping the attribute-computation block in _outgoingRequestFunction in safeExecuteInTheMiddle(). Any future defect in attribute building currently takes down the caller's request, which is a severe failure mode for auto-instrumentation — especially in serverless environments, where it kills the whole invocation.
OpenTelemetry Setup Code
otel.js, loaded via node -r ./otel.js app.js:
// otel.js
'use strict';
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
new NodeTracerProvider().register();
registerInstrumentations({ instrumentations: [new HttpInstrumentation()] });
package.json
{
"name": "otel-http-host-repro",
"version": "1.0.0",
"private": true,
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/instrumentation": "^0.221.0",
"@opentelemetry/instrumentation-http": "^0.221.0",
"@opentelemetry/sdk-trace-node": "^2.10.0"
}
}
Relevant log output
$ node app.js
request succeeded: 200
$ node -r ./otel.js app.js
http.request() threw synchronously:
TypeError: host.indexOf is not a function
at getAbsoluteUrl (node_modules/@opentelemetry/instrumentation-http/build/src/utils.js:24:14)
at getOutgoingRequestAttributes (node_modules/@opentelemetry/instrumentation-http/build/src/utils.js:255:48)
at Object.outgoingRequest [as request] (node_modules/@opentelemetry/instrumentation-http/build/src/http.js:398:73)
at Server.<anonymous> (app.js:22:22)
at Object.onceWrapper (node:events:633:28)
at Server.emit (node:events:531:35)
at Server.incomingRequest (node_modules/@opentelemetry/instrumentation-http/build/src/http.js:302:33)
at emitListeningNT (node:net:1983:10)
Operating System and Version
Reproduced independently on both:
- Linux 6.18.5 x86_64
- macOS (Darwin 25.5.0)
Runtime and Version
Reproduced independently on both:
- Node.js v22.22.2
- Node.js v24.13.0
@opentelemetry/instrumentation-httpthrows an unhandledTypeErrorout of a patchedhttp.request()/https.request()call whenoptions.hostis present but not a string, even though Node itself accepts those exact options and completes the request normally.Because
getOutgoingRequestAttributes()is called outside anysafeExecuteInTheMiddle()in_outgoingRequestFunction, the error propagates synchronously into application code. An instrumented process crashes where an uninstrumented one succeeds.Node's
ClientRequestderives the target fromoptions.hostnamefirst and only falls back tooptions.host(hostis then overwritten internally). So wheneverhostnameis set, any value inhostis inert as far as Node is concerned — a stale URL object, a config object, or a number sits there harmlessly and is never read. The instrumentation readsoptionsParsed.hostfirst and calls.indexOf()on it.This reproduces with pure OpenTelemetry — no distribution, no framework, no HTTP client library, just
node:httpandHttpInstrumentation.Steps to Reproduce
Two files plus the
package.jsonbelow. Module type iscommonjs; no bundler and no TypeScript involved.otel.jsis given under "OpenTelemetry Setup Code".app.js:Run it twice:
Expected Result
Both runs succeed. The instrumented run additionally produces a client span whose
url.fullis derived fromhostname, mirroring how Node resolves the target. Instrumentation must not reject input that an uninstrumented client accepts.Actual Result
The instrumented run never makes the request —
http.request()throws synchronously:Full stack under "Relevant log output" below.
Additional Details
Root cause.
utils.tsassumeshostis a string:The
as stringcast is doing real work —ParsedRequestOptions.hostisstring | undefinedat the type level, but the object reaching this function is whatever the caller passed tohttp.request(), unvalidated.getRequestInfo()copies it through verbatim viaObject.assign({ protocol: … }, options).A second instance of the same assumption is in
extractHostnameAndPort():That line is reached first, but it early-returns whenever
hostname && portare both truthy — and for https that is always the case, because_setDefaultOptions()setsport = options.port || 443. So https requests sail past it intogetAbsoluteUrl(). With plain http and no explicit port, this line fails instead withrequestOptions.host.match is not a function.Which values fail. Any truthy
hostwithout an.indexOfmethod.host: <URL object>,host: { … }andhost: 1234all throw identically;host: [hostname]andhost: new String(hostname)happen to survive because both have.indexOf.Affected versions. Every published version I checked: 0.50.0, 0.54.2, 0.55.0, 0.57.0, 0.200.0, 0.203.0, 0.205.0, 0.207.0, 0.208.0, 0.209.0, 0.210.0, 0.215.0, 0.219.0, 0.220.0, 0.221.0. Not a regression — these lines are unchanged since at least 0.50.0.
Real-world impact. Encountered in production in an AWS Lambda function: a request-options builder assigned a
URLobject tohostalongside a validhostnamestring, and most invocations began failing. The code had worked for as long as it existed; enabling instrumentation crashed it.Proposed fix. Skip non-string candidates rather than coercing them — this matches what Node does with
host/hostname, and avoidsString(urlObject)yielding a full URL where a host is expected:I applied both to 0.221.0 locally and checked the emitted
url.full. All three failing shapes then produce the correct attribute, and the already-working shapes are unchanged:(
url.fullishttp://127.0.0.1:<port>/some/pathin every passing case.)Happy to open a PR with these two guards plus tests covering the shape family.
Suggestion beyond the immediate fix. It may be worth wrapping the attribute-computation block in
_outgoingRequestFunctioninsafeExecuteInTheMiddle(). Any future defect in attribute building currently takes down the caller's request, which is a severe failure mode for auto-instrumentation — especially in serverless environments, where it kills the whole invocation.OpenTelemetry Setup Code
otel.js, loaded vianode -r ./otel.js app.js:package.json
{ "name": "otel-http-host-repro", "version": "1.0.0", "private": true, "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.221.0", "@opentelemetry/instrumentation-http": "^0.221.0", "@opentelemetry/sdk-trace-node": "^2.10.0" } }Relevant log output
Operating System and Version
Reproduced independently on both:
Runtime and Version
Reproduced independently on both: