Skip to content

Commit 8534336

Browse files
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.
1 parent d2c1be4 commit 8534336

File tree

4 files changed

+138
-71
lines changed

4 files changed

+138
-71
lines changed

plugins/node/instrumentation-undici/src/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ export interface UndiciRequest {
2222
path: string;
2323
/**
2424
* Serialized string of headers in the form `name: value\r\n` for v5
25-
* Array of strings v6
25+
* Array of strings `[key1, value1, key2, value2]`, where values are
26+
* `string | string[]` for v6
2627
*/
27-
headers: string | string[];
28+
headers: string | (string | string[])[];
2829
/**
2930
* Helper method to add headers (from v6)
3031
*/

plugins/node/instrumentation-undici/src/undici.ts

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,49 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
165165
});
166166
}
167167

168+
/**
169+
* For each header in the request, call the callback. Skips likely-invalid
170+
* headers. Multi-valued headers are passed through. The loop exits early if
171+
* the callback returns true.
172+
*/
173+
private forEachRequestHeader(
174+
request: UndiciRequest,
175+
callback: (key: string, value: string | string[]) => boolean | undefined
176+
): void {
177+
if (Array.isArray(request.headers)) {
178+
// headers are an array [k1, v2, k2, v2] (undici v6+)
179+
for (let i = 0; i < request.headers.length; i += 2) {
180+
const key = request.headers[i];
181+
if (typeof key !== 'string') {
182+
// Shouldn't happen, but the types don't know that, and let's be safe
183+
continue;
184+
}
185+
if (callback(key, request.headers[i + 1])) {
186+
break;
187+
}
188+
}
189+
} else if (typeof request.headers === 'string') {
190+
// headers are a raw string (undici v5)
191+
const headers = request.headers.split('\r\n');
192+
for (const line of headers) {
193+
if (!line) {
194+
continue;
195+
}
196+
const colonIndex = line.indexOf(':');
197+
if (colonIndex === -1) {
198+
// Invalid header? Probably this can't happen, but again let's be safe.
199+
continue;
200+
}
201+
const key = line.substring(0, colonIndex);
202+
const value = line.substring(0, colonIndex + 1);
203+
204+
if (callback(key, value)) {
205+
break;
206+
}
207+
}
208+
}
209+
}
210+
168211
// This is the 1st message we receive for each request (fired after request creation). Here we will
169212
// create the span and populate some atttributes, then link the span to the request for further
170213
// span processing
@@ -218,26 +261,16 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
218261
}
219262

220263
// Get user agent from headers
221-
let userAgent;
222-
if (Array.isArray(request.headers)) {
223-
const idx = request.headers.findIndex(
224-
h => h.toLowerCase() === 'user-agent'
225-
);
226-
if (idx >= 0) {
227-
userAgent = request.headers[idx + 1];
264+
this.forEachRequestHeader(request, (key, value) => {
265+
if (key.toLowerCase() === 'user-agent') {
266+
// user-agent should only appear once per the spec, but the library doesn't
267+
// prevent passing it multiple times, so we handle that to be safe.
268+
const userAgent = Array.isArray(value) ? value[0] : value;
269+
attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent;
270+
return true; // no need to keep iterating
228271
}
229-
} else if (typeof request.headers === 'string') {
230-
const headers = request.headers.split('\r\n');
231-
const uaHeader = headers.find(h =>
232-
h.toLowerCase().startsWith('user-agent')
233-
);
234-
userAgent =
235-
uaHeader && uaHeader.substring(uaHeader.indexOf(':') + 1).trim();
236-
}
237-
238-
if (userAgent) {
239-
attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent;
240-
}
272+
return false;
273+
});
241274

242275
// Get attributes from the hook if present
243276
const hookAttributes = safeExecuteInTheMiddle(
@@ -330,25 +363,14 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
330363
config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase())
331364
);
332365

333-
// headers could be in form
334-
// ['name: value', ...] for v5
335-
// ['name', 'value', ...] for v6
336-
const rawHeaders = Array.isArray(request.headers)
337-
? request.headers
338-
: request.headers.split('\r\n');
339-
rawHeaders.forEach((h, idx) => {
340-
const sepIndex = h.indexOf(':');
341-
const hasSeparator = sepIndex !== -1;
342-
const name = (
343-
hasSeparator ? h.substring(0, sepIndex) : h
344-
).toLowerCase();
345-
const value = hasSeparator
346-
? h.substring(sepIndex + 1)
347-
: rawHeaders[idx + 1];
348-
366+
this.forEachRequestHeader(request, (key, value) => {
367+
const name = key.toLowerCase();
349368
if (headersToAttribs.has(name)) {
350-
spanAttributes[`http.request.header.${name}`] = value.trim();
369+
spanAttributes[`http.request.header.${name}`] = value
370+
.toString()
371+
.trim();
351372
}
373+
return false; // keep iterating always, there may be more
352374
});
353375
}
354376

plugins/node/instrumentation-undici/test/undici.test.ts

Lines changed: 76 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -795,43 +795,87 @@ describe('UndiciInstrumentation `undici` tests', function () {
795795
});
796796
});
797797

798-
it('should not report an user-agent if it was not defined', async function () {
799-
let spans = memoryExporter.getFinishedSpans();
800-
assert.strictEqual(spans.length, 0);
798+
const userAgentRequests: Array<{
799+
name: string;
800+
headers: Record<string, string | string[] | undefined>;
801+
expectedUserAgent: string | undefined;
802+
}> = [
803+
{
804+
name: 'no user-agent',
805+
headers: { 'foo-client': 'bar' },
806+
expectedUserAgent: undefined,
807+
},
808+
{
809+
name: 'a user-agent',
810+
headers: { 'foo-client': 'bar', 'user-agent': 'custom' },
811+
expectedUserAgent: 'custom',
812+
},
813+
{
814+
name: 'explicitly-undefined user-agent',
815+
headers: { 'foo-client': 'bar', 'user-agent': undefined },
816+
expectedUserAgent: undefined,
817+
},
818+
// contra the spec, but we shouldn't crash
819+
{
820+
name: 'multiple user-agents',
821+
headers: {
822+
'foo-client': 'bar',
823+
'user-agent': ['agent', 'other-agent'],
824+
},
825+
expectedUserAgent: 'agent',
826+
},
827+
{
828+
name: 'another header with value user-agent',
829+
headers: { 'foo-client': 'user-agent', 'user-agent': 'custom' },
830+
expectedUserAgent: 'custom',
831+
},
832+
{
833+
name: 'another header with multiple values',
834+
headers: { 'foo-client': ['one', 'two'], 'user-agent': 'custom' },
835+
expectedUserAgent: 'custom',
836+
},
837+
{
838+
name: 'another header with explicitly-undefined value',
839+
headers: { 'foo-client': undefined, 'user-agent': 'custom' },
840+
expectedUserAgent: 'custom',
841+
},
842+
];
801843

802-
// Do some requests
803-
const headers = {
804-
'foo-client': 'bar',
805-
};
844+
for (const testCase of userAgentRequests) {
845+
it(`should report the correct user-agent when the request has ${testCase.name}`, async function () {
846+
let spans = memoryExporter.getFinishedSpans();
847+
assert.strictEqual(spans.length, 0);
806848

807-
const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`;
808-
const queryResponse = await undici.request(queryRequestUrl, { headers });
809-
await consumeResponseBody(queryResponse.body);
849+
const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`;
850+
const queryResponse = await undici.request(queryRequestUrl, {
851+
headers: testCase.headers,
852+
});
853+
await consumeResponseBody(queryResponse.body);
810854

811-
assert.ok(
812-
queryResponse.headers['propagation-error'] == null,
813-
'propagation is set for instrumented requests'
814-
);
855+
assert.ok(
856+
queryResponse.headers['propagation-error'] == null,
857+
'propagation is set for instrumented requests'
858+
);
815859

816-
spans = memoryExporter.getFinishedSpans();
817-
const span = spans[0];
818-
assert.ok(span, 'a span is present');
819-
assert.strictEqual(spans.length, 1);
820-
assertSpan(span, {
821-
hostname: 'localhost',
822-
httpStatusCode: queryResponse.statusCode,
823-
httpMethod: 'GET',
824-
path: '/',
825-
query: '?query=test',
826-
reqHeaders: headers,
827-
resHeaders: queryResponse.headers,
860+
spans = memoryExporter.getFinishedSpans();
861+
const span = spans[0];
862+
assert.ok(span, 'a span is present');
863+
assert.strictEqual(spans.length, 1);
864+
assertSpan(span, {
865+
hostname: 'localhost',
866+
httpStatusCode: queryResponse.statusCode,
867+
httpMethod: 'GET',
868+
path: '/',
869+
query: '?query=test',
870+
reqHeaders: testCase.headers,
871+
resHeaders: queryResponse.headers,
872+
});
873+
assert.strictEqual(
874+
span.attributes['user_agent.original'],
875+
testCase.expectedUserAgent
876+
);
828877
});
829-
assert.strictEqual(
830-
span.attributes['user_agent.original'],
831-
undefined,
832-
'user-agent is undefined'
833-
);
834-
});
878+
}
835879

836880
it('should create valid span if request.path is a full URL', async function () {
837881
let spans = memoryExporter.getFinishedSpans();

plugins/node/instrumentation-undici/test/utils/assertSpan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export const assertSpan = (
171171
if (userAgent) {
172172
assert.strictEqual(
173173
span.attributes[SemanticAttributes.USER_AGENT_ORIGINAL],
174-
userAgent
174+
Array.isArray(userAgent) ? userAgent[0] : userAgent
175175
);
176176
}
177177
}

0 commit comments

Comments
 (0)