From 3ec624089b55eeea88f8b1f92f02cc862494467d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 15:40:40 +0200 Subject: [PATCH 1/7] move functionality to captureSpan --- .../core/src/tracing/spans/captureSpan.ts | 86 ++++++++++++++++++- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index c06d4ce43560..ad357820ff7e 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -16,9 +16,12 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; +import type { QueryParams, RequestEventData } from '../../types-hoist/request'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; +import { httpHeadersToSpanAttributes } from '../../utils/request'; import { getCombinedScopeData } from '../../utils/scopeData'; import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; +import { getClientIPAddress } from '../../vendor/getIpAddress'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, @@ -63,7 +66,7 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW inferSpanDataFromOtelAttributes(spanJSON, spanKind); if (spanJSON.is_segment) { - applyScopeToSegmentSpan(spanJSON, finalScopeData); + applyScopeToSegmentSpan(spanJSON, finalScopeData, client); // Allow hook subscribers to mutate the segment span JSON // This also invokes the `processSegmentSpan` hook of all integrations client.emit('processSegmentSpan', spanJSON); @@ -96,9 +99,84 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW }; } -function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { - // TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span - // This will follow in a separate PR +function applyScopeToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, scopeData: ScopeData, client: Client): void { + const { normalizedRequest, ipAddress } = scopeData.sdkProcessingMetadata; + const { sendDefaultPii } = client.getOptions(); + + if (normalizedRequest && client.getIntegrationByName('RequestData')) { + applyRequestDataToSegmentSpan(segmentSpanJSON, normalizedRequest, ipAddress, sendDefaultPii); + } +} + +// Span-streaming counterpart of requestDataIntegration's processEvent. +function applyRequestDataToSegmentSpan( + segmentSpanJSON: StreamedSpanJSON, + normalizedRequest: RequestEventData, + ipAddress: string | undefined, + sendDefaultPii: boolean | undefined, +): void { + const attributes: Record = {}; + + if (normalizedRequest.url) { + attributes['url.full'] = normalizedRequest.url; + } + + if (normalizedRequest.method) { + attributes['http.request.method'] = normalizedRequest.method; + } + + if (normalizedRequest.query_string) { + attributes['url.query'] = normalizeQueryString(normalizedRequest.query_string); + } + + safeSetSpanJSONAttributes(segmentSpanJSON, attributes); + + if (normalizedRequest.headers) { + const headerAttributes = httpHeadersToSpanAttributes(normalizedRequest.headers, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(segmentSpanJSON, headerAttributes); + } + + if (normalizedRequest.cookies) { + // Reconstruct a cookie header string so httpHeadersToSpanAttributes can apply + // the same sensitivity filtering (session tokens, auth cookies, etc.) it uses for raw headers. + const cookieString = Object.entries(normalizedRequest.cookies) + .map(([name, value]) => `${name}=${value}`) + .join('; '); + if (cookieString) { + const cookieAttributes = httpHeadersToSpanAttributes( + { cookie: cookieString }, + sendDefaultPii ?? false, + 'request', + ); + safeSetSpanJSONAttributes(segmentSpanJSON, cookieAttributes); + } + } + + if (normalizedRequest.data != null) { + const serialized = + typeof normalizedRequest.data === 'string' ? normalizedRequest.data : JSON.stringify(normalizedRequest.data); + if (serialized) { + safeSetSpanJSONAttributes(segmentSpanJSON, { 'http.request.body.data': serialized }); + } + } + + if (sendDefaultPii) { + const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; + if (ip) { + safeSetSpanJSONAttributes(segmentSpanJSON, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); + } + } +} + +function normalizeQueryString(queryString: QueryParams): string | undefined { + if (typeof queryString === 'string') { + return queryString || undefined; + } + + const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); + const result = pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); + + return result || undefined; } function applyCommonSpanAttributes( From 4c78699e6e96e1974c8d0508cf0efa655b1ca40a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 15:40:46 +0200 Subject: [PATCH 2/7] tests --- .../instrument-without-request-data.mjs | 12 + .../requestData-streamed/instrument.mjs | 11 + .../tracing/requestData-streamed/server.mjs | 13 + .../tracing/requestData-streamed/test.ts | 74 +++++ .../lib/tracing/spans/captureSpan.test.ts | 311 ++++++++++++++++++ 5 files changed, 421 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs new file mode 100644 index 000000000000..04492fbce291 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument-without-request-data.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + sendDefaultPii: true, + integrations: defaults => defaults.filter(i => i.name !== 'RequestData'), +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs new file mode 100644 index 000000000000..761e5f9c474d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + sendDefaultPii: true, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs new file mode 100644 index 000000000000..07398392cb75 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/server.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts new file mode 100644 index 000000000000..c5657cef6c3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requestData-streamed/test.ts @@ -0,0 +1,74 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('requestData-streamed', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('applies request data attributes to the segment span', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find(item => item.is_segment); + + expect(serverSpan).toBeDefined(); + + expect(serverSpan?.attributes?.['url.full']).toEqual({ + type: 'string', + value: expect.stringContaining('/test?foo=bar'), + }); + + expect(serverSpan?.attributes?.['http.request.method']).toEqual({ + type: 'string', + value: 'GET', + }); + + expect(serverSpan?.attributes?.['url.query']).toEqual({ + type: 'string', + value: 'foo=bar', + }); + + expect(serverSpan?.attributes?.['http.request.header.host']).toEqual({ + type: 'string', + value: expect.any(String), + }); + + expect(serverSpan?.attributes?.['user.ip_address']).toEqual({ + type: 'string', + value: expect.any(String), + }); + }, + }) + .start(); + + await runner.makeRequest('get', '/test?foo=bar'); + + await runner.completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-without-request-data.mjs', (createRunner, test) => { + test('does not apply request data attributes when requestDataIntegration is removed', async () => { + const runner = createRunner() + .expect({ + span: container => { + const serverSpan = container.items.find(item => item.is_segment); + + expect(serverSpan).toBeDefined(); + + // url.query and user.ip_address are only set by applyScopeToSegmentSpan + // (not by OTel instrumentation), so they should be absent when the integration is removed + expect(serverSpan?.attributes?.['url.query']).toBeUndefined(); + expect(serverSpan?.attributes?.['user.ip_address']).toBeUndefined(); + }, + }) + .start(); + + await runner.makeRequest('get', '/test?foo=bar'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 186f7f23a536..f6c319731772 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { StreamedSpanJSON } from '../../../../src'; import { captureSpan, + requestDataIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -427,6 +428,316 @@ describe('captureSpan', () => { consoleWarnSpy.mockRestore(); }); }); + + describe('request data on segment spans', () => { + function createClientWithRequestDataIntegration(options: Record = {}): TestClient { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + integrations: [requestDataIntegration()], + ...options, + }), + ); + client.init(); + return client; + } + + it('applies normalizedRequest data as attributes on the segment span', () => { + const client = createClientWithRequestDataIntegration({ release: '1.0.0' }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com/api/users', + method: 'GET', + query_string: 'page=1&limit=10', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toMatchObject({ + 'url.full': { type: 'string', value: 'https://example.com/api/users' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.query': { type: 'string', value: 'page=1&limit=10' }, + 'http.request.header.content_type': { type: 'string', value: 'application/json' }, + 'http.request.header.accept': { type: 'string', value: 'application/json' }, + }); + }); + + it('does not apply request data to child (non-segment) spans', () => { + const client = createClientWithRequestDataIntegration({ release: '1.0.0' }); + + const serializedChildSpan = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com/api/users', + method: 'GET', + }, + }); + + return startSpan({ name: 'segment' }, () => { + const childSpan = startInactiveSpan({ name: 'child' }); + childSpan.end(); + return captureSpan(childSpan, client); + }); + }); + + expect(serializedChildSpan?.is_segment).toBe(false); + expect(serializedChildSpan?.attributes).not.toHaveProperty('url.full'); + expect(serializedChildSpan?.attributes).not.toHaveProperty('http.request.method'); + }); + + it('sets user.ip_address from request headers when sendDefaultPii is true', () => { + const client = createClientWithRequestDataIntegration({ sendDefaultPii: true }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com', + headers: { + 'x-forwarded-for': '203.0.113.50', + }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { type: 'string', value: '203.0.113.50' }, + }); + }); + + it('falls back to ipAddress from sdkProcessingMetadata when headers have no IP', () => { + const client = createClientWithRequestDataIntegration({ sendDefaultPii: true }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com', + headers: {}, + }, + ipAddress: '192.168.1.1', + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toMatchObject({ + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { type: 'string', value: '192.168.1.1' }, + }); + }); + + it('does not set user.ip_address when sendDefaultPii is false', () => { + const client = createClientWithRequestDataIntegration({ sendDefaultPii: false }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com', + headers: { + 'x-forwarded-for': '203.0.113.50', + }, + }, + ipAddress: '192.168.1.1', + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + // User IP should not be set because sendDefaultPii is false + // (Note: applyCommonSpanAttributes also skips user attributes when sendDefaultPii is false) + expect(serialized.attributes).not.toMatchObject({ + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: expect.anything(), + }); + }); + + it('does not add request attributes when normalizedRequest is missing', () => { + const client = createClientWithRequestDataIntegration(); + + const span = withScope(scope => { + scope.setClient(client); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).not.toHaveProperty('url.full'); + expect(serialized.attributes).not.toHaveProperty('http.request.method'); + expect(serialized.attributes).not.toHaveProperty('url.query'); + }); + + it('handles query_string in object format', () => { + const client = createClientWithRequestDataIntegration(); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + query_string: { page: '1', limit: '10' }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toMatchObject({ + 'url.query': { type: 'string', value: 'page=1&limit=10' }, + }); + }); + + it('applies cookies as individual header attributes', () => { + const client = createClientWithRequestDataIntegration(); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + cookies: { + theme: 'dark', + locale: 'en', + }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toMatchObject({ + 'http.request.header.cookie.theme': { type: 'string', value: 'dark' }, + 'http.request.header.cookie.locale': { type: 'string', value: 'en' }, + }); + }); + + it('filters sensitive cookies from normalizedRequest.cookies', () => { + const client = createClientWithRequestDataIntegration(); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + cookies: { + theme: 'dark', + 'connect.sid': 's%3Aabc123.signature', + session_token: 'secret-value', + }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + // Non-sensitive cookie passes through + expect(serialized.attributes).toMatchObject({ + 'http.request.header.cookie.theme': { type: 'string', value: 'dark' }, + }); + + // Sensitive cookies are filtered + expect(serialized.attributes).toMatchObject({ + 'http.request.header.cookie.connect.sid': { type: 'string', value: '[Filtered]' }, + 'http.request.header.cookie.session_token': { type: 'string', value: '[Filtered]' }, + }); + }); + + it('applies request body data as http.request.body.data attribute', () => { + const client = createClientWithRequestDataIntegration(); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + data: { key: 'value' }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toMatchObject({ + 'http.request.body.data': { type: 'string', value: '{"key":"value"}' }, + }); + }); + + it('applies string request body data as-is', () => { + const client = createClientWithRequestDataIntegration(); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + data: 'raw body content', + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toMatchObject({ + 'http.request.body.data': { type: 'string', value: 'raw body content' }, + }); + }); + }); }); describe('safeSetSpanJSONAttributes', () => { From 862e0b23af9e80d359a950b7ee799363967179bb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 13:51:13 +0200 Subject: [PATCH 3/7] respect integration options --- packages/core/src/integrations/requestdata.ts | 3 +- .../core/src/tracing/spans/captureSpan.ts | 48 +++-- .../lib/tracing/spans/captureSpan.test.ts | 173 +++++++++++++++++- 3 files changed, 208 insertions(+), 16 deletions(-) diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 9ff6033ed7a2..a42dfc7f2665 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -5,7 +5,7 @@ import type { RequestEventData } from '../types-hoist/request'; import { parseCookie } from '../utils/cookie'; import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; -interface RequestDataIncludeOptions { +export interface RequestDataIncludeOptions { cookies?: boolean; data?: boolean; headers?: boolean; @@ -40,6 +40,7 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return { name: INTEGRATION_NAME, + _include: include, processEvent(event, _hint, client) { const { sdkProcessingMetadata = {} } = event; const { normalizedRequest, ipAddress } = sdkProcessingMetadata; diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index ad357820ff7e..edcb011f38ad 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -16,12 +16,14 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; +import type { RequestDataIncludeOptions } from '../../integrations/requestdata'; +import type { Integration } from '../../types-hoist/integration'; import type { QueryParams, RequestEventData } from '../../types-hoist/request'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { httpHeadersToSpanAttributes } from '../../utils/request'; import { getCombinedScopeData } from '../../utils/scopeData'; import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; -import { getClientIPAddress } from '../../vendor/getIpAddress'; +import { getClientIPAddress, ipHeaderNames } from '../../vendor/getIpAddress'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, @@ -101,10 +103,15 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW function applyScopeToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, scopeData: ScopeData, client: Client): void { const { normalizedRequest, ipAddress } = scopeData.sdkProcessingMetadata; - const { sendDefaultPii } = client.getOptions(); - if (normalizedRequest && client.getIntegrationByName('RequestData')) { - applyRequestDataToSegmentSpan(segmentSpanJSON, normalizedRequest, ipAddress, sendDefaultPii); + const integration = client.getIntegrationByName('RequestData'); + if (normalizedRequest && integration) { + const { sendDefaultPii } = client.getOptions(); + const include: RequestDataIncludeOptions = { + ...integration._include, + ip: integration._include.ip ?? sendDefaultPii, + }; + applyRequestDataToSegmentSpan(segmentSpanJSON, normalizedRequest, ipAddress, include, sendDefaultPii); } } @@ -113,11 +120,12 @@ function applyRequestDataToSegmentSpan( segmentSpanJSON: StreamedSpanJSON, normalizedRequest: RequestEventData, ipAddress: string | undefined, + include: RequestDataIncludeOptions, sendDefaultPii: boolean | undefined, ): void { const attributes: Record = {}; - if (normalizedRequest.url) { + if (include.url && normalizedRequest.url) { attributes['url.full'] = normalizedRequest.url; } @@ -125,20 +133,34 @@ function applyRequestDataToSegmentSpan( attributes['http.request.method'] = normalizedRequest.method; } - if (normalizedRequest.query_string) { + if (include.query_string && normalizedRequest.query_string) { attributes['url.query'] = normalizeQueryString(normalizedRequest.query_string); } safeSetSpanJSONAttributes(segmentSpanJSON, attributes); - if (normalizedRequest.headers) { - const headerAttributes = httpHeadersToSpanAttributes(normalizedRequest.headers, sendDefaultPii ?? false, 'request'); + if (include.headers && normalizedRequest.headers) { + const headers = { ...normalizedRequest.headers }; + + if (!include.cookies) { + delete headers.cookie; + } + + if (!include.ip) { + const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); + for (const key of Object.keys(headers)) { + if (ipHeaderNamesLower.has(key.toLowerCase())) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete headers[key]; + } + } + } + + const headerAttributes = httpHeadersToSpanAttributes(headers, sendDefaultPii ?? false, 'request'); safeSetSpanJSONAttributes(segmentSpanJSON, headerAttributes); } - if (normalizedRequest.cookies) { - // Reconstruct a cookie header string so httpHeadersToSpanAttributes can apply - // the same sensitivity filtering (session tokens, auth cookies, etc.) it uses for raw headers. + if (include.cookies && normalizedRequest.cookies) { const cookieString = Object.entries(normalizedRequest.cookies) .map(([name, value]) => `${name}=${value}`) .join('; '); @@ -152,7 +174,7 @@ function applyRequestDataToSegmentSpan( } } - if (normalizedRequest.data != null) { + if (include.data && normalizedRequest.data != null) { const serialized = typeof normalizedRequest.data === 'string' ? normalizedRequest.data : JSON.stringify(normalizedRequest.data); if (serialized) { @@ -160,7 +182,7 @@ function applyRequestDataToSegmentSpan( } } - if (sendDefaultPii) { + if (include.ip) { const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; if (ip) { safeSetSpanJSONAttributes(segmentSpanJSON, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index f6c319731772..740f43f6556a 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -430,12 +430,15 @@ describe('captureSpan', () => { }); describe('request data on segment spans', () => { - function createClientWithRequestDataIntegration(options: Record = {}): TestClient { + function createClientWithRequestDataIntegration( + options: Record = {}, + integrationOptions?: Parameters[0], + ): TestClient { const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', tracesSampleRate: 1, - integrations: [requestDataIntegration()], + integrations: [requestDataIntegration(integrationOptions)], ...options, }), ); @@ -737,6 +740,172 @@ describe('captureSpan', () => { 'http.request.body.data': { type: 'string', value: 'raw body content' }, }); }); + + describe('respects include options', () => { + it('excludes url when include.url is false', () => { + const client = createClientWithRequestDataIntegration({}, { include: { url: false } }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com/api', + method: 'GET', + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).not.toHaveProperty('url.full'); + expect(serialized.attributes).toMatchObject({ + 'http.request.method': { type: 'string', value: 'GET' }, + }); + }); + + it('excludes query_string when include.query_string is false', () => { + const client = createClientWithRequestDataIntegration({}, { include: { query_string: false } }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com/api', + query_string: 'page=1', + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).not.toHaveProperty('url.query'); + expect(serialized.attributes).toMatchObject({ + 'url.full': { type: 'string', value: 'https://example.com/api' }, + }); + }); + + it('excludes headers when include.headers is false', () => { + const client = createClientWithRequestDataIntegration({}, { include: { headers: false } }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com', + headers: { 'content-type': 'application/json' }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).not.toHaveProperty('http.request.header.content_type'); + expect(serialized.attributes).toMatchObject({ + 'url.full': { type: 'string', value: 'https://example.com' }, + }); + }); + + it('strips cookie header when include.cookies is false', () => { + const client = createClientWithRequestDataIntegration({}, { include: { cookies: false } }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com', + headers: { + 'content-type': 'application/json', + cookie: 'theme=dark; locale=en', + }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + return span; + }); + + const serialized = captureSpan(span, client); + + // content-type header should still be present + expect(serialized.attributes).toMatchObject({ + 'http.request.header.content_type': { type: 'string', value: 'application/json' }, + }); + + // cookie attributes should not be present + expect(serialized.attributes).not.toHaveProperty('http.request.header.cookie.theme'); + expect(serialized.attributes).not.toHaveProperty('http.request.header.cookie.locale'); + }); + + it('strips IP headers when include.ip is false', () => { + const client = createClientWithRequestDataIntegration({}, { include: { ip: false } }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com', + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': '203.0.113.50', + }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + return span; + }); + + const serialized = captureSpan(span, client); + + // content-type header should still be present + expect(serialized.attributes).toMatchObject({ + 'http.request.header.content_type': { type: 'string', value: 'application/json' }, + }); + + // IP header and user.ip_address should not be present + expect(serialized.attributes).not.toHaveProperty('http.request.header.x_forwarded_for'); + expect(serialized.attributes).not.toHaveProperty('user.ip_address'); + }); + + it('excludes data when include.data is false', () => { + const client = createClientWithRequestDataIntegration({}, { include: { data: false } }); + + const span = withScope(scope => { + scope.setClient(client); + scope.setSDKProcessingMetadata({ + normalizedRequest: { + url: 'https://example.com', + data: { key: 'value' }, + }, + }); + + const span = startInactiveSpan({ name: 'my-span' }); + span.end(); + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).not.toHaveProperty('http.request.body.data'); + expect(serialized.attributes).toMatchObject({ + 'url.full': { type: 'string', value: 'https://example.com' }, + }); + }); + }); }); }); From fd48621ff3e4bcdb47fedcf614a89a70cb8a73de Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 14:12:02 +0200 Subject: [PATCH 4/7] refactor --- .../core/src/tracing/spans/captureSpan.ts | 90 +---------------- .../core/src/tracing/spans/requestData.ts | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 89 deletions(-) create mode 100644 packages/core/src/tracing/spans/requestData.ts diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index edcb011f38ad..79a38d096f9b 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -18,12 +18,9 @@ import { } from '../../semanticAttributes'; import type { RequestDataIncludeOptions } from '../../integrations/requestdata'; import type { Integration } from '../../types-hoist/integration'; -import type { QueryParams, RequestEventData } from '../../types-hoist/request'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; -import { httpHeadersToSpanAttributes } from '../../utils/request'; import { getCombinedScopeData } from '../../utils/scopeData'; import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; -import { getClientIPAddress, ipHeaderNames } from '../../vendor/getIpAddress'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, @@ -32,6 +29,7 @@ import { } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { applyRequestDataToSegmentSpan } from './requestData'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { _segmentSpan: Span; @@ -115,92 +113,6 @@ function applyScopeToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, scopeData: S } } -// Span-streaming counterpart of requestDataIntegration's processEvent. -function applyRequestDataToSegmentSpan( - segmentSpanJSON: StreamedSpanJSON, - normalizedRequest: RequestEventData, - ipAddress: string | undefined, - include: RequestDataIncludeOptions, - sendDefaultPii: boolean | undefined, -): void { - const attributes: Record = {}; - - if (include.url && normalizedRequest.url) { - attributes['url.full'] = normalizedRequest.url; - } - - if (normalizedRequest.method) { - attributes['http.request.method'] = normalizedRequest.method; - } - - if (include.query_string && normalizedRequest.query_string) { - attributes['url.query'] = normalizeQueryString(normalizedRequest.query_string); - } - - safeSetSpanJSONAttributes(segmentSpanJSON, attributes); - - if (include.headers && normalizedRequest.headers) { - const headers = { ...normalizedRequest.headers }; - - if (!include.cookies) { - delete headers.cookie; - } - - if (!include.ip) { - const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); - for (const key of Object.keys(headers)) { - if (ipHeaderNamesLower.has(key.toLowerCase())) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete headers[key]; - } - } - } - - const headerAttributes = httpHeadersToSpanAttributes(headers, sendDefaultPii ?? false, 'request'); - safeSetSpanJSONAttributes(segmentSpanJSON, headerAttributes); - } - - if (include.cookies && normalizedRequest.cookies) { - const cookieString = Object.entries(normalizedRequest.cookies) - .map(([name, value]) => `${name}=${value}`) - .join('; '); - if (cookieString) { - const cookieAttributes = httpHeadersToSpanAttributes( - { cookie: cookieString }, - sendDefaultPii ?? false, - 'request', - ); - safeSetSpanJSONAttributes(segmentSpanJSON, cookieAttributes); - } - } - - if (include.data && normalizedRequest.data != null) { - const serialized = - typeof normalizedRequest.data === 'string' ? normalizedRequest.data : JSON.stringify(normalizedRequest.data); - if (serialized) { - safeSetSpanJSONAttributes(segmentSpanJSON, { 'http.request.body.data': serialized }); - } - } - - if (include.ip) { - const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; - if (ip) { - safeSetSpanJSONAttributes(segmentSpanJSON, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); - } - } -} - -function normalizeQueryString(queryString: QueryParams): string | undefined { - if (typeof queryString === 'string') { - return queryString || undefined; - } - - const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); - const result = pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); - - return result || undefined; -} - function applyCommonSpanAttributes( spanJSON: StreamedSpanJSON, serializedSegmentSpan: StreamedSpanJSON, diff --git a/packages/core/src/tracing/spans/requestData.ts b/packages/core/src/tracing/spans/requestData.ts new file mode 100644 index 000000000000..090397258b8b --- /dev/null +++ b/packages/core/src/tracing/spans/requestData.ts @@ -0,0 +1,96 @@ +import type { RequestDataIncludeOptions } from '../../integrations/requestdata'; +import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../../semanticAttributes'; +import type { QueryParams, RequestEventData } from '../../types-hoist/request'; +import type { StreamedSpanJSON } from '../../types-hoist/span'; +import { httpHeadersToSpanAttributes } from '../../utils/request'; +import { getClientIPAddress, ipHeaderNames } from '../../vendor/getIpAddress'; +import { safeSetSpanJSONAttributes } from './captureSpan'; + +// Span-streaming counterpart of requestDataIntegration's processEvent. +export function applyRequestDataToSegmentSpan( + segmentSpanJSON: StreamedSpanJSON, + normalizedRequest: RequestEventData, + ipAddress: string | undefined, + include: RequestDataIncludeOptions, + sendDefaultPii: boolean | undefined, +): void { + const attributes: Record = {}; + + if (include.url && normalizedRequest.url) { + attributes['url.full'] = normalizedRequest.url; + } + + if (normalizedRequest.method) { + attributes['http.request.method'] = normalizedRequest.method; + } + + if (include.query_string && normalizedRequest.query_string) { + attributes['url.query'] = normalizeQueryString(normalizedRequest.query_string); + } + + safeSetSpanJSONAttributes(segmentSpanJSON, attributes); + + if (include.headers && normalizedRequest.headers) { + const headers = { ...normalizedRequest.headers }; + + if (!include.cookies) { + delete headers.cookie; + } + + if (!include.ip) { + const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); + for (const key of Object.keys(headers)) { + if (ipHeaderNamesLower.has(key.toLowerCase())) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete headers[key]; + } + } + } + + const headerAttributes = httpHeadersToSpanAttributes(headers, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(segmentSpanJSON, headerAttributes); + } + + if (include.cookies) { + const cookieString = normalizedRequest.cookies + ? Object.entries(normalizedRequest.cookies) + .map(([name, value]) => `${name}=${value}`) + .join('; ') + : normalizedRequest.headers?.cookie; + + if (cookieString) { + const cookieAttributes = httpHeadersToSpanAttributes( + { cookie: cookieString }, + sendDefaultPii ?? false, + 'request', + ); + safeSetSpanJSONAttributes(segmentSpanJSON, cookieAttributes); + } + } + + if (include.data && normalizedRequest.data != null) { + const serialized = + typeof normalizedRequest.data === 'string' ? normalizedRequest.data : JSON.stringify(normalizedRequest.data); + if (serialized) { + safeSetSpanJSONAttributes(segmentSpanJSON, { 'http.request.body.data': serialized }); + } + } + + if (include.ip) { + const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; + if (ip) { + safeSetSpanJSONAttributes(segmentSpanJSON, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); + } + } +} + +function normalizeQueryString(queryString: QueryParams): string | undefined { + if (typeof queryString === 'string') { + return queryString || undefined; + } + + const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); + const result = pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); + + return result || undefined; +} From c40c729411c0391a8aa1f558c509e82023cfffa8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 14:54:18 +0200 Subject: [PATCH 5/7] que no puedo mas --- .../core/src/tracing/spans/captureSpan.ts | 20 ++-------- .../core/src/tracing/spans/requestData.ts | 38 ++++++++++--------- .../src/tracing/spans/spanAttributeUtils.ts | 19 ++++++++++ 3 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/tracing/spans/spanAttributeUtils.ts diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 79a38d096f9b..3886820e2117 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -1,5 +1,6 @@ import type { RawAttributes } from '../../attributes'; import type { Client } from '../../client'; +import type { RequestDataIncludeOptions } from '../../integrations/requestdata'; import type { ScopeData } from '../../scope'; import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, @@ -16,7 +17,6 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; -import type { RequestDataIncludeOptions } from '../../integrations/requestdata'; import type { Integration } from '../../types-hoist/integration'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { getCombinedScopeData } from '../../utils/scopeData'; @@ -30,6 +30,7 @@ import { import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; import { applyRequestDataToSegmentSpan } from './requestData'; +import { safeSetSpanJSONAttributes } from './spanAttributeUtils'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { _segmentSpan: Span; @@ -157,22 +158,7 @@ export function applyBeforeSendSpanCallback( return modifedSpan; } -/** - * Safely set attributes on a span JSON. - * If an attribute already exists, it will not be overwritten. - */ -export function safeSetSpanJSONAttributes( - spanJSON: StreamedSpanJSON, - newAttributes: RawAttributes>, -): void { - const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); - - Object.entries(newAttributes).forEach(([key, value]) => { - if (value != null && !(key in originalAttributes)) { - originalAttributes[key] = value; - } - }); -} +export { safeSetSpanJSONAttributes } from './spanAttributeUtils'; // OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) const SPAN_KIND_SERVER = 1; diff --git a/packages/core/src/tracing/spans/requestData.ts b/packages/core/src/tracing/spans/requestData.ts index 090397258b8b..94bc82999222 100644 --- a/packages/core/src/tracing/spans/requestData.ts +++ b/packages/core/src/tracing/spans/requestData.ts @@ -4,7 +4,7 @@ import type { QueryParams, RequestEventData } from '../../types-hoist/request'; import type { StreamedSpanJSON } from '../../types-hoist/span'; import { httpHeadersToSpanAttributes } from '../../utils/request'; import { getClientIPAddress, ipHeaderNames } from '../../vendor/getIpAddress'; -import { safeSetSpanJSONAttributes } from './captureSpan'; +import { safeSetSpanJSONAttributes } from './spanAttributeUtils'; // Span-streaming counterpart of requestDataIntegration's processEvent. export function applyRequestDataToSegmentSpan( @@ -30,6 +30,25 @@ export function applyRequestDataToSegmentSpan( safeSetSpanJSONAttributes(segmentSpanJSON, attributes); + // Process cookies before headers so normalizedRequest.cookies takes precedence + // over the raw cookie header (matching the processEvent path in requestdata.ts). + if (include.cookies) { + const cookieString = normalizedRequest.cookies + ? Object.entries(normalizedRequest.cookies) + .map(([name, value]) => `${name}=${value}`) + .join('; ') + : normalizedRequest.headers?.cookie; + + if (cookieString) { + const cookieAttributes = httpHeadersToSpanAttributes( + { cookie: cookieString }, + sendDefaultPii ?? false, + 'request', + ); + safeSetSpanJSONAttributes(segmentSpanJSON, cookieAttributes); + } + } + if (include.headers && normalizedRequest.headers) { const headers = { ...normalizedRequest.headers }; @@ -51,23 +70,6 @@ export function applyRequestDataToSegmentSpan( safeSetSpanJSONAttributes(segmentSpanJSON, headerAttributes); } - if (include.cookies) { - const cookieString = normalizedRequest.cookies - ? Object.entries(normalizedRequest.cookies) - .map(([name, value]) => `${name}=${value}`) - .join('; ') - : normalizedRequest.headers?.cookie; - - if (cookieString) { - const cookieAttributes = httpHeadersToSpanAttributes( - { cookie: cookieString }, - sendDefaultPii ?? false, - 'request', - ); - safeSetSpanJSONAttributes(segmentSpanJSON, cookieAttributes); - } - } - if (include.data && normalizedRequest.data != null) { const serialized = typeof normalizedRequest.data === 'string' ? normalizedRequest.data : JSON.stringify(normalizedRequest.data); diff --git a/packages/core/src/tracing/spans/spanAttributeUtils.ts b/packages/core/src/tracing/spans/spanAttributeUtils.ts new file mode 100644 index 000000000000..49fed79cc61b --- /dev/null +++ b/packages/core/src/tracing/spans/spanAttributeUtils.ts @@ -0,0 +1,19 @@ +import type { RawAttributes } from '../../attributes'; +import type { StreamedSpanJSON } from '../../types-hoist/span'; + +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { + originalAttributes[key] = value; + } + }); +} From 0620a89ad109deb920360cd5aec3f5b2a472300e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 16:47:48 +0200 Subject: [PATCH 6/7] wip --- packages/core/src/integrations/requestdata.ts | 95 +++- .../core/src/tracing/spans/captureSpan.ts | 40 +- .../core/src/tracing/spans/requestData.ts | 98 ---- .../src/tracing/spans/spanAttributeUtils.ts | 19 - .../test/lib/integrations/requestdata.test.ts | 265 +++++++++- .../lib/tracing/spans/captureSpan.test.ts | 480 ------------------ 6 files changed, 373 insertions(+), 624 deletions(-) delete mode 100644 packages/core/src/tracing/spans/requestData.ts delete mode 100644 packages/core/src/tracing/spans/spanAttributeUtils.ts diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index a42dfc7f2665..2cc35ad2b3e7 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,11 +1,16 @@ +import { getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; +import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes'; import type { Event } from '../types-hoist/event'; import type { IntegrationFn } from '../types-hoist/integration'; -import type { RequestEventData } from '../types-hoist/request'; +import type { QueryParams, RequestEventData } from '../types-hoist/request'; +import type { StreamedSpanJSON } from '../types-hoist/span'; import { parseCookie } from '../utils/cookie'; +import { httpHeadersToSpanAttributes } from '../utils/request'; import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; +import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan'; -export interface RequestDataIncludeOptions { +interface RequestDataIncludeOptions { cookies?: boolean; data?: boolean; headers?: boolean; @@ -40,7 +45,6 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return { name: INTEGRATION_NAME, - _include: include, processEvent(event, _hint, client) { const { sdkProcessingMetadata = {} } = event; const { normalizedRequest, ipAddress } = sdkProcessingMetadata; @@ -56,6 +60,22 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = return event; }, + processSegmentSpan(span, client) { + const { sdkProcessingMetadata } = getIsolationScope().getScopeData(); + const { normalizedRequest, ipAddress } = sdkProcessingMetadata; + + if (!normalizedRequest) { + return; + } + + const { sendDefaultPii } = client.getOptions(); + const includeWithDefaultPiiApplied: RequestDataIncludeOptions = { + ...include, + ip: include.ip ?? sendDefaultPii, + }; + + addNormalizedRequestDataToSpan(span, normalizedRequest, ipAddress, includeWithDefaultPiiApplied, sendDefaultPii); + }, }; }) satisfies IntegrationFn; @@ -72,7 +92,6 @@ export const requestDataIntegration = defineIntegration(_requestDataIntegration) function addNormalizedRequestDataToEvent( event: Event, req: RequestEventData, - // Data that should not go into `event.request` but is somehow related to requests additionalData: { ipAddress?: string }, include: RequestDataIncludeOptions, ): void { @@ -92,6 +111,60 @@ function addNormalizedRequestDataToEvent( } } +function addNormalizedRequestDataToSpan( + span: StreamedSpanJSON, + normalizedRequest: RequestEventData, + ipAddress: string | undefined, + include: RequestDataIncludeOptions, + sendDefaultPii: boolean | undefined, +): void { + const requestData = extractNormalizedRequestData(normalizedRequest, include); + const attributes: Record = {}; + + if (requestData.url) { + attributes['url.full'] = requestData.url; + } + + if (requestData.method) { + attributes['http.request.method'] = requestData.method; + } + + if (requestData.query_string) { + attributes['url.query'] = normalizeQueryString(requestData.query_string); + } + + safeSetSpanJSONAttributes(span, attributes); + + // Process cookies before headers so normalizedRequest.cookies takes precedence + // over the raw cookie header (matching the processEvent path). + if (requestData.cookies && Object.keys(requestData.cookies).length > 0) { + const cookieString = Object.entries(requestData.cookies) + .map(([name, value]) => `${name}=${value}`) + .join('; '); + const cookieAttributes = httpHeadersToSpanAttributes({ cookie: cookieString }, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(span, cookieAttributes); + } + + if (requestData.headers) { + const headerAttributes = httpHeadersToSpanAttributes(requestData.headers, sendDefaultPii ?? false, 'request'); + safeSetSpanJSONAttributes(span, headerAttributes); + } + + if (requestData.data != null) { + const serialized = typeof requestData.data === 'string' ? requestData.data : JSON.stringify(requestData.data); + if (serialized) { + safeSetSpanJSONAttributes(span, { 'http.request.body.data': serialized }); + } + } + + if (include.ip) { + const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; + if (ip) { + safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); + } + } +} + function extractNormalizedRequestData( normalizedRequest: RequestEventData, include: RequestDataIncludeOptions, @@ -102,13 +175,10 @@ function extractNormalizedRequestData( if (include.headers) { requestData.headers = headers; - // Remove the Cookie header in case cookie data should not be included in the event if (!include.cookies) { delete (headers as { cookie?: string }).cookie; } - // Remove IP headers in case IP data should not be included in the event. - // Match case-insensitively — same as getClientIPAddress — so lowercase keys are stripped too. if (!include.ip) { const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); for (const key of Object.keys(headers)) { @@ -141,3 +211,14 @@ function extractNormalizedRequestData( return requestData; } + +function normalizeQueryString(queryString: QueryParams): string | undefined { + if (typeof queryString === 'string') { + return queryString || undefined; + } + + const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); + const result = pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); + + return result || undefined; +} diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 3886820e2117..bed3f1790740 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -1,6 +1,5 @@ import type { RawAttributes } from '../../attributes'; import type { Client } from '../../client'; -import type { RequestDataIncludeOptions } from '../../integrations/requestdata'; import type { ScopeData } from '../../scope'; import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, @@ -17,7 +16,6 @@ import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; -import type { Integration } from '../../types-hoist/integration'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; @@ -29,8 +27,6 @@ import { } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; -import { applyRequestDataToSegmentSpan } from './requestData'; -import { safeSetSpanJSONAttributes } from './spanAttributeUtils'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { _segmentSpan: Span; @@ -67,7 +63,7 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW inferSpanDataFromOtelAttributes(spanJSON, spanKind); if (spanJSON.is_segment) { - applyScopeToSegmentSpan(spanJSON, finalScopeData, client); + applyScopeToSegmentSpan(spanJSON, finalScopeData); // Allow hook subscribers to mutate the segment span JSON // This also invokes the `processSegmentSpan` hook of all integrations client.emit('processSegmentSpan', spanJSON); @@ -100,18 +96,26 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW }; } -function applyScopeToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, scopeData: ScopeData, client: Client): void { - const { normalizedRequest, ipAddress } = scopeData.sdkProcessingMetadata; - - const integration = client.getIntegrationByName('RequestData'); - if (normalizedRequest && integration) { - const { sendDefaultPii } = client.getOptions(); - const include: RequestDataIncludeOptions = { - ...integration._include, - ip: integration._include.ip ?? sendDefaultPii, - }; - applyRequestDataToSegmentSpan(segmentSpanJSON, normalizedRequest, ipAddress, include, sendDefaultPii); - } +function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { + // TODO: Apply contexts data from auto instrumentation to segment span + // This will follow in a separate PR +} + +/** + * Safely set attributes on a span JSON. + * If an attribute already exists, it will not be overwritten. + */ +export function safeSetSpanJSONAttributes( + spanJSON: StreamedSpanJSON, + newAttributes: RawAttributes>, +): void { + const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); + + Object.entries(newAttributes).forEach(([key, value]) => { + if (value != null && !(key in originalAttributes)) { + originalAttributes[key] = value; + } + }); } function applyCommonSpanAttributes( @@ -158,8 +162,6 @@ export function applyBeforeSendSpanCallback( return modifedSpan; } -export { safeSetSpanJSONAttributes } from './spanAttributeUtils'; - // OTel SpanKind values (numeric to avoid importing from @opentelemetry/api) const SPAN_KIND_SERVER = 1; const SPAN_KIND_CLIENT = 2; diff --git a/packages/core/src/tracing/spans/requestData.ts b/packages/core/src/tracing/spans/requestData.ts deleted file mode 100644 index 94bc82999222..000000000000 --- a/packages/core/src/tracing/spans/requestData.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { RequestDataIncludeOptions } from '../../integrations/requestdata'; -import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../../semanticAttributes'; -import type { QueryParams, RequestEventData } from '../../types-hoist/request'; -import type { StreamedSpanJSON } from '../../types-hoist/span'; -import { httpHeadersToSpanAttributes } from '../../utils/request'; -import { getClientIPAddress, ipHeaderNames } from '../../vendor/getIpAddress'; -import { safeSetSpanJSONAttributes } from './spanAttributeUtils'; - -// Span-streaming counterpart of requestDataIntegration's processEvent. -export function applyRequestDataToSegmentSpan( - segmentSpanJSON: StreamedSpanJSON, - normalizedRequest: RequestEventData, - ipAddress: string | undefined, - include: RequestDataIncludeOptions, - sendDefaultPii: boolean | undefined, -): void { - const attributes: Record = {}; - - if (include.url && normalizedRequest.url) { - attributes['url.full'] = normalizedRequest.url; - } - - if (normalizedRequest.method) { - attributes['http.request.method'] = normalizedRequest.method; - } - - if (include.query_string && normalizedRequest.query_string) { - attributes['url.query'] = normalizeQueryString(normalizedRequest.query_string); - } - - safeSetSpanJSONAttributes(segmentSpanJSON, attributes); - - // Process cookies before headers so normalizedRequest.cookies takes precedence - // over the raw cookie header (matching the processEvent path in requestdata.ts). - if (include.cookies) { - const cookieString = normalizedRequest.cookies - ? Object.entries(normalizedRequest.cookies) - .map(([name, value]) => `${name}=${value}`) - .join('; ') - : normalizedRequest.headers?.cookie; - - if (cookieString) { - const cookieAttributes = httpHeadersToSpanAttributes( - { cookie: cookieString }, - sendDefaultPii ?? false, - 'request', - ); - safeSetSpanJSONAttributes(segmentSpanJSON, cookieAttributes); - } - } - - if (include.headers && normalizedRequest.headers) { - const headers = { ...normalizedRequest.headers }; - - if (!include.cookies) { - delete headers.cookie; - } - - if (!include.ip) { - const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase())); - for (const key of Object.keys(headers)) { - if (ipHeaderNamesLower.has(key.toLowerCase())) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete headers[key]; - } - } - } - - const headerAttributes = httpHeadersToSpanAttributes(headers, sendDefaultPii ?? false, 'request'); - safeSetSpanJSONAttributes(segmentSpanJSON, headerAttributes); - } - - if (include.data && normalizedRequest.data != null) { - const serialized = - typeof normalizedRequest.data === 'string' ? normalizedRequest.data : JSON.stringify(normalizedRequest.data); - if (serialized) { - safeSetSpanJSONAttributes(segmentSpanJSON, { 'http.request.body.data': serialized }); - } - } - - if (include.ip) { - const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; - if (ip) { - safeSetSpanJSONAttributes(segmentSpanJSON, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); - } - } -} - -function normalizeQueryString(queryString: QueryParams): string | undefined { - if (typeof queryString === 'string') { - return queryString || undefined; - } - - const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); - const result = pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); - - return result || undefined; -} diff --git a/packages/core/src/tracing/spans/spanAttributeUtils.ts b/packages/core/src/tracing/spans/spanAttributeUtils.ts deleted file mode 100644 index 49fed79cc61b..000000000000 --- a/packages/core/src/tracing/spans/spanAttributeUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RawAttributes } from '../../attributes'; -import type { StreamedSpanJSON } from '../../types-hoist/span'; - -/** - * Safely set attributes on a span JSON. - * If an attribute already exists, it will not be overwritten. - */ -export function safeSetSpanJSONAttributes( - spanJSON: StreamedSpanJSON, - newAttributes: RawAttributes>, -): void { - const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {}); - - Object.entries(newAttributes).forEach(([key, value]) => { - if (value != null && !(key in originalAttributes)) { - originalAttributes[key] = value; - } - }); -} diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts index df8e8d4d8766..37e104e897b5 100644 --- a/packages/core/test/lib/integrations/requestdata.test.ts +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { Client } from '../../../src/client'; +import * as currentScopes from '../../../src/currentScopes'; import { requestDataIntegration } from '../../../src/integrations/requestdata'; import type { Event } from '../../../src/types-hoist/event'; +import type { StreamedSpanJSON } from '../../../src/types-hoist/span'; import { ipHeaderNames } from '../../../src/vendor/getIpAddress'; function mockClient(sendDefaultPii: boolean | undefined): Client { @@ -602,3 +604,264 @@ describe('requestDataIntegration', () => { expect(event.request?.headers?.['X-Forwarded-For']).toBeUndefined(); }); }); + +describe('requestDataIntegration processSegmentSpan', () => { + function makeSpan(overrides: Partial = {}): StreamedSpanJSON { + return { + name: 'GET /test', + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: true, + attributes: {}, + ...overrides, + }; + } + + function mockIsolationScope(normalizedRequest: Record, ipAddress?: string): void { + vi.spyOn(currentScopes, 'getIsolationScope').mockReturnValue({ + getScopeData: () => ({ + sdkProcessingMetadata: { normalizedRequest, ipAddress }, + }), + } as ReturnType); + } + + it('applies request data attributes to the segment span', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com/api/users', + method: 'GET', + query_string: 'page=1&limit=10', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'url.full': 'https://example.com/api/users', + 'http.request.method': 'GET', + 'url.query': 'page=1&limit=10', + 'http.request.header.content_type': 'application/json', + 'http.request.header.accept': 'application/json', + }); + }); + + it('does not apply attributes when normalizedRequest is missing', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({}); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toEqual({}); + }); + + it('sets user.ip_address from headers when sendDefaultPii is true', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(true)); + + expect(span.attributes).toMatchObject({ + 'user.ip_address': '203.0.113.50', + }); + }); + + it('falls back to ipAddress from sdkProcessingMetadata', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', headers: {} }, '192.168.1.1'); + + integration.processSegmentSpan!(span, mockClient(true)); + + expect(span.attributes).toMatchObject({ + 'user.ip_address': '192.168.1.1', + }); + }); + + it('does not set user.ip_address when sendDefaultPii is false', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('user.ip_address'); + }); + + it('applies cookies from normalizedRequest.cookies', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + cookies: { theme: 'dark', locale: 'en' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.locale': 'en', + }); + }); + + it('falls back to cookie header when normalizedRequest.cookies is not set', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { cookie: 'theme=dark; locale=en' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.locale': 'en', + }); + }); + + it('filters sensitive cookies', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ + cookies: { theme: 'dark', 'connect.sid': 'secret', session_token: 'secret' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.cookie.theme': 'dark', + 'http.request.header.cookie.connect.sid': '[Filtered]', + 'http.request.header.cookie.session_token': '[Filtered]', + }); + }); + + it('applies request body data', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ data: { key: 'value' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.body.data': '{"key":"value"}', + }); + }); + + it('handles query_string in object format', () => { + const integration = requestDataIntegration(); + const span = makeSpan(); + + mockIsolationScope({ query_string: { page: '1', limit: '10' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'url.query': 'page=1&limit=10', + }); + }); + + describe('respects include options', () => { + it('excludes url when include.url is false', () => { + const integration = requestDataIntegration({ include: { url: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', method: 'GET' }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('url.full'); + expect(span.attributes).toMatchObject({ 'http.request.method': 'GET' }); + }); + + it('excludes headers when include.headers is false', () => { + const integration = requestDataIntegration({ include: { headers: false } }); + const span = makeSpan(); + + mockIsolationScope({ + url: 'https://example.com', + headers: { 'content-type': 'application/json' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('http.request.header.content_type'); + }); + + it('strips cookie header when include.cookies is false', () => { + const integration = requestDataIntegration({ include: { cookies: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { 'content-type': 'application/json', cookie: 'theme=dark' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.content_type': 'application/json', + }); + expect(span.attributes).not.toHaveProperty('http.request.header.cookie.theme'); + }); + + it('strips IP headers when include.ip is false', () => { + const integration = requestDataIntegration({ include: { ip: false } }); + const span = makeSpan(); + + mockIsolationScope({ + headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.50' }, + }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).toMatchObject({ + 'http.request.header.content_type': 'application/json', + }); + expect(span.attributes).not.toHaveProperty('http.request.header.x_forwarded_for'); + expect(span.attributes).not.toHaveProperty('user.ip_address'); + }); + + it('excludes data when include.data is false', () => { + const integration = requestDataIntegration({ include: { data: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', data: { key: 'value' } }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('http.request.body.data'); + }); + + it('excludes query_string when include.query_string is false', () => { + const integration = requestDataIntegration({ include: { query_string: false } }); + const span = makeSpan(); + + mockIsolationScope({ url: 'https://example.com', query_string: 'page=1' }); + + integration.processSegmentSpan!(span, mockClient(false)); + + expect(span.attributes).not.toHaveProperty('url.query'); + }); + }); +}); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 740f43f6556a..186f7f23a536 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { StreamedSpanJSON } from '../../../../src'; import { captureSpan, - requestDataIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -428,485 +427,6 @@ describe('captureSpan', () => { consoleWarnSpy.mockRestore(); }); }); - - describe('request data on segment spans', () => { - function createClientWithRequestDataIntegration( - options: Record = {}, - integrationOptions?: Parameters[0], - ): TestClient { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://dsn@ingest.f00.f00/1', - tracesSampleRate: 1, - integrations: [requestDataIntegration(integrationOptions)], - ...options, - }), - ); - client.init(); - return client; - } - - it('applies normalizedRequest data as attributes on the segment span', () => { - const client = createClientWithRequestDataIntegration({ release: '1.0.0' }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com/api/users', - method: 'GET', - query_string: 'page=1&limit=10', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).toMatchObject({ - 'url.full': { type: 'string', value: 'https://example.com/api/users' }, - 'http.request.method': { type: 'string', value: 'GET' }, - 'url.query': { type: 'string', value: 'page=1&limit=10' }, - 'http.request.header.content_type': { type: 'string', value: 'application/json' }, - 'http.request.header.accept': { type: 'string', value: 'application/json' }, - }); - }); - - it('does not apply request data to child (non-segment) spans', () => { - const client = createClientWithRequestDataIntegration({ release: '1.0.0' }); - - const serializedChildSpan = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com/api/users', - method: 'GET', - }, - }); - - return startSpan({ name: 'segment' }, () => { - const childSpan = startInactiveSpan({ name: 'child' }); - childSpan.end(); - return captureSpan(childSpan, client); - }); - }); - - expect(serializedChildSpan?.is_segment).toBe(false); - expect(serializedChildSpan?.attributes).not.toHaveProperty('url.full'); - expect(serializedChildSpan?.attributes).not.toHaveProperty('http.request.method'); - }); - - it('sets user.ip_address from request headers when sendDefaultPii is true', () => { - const client = createClientWithRequestDataIntegration({ sendDefaultPii: true }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com', - headers: { - 'x-forwarded-for': '203.0.113.50', - }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).toMatchObject({ - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { type: 'string', value: '203.0.113.50' }, - }); - }); - - it('falls back to ipAddress from sdkProcessingMetadata when headers have no IP', () => { - const client = createClientWithRequestDataIntegration({ sendDefaultPii: true }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com', - headers: {}, - }, - ipAddress: '192.168.1.1', - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).toMatchObject({ - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { type: 'string', value: '192.168.1.1' }, - }); - }); - - it('does not set user.ip_address when sendDefaultPii is false', () => { - const client = createClientWithRequestDataIntegration({ sendDefaultPii: false }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com', - headers: { - 'x-forwarded-for': '203.0.113.50', - }, - }, - ipAddress: '192.168.1.1', - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - // User IP should not be set because sendDefaultPii is false - // (Note: applyCommonSpanAttributes also skips user attributes when sendDefaultPii is false) - expect(serialized.attributes).not.toMatchObject({ - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: expect.anything(), - }); - }); - - it('does not add request attributes when normalizedRequest is missing', () => { - const client = createClientWithRequestDataIntegration(); - - const span = withScope(scope => { - scope.setClient(client); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).not.toHaveProperty('url.full'); - expect(serialized.attributes).not.toHaveProperty('http.request.method'); - expect(serialized.attributes).not.toHaveProperty('url.query'); - }); - - it('handles query_string in object format', () => { - const client = createClientWithRequestDataIntegration(); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - query_string: { page: '1', limit: '10' }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).toMatchObject({ - 'url.query': { type: 'string', value: 'page=1&limit=10' }, - }); - }); - - it('applies cookies as individual header attributes', () => { - const client = createClientWithRequestDataIntegration(); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - cookies: { - theme: 'dark', - locale: 'en', - }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).toMatchObject({ - 'http.request.header.cookie.theme': { type: 'string', value: 'dark' }, - 'http.request.header.cookie.locale': { type: 'string', value: 'en' }, - }); - }); - - it('filters sensitive cookies from normalizedRequest.cookies', () => { - const client = createClientWithRequestDataIntegration(); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - cookies: { - theme: 'dark', - 'connect.sid': 's%3Aabc123.signature', - session_token: 'secret-value', - }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - // Non-sensitive cookie passes through - expect(serialized.attributes).toMatchObject({ - 'http.request.header.cookie.theme': { type: 'string', value: 'dark' }, - }); - - // Sensitive cookies are filtered - expect(serialized.attributes).toMatchObject({ - 'http.request.header.cookie.connect.sid': { type: 'string', value: '[Filtered]' }, - 'http.request.header.cookie.session_token': { type: 'string', value: '[Filtered]' }, - }); - }); - - it('applies request body data as http.request.body.data attribute', () => { - const client = createClientWithRequestDataIntegration(); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - data: { key: 'value' }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).toMatchObject({ - 'http.request.body.data': { type: 'string', value: '{"key":"value"}' }, - }); - }); - - it('applies string request body data as-is', () => { - const client = createClientWithRequestDataIntegration(); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - data: 'raw body content', - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).toMatchObject({ - 'http.request.body.data': { type: 'string', value: 'raw body content' }, - }); - }); - - describe('respects include options', () => { - it('excludes url when include.url is false', () => { - const client = createClientWithRequestDataIntegration({}, { include: { url: false } }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com/api', - method: 'GET', - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).not.toHaveProperty('url.full'); - expect(serialized.attributes).toMatchObject({ - 'http.request.method': { type: 'string', value: 'GET' }, - }); - }); - - it('excludes query_string when include.query_string is false', () => { - const client = createClientWithRequestDataIntegration({}, { include: { query_string: false } }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com/api', - query_string: 'page=1', - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).not.toHaveProperty('url.query'); - expect(serialized.attributes).toMatchObject({ - 'url.full': { type: 'string', value: 'https://example.com/api' }, - }); - }); - - it('excludes headers when include.headers is false', () => { - const client = createClientWithRequestDataIntegration({}, { include: { headers: false } }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com', - headers: { 'content-type': 'application/json' }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).not.toHaveProperty('http.request.header.content_type'); - expect(serialized.attributes).toMatchObject({ - 'url.full': { type: 'string', value: 'https://example.com' }, - }); - }); - - it('strips cookie header when include.cookies is false', () => { - const client = createClientWithRequestDataIntegration({}, { include: { cookies: false } }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com', - headers: { - 'content-type': 'application/json', - cookie: 'theme=dark; locale=en', - }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - return span; - }); - - const serialized = captureSpan(span, client); - - // content-type header should still be present - expect(serialized.attributes).toMatchObject({ - 'http.request.header.content_type': { type: 'string', value: 'application/json' }, - }); - - // cookie attributes should not be present - expect(serialized.attributes).not.toHaveProperty('http.request.header.cookie.theme'); - expect(serialized.attributes).not.toHaveProperty('http.request.header.cookie.locale'); - }); - - it('strips IP headers when include.ip is false', () => { - const client = createClientWithRequestDataIntegration({}, { include: { ip: false } }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com', - headers: { - 'content-type': 'application/json', - 'x-forwarded-for': '203.0.113.50', - }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - return span; - }); - - const serialized = captureSpan(span, client); - - // content-type header should still be present - expect(serialized.attributes).toMatchObject({ - 'http.request.header.content_type': { type: 'string', value: 'application/json' }, - }); - - // IP header and user.ip_address should not be present - expect(serialized.attributes).not.toHaveProperty('http.request.header.x_forwarded_for'); - expect(serialized.attributes).not.toHaveProperty('user.ip_address'); - }); - - it('excludes data when include.data is false', () => { - const client = createClientWithRequestDataIntegration({}, { include: { data: false } }); - - const span = withScope(scope => { - scope.setClient(client); - scope.setSDKProcessingMetadata({ - normalizedRequest: { - url: 'https://example.com', - data: { key: 'value' }, - }, - }); - - const span = startInactiveSpan({ name: 'my-span' }); - span.end(); - return span; - }); - - const serialized = captureSpan(span, client); - - expect(serialized.attributes).not.toHaveProperty('http.request.body.data'); - expect(serialized.attributes).toMatchObject({ - 'url.full': { type: 'string', value: 'https://example.com' }, - }); - }); - }); - }); }); describe('safeSetSpanJSONAttributes', () => { From 70496a8297fd0de5091eba8ccbfb10c77d3a1deb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 6 May 2026 10:32:48 +0200 Subject: [PATCH 7/7] fix normalizing --- packages/core/src/integrations/requestdata.ts | 2 +- packages/core/test/lib/integrations/requestdata.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 2cc35ad2b3e7..973ea7a8296d 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -218,7 +218,7 @@ function normalizeQueryString(queryString: QueryParams): string | undefined { } const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString); - const result = pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); + const result = pairs.map(([key, value]) => `${key}=${value}`).join('&'); return result || undefined; } diff --git a/packages/core/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts index 37e104e897b5..7b2dca819ea3 100644 --- a/packages/core/test/lib/integrations/requestdata.test.ts +++ b/packages/core/test/lib/integrations/requestdata.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Client } from '../../../src/client'; import * as currentScopes from '../../../src/currentScopes'; import { requestDataIntegration } from '../../../src/integrations/requestdata'; @@ -606,6 +606,10 @@ describe('requestDataIntegration', () => { }); describe('requestDataIntegration processSegmentSpan', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + function makeSpan(overrides: Partial = {}): StreamedSpanJSON { return { name: 'GET /test',