Skip to content

Commit 7efc03f

Browse files
authored
feat(core): Apply request data to segment spans in span streaming (#20654)
Implements the span-streaming counterpart of the `requestDataIntegration.processEvent` hook. Request data from the scope's `sdkProcessingMetadata` is mapped to span attributes following sentry-conventions, reusing `httpHeadersToSpanAttributes` for sensitivity filtering and gating IP extraction behind `sendDefaultPii`. Note: The logic is also guarded by `client.getIntegrationByName('RequestData')` so users who opt out of the integration don't get request data on streamed spans either. This approach was chosen over adding a `processSegmentSpan` hook to the integration because captureSpan is tree-shakeable for non-streaming users, keeping the request data code out of bundles that don't need it (see linked ticket). Closes #20380
1 parent 01d0a70 commit 7efc03f

7 files changed

Lines changed: 483 additions & 23 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
traceLifecycle: 'stream',
10+
sendDefaultPii: true,
11+
integrations: defaults => defaults.filter(i => i.name !== 'RequestData'),
12+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
traceLifecycle: 'stream',
10+
sendDefaultPii: true,
11+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Sentry from '@sentry/node';
2+
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
3+
import express from 'express';
4+
5+
const app = express();
6+
7+
app.get('/test', (_req, res) => {
8+
res.send({ response: 'ok' });
9+
});
10+
11+
Sentry.setupExpressErrorHandler(app);
12+
13+
startExpressServerAndSendPortToRunner(app);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { afterAll, describe, expect } from 'vitest';
2+
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
3+
4+
describe('requestData-streamed', () => {
5+
afterAll(() => {
6+
cleanupChildProcesses();
7+
});
8+
9+
createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
10+
test('applies request data attributes to the segment span', async () => {
11+
const runner = createRunner()
12+
.expect({
13+
span: container => {
14+
const serverSpan = container.items.find(item => item.is_segment);
15+
16+
expect(serverSpan).toBeDefined();
17+
18+
expect(serverSpan?.attributes?.['url.full']).toEqual({
19+
type: 'string',
20+
value: expect.stringContaining('/test?foo=bar'),
21+
});
22+
23+
expect(serverSpan?.attributes?.['http.request.method']).toEqual({
24+
type: 'string',
25+
value: 'GET',
26+
});
27+
28+
expect(serverSpan?.attributes?.['url.query']).toEqual({
29+
type: 'string',
30+
value: 'foo=bar',
31+
});
32+
33+
expect(serverSpan?.attributes?.['http.request.header.host']).toEqual({
34+
type: 'string',
35+
value: expect.any(String),
36+
});
37+
38+
expect(serverSpan?.attributes?.['user.ip_address']).toEqual({
39+
type: 'string',
40+
value: expect.any(String),
41+
});
42+
},
43+
})
44+
.start();
45+
46+
await runner.makeRequest('get', '/test?foo=bar');
47+
48+
await runner.completed();
49+
});
50+
});
51+
52+
createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-without-request-data.mjs', (createRunner, test) => {
53+
test('does not apply request data attributes when requestDataIntegration is removed', async () => {
54+
const runner = createRunner()
55+
.expect({
56+
span: container => {
57+
const serverSpan = container.items.find(item => item.is_segment);
58+
59+
expect(serverSpan).toBeDefined();
60+
61+
// url.query and user.ip_address are only set by applyScopeToSegmentSpan
62+
// (not by OTel instrumentation), so they should be absent when the integration is removed
63+
expect(serverSpan?.attributes?.['url.query']).toBeUndefined();
64+
expect(serverSpan?.attributes?.['user.ip_address']).toBeUndefined();
65+
},
66+
})
67+
.start();
68+
69+
await runner.makeRequest('get', '/test?foo=bar');
70+
71+
await runner.completed();
72+
});
73+
});
74+
});

packages/core/src/integrations/requestdata.ts

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import { getIsolationScope } from '../currentScopes';
12
import { defineIntegration } from '../integration';
3+
import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes';
24
import type { Event } from '../types-hoist/event';
35
import type { IntegrationFn } from '../types-hoist/integration';
4-
import type { RequestEventData } from '../types-hoist/request';
6+
import type { QueryParams, RequestEventData } from '../types-hoist/request';
7+
import type { StreamedSpanJSON } from '../types-hoist/span';
58
import { parseCookie } from '../utils/cookie';
9+
import { httpHeadersToSpanAttributes } from '../utils/request';
610
import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress';
11+
import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan';
712

813
interface RequestDataIncludeOptions {
914
cookies?: boolean;
@@ -55,6 +60,22 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) =
5560

5661
return event;
5762
},
63+
processSegmentSpan(span, client) {
64+
const { sdkProcessingMetadata = {} } = getIsolationScope().getScopeData();
65+
const { normalizedRequest, ipAddress } = sdkProcessingMetadata;
66+
67+
if (!normalizedRequest) {
68+
return;
69+
}
70+
71+
const { sendDefaultPii } = client.getOptions();
72+
const includeWithDefaultPiiApplied: RequestDataIncludeOptions = {
73+
...include,
74+
ip: include.ip ?? sendDefaultPii,
75+
};
76+
77+
addNormalizedRequestDataToSpan(span, normalizedRequest, ipAddress, includeWithDefaultPiiApplied, sendDefaultPii);
78+
},
5879
};
5980
}) satisfies IntegrationFn;
6081

@@ -91,6 +112,60 @@ function addNormalizedRequestDataToEvent(
91112
}
92113
}
93114

115+
function addNormalizedRequestDataToSpan(
116+
span: StreamedSpanJSON,
117+
normalizedRequest: RequestEventData,
118+
ipAddress: string | undefined,
119+
include: RequestDataIncludeOptions,
120+
sendDefaultPii: boolean | undefined,
121+
): void {
122+
const requestData = extractNormalizedRequestData(normalizedRequest, include);
123+
const attributes: Record<string, unknown> = {};
124+
125+
if (requestData.url) {
126+
attributes['url.full'] = requestData.url;
127+
}
128+
129+
if (requestData.method) {
130+
attributes['http.request.method'] = requestData.method;
131+
}
132+
133+
if (requestData.query_string) {
134+
attributes['url.query'] = normalizeQueryString(requestData.query_string);
135+
}
136+
137+
safeSetSpanJSONAttributes(span, attributes);
138+
139+
// Process cookies before headers so normalizedRequest.cookies takes precedence
140+
// over the raw cookie header (matching the processEvent path).
141+
if (requestData.cookies && Object.keys(requestData.cookies).length > 0) {
142+
const cookieString = Object.entries(requestData.cookies)
143+
.map(([name, value]) => `${name}=${value}`)
144+
.join('; ');
145+
const cookieAttributes = httpHeadersToSpanAttributes({ cookie: cookieString }, sendDefaultPii ?? false, 'request');
146+
safeSetSpanJSONAttributes(span, cookieAttributes);
147+
}
148+
149+
if (requestData.headers) {
150+
const headerAttributes = httpHeadersToSpanAttributes(requestData.headers, sendDefaultPii ?? false, 'request');
151+
safeSetSpanJSONAttributes(span, headerAttributes);
152+
}
153+
154+
if (requestData.data != null) {
155+
const serialized = typeof requestData.data === 'string' ? requestData.data : JSON.stringify(requestData.data);
156+
if (serialized) {
157+
safeSetSpanJSONAttributes(span, { 'http.request.body.data': serialized });
158+
}
159+
}
160+
161+
if (include.ip) {
162+
const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined;
163+
if (ip) {
164+
safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip });
165+
}
166+
}
167+
}
168+
94169
function extractNormalizedRequestData(
95170
normalizedRequest: RequestEventData,
96171
include: RequestDataIncludeOptions,
@@ -101,13 +176,10 @@ function extractNormalizedRequestData(
101176
if (include.headers) {
102177
requestData.headers = headers;
103178

104-
// Remove the Cookie header in case cookie data should not be included in the event
105179
if (!include.cookies) {
106180
delete (headers as { cookie?: string }).cookie;
107181
}
108182

109-
// Remove IP headers in case IP data should not be included in the event.
110-
// Match case-insensitively — same as getClientIPAddress — so lowercase keys are stripped too.
111183
if (!include.ip) {
112184
const ipHeaderNamesLower = new Set(ipHeaderNames.map(name => name.toLowerCase()));
113185
for (const key of Object.keys(headers)) {
@@ -140,3 +212,14 @@ function extractNormalizedRequestData(
140212

141213
return requestData;
142214
}
215+
216+
function normalizeQueryString(queryString: QueryParams): string | undefined {
217+
if (typeof queryString === 'string') {
218+
return queryString || undefined;
219+
}
220+
221+
const pairs = Array.isArray(queryString) ? queryString : Object.entries(queryString);
222+
const result = pairs.map(([key, value]) => `${key}=${value}`).join('&');
223+
224+
return result || undefined;
225+
}

packages/core/src/tracing/spans/captureSpan.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,27 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW
9797
}
9898

9999
function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void {
100-
// TODO: Apply all scope and request data from auto instrumentation (contexts, request) to segment span
100+
// TODO: Apply contexts data from auto instrumentation to segment span
101101
// This will follow in a separate PR
102102
}
103103

104+
/**
105+
* Safely set attributes on a span JSON.
106+
* If an attribute already exists, it will not be overwritten.
107+
*/
108+
export function safeSetSpanJSONAttributes(
109+
spanJSON: StreamedSpanJSON,
110+
newAttributes: RawAttributes<Record<string, unknown>>,
111+
): void {
112+
const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {});
113+
114+
Object.entries(newAttributes).forEach(([key, value]) => {
115+
if (value != null && !(key in originalAttributes)) {
116+
originalAttributes[key] = value;
117+
}
118+
});
119+
}
120+
104121
function applyCommonSpanAttributes(
105122
spanJSON: StreamedSpanJSON,
106123
serializedSegmentSpan: StreamedSpanJSON,
@@ -145,23 +162,6 @@ export function applyBeforeSendSpanCallback(
145162
return modifedSpan;
146163
}
147164

148-
/**
149-
* Safely set attributes on a span JSON.
150-
* If an attribute already exists, it will not be overwritten.
151-
*/
152-
export function safeSetSpanJSONAttributes(
153-
spanJSON: StreamedSpanJSON,
154-
newAttributes: RawAttributes<Record<string, unknown>>,
155-
): void {
156-
const originalAttributes = spanJSON.attributes ?? (spanJSON.attributes = {});
157-
158-
Object.entries(newAttributes).forEach(([key, value]) => {
159-
if (value != null && !(key in originalAttributes)) {
160-
originalAttributes[key] = value;
161-
}
162-
});
163-
}
164-
165165
// OTel SpanKind values (numeric to avoid importing from @opentelemetry/api)
166166
const SPAN_KIND_SERVER = 1;
167167
const SPAN_KIND_CLIENT = 2;

0 commit comments

Comments
 (0)