Skip to content

Commit 8820f65

Browse files
fix(instr-undici): fix user agent extraction and handle of multiple values on headers (#2875)
Co-authored-by: Ben Kraft <[email protected]>
1 parent 5861dfa commit 8820f65

File tree

4 files changed

+139
-71
lines changed

4 files changed

+139
-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: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,50 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
165165
});
166166
}
167167

168+
private parseRequestHeaders(request: UndiciRequest) {
169+
const result = new Map<string, string | string[]>();
170+
171+
if (Array.isArray(request.headers)) {
172+
// headers are an array [k1, v2, k2, v2] (undici v6+)
173+
// values could be string or a string[] for multiple values
174+
for (let i = 0; i < request.headers.length; i += 2) {
175+
const key = request.headers[i];
176+
const value = request.headers[i + 1];
177+
178+
// Key should always be a string, but the types don't know that, and let's be safe
179+
if (typeof key === 'string') {
180+
result.set(key.toLowerCase(), value);
181+
}
182+
}
183+
} else if (typeof request.headers === 'string') {
184+
// headers are a raw string (undici v5)
185+
// headers could be repeated in several lines for multiple values
186+
const headers = request.headers.split('\r\n');
187+
for (const line of headers) {
188+
if (!line) {
189+
continue;
190+
}
191+
const colonIndex = line.indexOf(':');
192+
if (colonIndex === -1) {
193+
// Invalid header? Probably this can't happen, but again let's be safe.
194+
continue;
195+
}
196+
const key = line.substring(0, colonIndex).toLowerCase();
197+
const value = line.substring(colonIndex + 1).trim();
198+
const allValues = result.get(key);
199+
200+
if (allValues && Array.isArray(allValues)) {
201+
allValues.push(value);
202+
} else if (allValues) {
203+
result.set(key, [allValues, value]);
204+
} else {
205+
result.set(key, value);
206+
}
207+
}
208+
}
209+
return result;
210+
}
211+
168212
// This is the 1st message we receive for each request (fired after request creation). Here we will
169213
// create the span and populate some atttributes, then link the span to the request for further
170214
// span processing
@@ -218,24 +262,16 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
218262
}
219263

220264
// 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];
228-
}
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) {
265+
const headersMap = this.parseRequestHeaders(request);
266+
const userAgentValues = headersMap.get('user-agent');
267+
268+
if (userAgentValues) {
269+
// NOTE: having multiple user agents is not expected so
270+
// we're going to take last one like `curl` does
271+
// ref: https://curl.se/docs/manpage.html#-A
272+
const userAgent = Array.isArray(userAgentValues)
273+
? userAgentValues[userAgentValues.length - 1]
274+
: userAgentValues;
239275
attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent;
240276
}
241277

@@ -329,27 +365,14 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
329365
const headersToAttribs = new Set(
330366
config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase())
331367
);
368+
const headersMap = this.parseRequestHeaders(request);
332369

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-
370+
for (const [name, value] of headersMap.entries()) {
349371
if (headersToAttribs.has(name)) {
350-
spanAttributes[`http.request.header.${name}`] = value.trim();
372+
const attrValue = Array.isArray(value) ? value.join(', ') : value;
373+
spanAttributes[`http.request.header.${name}`] = attrValue;
351374
}
352-
});
375+
}
353376
}
354377

355378
span.setAttributes(spanAttributes);

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

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

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

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

809-
const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`;
810-
const queryResponse = await undici.request(queryRequestUrl, { headers });
811-
await consumeResponseBody(queryResponse.body);
851+
const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`;
852+
const queryResponse = await undici.request(queryRequestUrl, {
853+
headers: testCase.headers,
854+
});
855+
await consumeResponseBody(queryResponse.body);
812856

813-
assert.ok(
814-
queryResponse.headers['propagation-error'] == null,
815-
'propagation is set for instrumented requests'
816-
);
857+
assert.ok(
858+
queryResponse.headers['propagation-error'] == null,
859+
'propagation is set for instrumented requests'
860+
);
817861

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

838882
it('should create valid span if request.path is a full URL', async function () {
839883
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[userAgent.length - 1] : userAgent
175175
);
176176
}
177177
}

0 commit comments

Comments
 (0)