diff --git a/experimental/packages/opentelemetry-instrumentation-http/README.md b/experimental/packages/opentelemetry-instrumentation-http/README.md index 139014304f..6d5fb9a073 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/README.md +++ b/experimental/packages/opentelemetry-instrumentation-http/README.md @@ -57,6 +57,7 @@ Http instrumentation has few options available to choose from. You can set the f | [`serverName`](https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-instrumentation-http/src/types.ts#L101) | `string` | The primary server name of the matched virtual host. | | [`requireParentforOutgoingSpans`](https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-instrumentation-http/src/types.ts#L103) | Boolean | Require that is a parent span to create new span for outgoing requests. | | [`requireParentforIncomingSpans`](https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-instrumentation-http/src/types.ts#L105) | Boolean | Require that is a parent span to create new span for incoming requests. | +| [`headersToSpanAttributes`](https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-instrumentation-http/src/types.ts#L107) | `object` | List of case insensitive HTTP headers to convert to span attributes. Client (outgoing requests, incoming responses) and server (incoming requests, outgoing responses) headers will be converted to span attributes in the form of `http.{request\|response}.header.header_name`, e.g. `http.response.header.content_length` | ## Useful links diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/http.ts b/experimental/packages/opentelemetry-instrumentation-http/src/http.ts index 2229ae92c7..b6cba079fc 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/http.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/http.ts @@ -58,6 +58,7 @@ export class HttpInstrumentation extends InstrumentationBase { /** keep track on spans not ended */ private readonly _spanNotEnded: WeakSet = new WeakSet(); private readonly _version = process.versions.node; + private _headerCapture; constructor(config: HttpInstrumentationConfig & InstrumentationConfig = {}) { super( @@ -65,6 +66,8 @@ export class HttpInstrumentation extends InstrumentationBase { VERSION, Object.assign({}, config) ); + + this._headerCapture = this._createHeaderCapture(); } private _getConfig(): HttpInstrumentationConfig { @@ -73,6 +76,7 @@ export class HttpInstrumentation extends InstrumentationBase { override setConfig(config: HttpInstrumentationConfig & InstrumentationConfig = {}): void { this._config = Object.assign({}, config); + this._headerCapture = this._createHeaderCapture(); } init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] { @@ -296,6 +300,9 @@ export class HttpInstrumentation extends InstrumentationBase { this._callResponseHook(span, response); } + this._headerCapture.client.captureRequestHeaders(span, header => request.getHeader(header)); + this._headerCapture.client.captureResponseHeaders(span, header => response.headers[header]); + context.bind(context.active(), response); this._diag.debug('outgoingRequest on response()'); response.on('end', () => { @@ -424,6 +431,8 @@ export class HttpInstrumentation extends InstrumentationBase { instrumentation._callResponseHook(span, response); } + instrumentation._headerCapture.server.captureRequestHeaders(span, header => request.headers[header]); + // Wraps end (inspired by: // https://github.com/GoogleCloudPlatform/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-connect.ts#L75) const originalEnd = response.end; @@ -449,6 +458,8 @@ export class HttpInstrumentation extends InstrumentationBase { response ); + instrumentation._headerCapture.server.captureResponseHeaders(span, header => response.getHeader(header)); + span .setAttributes(attributes) .setStatus(utils.parseResponseStatus(response.statusCode)); @@ -662,4 +673,19 @@ export class HttpInstrumentation extends InstrumentationBase { ); } } + + private _createHeaderCapture() { + const config = this._getConfig(); + + return { + client: { + captureRequestHeaders: utils.headerCapture('request', config.headersToSpanAttributes?.client?.requestHeaders ?? []), + captureResponseHeaders: utils.headerCapture('response', config.headersToSpanAttributes?.client?.responseHeaders ?? []) + }, + server: { + captureRequestHeaders: utils.headerCapture('request', config.headersToSpanAttributes?.server?.requestHeaders ?? []), + captureResponseHeaders: utils.headerCapture('response', config.headersToSpanAttributes?.server?.responseHeaders ?? []), + } + } + } } diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/types.ts b/experimental/packages/opentelemetry-instrumentation-http/src/types.ts index 7be9999d8b..79f4e844cd 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/types.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/types.ts @@ -103,6 +103,11 @@ export interface HttpInstrumentationConfig extends InstrumentationConfig { requireParentforOutgoingSpans?: boolean; /** Require parent to create span for incoming requests */ requireParentforIncomingSpans?: boolean; + /** Map the following HTTP headers to span attributes. */ + headersToSpanAttributes?: { + client?: { requestHeaders?: string[]; responseHeaders?: string[]; }, + server?: { requestHeaders?: string[]; responseHeaders?: string[]; }, + } } export interface Err extends Error { diff --git a/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts b/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts index a24ff6f12a..d7c3ff8376 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts @@ -495,3 +495,27 @@ export const getIncomingRequestAttributesOnResponse = ( } return attributes; }; + +export function headerCapture(type: 'request' | 'response', headers: string[]) { + const normalizedHeaders = new Map(headers.map(header => [header.toLowerCase(), header.toLowerCase().replace(/-/g, '_')])); + + return (span: Span, getHeader: (key: string) => undefined | string | string[] | number) => { + for (const [capturedHeader, normalizedHeader] of normalizedHeaders) { + const value = getHeader(capturedHeader); + + if (value === undefined) { + continue; + } + + const key = `http.${type}.header.${normalizedHeader}`; + + if (typeof value === 'string') { + span.setAttribute(key, [value]); + } else if (Array.isArray(value)) { + span.setAttribute(key, value); + } else { + span.setAttribute(key, [value]); + } + } + }; +} diff --git a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-enable.test.ts b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-enable.test.ts index 9f62d807b1..7658bfa65b 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-enable.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-enable.test.ts @@ -908,4 +908,87 @@ describe('HttpInstrumentation', () => { }); }); }); + + describe('capturing headers as span attributes', () => { + beforeEach(() => { + memoryExporter.reset(); + }); + + before(() => { + instrumentation.setConfig({ + headersToSpanAttributes: { + client: { requestHeaders: ['X-Client-Header1'], responseHeaders: ['X-Server-Header1'] }, + server: { requestHeaders: ['X-Client-Header2'], responseHeaders: ['X-Server-Header2'] }, + } + }); + instrumentation.enable(); + server = http.createServer((request, response) => { + response.setHeader('X-ServeR-header1', 'server123'); + response.setHeader('X-Server-header2', '123server'); + response.end('Test Server Response'); + }); + + server.listen(serverPort); + }); + + after(() => { + server.close(); + instrumentation.disable(); + }); + + it('should convert headers to span attributes', async () => { + await httpRequest.get( + `${protocol}://${hostname}:${serverPort}${pathname}`, + { + headers: { + 'X-client-header1': 'client123', + 'X-CLIENT-HEADER2': '123client', + } + } + ); + const spans = memoryExporter.getFinishedSpans(); + const [incomingSpan, outgoingSpan] = spans; + + assert.strictEqual(spans.length, 2); + + assert.deepStrictEqual( + incomingSpan.attributes['http.request.header.x_client_header2'], + ['123client'] + ); + + assert.deepStrictEqual( + incomingSpan.attributes['http.response.header.x_server_header2'], + ['123server'] + ); + + assert.strictEqual( + incomingSpan.attributes['http.request.header.x_client_header1'], + undefined + ); + + assert.strictEqual( + incomingSpan.attributes['http.response.header.x_server_header1'], + undefined + ); + + assert.deepStrictEqual( + outgoingSpan.attributes['http.request.header.x_client_header1'], + ['client123'] + ); + assert.deepStrictEqual( + outgoingSpan.attributes['http.response.header.x_server_header1'], + ['server123'] + ); + + assert.strictEqual( + outgoingSpan.attributes['http.request.header.x_client_header2'], + undefined + ); + + assert.strictEqual( + outgoingSpan.attributes['http.response.header.x_server_header2'], + undefined + ); + }); + }); }); 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 d530fac61c..cde1aafdc7 100644 --- a/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts @@ -465,4 +465,65 @@ describe('Utility', () => { verifyValueInAttributes(attributes, undefined, 1200); }); }); + + describe('headers to span attributes capture', () => { + let span: Span; + + beforeEach(() => { + span = new Span( + new BasicTracerProvider().getTracer('default'), + ROOT_CONTEXT, + 'test', + { spanId: '', traceId: '', traceFlags: TraceFlags.SAMPLED }, + SpanKind.INTERNAL + ); + }); + + it('should set attributes for request and response keys', () => { + utils.headerCapture('request', ['Origin'])(span, () => 'localhost'); + utils.headerCapture('response', ['Cookie'])(span, () => 'token=123'); + assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost']); + assert.deepStrictEqual(span.attributes['http.response.header.cookie'], ['token=123']); + }); + + it('should set attributes for multiple values', () => { + utils.headerCapture('request', ['Origin'])(span, () => ['localhost', 'www.example.com']); + assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost', 'www.example.com']); + }); + + it('sets attributes for multiple headers', () => { + utils.headerCapture('request', ['Origin', 'Foo'])(span, header => { + if (header === 'origin') { + return 'localhost'; + } + + if (header === 'foo') { + return 42; + } + + return undefined; + }); + + assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost']); + assert.deepStrictEqual(span.attributes['http.request.header.foo'], [42]); + }); + + it('should normalize header names', () => { + utils.headerCapture('request', ['X-Forwarded-For'])(span, () => 'foo'); + assert.deepStrictEqual(span.attributes['http.request.header.x_forwarded_for'], ['foo']); + }); + + it('ignores non-existent headers', () => { + utils.headerCapture('request', ['Origin', 'Accept'])(span, header => { + if (header === 'origin') { + return 'localhost'; + } + + return undefined; + }); + + assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost']); + assert.deepStrictEqual(span.attributes['http.request.header.accept'], undefined); + }) + }); });