Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'),
});
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
92 changes: 87 additions & 5 deletions packages/core/src/integrations/requestdata.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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 {
Expand All @@ -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<string, unknown> = {};

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,
Expand All @@ -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)) {
Expand Down Expand Up @@ -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]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');

return result || undefined;
}
36 changes: 18 additions & 18 deletions packages/core/src/tracing/spans/captureSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>,
): 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,
Expand Down Expand Up @@ -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<Record<string, unknown>>,
): 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;
Expand Down
Loading
Loading