diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts index 6a12c77eb768..115b2517e81f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts @@ -30,7 +30,7 @@ describe('outgoing http spans - strip query', () => { 'http.request.method': 'GET', 'url.query': 'id=1', 'http.response.status_code': 200, - 'http.response.body.decoded_size': 0, + 'http.response.body.size': 0, 'http.response.status_text': 'OK', 'network.peer.address': '::1', 'server.address': 'localhost', diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index a49aec9f49c0..b7960f6fe0cb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -136,7 +136,7 @@ describe('httpIntegration', () => { expect(transaction.contexts?.trace?.data).toEqual({ 'http.request.method': 'POST', 'url.query': 'a=1&b=2', - 'http.request.body.decoded_size': 9, + 'http.request.body.size': 9, 'http.response.status_code': 200, 'http.route': '/test', 'url.scheme': 'http', diff --git a/docs/migration/v11-end-state.md b/docs/migration/v11-end-state.md index f2749ab6cbe1..ee1c47dad1a5 100644 --- a/docs/migration/v11-end-state.md +++ b/docs/migration/v11-end-state.md @@ -638,6 +638,8 @@ Legacy HTTP span attributes were replaced by their current semantic-convention e `SanitizedRequestData` — the shape used for `http` breadcrumb data and `http.client` span data — now uses `http.request.method` instead of `http.method` as a key for the request method. +On server-side HTTP spans, the `content-length` header is now always reported as `http.request.body.size`/`http.response.body.size` instead of switching to `http.request_body_size_uncompressed` when the no encoding was present. + #### Network attributes Network-related span attributes now use the current Sentry semantic conventions, aligned across SDKs. If you query, transform, or alert on the legacy `net.*` fields, update those references: diff --git a/packages/core/src/integrations/http/get-outgoing-span-data.ts b/packages/core/src/integrations/http/get-outgoing-span-data.ts index 3c5454b8da73..f345b5052b53 100644 --- a/packages/core/src/integrations/http/get-outgoing-span-data.ts +++ b/packages/core/src/integrations/http/get-outgoing-span-data.ts @@ -1,6 +1,7 @@ import type { Span, SpanAttributes } from '../../types/span'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../../semanticAttributes'; import { filterCollectedUrl } from '../../utils/data-collection/filterCollectedUrl'; +import { getContentLengthFromHeaders } from '../../utils/request'; import { getHttpSpanDetailsFromUrlObject, parseStringToURLObject } from '../../utils/url'; import type { HttpClientRequest, HttpIncomingMessage } from './types'; import { getRequestUrlFromClientRequest } from './get-request-url'; @@ -67,7 +68,7 @@ export function setIncomingResponseSpanData(response: HttpIncomingMessage, span: [NETWORK_PROTOCOL_VERSION]: httpVersion, [NETWORK_TRANSPORT]: transport, 'http.response.status_text': statusMessage?.toUpperCase(), - ...getResponseContentLengthAttributes(response), + [HTTP_RESPONSE_BODY_SIZE]: getContentLengthFromHeaders(response.headers), ...getSocketAttrs(socket), }); } @@ -82,15 +83,3 @@ function getSocketAttrs(socket: HttpIncomingMessage['socket']): SpanAttributes { [NETWORK_PEER_PORT]: remotePort, }; } - -function getResponseContentLengthAttributes(response: HttpIncomingMessage): SpanAttributes { - const { headers } = response; - const contentLengthHeader = headers['content-length']; - const length = contentLengthHeader ? parseInt(String(contentLengthHeader), 10) : -1; - const encoding = headers['content-encoding']; - return length >= 0 - ? encoding && encoding !== 'identity' - ? { [HTTP_RESPONSE_BODY_SIZE]: length } - : { 'http.response.body.decoded_size': length } - : {}; -} diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index 70e733fe6709..055c1c359fbf 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -25,7 +25,12 @@ import { DEBUG_BUILD } from '../../debug-build'; import { debug } from '../../utils/debug-logger'; import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from '../../currentScopes'; import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; -import { headersToDict, httpHeadersToSpanAttributes, httpRequestToRequestData } from '../../utils/request'; +import { + getContentLengthFromHeaders, + headersToDict, + httpHeadersToSpanAttributes, + httpRequestToRequestData, +} from '../../utils/request'; import { patchRequestToCaptureBody } from './patch-request-to-capture-body'; import { getUrlFragment, getUrlQuery, parseStringToURLObject, stripUrlQueryAndFragment } from '../../utils/url'; import { recordRequestSession } from './record-request-session'; @@ -34,7 +39,6 @@ import { continueTrace, startSpanManual } from '../../tracing/trace'; import { getSpanStatusFromHttpCode, SPAN_STATUS_ERROR } from '../../tracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { safeMathRandom } from '../../utils/randomSafeContext'; -import type { SpanAttributes } from '../../types/span'; import type { SpanStatus } from '../../types/spanStatus'; import { CLIENT_ADDRESS, @@ -335,7 +339,7 @@ function buildServerSpanWrap( [USER_AGENT_ORIGINAL]: userAgent, [URL_SCHEME]: scheme, [NETWORK_TRANSPORT]: httpVersion?.toUpperCase() === 'QUIC' ? 'udp' : 'tcp', - ...getRequestContentLengthAttribute(request), + 'http.request.body.size': getContentLengthFromHeaders(request.headers), ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, dataCollectionOptions), }, }, @@ -439,15 +443,3 @@ function isKnownPrefetchRequest(req: HttpIncomingMessage): boolean { // Currently only handles Next.js prefetch requests but may check other frameworks in the future. return req.headers['next-router-prefetch'] === '1'; } - -function getRequestContentLengthAttribute(request: HttpIncomingMessage): SpanAttributes { - const { headers } = request; - const contentLengthHeader = headers['content-length']; - const length = contentLengthHeader ? parseInt(String(contentLengthHeader), 10) : -1; - const encoding = headers['content-encoding']; - return length >= 0 - ? encoding && encoding !== 'identity' - ? { 'http.request.body.size': length } - : { 'http.request.body.decoded_size': length } - : {}; -} diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 950a0c81ef45..aacaa27a4d26 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -135,6 +135,7 @@ export { extractQueryParamsFromUrl, headersToDict, httpHeadersToSpanAttributes, + getContentLengthFromHeaders, getMaxBodyByteLength, MAX_BODY_BYTE_LENGTH, } from './utils/request'; diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 99c7f809383b..b013f09e8ce6 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -375,3 +375,22 @@ export function extractQueryParamsFromUrl(url: string): string | undefined { return undefined; } } + +/** + * Read the `content-length` header as a number, if it holds a valid one. + * + * `content-length` is the encoded (on-the-wire) body size whether or not a `content-encoding` is + * applied, so it maps to `http.request.body.size` / `http.response.body.size`. The decoded body size + * cannot be derived from it. + */ +export function getContentLengthFromHeaders( + headers: Record, +): number | undefined { + const contentLength = headers['content-length']; + if (typeof contentLength !== 'string') { + return undefined; + } + + const length = parseInt(contentLength, 10); + return length >= 0 ? length : undefined; +} diff --git a/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts index 38e06f6c8172..5d3f7417a5b4 100644 --- a/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts +++ b/packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts @@ -159,16 +159,16 @@ describe('setIncomingResponseSpanData', () => { ); }); - it('includes uncompressed content-length when content-encoding is identity', () => { + it('includes content-length as the encoded body size when content-encoding is identity', () => { const span = makeMockSpan(); const response = makeMockResponse({ headers: { 'content-length': '42', 'content-encoding': 'identity' }, }); setIncomingResponseSpanData(response, span); - expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response.body.decoded_size': 42 })); + expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response.body.size': 42 })); }); - it('includes compressed content-length when content-encoding is gzip', () => { + it('includes content-length as the encoded body size when content-encoding is gzip', () => { const span = makeMockSpan(); const response = makeMockResponse({ headers: { 'content-length': '100', 'content-encoding': 'gzip' }, diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index d930bd141ab6..eaa82b8eaa31 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ import { errorMonitor } from 'node:events'; -import type { IncomingHttpHeaders } from 'node:http'; import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_REQUEST_METHOD, @@ -39,6 +38,7 @@ import { debug, getSpanStatusFromHttpCode, httpHeadersToSpanAttributes, + getContentLengthFromHeaders, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -173,7 +173,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions [NETWORK_PROTOCOL_NAME]: 'http', [NETWORK_PROTOCOL_VERSION]: httpVersion, [NETWORK_TRANSPORT]: httpVersion?.toUpperCase() === 'QUIC' ? 'udp' : 'tcp', - ...getRequestContentLengthAttribute(request), + 'http.request.body.size': getContentLengthFromHeaders(request.headers), ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, client.getDataCollectionOptions()), }, }); @@ -338,39 +338,6 @@ function shouldIgnoreSpansForIncomingRequest( return false; } -function getRequestContentLengthAttribute(request: HttpIncomingMessage): SpanAttributes { - const length = getContentLength(request.headers); - if (length == null) { - return {}; - } - - if (isCompressed(request.headers)) { - return { - ['http.request.body.size']: length, - }; - } else { - return { - ['http.request.body.decoded_size']: length, - }; - } -} - -function getContentLength(headers: IncomingHttpHeaders): number | null { - const contentLengthHeader = headers['content-length']; - if (contentLengthHeader === undefined) return null; - - const contentLength = parseInt(contentLengthHeader, 10); - if (isNaN(contentLength)) return null; - - return contentLength; -} - -function isCompressed(headers: IncomingHttpHeaders): boolean { - const encoding = headers['content-encoding']; - - return !!encoding && encoding !== 'identity'; -} - /** * First entry of `X-Forwarded-For`: the client as seen by the outermost proxy. * https://opentelemetry.io/docs/specs/semconv/registry/attributes/client/#client-address