From d9d18ef253f7e235ec3df0ea7f21b6e9d1a0da97 Mon Sep 17 00:00:00 2001 From: Vitor Vasconcellos Date: Wed, 11 Feb 2026 07:04:52 -0300 Subject: [PATCH 1/5] chore(instrumentation-http): provide http.request.header at server span creation time Signed-off-by: Vitor Vasconcellos --- .../src/http.ts | 31 +++--- .../src/utils.ts | 11 +- .../test/functionals/utils.test.ts | 101 ++++++++---------- 3 files changed, 69 insertions(+), 74 deletions(-) diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/http.ts b/experimental/packages/opentelemetry-instrumentation-http/src/http.ts index dc47fe0c891..b3013bc9907 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/http.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/http.ts @@ -459,12 +459,15 @@ export class HttpInstrumentation extends InstrumentationBase - request.getHeader(header) + span.setAttributes( + this._headerCapture.client.captureRequestHeaders(header => + request.getHeader(header) + ) ); - this._headerCapture.client.captureResponseHeaders( - span, - header => response.headers[header] + span.setAttributes( + this._headerCapture.client.captureResponseHeaders( + header => response.headers[header] + ) ); context.bind(context.active(), response); @@ -623,6 +626,13 @@ export class HttpInstrumentation extends InstrumentationBase request.headers[header] + ) + ); + const spanOptions: SpanOptions = { kind: SpanKind.SERVER, attributes: spanAttributes, @@ -664,11 +674,6 @@ export class HttpInstrumentation extends InstrumentationBase request.headers[header] - ); - // After 'error', no further events other than 'close' should be emitted. let hasError = false; response.on('close', () => { @@ -889,8 +894,10 @@ export class HttpInstrumentation extends InstrumentationBase - response.getHeader(header) + span.setAttributes( + this._headerCapture.server.captureResponseHeaders(header => + response.getHeader(header) + ) ); span.setAttributes(attributes).setStatus({ diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts b/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts index 2feed8b1807..37f93bc711a 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts @@ -1095,9 +1095,9 @@ export function headerCapture(type: 'request' | 'response', headers: string[]) { } return ( - span: Span, getHeader: (key: string) => undefined | string | string[] | number - ) => { + ): Attributes => { + const attributes: Attributes = {}; for (const capturedHeader of normalizedHeaders.keys()) { const value = getHeader(capturedHeader); @@ -1109,13 +1109,14 @@ export function headerCapture(type: 'request' | 'response', headers: string[]) { const key = `http.${type}.header.${normalizedHeader}`; if (typeof value === 'string') { - span.setAttribute(key, [value]); + attributes[key] = [value]; } else if (Array.isArray(value)) { - span.setAttribute(key, value); + attributes[key] = value; } else { - span.setAttribute(key, [value]); + attributes[key] = [value]; } } + return attributes; }; } diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts index e0b224a7468..8964257e774 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts @@ -590,89 +590,76 @@ describe('Utility', () => { }); describe('headers to span attributes capture', () => { - let span: Span; - let mock: sinon.SinonMock; - - beforeEach(() => { - span = { - setAttribute: () => undefined, - } as unknown as Span; - mock = sinon.mock(span); - }); - - it('should set attributes for request and response keys', () => { - mock - .expects('setAttribute') - .calledWithExactly('http.request.header.origin', ['localhost']); - mock - .expects('setAttribute') - .calledWithExactly('http.response.header.cookie', ['token=123']); + it('should capture attributes for request and response keys', () => { + const reqAttrs = utils.headerCapture('request', ['Origin'])( + () => 'localhost' + ); + const resAttrs = utils.headerCapture('response', ['Cookie'])( + () => 'token=123' + ); - utils.headerCapture('request', ['Origin'])(span, () => 'localhost'); - utils.headerCapture('response', ['Cookie'])(span, () => 'token=123'); - mock.verify(); + assert.deepStrictEqual(reqAttrs, { + 'http.request.header.origin': ['localhost'], + }); + assert.deepStrictEqual(resAttrs, { + 'http.response.header.cookie': ['token=123'], + }); }); - it('should set attributes for multiple values', () => { - mock - .expects('setAttribute') - .calledWithExactly('http.request.header.origin', [ - 'localhost', - 'www.example.com', - ]); - - utils.headerCapture('request', ['Origin'])(span, () => [ + it('should capture attributes for multiple values', () => { + const attrs = utils.headerCapture('request', ['Origin'])(() => [ 'localhost', 'www.example.com', ]); - mock.verify(); + + assert.deepStrictEqual(attrs, { + 'http.request.header.origin': ['localhost', 'www.example.com'], + }); }); - it('sets attributes for multiple headers', () => { - mock - .expects('setAttribute') - .calledWithExactly('http.request.header.origin', ['localhost']); - mock - .expects('setAttribute') - .calledWithExactly('http.request.header.foo', [42]); + it('should capture attributes for multiple headers', () => { + const attrs = utils.headerCapture('request', ['Origin', 'Foo'])( + header => { + if (header === 'origin') { + return 'localhost'; + } - utils.headerCapture('request', ['Origin', 'Foo'])(span, header => { - if (header === 'origin') { - return 'localhost'; - } + if (header === 'foo') { + return 42; + } - if (header === 'foo') { - return 42; + return undefined; } + ); - return undefined; + assert.deepStrictEqual(attrs, { + 'http.request.header.origin': ['localhost'], + 'http.request.header.foo': [42], }); - mock.verify(); }); it('should normalize header names', () => { - mock - .expects('setAttribute') - .calledWithExactly('http.request.header.x_forwarded_for', ['foo']); + const attrs = utils.headerCapture('request', ['X-Forwarded-For'])( + () => 'foo' + ); - utils.headerCapture('request', ['X-Forwarded-For'])(span, () => 'foo'); - mock.verify(); + assert.deepStrictEqual(attrs, { + 'http.request.header.x_forwarded_for': ['foo'] + }); }); it('ignores non-existent headers', () => { - mock - .expects('setAttribute') - .once() - .calledWithExactly('http.request.header.origin', ['localhost']); - - utils.headerCapture('request', ['Origin', 'Accept'])(span, header => { + const attrs = utils.headerCapture('request', ['Origin', 'Accept'])(header => { if (header === 'origin') { return 'localhost'; } return undefined; }); - mock.verify(); + + assert.deepStrictEqual(attrs, { + 'http.request.header.origin': ['localhost'] + }); }); }); From 1283b83638ca08546014db8bb98c451b6392fdef Mon Sep 17 00:00:00 2001 From: Vitor Vasconcellos Date: Wed, 11 Feb 2026 07:33:56 -0300 Subject: [PATCH 2/5] chore(experimental): update changelog Signed-off-by: Vitor Vasconcellos --- experimental/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 72c86519839..b909d56a751 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -32,6 +32,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :house: Internal * perf(otlp-transformer): optimize toAnyValue performance [#6287](https://github.com/open-telemetry/opentelemetry-js/pull/6287) @AbhiPrasad +* chore(instrumentation-http): provide `http.request.header.` at server span creation time [#6396](https://github.com/open-telemetry/opentelemetry-js/pull/6396) @vitorvasc ## 0.211.0 From 1ee20af893e7a6097366ca021df626724b8e2e1b Mon Sep 17 00:00:00 2001 From: Vitor Vasconcellos Date: Wed, 11 Feb 2026 07:34:21 -0300 Subject: [PATCH 3/5] style(instrumentation-http): fix formatting Signed-off-by: Vitor Vasconcellos --- .../test/functionals/utils.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts index 8964257e774..c835685a252 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts @@ -644,21 +644,23 @@ describe('Utility', () => { ); assert.deepStrictEqual(attrs, { - 'http.request.header.x_forwarded_for': ['foo'] + 'http.request.header.x_forwarded_for': ['foo'], }); }); it('ignores non-existent headers', () => { - const attrs = utils.headerCapture('request', ['Origin', 'Accept'])(header => { - if (header === 'origin') { - return 'localhost'; - } + const attrs = utils.headerCapture('request', ['Origin', 'Accept'])( + header => { + if (header === 'origin') { + return 'localhost'; + } - return undefined; - }); + return undefined; + } + ); assert.deepStrictEqual(attrs, { - 'http.request.header.origin': ['localhost'] + 'http.request.header.origin': ['localhost'], }); }); }); From 1761986bcc44ec8b6124feedac80383d83d69ec7 Mon Sep 17 00:00:00 2001 From: Vitor Vasconcellos Date: Mon, 2 Mar 2026 06:42:00 -0300 Subject: [PATCH 4/5] chore(changelog): update changelog Signed-off-by: Vitor Vasconcellos --- experimental/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index f1dcb39e3b8..8dbe3a0a6ba 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -12,6 +12,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features +* feat(instrumentation-http): provide `http.request.header.` at server span creation time [#6396](https://github.com/open-telemetry/opentelemetry-js/pull/6396) @vitorvasc + ### :bug: Bug Fixes * fix(instrumentation-http): guard against double-instrumentation if loaded with `require('http')` and `import 'http'` [#6428](https://github.com/open-telemetry/opentelemetry-js/issues/6428) @trentm @@ -59,7 +61,6 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :house: Internal * perf(otlp-transformer): optimize toAnyValue performance [#6287](https://github.com/open-telemetry/opentelemetry-js/pull/6287) @AbhiPrasad -* chore(instrumentation-http): provide `http.request.header.` at server span creation time [#6396](https://github.com/open-telemetry/opentelemetry-js/pull/6396) @vitorvasc ## 0.211.0 From 87fbb15fe40accc648f1e71ddacf17a52ec19d7f Mon Sep 17 00:00:00 2001 From: Vitor Vasconcellos Date: Mon, 2 Mar 2026 07:22:10 -0300 Subject: [PATCH 5/5] test(http-instrumentation): add http-sampler test to validate Sampler attribute capturing Signed-off-by: Vitor Vasconcellos --- .../test/functionals/http-sampler.test.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-sampler.test.ts diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-sampler.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-sampler.test.ts new file mode 100644 index 00000000000..7a320ce73a3 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-sampler.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Attributes, + Context, + ContextManager, + Link, + SpanKind, + context, +} from '@opentelemetry/api'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + InMemorySpanExporter, + Sampler, + SamplingDecision, + SamplingResult, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import * as assert from 'assert'; +import { HttpInstrumentation } from '../../src/http'; +import { httpRequest } from '../utils/httpRequest'; + +class CapturingSampler implements Sampler { + public capturedAttributes: Attributes | undefined; + + shouldSample( + _context: Context, + _traceId: string, + _spanName: string, + _spanKind: SpanKind, + attributes: Attributes, + _links: Link[] + ): SamplingResult { + this.capturedAttributes = attributes; + return { decision: SamplingDecision.RECORD_AND_SAMPLED }; + } + + toString(): string { + return 'CapturingSampler'; + } +} + +const sampler = new CapturingSampler(); + +const instrumentation = new HttpInstrumentation({ + headersToSpanAttributes: { + server: { requestHeaders: ['x-custom-header'] }, + }, +}); +instrumentation.enable(); +instrumentation.disable(); + +import * as http from 'http'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; + +const memoryExporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider({ + sampler, + spanProcessors: [new SimpleSpanProcessor(memoryExporter)], +}); +instrumentation.setTracerProvider(provider); + +describe('HttpInstrumentation sampler integration', () => { + const PORT = 22399; + let server: http.Server; + let contextManager: ContextManager; + + before(async () => { + instrumentation.enable(); + server = http.createServer((_req, res) => { + res.writeHead(200); + res.end(); + }); + await new Promise(resolve => server.listen(PORT, resolve)); + }); + + after(done => { + instrumentation.disable(); + server.close(done); + }); + + beforeEach(() => { + contextManager = new AsyncHooksContextManager(); + context.setGlobalContextManager(contextManager); + memoryExporter.reset(); + sampler.capturedAttributes = undefined; + }); + + afterEach(() => { + context.disable(); + }); + + it('provides http.request.header.* attributes to shouldSample', async () => { + await httpRequest.get(`http://localhost:${PORT}/`, { + headers: { 'x-custom-header': 'test-value' }, + }); + + assert.deepStrictEqual( + sampler.capturedAttributes?.['http.request.header.x_custom_header'], + ['test-value'] + ); + }); +});