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/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 9ff6033ed7a2..973ea7a8296d 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,9 +1,14 @@ +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'; interface RequestDataIncludeOptions { cookies?: boolean; @@ -55,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; @@ -71,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 { @@ -91,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, @@ -101,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)) { @@ -140,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]) => `${key}=${value}`).join('&'); + + return result || undefined; +} diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index c06d4ce43560..bed3f1790740 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -97,10 +97,27 @@ 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 + // 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( spanJSON: StreamedSpanJSON, serializedSegmentSpan: StreamedSpanJSON, @@ -145,23 +162,6 @@ 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; - } - }); -} - // 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/test/lib/integrations/requestdata.test.ts b/packages/core/test/lib/integrations/requestdata.test.ts index df8e8d4d8766..7b2dca819ea3 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 { 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'; 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,268 @@ describe('requestDataIntegration', () => { expect(event.request?.headers?.['X-Forwarded-For']).toBeUndefined(); }); }); + +describe('requestDataIntegration processSegmentSpan', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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'); + }); + }); +});