From d77a8b940bd06f68454781d80bc4e975aab2dcee Mon Sep 17 00:00:00 2001 From: Valentin Marchaud Date: Tue, 22 Dec 2020 21:31:59 +0100 Subject: [PATCH] feat(http-instrumentation): add content size attributes to spans (#1771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gerhard Stöbich Co-authored-by: Daniel Dyla --- .../src/utils.ts | 62 ++++++++ .../test/functionals/utils.test.ts | 142 ++++++++++++++++++ .../test/utils/assertSpan.ts | 40 +++++ 3 files changed, 244 insertions(+) diff --git a/packages/opentelemetry-instrumentation-http/src/utils.ts b/packages/opentelemetry-instrumentation-http/src/utils.ts index 32673cb6bf..402d003cc3 100644 --- a/packages/opentelemetry-instrumentation-http/src/utils.ts +++ b/packages/opentelemetry-instrumentation-http/src/utils.ts @@ -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 @@ -326,6 +386,7 @@ export const getOutgoingRequestAttributesOnResponse = ( [GeneralAttribute.NET_PEER_PORT]: remotePort, [HttpAttribute.HTTP_HOST]: `${options.hostname}:${remotePort}`, }; + setResponseContentLengthAttribute(response, attributes); if (statusCode) { attributes[HttpAttribute.HTTP_STATUS_CODE] = statusCode; @@ -386,6 +447,7 @@ export const getIncomingRequestAttributes = ( if (userAgent !== undefined) { attributes[HttpAttribute.HTTP_USER_AGENT] = userAgent; } + setRequestContentLengthAttribute(request, attributes); const httpKindAttributes = getAttributesFromHttpKind(httpVersion); return Object.assign(attributes, httpKindAttributes); diff --git a/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts b/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts index 761238d222..da59b4ea0a 100644 --- a/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts +++ b/packages/opentelemetry-instrumentation-http/test/functionals/utils.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { + Attributes, StatusCode, ROOT_CONTEXT, SpanKind, @@ -308,4 +309,145 @@ 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); + }); + }); }); diff --git a/packages/opentelemetry-instrumentation-http/test/utils/assertSpan.ts b/packages/opentelemetry-instrumentation-http/test/utils/assertSpan.ts index 68ea829cb4..d5bea5af46 100644 --- a/packages/opentelemetry-instrumentation-http/test/utils/assertSpan.ts +++ b/packages/opentelemetry-instrumentation-http/test/utils/assertSpan.ts @@ -87,6 +87,26 @@ 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, @@ -108,6 +128,26 @@ 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],