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
12 changes: 12 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { SeverityLevel } from './types/severity';
import type { Span, SpanAttributes, SpanContextData, SpanJSON, StreamedSpanJSON } from './types/span';
import type { StartSpanOptions } from './types/startSpanOptions';
import type { Transport, TransportMakeRequestResponse } from './types/transport';
import type { ResolvedDataCollection } from './types/datacollection';
import { createClientReportEnvelope } from './utils/clientreport';
import { debug } from './utils/debug-logger';
import { dsnToString, makeDsn } from './utils/dsn';
Expand All @@ -54,6 +55,7 @@ import { showSpanDropWarning } from './utils/spanUtils';
import { rejectedSyncPromise } from './utils/syncpromise';
import { safeUnref } from './utils/timer';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { resolveDataCollectionOptions } from './utils/data-collection/resolveDataCollectionOptions';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand Down Expand Up @@ -213,6 +215,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {

protected _promiseBuffer: PromiseBuffer<unknown>;

protected readonly _dataCollection: ResolvedDataCollection;

/**
* Initializes this client instance.
*
Expand All @@ -226,6 +230,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
this._hooks = {};
this._eventProcessors = [];
this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE);
this._dataCollection = resolveDataCollectionOptions(options);

if (options.dsn) {
this._dsn = makeDsn(options.dsn);
Expand Down Expand Up @@ -399,6 +404,13 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return this._options;
}

/**
* Get the resolved data collection configuration.
*/
public getDataCollectionOptions(): ResolvedDataCollection {
return this._dataCollection;
}

/**
* Get the SDK metadata.
* @see SdkMetadata
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export {
_INTERNAL_shouldSkipAiProviderWrapping,
_INTERNAL_clearAiProviderSkips,
} from './utils/ai/providerSkip';
export { defaultPiiToCollectionOptions } from './utils/data-collection/defaultPiiToCollectionOptions';
export { filterKeyValueData as _INTERNAL_filterKeyValueData } from './utils/data-collection/filterKeyValueData';
export { filterCookies as _INTERNAL_filterCookies } from './utils/data-collection/filterCookies';
export { filterQueryParams as _INTERNAL_filterQueryParams } from './utils/data-collection/filterQueryParams';
export { envToBool } from './utils/envToBool';
export { applyScopeDataToEvent, mergeScopeData, getCombinedScopeData } from './utils/scopeData';
export { prepareEvent } from './utils/prepareEvent';
Expand Down Expand Up @@ -403,7 +405,12 @@ export type { Extra, Extras } from './types/extra';
export type { Integration, IntegrationFn } from './types/integration';
export type { Mechanism } from './types/mechanism';
export type { ExtractedNodeRequestData, HttpHeaderValue, Primitive, WorkerLocation } from './types/misc';
export type { CollectBehavior, DataCollection, HttpBodyCollectionTarget } from './types/datacollection';
export type {
CollectBehavior,
DataCollection,
HttpBodyCollectionTarget,
ResolvedDataCollection,
} from './types/datacollection';
export type { ClientOptions, CoreOptions as Options } from './types/options';
export type { Package } from './types/package';
export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics';
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/types/datacollection.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Controls how key-value data (headers, cookies, query params) is collected and filtered.
*
* - `true`: Collect all data without filtering (empty denylist). Senstive values like keys and tokens are always filtered out.
* - `true`: Collect all data without filtering (empty denylist). Sensitive values like keys and tokens are always filtered out.
* - `false`: Do not collect any data.
* - `{ allow: string[] }`: Collect only the specified keys.
* - `{ deny: string[] }`: Collect all keys except the specified ones.
Expand Down Expand Up @@ -70,3 +70,11 @@ export interface DataCollection {
*/
frameContextLines?: number;
}

/**
* Fully resolved `DataCollection` with all defaults applied.
*/
export type ResolvedDataCollection = Required<DataCollection> & {
httpHeaders: Required<NonNullable<DataCollection['httpHeaders']>>;
genAI: Required<NonNullable<DataCollection['genAI']>>;
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { PII_HEADER_SNIPPETS } from './filtering-snippets';
import type { DataCollection } from '../../types/datacollection';
import type { ResolvedDataCollection } from '../../types/datacollection';

/**
* Helper function that maps the `sendDefaultPii` boolean flag to the corresponding `DataCollection` configuration.
*/
export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): DataCollection {
export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): ResolvedDataCollection {
return sendDefaultPii === true
? {
userInfo: true,
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/utils/data-collection/filterCookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { CollectBehavior } from '../../types/datacollection';
import { parseCookie } from '../cookie';
import { FILTERED_VALUE as FILTERED } from './filtering-snippets';
import { filterKeyValueData } from './filterKeyValueData';

/**
* Filters a cookie string according to a `CollectBehavior`.
*
* When individual cookies can be parsed, each key-value pair is filtered
* independently. When parsing fails, the entire string is replaced with `[Filtered]`.
*/
export function filterCookies(cookieString: string, behavior: CollectBehavior): Record<string, string> | string {
if (behavior === false) {
return {};
}

try {
const parsed = parseCookie(cookieString);

if (Object.keys(parsed).length === 0) {
return {};
}

return filterKeyValueData(parsed, behavior);
} catch {
return FILTERED;
}
}
51 changes: 51 additions & 0 deletions packages/core/src/utils/data-collection/filterKeyValueData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { CollectBehavior } from '../../types/datacollection';
import { FILTERED_VALUE as FILTERED, SENSITIVE_KEY_SNIPPETS } from './filtering-snippets';

function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase();
return SENSITIVE_KEY_SNIPPETS.some(snippet => lower.includes(snippet));
}

/**
* Filters a key-value record according to a `CollectBehavior`.
*
* Key names are always preserved. Values are either kept, replaced with
* `[Filtered]`, or the entire record is dropped (off mode).
*/
export function filterKeyValueData(data: Record<string, string>, behavior: CollectBehavior): Record<string, string> {
if (behavior === false) {
return {};
}

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

if (behavior === true) {
for (const key of Object.keys(data)) {
result[key] = isSensitiveKey(key) ? FILTERED : data[key]!;
}
return result;
}

if ('deny' in behavior) {
const lowerTerms = behavior.deny.map(t => t.toLowerCase());
for (const key of Object.keys(data)) {
const lower = key.toLowerCase();
const isDenied = isSensitiveKey(key) || lowerTerms.some(term => lower.includes(term));
result[key] = isDenied ? FILTERED : data[key]!;
}
return result;
}

// allowList mode
const lowerTerms = behavior.allow.map(t => t.toLowerCase());
for (const key of Object.keys(data)) {
if (isSensitiveKey(key)) {
result[key] = FILTERED;
} else {
const lower = key.toLowerCase();
const isAllowed = lowerTerms.some(term => lower.includes(term));
result[key] = isAllowed ? data[key]! : FILTERED;
}
}
return result;
}
36 changes: 36 additions & 0 deletions packages/core/src/utils/data-collection/filterQueryParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { CollectBehavior } from '../../types/datacollection';
import { FILTERED_VALUE as FILTERED } from './filtering-snippets';
import { filterKeyValueData } from './filterKeyValueData';

function parseQueryParams(queryString: string): Record<string, string> | undefined {
try {
const params = new URLSearchParams(queryString);
const result: Record<string, string> = {};
params.forEach((value, key) => {
result[key] = value;
});
return Object.keys(result).length > 0 ? result : undefined;
} catch {
return undefined;
}
}

/**
* Filters a query parameter string according to a `CollectBehavior`.
*
* When individual params can be parsed, each key-value pair is filtered
* independently. When parsing fails, the entire string is replaced with `[Filtered]`.
*/
export function filterQueryParams(queryString: string, behavior: CollectBehavior): Record<string, string> | string {
if (behavior === false) {
return {};
}

const parsed = parseQueryParams(queryString);

if (parsed == null) {
return FILTERED;
}

return filterKeyValueData(parsed, behavior);
}
63 changes: 63 additions & 0 deletions packages/core/src/utils/data-collection/filtering-snippets.ts
Original file line number Diff line number Diff line change
@@ -1 +1,64 @@
export const FILTERED_VALUE = '[Filtered]';

export const PII_HEADER_SNIPPETS = ['forwarded', '-ip', 'remote-', 'via', '-user'];

export const SENSITIVE_KEY_SNIPPETS = [
'auth',
'token',
'secret',
'session', // for the user_session cookie
'password',
'passwd',
'pwd',
'key',
'jwt',
'bearer',
'sso',
'saml',
'csrf',
'xsrf',
'credentials',
'sid',
'identity',
// Always treat cookie headers as sensitive in case individual key-value cookie pairs cannot properly be extracted
'set-cookie',
'cookie',
];

/**
* Extra substrings matched only against individual Cookie / Set-Cookie **names** (not header names),
* so we can cover common session secrets that do not match {@link SENSITIVE_KEY_SNIPPETS}
* (e.g. `connect.sid` does not contain `session`) without false positives on arbitrary HTTP headers.
*
* Cookie names are checked with the same `includes()` list as headers plus these entries; omit redundant
* cookie-only snippets that are already implied by a header match (e.g. `oauth` → `auth`, `id_token` → `token`,
* `next-auth` → `auth`).
*/
export const SENSITIVE_COOKIE_NAME_SNIPPETS = [
// Express / Connect default session cookie
'.sid',
// Opaque session ids (PHPSESSID, ASPSESSIONID*, BIGipServer*, *sessid*, …)
'sessid',
// Laravel etc. "remember me" tokens
'remember',
// OIDC / OAuth auxiliary (`oauth*` covered by header snippet `auth`)
'oidc',
'pkce',
'nonce',
// RFC 6265bis high-security cookie name prefixes
'__secure-',
'__host-',
// Load balancer / CDN sticky-session cookies (opaque routing tokens)
'awsalb',
'awselb',
'akamai',
// BaaS / IdP session cookies (names often omit "session")
'__stripe',
'cognito',
'firebase',
'supabase',
'sb-',
// Step-up / MFA cookies
'mfa',
'2fa',
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { DataCollection, ResolvedDataCollection } from '../../types/datacollection';
import { defaultPiiToCollectionOptions } from './defaultPiiToCollectionOptions';

const DEFAULTS: ResolvedDataCollection = {
userInfo: false,
cookies: true,
httpHeaders: { request: true, response: true },
httpBodies: [],
queryParams: true,
genAI: { inputs: true, outputs: true },
stackFrameVariables: true,
frameContextLines: 5,
};

/**
* Resolves the effective `DataCollection` configuration from client options.
*
* Precedence:
* 1. Fields explicitly set in `dataCollection`
* 2. If `sendDefaultPii` is set and `dataCollection` is absent, bridge via `defaultPiiToCollectionOptions`
* 3. Spec defaults
*/
export function resolveDataCollectionOptions(options: {
dataCollection?: DataCollection;
sendDefaultPii?: boolean;
}): ResolvedDataCollection {
const base = options.dataCollection != null ? DEFAULTS : defaultPiiToCollectionOptions(options.sendDefaultPii);

const dc = options.dataCollection ?? {};

return {
userInfo: dc.userInfo ?? base.userInfo,
cookies: dc.cookies ?? base.cookies,
httpHeaders: {
request: dc.httpHeaders?.request ?? base.httpHeaders.request,
response: dc.httpHeaders?.response ?? base.httpHeaders.response,
},
httpBodies: dc.httpBodies ?? base.httpBodies,
queryParams: dc.queryParams ?? base.queryParams,
genAI: {
inputs: dc.genAI?.inputs ?? base.genAI.inputs,
outputs: dc.genAI?.outputs ?? base.genAI.outputs,
},
stackFrameVariables: dc.stackFrameVariables ?? base.stackFrameVariables,
frameContextLines: dc.frameContextLines ?? base.frameContextLines,
};
}
Loading
Loading