Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions plugins/node/instrumentation-undici/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
94 changes: 58 additions & 36 deletions plugins/node/instrumentation-undici/src/undici.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,49 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
});
}

/**
* For each header in the request, call the callback. Skips likely-invalid
* headers. Multi-valued headers are passed through. The loop exits early if
* the callback returns true.
*/
private forEachRequestHeader(
request: UndiciRequest,
callback: (key: string, value: string | string[]) => 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this capture the key again since you're starting from the zeroth index of the line string again? Could be something like: const value = line.substring(colonIndex + 1).trim();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eek, you're right!

Which makes me think -- do we have a way to actually unit test the patches against older library versions? I guess that would have to be a separate test package?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eek, you're right!

Which makes me think -- do we have a way to actually unit test the patches against older library versions? I guess that would have to be a separate test package?

The test-all- versions script should perform these tests. You can inspect which versions are tested in the .tav.yml file at the root of this instrumentation.


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
Expand Down Expand Up @@ -218,26 +261,16 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
}

// Get user agent from headers
let userAgent;
if (Array.isArray(request.headers)) {
const idx = request.headers.findIndex(
h => 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One may wonder if the 1st occurrence of the UA header value may be the best option but I guess there is no best option in this scenario. If the request not complies the spec and sets multiple values for UA it may be good to log this issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could log it, although I dunno where that goes in practice? We would probably also be spec-compliant-ish to do value.join(", ").

attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent;
return true; // no need to keep iterating
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: IMO forEachXXX name implies we're executing for all items in the collection. Depending on a previous result to decide to continue the loop does not follow the semantics of the name. Without the comment after the return statement one not familiar with the code will not know that the loop breaks.

The control of the format and access to the headers in a separate function is a good idea but maybe it will serve better if it returns an iterable object. This way the consumer code can loop through it (and break the loop) with the language specifics like for..of and break

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, I can do an iterable, I wasn't totally sure if that was kosher yet for the lib but I guess they're supported pretty much everywhere these days.

}
} 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(
Expand Down Expand Up @@ -330,25 +363,14 @@ export class UndiciInstrumentation extends InstrumentationBase<UndiciInstrumenta
config.headersToSpanAttributes.requestHeaders.map(n => 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
});
}

Expand Down
108 changes: 76 additions & 32 deletions plugins/node/instrumentation-undici/test/undici.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[] | undefined>;
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const assertSpan = (
if (userAgent) {
assert.strictEqual(
span.attributes[SemanticAttributes.USER_AGENT_ORIGINAL],
userAgent
Array.isArray(userAgent) ? userAgent[0] : userAgent
);
}
}
Expand Down