From 8534336c29b63d5121c7be7deb31ebac10f5b62a Mon Sep 17 00:00:00 2001 From: Ben Kraft Date: Thu, 3 Apr 2025 12:38:19 -0700 Subject: [PATCH 1/9] fix(instrumentation-undici): fix several header handling handling bugs The handling for User-Agent had a bunch of bugs, most notably with the handling of multiple-valued headers, which are rare but legal. Code similar to the added test "another header with multiple values" caused us errors in production from the user-agent code, and is where I started. Reading the code, writing tests, and improving the types revealed several more bugs in the same code as well as the span-to-attributes handling; the added tests "multiple user-agents", "another header with value user-agent" also fail before this PR (the others are just to actually cover the ordinary cases. Similarly, I also fixed an incorrect case in undici v5 where it would treat a `user-agent-bogus` header as a user agent, but I couldn't write a test case since the tests run with the newer version. --- .../node/instrumentation-undici/src/types.ts | 5 +- .../node/instrumentation-undici/src/undici.ts | 94 +++++++++------ .../test/undici.test.ts | 108 ++++++++++++------ .../test/utils/assertSpan.ts | 2 +- 4 files changed, 138 insertions(+), 71 deletions(-) diff --git a/plugins/node/instrumentation-undici/src/types.ts b/plugins/node/instrumentation-undici/src/types.ts index 0b56998e6b..31cfb4d6cc 100644 --- a/plugins/node/instrumentation-undici/src/types.ts +++ b/plugins/node/instrumentation-undici/src/types.ts @@ -22,9 +22,10 @@ export interface UndiciRequest { path: string; /** * Serialized string of headers in the form `name: value\r\n` for v5 - * Array of strings v6 + * Array of strings `[key1, value1, key2, value2]`, where values are + * `string | string[]` for v6 */ - headers: string | string[]; + headers: string | (string | string[])[]; /** * Helper method to add headers (from v6) */ diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts index f2fce42108..0d3d907c62 100644 --- a/plugins/node/instrumentation-undici/src/undici.ts +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -165,6 +165,49 @@ export class UndiciInstrumentation extends InstrumentationBase boolean | undefined + ): void { + if (Array.isArray(request.headers)) { + // headers are an array [k1, v2, k2, v2] (undici v6+) + for (let i = 0; i < request.headers.length; i += 2) { + const key = request.headers[i]; + if (typeof key !== 'string') { + // Shouldn't happen, but the types don't know that, and let's be safe + continue; + } + if (callback(key, request.headers[i + 1])) { + break; + } + } + } else if (typeof request.headers === 'string') { + // headers are a raw string (undici v5) + const headers = request.headers.split('\r\n'); + for (const line of headers) { + if (!line) { + continue; + } + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) { + // Invalid header? Probably this can't happen, but again let's be safe. + continue; + } + const key = line.substring(0, colonIndex); + const value = line.substring(0, colonIndex + 1); + + if (callback(key, value)) { + break; + } + } + } + } + // This is the 1st message we receive for each request (fired after request creation). Here we will // create the span and populate some atttributes, then link the span to the request for further // span processing @@ -218,26 +261,16 @@ export class UndiciInstrumentation extends InstrumentationBase h.toLowerCase() === 'user-agent' - ); - if (idx >= 0) { - userAgent = request.headers[idx + 1]; + this.forEachRequestHeader(request, (key, value) => { + if (key.toLowerCase() === 'user-agent') { + // user-agent should only appear once per the spec, but the library doesn't + // prevent passing it multiple times, so we handle that to be safe. + const userAgent = Array.isArray(value) ? value[0] : value; + attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent; + return true; // no need to keep iterating } - } else if (typeof request.headers === 'string') { - const headers = request.headers.split('\r\n'); - const uaHeader = headers.find(h => - h.toLowerCase().startsWith('user-agent') - ); - userAgent = - uaHeader && uaHeader.substring(uaHeader.indexOf(':') + 1).trim(); - } - - if (userAgent) { - attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent; - } + return false; + }); // Get attributes from the hook if present const hookAttributes = safeExecuteInTheMiddle( @@ -330,25 +363,14 @@ export class UndiciInstrumentation extends InstrumentationBase n.toLowerCase()) ); - // headers could be in form - // ['name: value', ...] for v5 - // ['name', 'value', ...] for v6 - const rawHeaders = Array.isArray(request.headers) - ? request.headers - : request.headers.split('\r\n'); - rawHeaders.forEach((h, idx) => { - const sepIndex = h.indexOf(':'); - const hasSeparator = sepIndex !== -1; - const name = ( - hasSeparator ? h.substring(0, sepIndex) : h - ).toLowerCase(); - const value = hasSeparator - ? h.substring(sepIndex + 1) - : rawHeaders[idx + 1]; - + this.forEachRequestHeader(request, (key, value) => { + const name = key.toLowerCase(); if (headersToAttribs.has(name)) { - spanAttributes[`http.request.header.${name}`] = value.trim(); + spanAttributes[`http.request.header.${name}`] = value + .toString() + .trim(); } + return false; // keep iterating always, there may be more }); } diff --git a/plugins/node/instrumentation-undici/test/undici.test.ts b/plugins/node/instrumentation-undici/test/undici.test.ts index 5a0757460c..5ebab1c5d1 100644 --- a/plugins/node/instrumentation-undici/test/undici.test.ts +++ b/plugins/node/instrumentation-undici/test/undici.test.ts @@ -795,43 +795,87 @@ describe('UndiciInstrumentation `undici` tests', function () { }); }); - it('should not report an user-agent if it was not defined', async function () { - let spans = memoryExporter.getFinishedSpans(); - assert.strictEqual(spans.length, 0); + const userAgentRequests: Array<{ + name: string; + headers: Record; + expectedUserAgent: string | undefined; + }> = [ + { + name: 'no user-agent', + headers: { 'foo-client': 'bar' }, + expectedUserAgent: undefined, + }, + { + name: 'a user-agent', + headers: { 'foo-client': 'bar', 'user-agent': 'custom' }, + expectedUserAgent: 'custom', + }, + { + name: 'explicitly-undefined user-agent', + headers: { 'foo-client': 'bar', 'user-agent': undefined }, + expectedUserAgent: undefined, + }, + // contra the spec, but we shouldn't crash + { + name: 'multiple user-agents', + headers: { + 'foo-client': 'bar', + 'user-agent': ['agent', 'other-agent'], + }, + expectedUserAgent: 'agent', + }, + { + name: 'another header with value user-agent', + headers: { 'foo-client': 'user-agent', 'user-agent': 'custom' }, + expectedUserAgent: 'custom', + }, + { + name: 'another header with multiple values', + headers: { 'foo-client': ['one', 'two'], 'user-agent': 'custom' }, + expectedUserAgent: 'custom', + }, + { + name: 'another header with explicitly-undefined value', + headers: { 'foo-client': undefined, 'user-agent': 'custom' }, + expectedUserAgent: 'custom', + }, + ]; - // Do some requests - const headers = { - 'foo-client': 'bar', - }; + for (const testCase of userAgentRequests) { + it(`should report the correct user-agent when the request has ${testCase.name}`, async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); - const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; - const queryResponse = await undici.request(queryRequestUrl, { headers }); - await consumeResponseBody(queryResponse.body); + const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const queryResponse = await undici.request(queryRequestUrl, { + headers: testCase.headers, + }); + await consumeResponseBody(queryResponse.body); - assert.ok( - queryResponse.headers['propagation-error'] == null, - 'propagation is set for instrumented requests' - ); + assert.ok( + queryResponse.headers['propagation-error'] == null, + 'propagation is set for instrumented requests' + ); - spans = memoryExporter.getFinishedSpans(); - const span = spans[0]; - assert.ok(span, 'a span is present'); - assert.strictEqual(spans.length, 1); - assertSpan(span, { - hostname: 'localhost', - httpStatusCode: queryResponse.statusCode, - httpMethod: 'GET', - path: '/', - query: '?query=test', - reqHeaders: headers, - resHeaders: queryResponse.headers, + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: queryResponse.statusCode, + httpMethod: 'GET', + path: '/', + query: '?query=test', + reqHeaders: testCase.headers, + resHeaders: queryResponse.headers, + }); + assert.strictEqual( + span.attributes['user_agent.original'], + testCase.expectedUserAgent + ); }); - assert.strictEqual( - span.attributes['user_agent.original'], - undefined, - 'user-agent is undefined' - ); - }); + } it('should create valid span if request.path is a full URL', async function () { let spans = memoryExporter.getFinishedSpans(); diff --git a/plugins/node/instrumentation-undici/test/utils/assertSpan.ts b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts index 3ab0a66d7d..f7197d8889 100644 --- a/plugins/node/instrumentation-undici/test/utils/assertSpan.ts +++ b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts @@ -171,7 +171,7 @@ export const assertSpan = ( if (userAgent) { assert.strictEqual( span.attributes[SemanticAttributes.USER_AGENT_ORIGINAL], - userAgent + Array.isArray(userAgent) ? userAgent[0] : userAgent ); } } From f41050de8ad4f186d83adfb5045aefbcd51f532e Mon Sep 17 00:00:00 2001 From: Ben Kraft Date: Wed, 9 Apr 2025 14:19:34 -0700 Subject: [PATCH 2/9] review comments --- .../node/instrumentation-undici/src/undici.ts | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts index 0d3d907c62..5101bbb76e 100644 --- a/plugins/node/instrumentation-undici/src/undici.ts +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -166,14 +166,12 @@ export class UndiciInstrumentation extends InstrumentationBase boolean | undefined - ): void { + private *requestHeaders( + request: UndiciRequest + ): Generator<{ key: string; value: string }, never, never> { if (Array.isArray(request.headers)) { // headers are an array [k1, v2, k2, v2] (undici v6+) for (let i = 0; i < request.headers.length; i += 2) { @@ -182,9 +180,7 @@ export class UndiciInstrumentation extends InstrumentationBase { + for (const { key, value } of this.requestHeaders(request)) { if (key.toLowerCase() === 'user-agent') { // user-agent should only appear once per the spec, but the library doesn't // prevent passing it multiple times, so we handle that to be safe. const userAgent = Array.isArray(value) ? value[0] : value; attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent; - return true; // no need to keep iterating + break; } - return false; - }); + } // Get attributes from the hook if present const hookAttributes = safeExecuteInTheMiddle( @@ -363,15 +355,14 @@ export class UndiciInstrumentation extends InstrumentationBase n.toLowerCase()) ); - this.forEachRequestHeader(request, (key, value) => { + for (const { key, value } of this.requestHeaders(request)) { const name = key.toLowerCase(); if (headersToAttribs.has(name)) { spanAttributes[`http.request.header.${name}`] = value .toString() .trim(); } - return false; // keep iterating always, there may be more - }); + } } span.setAttributes(spanAttributes); From 3b18bf82f45cf73b3323f64270a43aa7ea444128 Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 3 Jun 2025 17:56:56 +0200 Subject: [PATCH 3/9] fix(instr-undici): fix compile error --- plugins/node/instrumentation-undici/src/undici.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts index 5101bbb76e..6dcdcee062 100644 --- a/plugins/node/instrumentation-undici/src/undici.ts +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -169,9 +169,9 @@ export class UndiciInstrumentation extends InstrumentationBase { + ): Generator<{ key: string; value: string | string[] }> { if (Array.isArray(request.headers)) { // headers are an array [k1, v2, k2, v2] (undici v6+) for (let i = 0; i < request.headers.length; i += 2) { @@ -254,7 +254,7 @@ export class UndiciInstrumentation extends InstrumentationBase n.toLowerCase()) ); - for (const { key, value } of this.requestHeaders(request)) { + for (const { key, value } of this.parseRequestHeaders(request)) { const name = key.toLowerCase(); if (headersToAttribs.has(name)) { spanAttributes[`http.request.header.${name}`] = value From 9a7f8d72def50fc0048eca5c0cb165f5e335b748 Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 3 Jun 2025 21:34:19 +0200 Subject: [PATCH 4/9] chore: use preferred separator for header values --- plugins/node/instrumentation-undici/src/undici.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts index 6dcdcee062..9a916c019c 100644 --- a/plugins/node/instrumentation-undici/src/undici.ts +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -258,7 +258,8 @@ export class UndiciInstrumentation extends InstrumentationBase Date: Tue, 3 Jun 2025 21:36:03 +0200 Subject: [PATCH 5/9] chore: fix lint errors --- plugins/node/instrumentation-undici/src/undici.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts index 9a916c019c..f7a682b951 100644 --- a/plugins/node/instrumentation-undici/src/undici.ts +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -259,7 +259,9 @@ export class UndiciInstrumentation extends InstrumentationBase Date: Tue, 3 Jun 2025 22:44:58 +0200 Subject: [PATCH 6/9] tests(inst-undici): fix test assertions --- plugins/node/instrumentation-undici/test/undici.test.ts | 2 +- plugins/node/instrumentation-undici/test/utils/assertSpan.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/node/instrumentation-undici/test/undici.test.ts b/plugins/node/instrumentation-undici/test/undici.test.ts index f58d22d95d..2a124fb199 100644 --- a/plugins/node/instrumentation-undici/test/undici.test.ts +++ b/plugins/node/instrumentation-undici/test/undici.test.ts @@ -824,7 +824,7 @@ describe('UndiciInstrumentation `undici` tests', function () { 'foo-client': 'bar', 'user-agent': ['agent', 'other-agent'], }, - expectedUserAgent: 'agent', + expectedUserAgent: 'other-agent', }, { name: 'another header with value user-agent', diff --git a/plugins/node/instrumentation-undici/test/utils/assertSpan.ts b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts index f7197d8889..52f3f1dcae 100644 --- a/plugins/node/instrumentation-undici/test/utils/assertSpan.ts +++ b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts @@ -171,7 +171,7 @@ export const assertSpan = ( if (userAgent) { assert.strictEqual( span.attributes[SemanticAttributes.USER_AGENT_ORIGINAL], - Array.isArray(userAgent) ? userAgent[0] : userAgent + Array.isArray(userAgent) ? userAgent[userAgent.length -1] : userAgent ); } } From 96880eac01a41583063c955c4651f15e915735bf Mon Sep 17 00:00:00 2001 From: David Luna Date: Tue, 3 Jun 2025 23:00:26 +0200 Subject: [PATCH 7/9] tests(instr-undici): fix lint issue --- plugins/node/instrumentation-undici/test/utils/assertSpan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/node/instrumentation-undici/test/utils/assertSpan.ts b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts index 52f3f1dcae..c5d584dd46 100644 --- a/plugins/node/instrumentation-undici/test/utils/assertSpan.ts +++ b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts @@ -171,7 +171,7 @@ export const assertSpan = ( if (userAgent) { assert.strictEqual( span.attributes[SemanticAttributes.USER_AGENT_ORIGINAL], - Array.isArray(userAgent) ? userAgent[userAgent.length -1] : userAgent + Array.isArray(userAgent) ? userAgent[userAgent.length - 1] : userAgent ); } } From 01c1c865fa2c29fe6e0b822a96a47cdd181954f8 Mon Sep 17 00:00:00 2001 From: David Luna Date: Wed, 4 Jun 2025 00:23:27 +0200 Subject: [PATCH 8/9] fix(instr-undici): fix headers parsing --- .../node/instrumentation-undici/src/undici.ts | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts index f7a682b951..b0c32df5da 100644 --- a/plugins/node/instrumentation-undici/src/undici.ts +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -165,25 +165,24 @@ export class UndiciInstrumentation extends InstrumentationBase { + private parseRequestHeaders(request: UndiciRequest) { + const result = new Map(); + if (Array.isArray(request.headers)) { // headers are an array [k1, v2, k2, v2] (undici v6+) + // values could be string or a string[] for multiple values for (let i = 0; i < request.headers.length; i += 2) { const key = request.headers[i]; - if (typeof key !== 'string') { - // Shouldn't happen, but the types don't know that, and let's be safe - continue; + const value = request.headers[i + 1]; + + // Key should always be a string, but the types don't know that, and let's be safe + if (typeof key === 'string') { + result.set(key.toLowerCase(), value); } - yield { key, value: request.headers[i + 1] }; } } else if (typeof request.headers === 'string') { // headers are a raw string (undici v5) + // headers could be repeated in several lines for multiple values const headers = request.headers.split('\r\n'); for (const line of headers) { if (!line) { @@ -194,11 +193,20 @@ export class UndiciInstrumentation extends InstrumentationBase n.toLowerCase()) ); + const headersMap = this.parseRequestHeaders(request); - for (const { key, value } of this.parseRequestHeaders(request)) { - const name = key.toLowerCase(); + for (const [name, value] of headersMap.entries()) { if (headersToAttribs.has(name)) { const attrValue = Array.isArray(value) ? value.join(', ') : value; spanAttributes[`http.request.header.${name}`] = attrValue.trim(); From 48ed2239ebcc2eeaeee40cd4a02166ea73db756e Mon Sep 17 00:00:00 2001 From: David Luna Date: Wed, 4 Jun 2025 19:48:21 +0200 Subject: [PATCH 9/9] chore: remove unnecessary trim --- plugins/node/instrumentation-undici/src/undici.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts index b0c32df5da..519ba4691f 100644 --- a/plugins/node/instrumentation-undici/src/undici.ts +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -266,6 +266,9 @@ export class UndiciInstrumentation extends InstrumentationBase