diff --git a/packages/opentelemetry-plugin-http/src/utils.ts b/packages/opentelemetry-plugin-http/src/utils.ts index 0cba43d95c..45d460891c 100644 --- a/packages/opentelemetry-plugin-http/src/utils.ts +++ b/packages/opentelemetry-plugin-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 @@ -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); }; diff --git a/packages/opentelemetry-plugin-http/test/functionals/utils.test.ts b/packages/opentelemetry-plugin-http/test/functionals/utils.test.ts index 1a43c057bb..9bfd6126ed 100644 --- a/packages/opentelemetry-plugin-http/test/functionals/utils.test.ts +++ b/packages/opentelemetry-plugin-http/test/functionals/utils.test.ts @@ -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); + }); + }); }); diff --git a/packages/opentelemetry-plugin-http/test/utils/assertSpan.ts b/packages/opentelemetry-plugin-http/test/utils/assertSpan.ts index f9bda54dd0..b7085f6039 100644 --- a/packages/opentelemetry-plugin-http/test/utils/assertSpan.ts +++ b/packages/opentelemetry-plugin-http/test/utils/assertSpan.ts @@ -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], diff --git a/packages/opentelemetry-semantic-conventions/src/trace/http.ts b/packages/opentelemetry-semantic-conventions/src/trace/http.ts index d54166c545..04618c5fd9 100644 --- a/packages/opentelemetry-semantic-conventions/src/trace/http.ts +++ b/packages/opentelemetry-semantic-conventions/src/trace/http.ts @@ -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',