Skip to content

Commit d41fa7b

Browse files
authored
feat: HTTP instrumentation: add the option to capture headers as span attributes (#2492)
1 parent 33935e7 commit d41fa7b

File tree

6 files changed

+200
-0
lines changed

6 files changed

+200
-0
lines changed

experimental/packages/opentelemetry-instrumentation-http/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Http instrumentation has few options available to choose from. You can set the f
5757
| [`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. |
5858
| [`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. |
5959
| [`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. |
60+
| [`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` |
6061

6162
## Useful links
6263

experimental/packages/opentelemetry-instrumentation-http/src/http.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,16 @@ export class HttpInstrumentation extends InstrumentationBase<Http> {
5858
/** keep track on spans not ended */
5959
private readonly _spanNotEnded: WeakSet<Span> = new WeakSet<Span>();
6060
private readonly _version = process.versions.node;
61+
private _headerCapture;
6162

6263
constructor(config: HttpInstrumentationConfig & InstrumentationConfig = {}) {
6364
super(
6465
'@opentelemetry/instrumentation-http',
6566
VERSION,
6667
Object.assign({}, config)
6768
);
69+
70+
this._headerCapture = this._createHeaderCapture();
6871
}
6972

7073
private _getConfig(): HttpInstrumentationConfig {
@@ -73,6 +76,7 @@ export class HttpInstrumentation extends InstrumentationBase<Http> {
7376

7477
override setConfig(config: HttpInstrumentationConfig & InstrumentationConfig = {}): void {
7578
this._config = Object.assign({}, config);
79+
this._headerCapture = this._createHeaderCapture();
7680
}
7781

7882
init(): [InstrumentationNodeModuleDefinition<Https>, InstrumentationNodeModuleDefinition<Http>] {
@@ -296,6 +300,9 @@ export class HttpInstrumentation extends InstrumentationBase<Http> {
296300
this._callResponseHook(span, response);
297301
}
298302

303+
this._headerCapture.client.captureRequestHeaders(span, header => request.getHeader(header));
304+
this._headerCapture.client.captureResponseHeaders(span, header => response.headers[header]);
305+
299306
context.bind(context.active(), response);
300307
this._diag.debug('outgoingRequest on response()');
301308
response.on('end', () => {
@@ -424,6 +431,8 @@ export class HttpInstrumentation extends InstrumentationBase<Http> {
424431
instrumentation._callResponseHook(span, response);
425432
}
426433

434+
instrumentation._headerCapture.server.captureRequestHeaders(span, header => request.headers[header]);
435+
427436
// Wraps end (inspired by:
428437
// https://github.com/GoogleCloudPlatform/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-connect.ts#L75)
429438
const originalEnd = response.end;
@@ -449,6 +458,8 @@ export class HttpInstrumentation extends InstrumentationBase<Http> {
449458
response
450459
);
451460

461+
instrumentation._headerCapture.server.captureResponseHeaders(span, header => response.getHeader(header));
462+
452463
span
453464
.setAttributes(attributes)
454465
.setStatus(utils.parseResponseStatus(response.statusCode));
@@ -662,4 +673,19 @@ export class HttpInstrumentation extends InstrumentationBase<Http> {
662673
);
663674
}
664675
}
676+
677+
private _createHeaderCapture() {
678+
const config = this._getConfig();
679+
680+
return {
681+
client: {
682+
captureRequestHeaders: utils.headerCapture('request', config.headersToSpanAttributes?.client?.requestHeaders ?? []),
683+
captureResponseHeaders: utils.headerCapture('response', config.headersToSpanAttributes?.client?.responseHeaders ?? [])
684+
},
685+
server: {
686+
captureRequestHeaders: utils.headerCapture('request', config.headersToSpanAttributes?.server?.requestHeaders ?? []),
687+
captureResponseHeaders: utils.headerCapture('response', config.headersToSpanAttributes?.server?.responseHeaders ?? []),
688+
}
689+
}
690+
}
665691
}

experimental/packages/opentelemetry-instrumentation-http/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ export interface HttpInstrumentationConfig extends InstrumentationConfig {
103103
requireParentforOutgoingSpans?: boolean;
104104
/** Require parent to create span for incoming requests */
105105
requireParentforIncomingSpans?: boolean;
106+
/** Map the following HTTP headers to span attributes. */
107+
headersToSpanAttributes?: {
108+
client?: { requestHeaders?: string[]; responseHeaders?: string[]; },
109+
server?: { requestHeaders?: string[]; responseHeaders?: string[]; },
110+
}
106111
}
107112

108113
export interface Err extends Error {

experimental/packages/opentelemetry-instrumentation-http/src/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,3 +495,27 @@ export const getIncomingRequestAttributesOnResponse = (
495495
}
496496
return attributes;
497497
};
498+
499+
export function headerCapture(type: 'request' | 'response', headers: string[]) {
500+
const normalizedHeaders = new Map(headers.map(header => [header.toLowerCase(), header.toLowerCase().replace(/-/g, '_')]));
501+
502+
return (span: Span, getHeader: (key: string) => undefined | string | string[] | number) => {
503+
for (const [capturedHeader, normalizedHeader] of normalizedHeaders) {
504+
const value = getHeader(capturedHeader);
505+
506+
if (value === undefined) {
507+
continue;
508+
}
509+
510+
const key = `http.${type}.header.${normalizedHeader}`;
511+
512+
if (typeof value === 'string') {
513+
span.setAttribute(key, [value]);
514+
} else if (Array.isArray(value)) {
515+
span.setAttribute(key, value);
516+
} else {
517+
span.setAttribute(key, [value]);
518+
}
519+
}
520+
};
521+
}

experimental/packages/opentelemetry-instrumentation-http/test/functionals/http-enable.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,4 +908,87 @@ describe('HttpInstrumentation', () => {
908908
});
909909
});
910910
});
911+
912+
describe('capturing headers as span attributes', () => {
913+
beforeEach(() => {
914+
memoryExporter.reset();
915+
});
916+
917+
before(() => {
918+
instrumentation.setConfig({
919+
headersToSpanAttributes: {
920+
client: { requestHeaders: ['X-Client-Header1'], responseHeaders: ['X-Server-Header1'] },
921+
server: { requestHeaders: ['X-Client-Header2'], responseHeaders: ['X-Server-Header2'] },
922+
}
923+
});
924+
instrumentation.enable();
925+
server = http.createServer((request, response) => {
926+
response.setHeader('X-ServeR-header1', 'server123');
927+
response.setHeader('X-Server-header2', '123server');
928+
response.end('Test Server Response');
929+
});
930+
931+
server.listen(serverPort);
932+
});
933+
934+
after(() => {
935+
server.close();
936+
instrumentation.disable();
937+
});
938+
939+
it('should convert headers to span attributes', async () => {
940+
await httpRequest.get(
941+
`${protocol}://${hostname}:${serverPort}${pathname}`,
942+
{
943+
headers: {
944+
'X-client-header1': 'client123',
945+
'X-CLIENT-HEADER2': '123client',
946+
}
947+
}
948+
);
949+
const spans = memoryExporter.getFinishedSpans();
950+
const [incomingSpan, outgoingSpan] = spans;
951+
952+
assert.strictEqual(spans.length, 2);
953+
954+
assert.deepStrictEqual(
955+
incomingSpan.attributes['http.request.header.x_client_header2'],
956+
['123client']
957+
);
958+
959+
assert.deepStrictEqual(
960+
incomingSpan.attributes['http.response.header.x_server_header2'],
961+
['123server']
962+
);
963+
964+
assert.strictEqual(
965+
incomingSpan.attributes['http.request.header.x_client_header1'],
966+
undefined
967+
);
968+
969+
assert.strictEqual(
970+
incomingSpan.attributes['http.response.header.x_server_header1'],
971+
undefined
972+
);
973+
974+
assert.deepStrictEqual(
975+
outgoingSpan.attributes['http.request.header.x_client_header1'],
976+
['client123']
977+
);
978+
assert.deepStrictEqual(
979+
outgoingSpan.attributes['http.response.header.x_server_header1'],
980+
['server123']
981+
);
982+
983+
assert.strictEqual(
984+
outgoingSpan.attributes['http.request.header.x_client_header2'],
985+
undefined
986+
);
987+
988+
assert.strictEqual(
989+
outgoingSpan.attributes['http.response.header.x_server_header2'],
990+
undefined
991+
);
992+
});
993+
});
911994
});

experimental/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,65 @@ describe('Utility', () => {
465465
verifyValueInAttributes(attributes, undefined, 1200);
466466
});
467467
});
468+
469+
describe('headers to span attributes capture', () => {
470+
let span: Span;
471+
472+
beforeEach(() => {
473+
span = new Span(
474+
new BasicTracerProvider().getTracer('default'),
475+
ROOT_CONTEXT,
476+
'test',
477+
{ spanId: '', traceId: '', traceFlags: TraceFlags.SAMPLED },
478+
SpanKind.INTERNAL
479+
);
480+
});
481+
482+
it('should set attributes for request and response keys', () => {
483+
utils.headerCapture('request', ['Origin'])(span, () => 'localhost');
484+
utils.headerCapture('response', ['Cookie'])(span, () => 'token=123');
485+
assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost']);
486+
assert.deepStrictEqual(span.attributes['http.response.header.cookie'], ['token=123']);
487+
});
488+
489+
it('should set attributes for multiple values', () => {
490+
utils.headerCapture('request', ['Origin'])(span, () => ['localhost', 'www.example.com']);
491+
assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost', 'www.example.com']);
492+
});
493+
494+
it('sets attributes for multiple headers', () => {
495+
utils.headerCapture('request', ['Origin', 'Foo'])(span, header => {
496+
if (header === 'origin') {
497+
return 'localhost';
498+
}
499+
500+
if (header === 'foo') {
501+
return 42;
502+
}
503+
504+
return undefined;
505+
});
506+
507+
assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost']);
508+
assert.deepStrictEqual(span.attributes['http.request.header.foo'], [42]);
509+
});
510+
511+
it('should normalize header names', () => {
512+
utils.headerCapture('request', ['X-Forwarded-For'])(span, () => 'foo');
513+
assert.deepStrictEqual(span.attributes['http.request.header.x_forwarded_for'], ['foo']);
514+
});
515+
516+
it('ignores non-existent headers', () => {
517+
utils.headerCapture('request', ['Origin', 'Accept'])(span, header => {
518+
if (header === 'origin') {
519+
return 'localhost';
520+
}
521+
522+
return undefined;
523+
});
524+
525+
assert.deepStrictEqual(span.attributes['http.request.header.origin'], ['localhost']);
526+
assert.deepStrictEqual(span.attributes['http.request.header.accept'], undefined);
527+
})
528+
});
468529
});

0 commit comments

Comments
 (0)