Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
7 changes: 2 additions & 5 deletions packages/core/src/integrations/http/server-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,7 @@ function buildServerSpanWrap(
'http.flavor': httpVersion,
'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp',
...getRequestContentLengthAttribute(request),
...httpHeadersToSpanAttributes(
normalizedRequest.headers || {},
client.getOptions().sendDefaultPii ?? false,
),
...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, client.getDataCollectionOptions()),
},
},
span => {
Expand All @@ -334,7 +331,7 @@ function buildServerSpanWrap(
'http.status_code': response.statusCode,
...httpHeadersToSpanAttributes(
headersToDict(response.headers),
client?.getOptions().sendDefaultPii ?? false,
client?.getDataCollectionOptions() ?? false,
'response',
),
});
Expand Down
68 changes: 41 additions & 27 deletions packages/core/src/integrations/requestdata.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Client } from '../client';
import { getIsolationScope } from '../currentScopes';
import { defineIntegration } from '../integration';
import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes';
import type { ResolvedDataCollection } from '../types/datacollection';
import type { Event } from '../types/event';
import type { IntegrationFn } from '../types/integration';
import type { QueryParams, RequestEventData } from '../types/request';
Expand All @@ -26,36 +28,52 @@ type RequestDataIntegrationOptions = {
include?: RequestDataIncludeOptions;
};

// TODO(v11): Change defaults based on `sendDefaultPii`
const DEFAULT_INCLUDE: RequestDataIncludeOptions = {
cookies: true,
data: true,
headers: true,
query_string: true,
url: true,
};

const INTEGRATION_NAME = 'RequestData';

const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) => {
const include = {
...DEFAULT_INCLUDE,
...options.include,
};
// Per spec, integration-level options override global dataCollection.
// When include overrides a category back on that dataCollection turned off,
// we flip the dataCollection behavior to true (default denylist filtering).
function resolveIncludeAndDataCollection(client: Client): {
include: RequestDataIncludeOptions;
dataCollection: ResolvedDataCollection;
} {
const dc = client.getDataCollectionOptions();
const dataCollection: ResolvedDataCollection = {
...dc,
...(options.include?.cookies === true && dc.cookies === false && { cookies: true as const }),
...(options.include?.headers === true &&
dc.httpHeaders.request === false && {
httpHeaders: { ...dc.httpHeaders, request: true as const },
}),
};

return {
dataCollection,
include: {
cookies: dataCollection.cookies !== false,
// Always attach body data that's already on the scope — dataCollection.httpBodies gates write-time, not read-time
data: true,
headers: dataCollection.httpHeaders.request !== false,
ip: dataCollection.userInfo,
query_string: dataCollection.queryParams !== false,
// No dataCollection equivalent — URL is always included
url: true,
...options.include,
},
};
}

return {
name: INTEGRATION_NAME,
processEvent(event, _hint, client) {
const { sdkProcessingMetadata = {} } = event;
const { normalizedRequest, ipAddress } = sdkProcessingMetadata;

const includeWithDefaultPiiApplied: RequestDataIncludeOptions = {
...include,
ip: include.ip ?? client.getOptions().sendDefaultPii,
};
const { include } = resolveIncludeAndDataCollection(client);

if (normalizedRequest) {
addNormalizedRequestDataToEvent(event, normalizedRequest, { ipAddress }, includeWithDefaultPiiApplied);
addNormalizedRequestDataToEvent(event, normalizedRequest, { ipAddress }, include);
}

return event;
Expand All @@ -68,13 +86,9 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) =
return;
}

const { sendDefaultPii } = client.getOptions();
const includeWithDefaultPiiApplied: RequestDataIncludeOptions = {
...include,
ip: include.ip ?? sendDefaultPii,
};
const { include, dataCollection } = resolveIncludeAndDataCollection(client);

addNormalizedRequestDataToSpan(span, normalizedRequest, ipAddress, includeWithDefaultPiiApplied, sendDefaultPii);
addNormalizedRequestDataToSpan(span, normalizedRequest, ipAddress, include, dataCollection);
Comment thread
chargome marked this conversation as resolved.
},
};
}) satisfies IntegrationFn;
Expand Down Expand Up @@ -117,7 +131,7 @@ function addNormalizedRequestDataToSpan(
normalizedRequest: RequestEventData,
ipAddress: string | undefined,
include: RequestDataIncludeOptions,
sendDefaultPii: boolean | undefined,
dataCollection: ResolvedDataCollection,
): void {
const requestData = extractNormalizedRequestData(normalizedRequest, include);
const attributes: Record<string, unknown> = {};
Expand All @@ -142,12 +156,12 @@ function addNormalizedRequestDataToSpan(
const cookieString = Object.entries(requestData.cookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
const cookieAttributes = httpHeadersToSpanAttributes({ cookie: cookieString }, sendDefaultPii ?? false, 'request');
const cookieAttributes = httpHeadersToSpanAttributes({ cookie: cookieString }, dataCollection, 'request');
safeSetSpanJSONAttributes(span, cookieAttributes);
}

if (requestData.headers) {
const headerAttributes = httpHeadersToSpanAttributes(requestData.headers, sendDefaultPii ?? false, 'request');
const headerAttributes = httpHeadersToSpanAttributes(requestData.headers, dataCollection, 'request');
safeSetSpanJSONAttributes(span, headerAttributes);
}

Expand Down
165 changes: 67 additions & 98 deletions packages/core/src/utils/request.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
/* eslint-disable max-lines-per-function */
import { DEBUG_BUILD } from '../debug-build';
import type { Scope } from '../scope';
import type { ResolvedDataCollection } from '../types/datacollection';
import type { PolymorphicRequest } from '../types/polymorphics';
import type { RequestEventData } from '../types/request';
import type { WebFetchHeaders, WebFetchRequest } from '../types/webfetchapi';
import { debug } from './debug-logger';
import { defaultPiiToCollectionOptions } from './data-collection/defaultPiiToCollectionOptions';
import { FILTERED_VALUE, SENSITIVE_COOKIE_NAME_SNIPPETS } from './data-collection/filtering-snippets';
import { filterKeyValueData } from './data-collection/filterKeyValueData';
import { safeUnref } from './timer';
import {
PII_HEADER_SNIPPETS,
SENSITIVE_COOKIE_NAME_SNIPPETS,
SENSITIVE_KEY_SNIPPETS,
} from '../utils/data-collection/filtering-snippets';

/**
* Maximum size of incoming HTTP request bodies attached to events.
Expand Down Expand Up @@ -278,55 +277,66 @@ function getAbsoluteUrl({
*/
export function httpHeadersToSpanAttributes(
headers: Record<string, string | string[] | undefined>,
sendDefaultPii: boolean = false,
// TODO(v11): Remove boolean support once sendDefaultPii is fully removed.
// Internally, always pass ResolvedDataCollection from client.getDataCollectionOptions().
dataCollection: ResolvedDataCollection | boolean = false,
lifecycle: 'request' | 'response' = 'request',
): Record<string, string> {
const resolvedDataCollection =
typeof dataCollection === 'boolean' ? defaultPiiToCollectionOptions(dataCollection) : dataCollection;

const headerBehavior =
lifecycle === 'request' ? resolvedDataCollection.httpHeaders.request : resolvedDataCollection.httpHeaders.response;
const cookieBehavior = resolvedDataCollection.cookies;
const prefix = `http.${lifecycle}.header.`;

const spanAttributes: Record<string, string> = {};

try {
Object.entries(headers).forEach(([key, value]) => {
const regularHeaders: Record<string, string> = {};

for (const [key, value] of Object.entries(headers)) {
if (value == null) {
return;
continue;
}

const lowerCasedHeaderKey = key.toLowerCase();
const isCookieHeader = lowerCasedHeaderKey === 'cookie' || lowerCasedHeaderKey === 'set-cookie';

if (isCookieHeader && typeof value === 'string' && value !== '') {
// Set-Cookie: single cookie with attributes ("name=value; HttpOnly; Secure")
// Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2")
const isSetCookie = lowerCasedHeaderKey === 'set-cookie';
const semicolonIndex = value.indexOf(';');
const cookieString = isSetCookie && semicolonIndex !== -1 ? value.substring(0, semicolonIndex) : value;
const cookies = isSetCookie ? [cookieString] : cookieString.split('; ');

for (const cookie of cookies) {
// Split only at the first '=' to preserve '=' characters in cookie values
const equalSignIndex = cookie.indexOf('=');
const cookieKey = equalSignIndex !== -1 ? cookie.substring(0, equalSignIndex) : cookie;
const cookieValue = equalSignIndex !== -1 ? cookie.substring(equalSignIndex + 1) : '';

const lowerCasedCookieKey = cookieKey.toLowerCase();

addSpanAttribute({
spanAttributes,
headerKey: lowerCasedHeaderKey,
cookieKey: lowerCasedCookieKey,
value: cookieValue,
sendDefaultPii,
lifecycle,
});
const lowerKey = key.toLowerCase();
const isCookieHeader = lowerKey === 'cookie' || lowerKey === 'set-cookie';

if (isCookieHeader) {
if (cookieBehavior === false) {
continue;
}

if (typeof value === 'string' && value !== '') {
const parsed = parseCookieHeader(value, lowerKey === 'set-cookie');
const filtered = filterKeyValueData(parsed, cookieBehavior, SENSITIVE_COOKIE_NAME_SNIPPETS);
for (const [cookieKey, cookieValue] of Object.entries(filtered)) {
spanAttributes[`${prefix}${normalizeAttributeKey(lowerKey)}.${normalizeAttributeKey(cookieKey)}`] =
cookieValue;
}
} else {
spanAttributes[`${prefix}${normalizeAttributeKey(lowerKey)}`] = FILTERED_VALUE;
}
} else {
addSpanAttribute({
spanAttributes,
headerKey: lowerCasedHeaderKey,
value,
sendDefaultPii,
lifecycle,
});
if (headerBehavior === false) {
continue;
}

if (Array.isArray(value)) {
regularHeaders[lowerKey] = value.map(v => (v != null ? String(v) : v)).join(';');
} else if (typeof value === 'string') {
regularHeaders[lowerKey] = value;
}
}
});
}

if (headerBehavior !== false) {
const filtered = filterKeyValueData(regularHeaders, headerBehavior);
for (const [headerKey, headerValue] of Object.entries(filtered)) {
spanAttributes[`${prefix}${normalizeAttributeKey(headerKey)}`] = headerValue;
}
}
} catch {
// Return empty object if there's an error
}
Expand All @@ -338,62 +348,21 @@ function normalizeAttributeKey(key: string): string {
return key.replace(/-/g, '_');
}

type AddSpanAttributeOptions = {
spanAttributes: Record<string, string>;
/** Lowercased HTTP header name (e.g. `cookie`, `set-cookie`, `accept`). */
headerKey: string;
/**
* Lowercased cookie name when this attribute comes from a parsed `Cookie` / `Set-Cookie` value.
* Omit for non-cookie headers; when present and non-empty, cookie-specific sensitivity rules apply.
*/
cookieKey?: string;
value: string | string[] | undefined;
sendDefaultPii: boolean;
lifecycle: 'request' | 'response';
};

function addSpanAttribute({
spanAttributes,
headerKey,
cookieKey,
value,
sendDefaultPii,
lifecycle,
}: AddSpanAttributeOptions): void {
const isCookieSubKey = Boolean(cookieKey);
const nameForSensitivity = cookieKey || headerKey;
const headerValue = handleHttpHeader(nameForSensitivity, value, sendDefaultPii, isCookieSubKey);
if (headerValue == null) {
return;
function parseCookieHeader(value: string, isSetCookie: boolean): Record<string, string> {
// Set-Cookie: single cookie with attributes ("name=value; HttpOnly; Secure")
// Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2")
const semicolonIndex = value.indexOf(';');
const cookieString = isSetCookie && semicolonIndex !== -1 ? value.substring(0, semicolonIndex) : value;
const cookies = isSetCookie ? [cookieString] : cookieString.split('; ');

const result: Record<string, string> = {};
for (const cookie of cookies) {
const equalSignIndex = cookie.indexOf('=');
const cookieKey = (equalSignIndex !== -1 ? cookie.substring(0, equalSignIndex) : cookie).toLowerCase();
const cookieValue = equalSignIndex !== -1 ? cookie.substring(equalSignIndex + 1) : '';
result[cookieKey] = cookieValue;
}

const normalizedKey = `http.${lifecycle}.header.${normalizeAttributeKey(headerKey)}${cookieKey ? `.${normalizeAttributeKey(cookieKey)}` : ''}`;
spanAttributes[normalizedKey] = headerValue;
}

function handleHttpHeader(
lowerCasedKey: string,
value: string | string[] | undefined,
sendPii: boolean,
isCookieSubKey: boolean = false,
): string | undefined {
const snippetsForSensitivity = isCookieSubKey
? [...SENSITIVE_KEY_SNIPPETS, ...SENSITIVE_COOKIE_NAME_SNIPPETS]
: SENSITIVE_KEY_SNIPPETS;

const isSensitive = sendPii
? snippetsForSensitivity.some(snippet => lowerCasedKey.includes(snippet))
: [...PII_HEADER_SNIPPETS, ...snippetsForSensitivity].some(snippet => lowerCasedKey.includes(snippet));

if (isSensitive) {
return '[Filtered]';
} else if (Array.isArray(value)) {
return value.map(v => (v != null ? String(v) : v)).join(';');
} else if (typeof value === 'string') {
return value;
}

return undefined;
return result;
}

/** Extract the query params from an URL. */
Expand Down
Loading
Loading