Skip to content

Commit 684378f

Browse files
msonnbclaude
andcommitted
ref(node)!: Always report the encoded body size on HTTP spans
Part of the v11 migration. Stacked on the `http.target` PR. The attribute renames landed in the first PR of the stack; this one is the behavior change behind them. Node HTTP spans read the `content-length` header and then reported it as either the encoded or the decoded body size, branching on whether a `content-encoding` header was present. Neither attribute was set on every span, so a query against either one only ever saw part of a user's traffic. `content-length` is the encoded size whether or not a content encoding is applied, so it now always maps to `http.request.body.size` / `http.response.body.size`. The decoded size cannot be derived from `content-length`, so Node HTTP spans no longer set `http.request.body.decoded_size` or `http.response.body.decoded_size`. Browser resource spans still report the latter, where the Resource Timing API measures it directly. Deriving the decoded size from `content-length` when no encoding is applied was considered and rejected: it would populate the attribute only where it duplicates the encoded size, and leave it empty exactly where it carries information, which makes any query over it a biased sample. Measuring the decompressed stream would be the real fix and is out of scope here. The three copies of the `content-length` parsing are now one `getContentLengthFromHeaders` util in `@sentry/core`, whose docblock holds the encoded-size rule so it is not restated at each call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c0f52f9 commit 684378f

9 files changed

Lines changed: 37 additions & 69 deletions

File tree

‎dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('outgoing http spans - strip query', () => {
3030
'http.request.method': 'GET',
3131
'url.query': 'id=1',
3232
'http.response.status_code': 200,
33-
'http.response.body.decoded_size': 0,
33+
'http.response.body.size': 0,
3434
'http.response.status_text': 'OK',
3535
'network.peer.address': '::1',
3636
'server.address': 'localhost',

‎dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ describe('httpIntegration', () => {
136136
expect(transaction.contexts?.trace?.data).toEqual({
137137
'http.request.method': 'POST',
138138
'url.query': 'a=1&b=2',
139-
'http.request.body.decoded_size': 9,
139+
'http.request.body.size': 9,
140140
'http.response.status_code': 200,
141141
'http.route': '/test',
142142
'url.scheme': 'http',

‎docs/migration/v11-end-state.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ Legacy HTTP span attributes were replaced by their current semantic-convention e
638638
639639
`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.
640640
641-
The `http.target` span attribute is no longer set. It held the pathname and the query string, which are now on `url.path` and `url.query`.
641+
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.
642642
643643
#### Network attributes
644644

‎packages/core/src/integrations/http/get-outgoing-span-data.ts‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Span, SpanAttributes } from '../../types/span';
22
import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../../semanticAttributes';
33
import { filterCollectedUrl } from '../../utils/data-collection/filterCollectedUrl';
4+
import { getContentLengthFromHeaders } from '../../utils/request';
45
import { getHttpSpanDetailsFromUrlObject, parseStringToURLObject } from '../../utils/url';
56
import type { HttpClientRequest, HttpIncomingMessage } from './types';
67
import { getRequestUrlFromClientRequest } from './get-request-url';
@@ -67,7 +68,7 @@ export function setIncomingResponseSpanData(response: HttpIncomingMessage, span:
6768
[NETWORK_PROTOCOL_VERSION]: httpVersion,
6869
[NETWORK_TRANSPORT]: transport,
6970
'http.response.status_text': statusMessage?.toUpperCase(),
70-
...getResponseContentLengthAttributes(response),
71+
[HTTP_RESPONSE_BODY_SIZE]: getContentLengthFromHeaders(response.headers),
7172
...getSocketAttrs(socket),
7273
});
7374
}
@@ -82,15 +83,3 @@ function getSocketAttrs(socket: HttpIncomingMessage['socket']): SpanAttributes {
8283
[NETWORK_PEER_PORT]: remotePort,
8384
};
8485
}
85-
86-
function getResponseContentLengthAttributes(response: HttpIncomingMessage): SpanAttributes {
87-
const { headers } = response;
88-
const contentLengthHeader = headers['content-length'];
89-
const length = contentLengthHeader ? parseInt(String(contentLengthHeader), 10) : -1;
90-
const encoding = headers['content-encoding'];
91-
return length >= 0
92-
? encoding && encoding !== 'identity'
93-
? { [HTTP_RESPONSE_BODY_SIZE]: length }
94-
: { 'http.response.body.decoded_size': length }
95-
: {};
96-
}

‎packages/core/src/integrations/http/server-subscription.ts‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import { DEBUG_BUILD } from '../../debug-build';
2525
import { debug } from '../../utils/debug-logger';
2626
import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from '../../currentScopes';
2727
import { hasSpansEnabled } from '../../utils/hasSpansEnabled';
28-
import { headersToDict, httpHeadersToSpanAttributes, httpRequestToRequestData } from '../../utils/request';
28+
import {
29+
getContentLengthFromHeaders,
30+
headersToDict,
31+
httpHeadersToSpanAttributes,
32+
httpRequestToRequestData,
33+
} from '../../utils/request';
2934
import { patchRequestToCaptureBody } from './patch-request-to-capture-body';
3035
import { getUrlFragment, getUrlQuery, parseStringToURLObject, stripUrlQueryAndFragment } from '../../utils/url';
3136
import { recordRequestSession } from './record-request-session';
@@ -39,7 +44,6 @@ import {
3944
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
4045
} from '../../semanticAttributes';
4146
import { safeMathRandom } from '../../utils/randomSafeContext';
42-
import type { SpanAttributes } from '../../types/span';
4347
import type { SpanStatus } from '../../types/spanStatus';
4448
import {
4549
CLIENT_ADDRESS,
@@ -339,7 +343,7 @@ function buildServerSpanWrap(
339343
[USER_AGENT_ORIGINAL]: userAgent,
340344
[URL_SCHEME]: scheme,
341345
[NETWORK_TRANSPORT]: httpVersion?.toUpperCase() === 'QUIC' ? 'udp' : 'tcp',
342-
...getRequestContentLengthAttribute(request),
346+
'http.request.body.size': getContentLengthFromHeaders(request.headers),
343347
...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, dataCollectionOptions),
344348
},
345349
},
@@ -443,15 +447,3 @@ function isKnownPrefetchRequest(req: HttpIncomingMessage): boolean {
443447
// Currently only handles Next.js prefetch requests but may check other frameworks in the future.
444448
return req.headers['next-router-prefetch'] === '1';
445449
}
446-
447-
function getRequestContentLengthAttribute(request: HttpIncomingMessage): SpanAttributes {
448-
const { headers } = request;
449-
const contentLengthHeader = headers['content-length'];
450-
const length = contentLengthHeader ? parseInt(String(contentLengthHeader), 10) : -1;
451-
const encoding = headers['content-encoding'];
452-
return length >= 0
453-
? encoding && encoding !== 'identity'
454-
? { 'http.request.body.size': length }
455-
: { 'http.request.body.decoded_size': length }
456-
: {};
457-
}

‎packages/core/src/shared-exports.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export {
133133
extractQueryParamsFromUrl,
134134
headersToDict,
135135
httpHeadersToSpanAttributes,
136+
getContentLengthFromHeaders,
136137
getMaxBodyByteLength,
137138
MAX_BODY_BYTE_LENGTH,
138139
} from './utils/request';

‎packages/core/src/utils/request.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,22 @@ export function extractQueryParamsFromUrl(url: string): string | undefined {
375375
return undefined;
376376
}
377377
}
378+
379+
/**
380+
* Read the `content-length` header as a number, if it holds a valid one.
381+
*
382+
* `content-length` is the encoded (on-the-wire) body size whether or not a `content-encoding` is
383+
* applied, so it maps to `http.request.body.size` / `http.response.body.size`. The decoded body size
384+
* cannot be derived from it.
385+
*/
386+
export function getContentLengthFromHeaders(
387+
headers: Record<string, string | string[] | undefined>,
388+
): number | undefined {
389+
const contentLength = headers['content-length'];
390+
if (typeof contentLength !== 'string') {
391+
return undefined;
392+
}
393+
394+
const length = parseInt(contentLength, 10);
395+
return length >= 0 ? length : undefined;
396+
}

‎packages/core/test/lib/integrations/http/get-outgoing-span-data.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,16 @@ describe('setIncomingResponseSpanData', () => {
159159
);
160160
});
161161

162-
it('includes uncompressed content-length when content-encoding is identity', () => {
162+
it('includes content-length as the encoded body size when content-encoding is identity', () => {
163163
const span = makeMockSpan();
164164
const response = makeMockResponse({
165165
headers: { 'content-length': '42', 'content-encoding': 'identity' },
166166
});
167167
setIncomingResponseSpanData(response, span);
168-
expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response.body.decoded_size': 42 }));
168+
expect(span.setAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'http.response.body.size': 42 }));
169169
});
170170

171-
it('includes compressed content-length when content-encoding is gzip', () => {
171+
it('includes content-length as the encoded body size when content-encoding is gzip', () => {
172172
const span = makeMockSpan();
173173
const response = makeMockResponse({
174174
headers: { 'content-length': '100', 'content-encoding': 'gzip' },

‎packages/node/src/integrations/http/httpServerSpansIntegration.ts‎

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* eslint-disable max-lines */
22
import { errorMonitor } from 'node:events';
3-
import type { IncomingHttpHeaders } from 'node:http';
43
import {
54
HTTP_REQUEST_METHOD,
65
HTTP_RESPONSE_STATUS_CODE,
@@ -38,6 +37,7 @@ import {
3837
debug,
3938
getSpanStatusFromHttpCode,
4039
httpHeadersToSpanAttributes,
40+
getContentLengthFromHeaders,
4141
parseStringToURLObject,
4242
SEMANTIC_ATTRIBUTE_SENTRY_OP,
4343
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
@@ -173,7 +173,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions
173173
[NETWORK_PROTOCOL_NAME]: 'http',
174174
[NETWORK_PROTOCOL_VERSION]: httpVersion,
175175
[NETWORK_TRANSPORT]: httpVersion?.toUpperCase() === 'QUIC' ? 'udp' : 'tcp',
176-
...getRequestContentLengthAttribute(request),
176+
'http.request.body.size': getContentLengthFromHeaders(request.headers),
177177
...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, client.getDataCollectionOptions()),
178178
},
179179
});
@@ -338,39 +338,6 @@ function shouldIgnoreSpansForIncomingRequest(
338338
return false;
339339
}
340340

341-
function getRequestContentLengthAttribute(request: HttpIncomingMessage): SpanAttributes {
342-
const length = getContentLength(request.headers);
343-
if (length == null) {
344-
return {};
345-
}
346-
347-
if (isCompressed(request.headers)) {
348-
return {
349-
['http.request.body.size']: length,
350-
};
351-
} else {
352-
return {
353-
['http.request.body.decoded_size']: length,
354-
};
355-
}
356-
}
357-
358-
function getContentLength(headers: IncomingHttpHeaders): number | null {
359-
const contentLengthHeader = headers['content-length'];
360-
if (contentLengthHeader === undefined) return null;
361-
362-
const contentLength = parseInt(contentLengthHeader, 10);
363-
if (isNaN(contentLength)) return null;
364-
365-
return contentLength;
366-
}
367-
368-
function isCompressed(headers: IncomingHttpHeaders): boolean {
369-
const encoding = headers['content-encoding'];
370-
371-
return !!encoding && encoding !== 'identity';
372-
}
373-
374341
/**
375342
* First entry of `X-Forwarded-For`: the client as seen by the outermost proxy.
376343
* https://opentelemetry.io/docs/specs/semconv/registry/attributes/client/#client-address

0 commit comments

Comments
 (0)