feat(opentelemetry-js): add content size attributes to HTTP spans (#1625)

* feat(opentelemetry-js): extract content-length header for span attrs

Signed-off-by: Carlo Pearson <cpearson@newrelic.com>

* feat(opentelemetry-js): add content-length attributes to HTTP spans

Signed-off-by: Carlo Pearson <cpearson@newrelic.com>

* feat(opentelemetry-js): linting

* feat(opentelemetry-js): verify content length attributes are a number

* feat(opentelemetry-js): refactor setting content-length attributes

* feat(opentelemetry-js): lint fixes

* feat(opentelemetry-js): lint fixes

* fix: incorrect docs

Co-authored-by: Carlo Pearson <cpearson@newrelic.com>
Co-authored-by: Daniel Dyla <dyladan@users.noreply.github.com>
Co-authored-by: Bartlomiej Obecny <bobecny@gmail.com>
This commit is contained in:
Nik Zap 2020-12-10 10:53:08 -08:00 committed by GitHub
parent b260f891ee
commit 52c60966f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 256 additions and 0 deletions

View File

@ -177,6 +177,66 @@ export const setSpanWithError = (
span.setStatus(status);
};
/**
* Adds attributes for request content-length and content-encoding HTTP headers
* @param { IncomingMessage } Request object whose headers will be analyzed
* @param { Attributes } Attributes object to be modified
*/
export const setRequestContentLengthAttribute = (
request: IncomingMessage,
attributes: Attributes
) => {
const length = getContentLength(request.headers);
if (length === null) return;
if (isCompressed(request.headers)) {
attributes[HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH] = length;
} else {
attributes[HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED] = length;
}
};
/**
* Adds attributes for response content-length and content-encoding HTTP headers
* @param { IncomingMessage } Response object whose headers will be analyzed
* @param { Attributes } Attributes object to be modified
*/
export const setResponseContentLengthAttribute = (
response: IncomingMessage,
attributes: Attributes
) => {
const length = getContentLength(response.headers);
if (length === null) return;
if (isCompressed(response.headers)) {
attributes[HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH] = length;
} else {
attributes[
HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED
] = length;
}
};
function getContentLength(
headers: OutgoingHttpHeaders | IncomingHttpHeaders
): number | null {
const contentLengthHeader = headers['content-length'];
if (contentLengthHeader === undefined) return null;
const contentLength = parseInt(contentLengthHeader as string, 10);
if (isNaN(contentLength)) return null;
return contentLength;
}
export const isCompressed = (
headers: OutgoingHttpHeaders | IncomingHttpHeaders
): boolean => {
const encoding = headers['content-encoding'];
return !!encoding && encoding !== 'identity';
};
/**
* Makes sure options is an url object
* return an object with default value and parsed options
@ -318,12 +378,15 @@ export const getOutgoingRequestAttributesOnResponse = (
): Attributes => {
const { statusCode, statusMessage, httpVersion, socket } = response;
const { remoteAddress, remotePort } = socket;
const attributes: Attributes = {
[GeneralAttribute.NET_PEER_IP]: remoteAddress,
[GeneralAttribute.NET_PEER_PORT]: remotePort,
[HttpAttribute.HTTP_HOST]: `${options.hostname}:${remotePort}`,
};
setResponseContentLengthAttribute(response, attributes);
if (statusCode) {
attributes[HttpAttribute.HTTP_STATUS_CODE] = statusCode;
attributes[HttpAttribute.HTTP_STATUS_TEXT] = (
@ -384,6 +447,8 @@ export const getIncomingRequestAttributes = (
attributes[HttpAttribute.HTTP_USER_AGENT] = userAgent;
}
setRequestContentLengthAttribute(request, attributes);
const httpKindAttributes = getAttributesFromHttpKind(httpVersion);
return Object.assign(attributes, httpKindAttributes);
};

View File

@ -14,6 +14,7 @@
* limitations under the License.
*/
import {
Attributes,
StatusCode,
ROOT_CONTEXT,
SpanKind,
@ -308,4 +309,146 @@ describe('Utility', () => {
assert.deepEqual(attributes[HttpAttribute.HTTP_ROUTE], undefined);
});
});
// Verify the key in the given attributes is set to the given value,
// and that no other HTTP Content Length attributes are set.
function verifyValueInAttributes(
attributes: Attributes,
key: string | undefined,
value: number
) {
const httpAttributes = [
HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED,
HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH,
HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED,
HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH,
];
for (const attr of httpAttributes) {
if (attr === key) {
assert.strictEqual(attributes[attr], value);
} else {
assert.strictEqual(attributes[attr], undefined);
}
}
}
describe('setRequestContentLengthAttributes()', () => {
it('should set request content-length uncompressed attribute with no content-encoding header', () => {
const attributes: Attributes = {};
const request = {} as IncomingMessage;
request.headers = {
'content-length': '1200',
};
utils.setRequestContentLengthAttribute(request, attributes);
verifyValueInAttributes(
attributes,
HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED,
1200
);
});
it('should set request content-length uncompressed attribute with "identity" content-encoding header', () => {
const attributes: Attributes = {};
const request = {} as IncomingMessage;
request.headers = {
'content-length': '1200',
'content-encoding': 'identity',
};
utils.setRequestContentLengthAttribute(request, attributes);
verifyValueInAttributes(
attributes,
HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED,
1200
);
});
it('should set request content-length compressed attribute with "gzip" content-encoding header', () => {
const attributes: Attributes = {};
const request = {} as IncomingMessage;
request.headers = {
'content-length': '1200',
'content-encoding': 'gzip',
};
utils.setRequestContentLengthAttribute(request, attributes);
verifyValueInAttributes(
attributes,
HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH,
1200
);
});
});
describe('setResponseContentLengthAttributes()', () => {
it('should set response content-length uncompressed attribute with no content-encoding header', () => {
const attributes: Attributes = {};
const response = {} as IncomingMessage;
response.headers = {
'content-length': '1200',
};
utils.setResponseContentLengthAttribute(response, attributes);
verifyValueInAttributes(
attributes,
HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED,
1200
);
});
it('should set response content-length uncompressed attribute with "identity" content-encoding header', () => {
const attributes: Attributes = {};
const response = {} as IncomingMessage;
response.headers = {
'content-length': '1200',
'content-encoding': 'identity',
};
utils.setResponseContentLengthAttribute(response, attributes);
verifyValueInAttributes(
attributes,
HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED,
1200
);
});
it('should set response content-length compressed attribute with "gzip" content-encoding header', () => {
const attributes: Attributes = {};
const response = {} as IncomingMessage;
response.headers = {
'content-length': '1200',
'content-encoding': 'gzip',
};
utils.setResponseContentLengthAttribute(response, attributes);
verifyValueInAttributes(
attributes,
HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH,
1200
);
});
it('should set no attributes with no content-length header', () => {
const attributes: Attributes = {};
const message = {} as IncomingMessage;
message.headers = {
'content-encoding': 'gzip',
};
utils.setResponseContentLengthAttribute(message, attributes);
verifyValueInAttributes(attributes, undefined, 1200);
});
});
});

View File

@ -83,7 +83,29 @@ export const assertSpan = (
);
}
}
if (span.kind === SpanKind.CLIENT) {
if (validations.resHeaders['content-length']) {
const contentLength = Number(validations.resHeaders['content-length']);
if (
validations.resHeaders['content-encoding'] &&
validations.resHeaders['content-encoding'] !== 'identity'
) {
assert.strictEqual(
span.attributes[HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH],
contentLength
);
} else {
assert.strictEqual(
span.attributes[
HttpAttribute.HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED
],
contentLength
);
}
}
assert.strictEqual(
span.attributes[GeneralAttribute.NET_PEER_NAME],
validations.hostname,
@ -105,6 +127,27 @@ export const assertSpan = (
);
}
if (span.kind === SpanKind.SERVER) {
if (validations.reqHeaders && validations.reqHeaders['content-length']) {
const contentLength = validations.reqHeaders['content-length'];
if (
validations.reqHeaders['content-encoding'] &&
validations.reqHeaders['content-encoding'] !== 'identity'
) {
assert.strictEqual(
span.attributes[HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH],
contentLength
);
} else {
assert.strictEqual(
span.attributes[
HttpAttribute.HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED
],
contentLength
);
}
}
if (validations.serverName) {
assert.strictEqual(
span.attributes[HttpAttribute.HTTP_SERVER_NAME],

View File

@ -26,6 +26,11 @@ export const HttpAttribute = {
HTTP_CLIENT_IP: 'http.client_ip',
HTTP_SCHEME: 'http.scheme',
HTTP_RESPONSE_CONTENT_LENGTH: 'http.response_content_length',
HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED:
'http.response_content_length_uncompressed',
HTTP_REQUEST_CONTENT_LENGTH: 'http.request_content_length',
HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED:
'http.request_content_length_uncompressed',
// NOT ON OFFICIAL SPEC
HTTP_ERROR_NAME: 'http.error_name',