From 1545b5599a6c08bbf6aacad2ebb95592c7e9977a Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 24 Feb 2026 16:40:21 +0100 Subject: [PATCH 01/26] feat: first implementation of http headers capture --- .gitignore | 2 + packages/core/src/DdSdkReactNative.tsx | 12 +- .../src/__tests__/DdSdkReactNative.test.tsx | 3 +- .../DdSdkReactNativeConfiguration.test.ts | 3 + .../__tests__/FileBasedConfiguration.test.ts | 3 + .../async/AutoInstrumentationConfiguration.ts | 6 +- .../src/config/features/RumConfiguration.ts | 9 + .../config/features/RumConfiguration.type.ts | 113 +++ packages/core/src/index.tsx | 7 + .../DdRumResourceTracking.tsx | 9 +- .../__tests__/__utils__/XMLHttpRequestMock.ts | 2 +- .../distributedTracing/firstPartyHosts.ts | 2 +- .../resourceTracking/headerCapture/README.md | 142 ++++ .../__tests__/captureHeaders.test.ts | 775 ++++++++++++++++++ .../__tests__/compileHeaderConfig.test.ts | 430 ++++++++++ .../enforceSizeLimits.integration.test.ts | 183 +++++ .../__tests__/enforceSizeLimits.test.ts | 226 +++++ .../__tests__/isHeaderAllowed.test.ts | 35 + .../__tests__/parseResponseHeaders.test.ts | 105 +++ .../sensitiveHeaderBlocklist.test.ts | 109 +++ .../__tests__/tracingHeaderExclusion.test.ts | 58 ++ .../headerCapture/captureHeaders.ts | 328 ++++++++ .../headerCapture/compileHeaderConfig.ts | 300 +++++++ .../headerCapture/enforceSizeLimits.ts | 131 +++ .../headerCapture/isHeaderAllowed.ts | 29 + .../headerCapture/parseResponseHeaders.ts | 57 ++ .../headerCapture/sensitiveHeaderBlocklist.ts | 31 + .../headerCapture/tracingHeaderExclusion.ts | 59 ++ .../resourceTracking/headerCapture/types.ts | 33 + .../__tests__/ResourceReporter.test.ts | 120 +++ .../requestProxy/XHRProxy/XHRProxy.ts | 67 +- .../XHRProxy/__tests__/XHRProxy.test.ts | 456 +++++++++++ .../requestProxy/common/ResourceReporter.ts | 18 +- .../requestProxy/interfaces/RequestProxy.ts | 2 + .../requestProxy/interfaces/RumResource.ts | 2 + .../__tests__/initialization.test.tsx | 3 +- 36 files changed, 3858 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts diff --git a/.gitignore b/.gitignore index 693bb50a6..a070a8854 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,5 @@ packages/react-native-babel-plugin/svg-map.json # Session Replay assets packages/react-native-session-replay/assets/* + +.planning diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index b74605ddb..1c69e3036 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -518,6 +518,8 @@ export class DdSdkReactNative { const resourceTraceSampleRate = configuration.rumConfiguration?.resourceTraceSampleRate || RUM_DEFAULTS.resourceTraceSampleRate; + const headerCaptureRules = + configuration.rumConfiguration?.headerCaptureRules; const logEventMapper = configuration.logsConfiguration?.logEventMapper; const errorEventMapper = configuration.rumConfiguration?.errorEventMapper; @@ -553,11 +555,19 @@ export class DdSdkReactNative { }); } + if (!trackResources && headerCaptureRules !== undefined) { + InternalLog.log( + 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', + SdkVerbosity.WARN + ); + } + if (trackResources) { DdRumResourceTracking.startTracking({ resourceTraceSampleRate, firstPartyHosts, - trackFetchResources + trackFetchResources, + headerCaptureRules }); } diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 1b35fc49e..8dc090fa7 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -813,7 +813,8 @@ describe('DdSdkReactNative', () => { propagatorTypes: ['datadog'] } ], - trackFetchResources: true + trackFetchResources: true, + headerCaptureRules: undefined }); }); diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 295bf5011..5f6285767 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -61,6 +61,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -212,6 +213,7 @@ describe('DdSdkReactNativeConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": 0.123, "longTaskThresholdMs": 567, "nativeCrashReportEnabled": true, @@ -320,6 +322,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": 0, "longTaskThresholdMs": false, "nativeCrashReportEnabled": false, diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index 93fe175c9..223b91dad 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -55,6 +55,7 @@ describe('FileBasedConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": 456, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": true, @@ -172,6 +173,7 @@ describe('FileBasedConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": false, @@ -239,6 +241,7 @@ describe('FileBasedConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, diff --git a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts index e4fdd787b..ee636a460 100644 --- a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts +++ b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts @@ -9,6 +9,7 @@ import type { ResourceEventMapper } from '../../rum/eventMappers/resourceEventMa import type { FirstPartyHost } from '../../rum/types'; import type { LogEventMapper } from '../../types'; import { LOGS_DEFAULTS } from '../features/LogsConfiguration'; +import type { HeaderCaptureRule } from '../features/RumConfiguration.type'; import { RUM_DEFAULTS } from '../features/RumConfiguration'; import type { TraceConfiguration } from '../features/TraceConfiguration'; @@ -25,6 +26,7 @@ export type AutoInstrumentationConfiguration = { readonly useAccessibilityLabel?: boolean; readonly actionNameAttribute?: string; readonly resourceTraceSampleRate?: number; + readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; readonly nativeCrashReportEnabled?: boolean; readonly nativeLongTaskThresholdMs?: number; readonly nativeViewTracking?: boolean; @@ -61,6 +63,7 @@ export type AutoInstrumentationParameters = { readonly errorEventMapper: ErrorEventMapper | null; readonly resourceEventMapper: ResourceEventMapper | null; readonly firstPartyHosts: FirstPartyHost[]; + readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; }; readonly logsConfiguration?: { readonly logEventMapper: LogEventMapper | null; @@ -128,7 +131,8 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( features.rumConfiguration.nativeIosResourceTrackingDisallowList, firstPartyHosts: features.rumConfiguration.firstPartyHosts || - RUM_DEFAULTS.getFirstPartyHosts() + RUM_DEFAULTS.getFirstPartyHosts(), + headerCaptureRules: features.rumConfiguration.headerCaptureRules }, logsConfiguration: { logEventMapper: diff --git a/packages/core/src/config/features/RumConfiguration.ts b/packages/core/src/config/features/RumConfiguration.ts index 2a257b578..41d389626 100644 --- a/packages/core/src/config/features/RumConfiguration.ts +++ b/packages/core/src/config/features/RumConfiguration.ts @@ -10,6 +10,7 @@ import type { FirstPartyHost } from '../../rum/types'; import { VitalsUpdateFrequency } from '../types'; import type { + HeaderCaptureRule, RumConfigurationOptions, RumConfigurationType } from './RumConfiguration.type'; @@ -40,6 +41,10 @@ const DEFAULTS = { trackInteractions: false, trackMemoryWarnings: true, trackNonFatalAnrs: undefined, + headerCaptureRules: undefined as + | 'defaults' + | HeaderCaptureRule[] + | undefined, trackResources: false, trackWatchdogTerminations: false, useAccessibilityLabel: true, @@ -126,6 +131,10 @@ export class RumConfiguration implements RumConfigurationType { // Track non-fatal ANRs enabled public trackNonFatalAnrs?: boolean = DEFAULTS.trackNonFatalAnrs; + // Header Capture Rules + public headerCaptureRules: 'defaults' | HeaderCaptureRule[] | undefined = + DEFAULTS.headerCaptureRules; + // Track Watchdog Terminations enabled public trackWatchdogTerminations: boolean = DEFAULTS.trackWatchdogTerminations; diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index be8cc48e1..fad8ff76d 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -37,6 +37,106 @@ export interface RumConfigurationRequired { trackErrors?: boolean; } +/** + * Captures a predefined set of caching and content headers from both + * request and response. + * + * Default headers captured: + * - **Response:** cache-control, etag, age, expires, content-type, + * content-encoding, content-length, vary, server-timing, x-cache + * - **Request:** cache-control, content-type + * + * Optionally scoped to specific URLs via `forURLs`. + */ +export type DefaultsRule = { + type: 'defaults'; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * Captures the specified headers from both request and response. + * + * Use this when the same header names should be captured regardless + * of direction (e.g. 'content-type', 'authorization'). + * Optionally scoped to specific URLs via `forURLs`. + */ +export type MatchHeadersRule = { + type: 'matchHeaders'; + /** + * Header names to capture from both request and response. + * Preserved as-is; compared case-insensitively at capture time. + */ + headers: string[]; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * Captures the specified headers from requests only. + * + * Use this when you need to capture request-specific headers + * (e.g. 'authorization', 'x-api-key') without capturing response headers. + * Optionally scoped to specific URLs via `forURLs`. + */ +export type MatchRequestHeadersRule = { + type: 'matchRequestHeaders'; + /** + * Request header names to capture. + * Preserved as-is; compared case-insensitively at capture time. + */ + headers: string[]; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * Captures the specified headers from responses only. + * + * Use this when you need to capture response-specific headers + * (e.g. 'x-request-id', 'x-ratelimit-remaining') without capturing + * request headers. + * Optionally scoped to specific URLs via `forURLs`. + */ +export type MatchResponseHeadersRule = { + type: 'matchResponseHeaders'; + /** + * Response header names to capture. + * Preserved as-is; compared case-insensitively at capture time. + */ + headers: string[]; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * A composable header capture rule. + * + * Discriminated union on the `type` field. Multiple rules can be combined + * in an array -- matching rules are merged additively (union of headers). + */ +export type HeaderCaptureRule = + | DefaultsRule + | MatchHeadersRule + | MatchRequestHeadersRule + | MatchResponseHeadersRule; + /** * Optional RUM configuration values. */ @@ -79,6 +179,19 @@ export interface RumConfigurationOptions { */ firstPartyHosts?: FirstPartyHost[]; + /** + * Controls which resource headers the SDK captures on network requests. + * + * - **Omitted** (default): No headers are captured. + * - `'defaults'`: Shortcut equivalent to `[{ type: 'defaults' }]`. + * Captures a predefined set of caching and content headers. + * - `HeaderCaptureRule[]`: An array of composable rules. Multiple rules + * are merged additively -- matching rules contribute their headers. + * + * Requires `trackResources: true` to take effect. + */ + headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; + /** * Initial resource collection threshold in seconds. */ diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index fc704bc81..9ec787665 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -116,6 +116,13 @@ export { __ddExtractText, __ddEnrichEvaluationContextWithRumUser }; +export type { + HeaderCaptureRule, + DefaultsRule, + MatchHeadersRule, + MatchRequestHeadersRule, + MatchResponseHeadersRule +} from './config/features/RumConfiguration.type'; export type { Timestamp, FirstPartyHost, diff --git a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx index 7204bc5c4..a470974a6 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx +++ b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx @@ -7,12 +7,14 @@ import BigInt from 'big-integer'; import { InternalLog } from '../../../InternalLog'; +import type { HeaderCaptureRule } from '../../../config/features/RumConfiguration.type'; import { SdkVerbosity } from '../../../config/types/SdkVerbosity'; import { getGlobalInstance } from '../../../utils/singletonUtils'; import type { FirstPartyHost } from '../../types'; import { DistributedTracingSampling } from './distributedTracing/distributedTracingSampling'; import { firstPartyHostsRegexMapBuilder } from './distributedTracing/firstPartyHosts'; +import { compileHeaderCaptureConfig } from './headerCapture/compileHeaderConfig'; import { FetchProxy } from './requestProxy/FetchProxy/FetchProxy'; import { XHRProxy } from './requestProxy/XHRProxy/XHRProxy'; import type { RequestProxy } from './requestProxy/interfaces/RequestProxy'; @@ -42,11 +44,13 @@ class RumResourceTracking { startTracking({ resourceTraceSampleRate, firstPartyHosts, - trackFetchResources = false + trackFetchResources = false, + headerCaptureRules }: { resourceTraceSampleRate: number; firstPartyHosts: FirstPartyHost[]; trackFetchResources?: boolean; + headerCaptureRules?: 'defaults' | HeaderCaptureRule[] | undefined; }): void { // extra safety to avoid proxying the XHR class twice if (this._isTracking) { @@ -61,7 +65,8 @@ class RumResourceTracking { tracingSamplingRate: resourceTraceSampleRate, firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder( firstPartyHosts - ) + ), + headerCaptureConfig: compileHeaderCaptureConfig(headerCaptureRules) }; const xhrProxy = XHRProxy.createWithResourceReporter(); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts index 7683b63f9..5b62bcf34 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts @@ -27,7 +27,7 @@ export class XMLHttpRequestMock implements XMLHttpRequest { timeout: number = -1; upload: XMLHttpRequestUpload = {} as XMLHttpRequestUpload; withCredentials: boolean = false; - getAllResponseHeaders = jest.fn(); + getAllResponseHeaders = jest.fn().mockReturnValue(''); overrideMimeType = jest.fn(); DONE = 4 as const; HEADERS_RECEIVED = 2 as const; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts index 015adfc06..946c478fc 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts @@ -15,7 +15,7 @@ export type Hostname = { _type: 'Hostname' } & string; export const NO_MATCH_REGEX = new RegExp('a^'); // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions -const escapeRegExp = (string: string) => { +export const escapeRegExp = (string: string) => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md new file mode 100644 index 000000000..cd77f76bc --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -0,0 +1,142 @@ +# Resource Header Capture + +The Datadog React Native SDK can capture HTTP request and response headers on RUM resource events. This helps debug caching, CDN, and content negotiation issues without manual instrumentation. + +## Configuration + +Header capture is **disabled by default**. Enable it via the `trackResourceHeaders` option in your RUM configuration: + +```typescript +// Capture a predefined set of caching/content headers +trackResourceHeaders: 'defaults' + +// Capture specific headers for specific URLs +trackResourceHeaders: { + custom: [ + { + match: 'api.example.com', + requestHeaderNames: ['x-request-id'], + responseHeaderNames: ['etag', 'cache-control'] + } + ] +} + +// Disabled (default behavior) +trackResourceHeaders: 'disabled' +``` + +### Custom Rules + +Each rule specifies a URL pattern and the header names to capture: + +```typescript +type HeaderCaptureRule = { + match: string; // URL pattern (same format as firstPartyHosts) + requestHeaderNames?: string[]; // Request headers to capture + responseHeaderNames?: string[]; // Response headers to capture +}; +``` + +The `match` field supports hostname-only (`api.example.com`), hostname+path prefix (`api.example.com/v2`), or wildcard (`*`) to match all URLs. This is the same format used by `firstPartyHosts`. + +When multiple rules could match a URL, **the first matching rule wins**. + +### Default Headers + +When using `'defaults'` mode, the SDK captures: + +**10 response headers:** +`cache-control`, `etag`, `age`, `expires`, `content-type`, `content-encoding`, `content-length`, `vary`, `server-timing`, `x-cache` + +**2 request headers:** +`cache-control`, `content-type` + +## Output Format + +Captured headers appear as context attributes on `stopResource` events: + +- `_dd.request_headers` — `Record` of captured request headers +- `_dd.response_headers` — `Record` of captured response headers + +When no headers are captured (disabled mode, no matching URL rules, or empty result), these attributes are **omitted entirely** rather than set to empty objects. + +## Constraints and Behavior Details + +### Header Name Normalization + +All header names in the output are **lowercased**, regardless of how the server or application originally cased them. `Content-Type` and `content-type` both appear as `content-type` in the captured output. + +### Duplicate Headers + +- **Response headers:** If a response contains duplicate header names (e.g., multiple `Set-Cookie`), the **last value wins**. +- **Request headers:** If `setRequestHeader` is called multiple times with the same header name, the **last value wins**. + +### Whitespace Handling + +Leading and trailing whitespace is **trimmed** from header values. For example, `" text/html "` becomes `"text/html"`. + +### Malformed Response Headers + +Lines from `getAllResponseHeaders()` that lack a colon separator, have an empty name, or are otherwise malformed are **silently skipped**. The SDK never throws on malformed header input. + +### Null or Empty Responses + +If `getAllResponseHeaders()` returns `null` or an empty string (e.g., due to a network error or CORS restriction), the result is an empty object. Since empty results are omitted, no `_dd.response_headers` attribute will appear on the event. + +### Aborted Requests + +Response header capture is **skipped entirely** on aborted requests (XHR status 0). Request headers that were accumulated before the abort are also discarded. + +### Security Filtering + +The SDK enforces two layers of security filtering that **cannot be overridden** by configuration: + +#### Sensitive Header Blocklist + +Headers matching the following pattern are **always blocked**, even if explicitly listed in a custom rule: + +``` +/(?:token|cookie|secret|authorization|password|credential|bearer| +(?:api|secret|access|app).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i +``` + +This blocks headers like `Authorization`, `Cookie`, `Set-Cookie`, `x-access-token`, `x-amz-security-token`, `x-api-key`, `X-Forwarded-For`, `X-Real-IP`, and any header containing `password`, `secret`, `credential`, or `bearer` in its name. + +The match is **case-insensitive** and applies to partial name matches (a header named `x-custom-token-id` would be blocked because it contains `token`). + +#### SDK Tracing Header Exclusion + +The 12 headers injected by the SDK's distributed tracing system are **always excluded** from capture: + +| Header | Protocol | +|--------|----------| +| `x-datadog-sampling-priority` | Datadog | +| `x-datadog-origin` | Datadog | +| `x-datadog-trace-id` | Datadog | +| `x-datadog-parent-id` | Datadog | +| `x-datadog-tags` | Datadog | +| `traceparent` | W3C / OpenTelemetry | +| `tracestate` | W3C / OpenTelemetry | +| `baggage` | W3C / OpenTelemetry | +| `b3` | B3 Single | +| `x-b3-traceid` | B3 Multi | +| `x-b3-spanid` | B3 Multi | +| `x-b3-sampled` | B3 Multi | + +#### Filter Order + +Security filtering is applied **before** URL rule matching. The pipeline is: capture raw header -> security filter (blocklist + tracing exclusion) -> mode/rule filter (defaults set or custom rule). + +### Request Header Capture Timing + +Request headers are filtered by the security blocklist **at capture time** — sensitive headers are never stored in memory, even briefly. However, URL-based rule filtering is applied **at request completion** (when the final URL is known), since the URL may change between `setRequestHeader` calls and request completion. + +### Configuration Lifecycle + +- The compiled header capture configuration is **snapshotted per request** at `open()` time. In-flight requests are unaffected by configuration changes. +- Configuration changes take effect on the **next request** — no SDK restart required. +- When disabled, no interception hooks are installed for header capture — **zero overhead** on the network hot path. + +### Fetch Support + +React Native's `fetch` API uses `XMLHttpRequest` under the hood. All fetch requests go through the same XHR interception pipeline and benefit from header capture identically to direct XHR usage. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts new file mode 100644 index 000000000..43ab426ba --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts @@ -0,0 +1,775 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + DEFAULT_RESPONSE_HEADERS, + DEFAULT_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + accumulateRequestHeader, + captureResponseHeaders, + filterRequestHeadersByMode +} from '../captureHeaders'; +import type { CompiledHeaderCaptureConfig } from '../types'; + +describe('captureHeaders', () => { + describe('DEFAULT_RESPONSE_HEADERS', () => { + it('contains exactly 10 header names', () => { + expect(DEFAULT_RESPONSE_HEADERS.size).toBe(10); + }); + + it.each([ + 'cache-control', + 'etag', + 'age', + 'expires', + 'content-type', + 'content-encoding', + 'content-length', + 'vary', + 'server-timing', + 'x-cache' + ])('contains %s', header => { + expect(DEFAULT_RESPONSE_HEADERS.has(header)).toBe(true); + }); + }); + + describe('DEFAULT_REQUEST_HEADERS', () => { + it('contains exactly 2 header names', () => { + expect(DEFAULT_REQUEST_HEADERS.size).toBe(2); + }); + + it.each(['cache-control', 'content-type'])('contains %s', header => { + expect(DEFAULT_REQUEST_HEADERS.has(header)).toBe(true); + }); + }); + + describe('accumulateRequestHeader', () => { + it('stores an allowed header with original casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + expect(acc).toEqual({ 'Content-Type': 'application/json' }); + }); + + it('blocks sensitive headers (authorization)', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Authorization', 'Bearer xyz'); + expect(acc).toEqual({}); + }); + + it('blocks tracing headers (x-datadog-trace-id)', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'x-datadog-trace-id', '123'); + expect(acc).toEqual({}); + }); + + it('uses last-value-wins for duplicate header names', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + accumulateRequestHeader(acc, 'Content-Type', 'text/html'); + expect(acc).toEqual({ 'Content-Type': 'text/html' }); + }); + + it('preserves custom header casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'X-Custom', 'val'); + expect(acc).toEqual({ 'X-Custom': 'val' }); + }); + + it('accumulates multiple allowed headers', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + accumulateRequestHeader(acc, 'Accept', 'text/html'); + expect(acc).toEqual({ + 'Content-Type': 'application/json', + Accept: 'text/html' + }); + }); + + it('preserves original setRequestHeader casing in accumulator key', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + expect(acc).toEqual({ 'Content-Type': 'application/json' }); + }); + + it('preserves mixed-case custom header name', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'X-Request-ID', 'abc'); + expect(acc).toEqual({ 'X-Request-ID': 'abc' }); + }); + + it('last-value-wins preserves latest casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'content-type', 'first'); + accumulateRequestHeader(acc, 'Content-Type', 'second'); + expect(acc).toEqual({ 'Content-Type': 'second' }); + expect(Object.keys(acc)).toEqual(['Content-Type']); + }); + + it('still blocks sensitive headers regardless of casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'AUTHORIZATION', 'Bearer xyz'); + expect(acc).toEqual({}); + + accumulateRequestHeader(acc, 'Authorization', 'Bearer xyz'); + expect(acc).toEqual({}); + }); + }); + + describe('captureResponseHeaders', () => { + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + const disabledConfig: CompiledHeaderCaptureConfig = null; + const testUrl = 'https://api.example.com/data'; + + it('returns undefined when config is null (disabled)', () => { + const result = captureResponseHeaders( + 'content-type: text/html\r\n', + testUrl, + disabledConfig + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when rawHeaders is null', () => { + const result = captureResponseHeaders( + null, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when rawHeaders is undefined', () => { + const result = captureResponseHeaders( + undefined, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when rawHeaders is empty string', () => { + const result = captureResponseHeaders('', testUrl, defaultsConfig); + expect(result).toBeUndefined(); + }); + + it('captures default response headers with defaults rule', () => { + const raw = + 'content-type: text/html\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + }); + }); + + it('filters out non-default headers with defaults rule', () => { + const raw = 'x-custom: val\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toBeUndefined(); + }); + + it('filters out sensitive headers via security filter', () => { + const raw = 'authorization: secret\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toEqual({ 'Cache-Control': 'no-cache' }); + }); + + it('returns undefined when all headers are sensitive', () => { + const raw = 'authorization: secret\r\ncookie: session=abc\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toBeUndefined(); + }); + + it('captures all 10 default response headers when present', () => { + const raw = `${[ + 'cache-control: max-age=3600', + 'etag: "abc123"', + 'age: 100', + 'expires: Thu, 01 Jan 2099 00:00:00 GMT', + 'content-type: application/json', + 'content-encoding: gzip', + 'content-length: 1234', + 'vary: Accept-Encoding', + 'server-timing: db;dur=53', + 'x-cache: HIT' + ].join('\r\n')}\r\n`; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toEqual({ + 'Cache-Control': 'max-age=3600', + ETag: '"abc123"', + Age: '100', + Expires: 'Thu, 01 Jan 2099 00:00:00 GMT', + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': '1234', + Vary: 'Accept-Encoding', + 'Server-Timing': 'db;dur=53', + 'X-Cache': 'HIT' + }); + }); + + describe('scoped rules', () => { + it('captures only headers from matching rule', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set([ + 'x-custom', + 'content-type' + ]), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map([ + ['x-custom', 'x-custom'], + ['content-type', 'content-type'] + ]) + } + ]; + const raw = + 'x-custom: val\r\ncontent-type: text/html\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ + 'x-custom': 'val', + 'content-type': 'text/html' + }); + }); + + it('returns undefined when no rule matches the URL', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/other\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'content-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toBeUndefined(); + }); + + it('still applies security filter with scoped rules', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set([ + 'authorization', + 'content-type' + ]), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['authorization', 'authorization'], + ['content-type', 'content-type'] + ]) + } + ]; + const raw = + 'authorization: secret\r\ncontent-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ 'content-type': 'text/html' }); + }); + }); + + describe('union merging', () => { + it('merges headers from multiple matching rules additively', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['x-cache', 'x-cache']]) + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'x-cache: HIT\r\ncontent-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ + 'x-cache': 'HIT', + 'content-type': 'text/html' + }); + }); + + it('scoped rules replace catch-all rules when both match', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['etag']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['etag', 'etag']]) + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['x-cache', 'x-cache']]) + } + ]; + const raw = 'etag: "abc"\r\nx-cache: HIT\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ 'x-cache': 'HIT' }); + }); + + it('catch-all rules apply when no scoped rule matches', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['etag']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['etag', 'etag']]) + }, + { + urlRegex: /other\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['x-cache', 'x-cache']]) + } + ]; + const raw = 'etag: "abc"\r\nx-cache: HIT\r\n'; + const result = captureResponseHeaders( + raw, + 'https://api.example.com/data', + config + ); + expect(result).toEqual({ etag: '"abc"' }); + }); + + it('multiple scoped rules merge additively', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache', 'vary']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['x-cache', 'x-cache'], + ['vary', 'vary'] + ]) + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type', 'etag']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'], + ['etag', 'etag'] + ]) + } + ]; + const raw = + 'x-cache: HIT\r\nvary: Accept\r\ncontent-type: text/html\r\netag: "abc"\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ + 'x-cache': 'HIT', + vary: 'Accept', + 'content-type': 'text/html', + etag: '"abc"' + }); + }); + }); + + describe('casing preservation', () => { + it('outputs default headers with canonical Title-Case casing', () => { + const raw = + 'content-type: text/html\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders( + raw, + testUrl, + defaultsConfig + ); + expect(result).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + }); + }); + + it('outputs custom rule headers with user-provided casing', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-ratelimit']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['x-ratelimit', 'x-ratelimit'] + ]) + } + ]; + const raw = 'x-ratelimit: 100\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ 'x-ratelimit': '100' }); + }); + + it('custom rule casing overrides defaults for same header', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map( + CANONICAL_RESPONSE_HEADERS + ) + }, + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'content-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + // Second rule's casing should override first for same header + expect(result).toEqual({ 'content-type': 'text/html' }); + }); + + it('first custom rule casing wins among custom rules', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'Content-Type'] + ]) + }, + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'content-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + // First rule's casing wins + expect(result).toEqual({ 'Content-Type': 'text/html' }); + }); + }); + }); + + describe('filterRequestHeadersByMode', () => { + const testUrl = 'https://api.example.com/data'; + const disabledConfig: CompiledHeaderCaptureConfig = null; + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + + it('returns undefined when config is null (disabled)', () => { + const result = filterRequestHeadersByMode( + { 'content-type': 'json' }, + testUrl, + disabledConfig + ); + expect(result).toBeUndefined(); + }); + + it('keeps only default request headers with defaults rule', () => { + const result = filterRequestHeadersByMode( + { 'content-type': 'json', 'x-custom': 'val' }, + testUrl, + defaultsConfig + ); + expect(result).toEqual({ 'Content-Type': 'json' }); + }); + + it('returns undefined when no default request headers present', () => { + const result = filterRequestHeadersByMode( + { 'x-custom': 'val' }, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + it('keeps both default request headers when present', () => { + const result = filterRequestHeadersByMode( + { + 'content-type': 'json', + 'cache-control': 'no-cache', + 'x-custom': 'val' + }, + testUrl, + defaultsConfig + ); + expect(result).toEqual({ + 'Content-Type': 'json', + 'Cache-Control': 'no-cache' + }); + }); + + describe('scoped rules', () => { + it('keeps only request headers from matching rule', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-request-id': 'abc', 'x-other': 'def' }, + testUrl, + config + ); + expect(result).toEqual({ 'x-request-id': 'abc' }); + }); + + it('returns undefined when no rule matches URL', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/other\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-request-id': 'abc' }, + testUrl, + config + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when matching rule has no request header matches', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-other': 'val' }, + testUrl, + config + ); + expect(result).toBeUndefined(); + }); + }); + + describe('union merging', () => { + it('merges request headers from multiple matching rules', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(['content-type']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { + 'x-request-id': 'abc', + 'content-type': 'json', + 'x-other': 'val' + }, + testUrl, + config + ); + expect(result).toEqual({ + 'x-request-id': 'abc', + 'content-type': 'json' + }); + }); + + it('scoped rules replace catch-all for request headers', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(['x-catch-all']), + responseHeaderNames: new Set(), + isScoped: false, + requestHeaderCasing: new Map([ + ['x-catch-all', 'x-catch-all'] + ]), + responseHeaderCasing: new Map() + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(['x-scoped']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-scoped', 'x-scoped'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-catch-all': 'a', 'x-scoped': 'b' }, + testUrl, + config + ); + expect(result).toEqual({ 'x-scoped': 'b' }); + }); + }); + + it('returns undefined for empty accumulated headers', () => { + const result = filterRequestHeadersByMode( + {}, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + describe('casing preservation', () => { + it('outputs request headers with original accumulated casing', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set([ + 'content-type', + 'x-request-id' + ]), + responseHeaderNames: new Set(), + isScoped: false, + requestHeaderCasing: new Map([ + ['content-type', 'Content-Type'], + ['x-request-id', 'X-Request-ID'] + ]), + responseHeaderCasing: new Map() + } + ]; + // Accumulated headers have original setRequestHeader casing + const result = filterRequestHeadersByMode( + { + 'Content-Type': 'application/json', + 'X-Request-ID': 'abc' + }, + testUrl, + config + ); + expect(result).toEqual({ + 'Content-Type': 'application/json', + 'X-Request-ID': 'abc' + }); + }); + + it('falls back to config casing when accumulated key is lowered', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set([ + 'content-type', + 'cache-control' + ]), + responseHeaderNames: new Set(), + isScoped: false, + requestHeaderCasing: new Map([ + ['content-type', 'Content-Type'], + ['cache-control', 'Cache-Control'] + ]), + responseHeaderCasing: new Map() + } + ]; + // Accumulated headers are lowered (legacy/fallback scenario) + const result = filterRequestHeadersByMode( + { + 'content-type': 'application/json', + 'cache-control': 'no-cache' + }, + testUrl, + config + ); + expect(result).toEqual({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }); + }); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts new file mode 100644 index 000000000..cae73e9e6 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -0,0 +1,430 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../../../../InternalLog'; +import { SdkVerbosity } from '../../../../../config/types/SdkVerbosity'; +import { + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS +} from '../captureHeaders'; +import { compileHeaderCaptureConfig } from '../compileHeaderConfig'; + +jest.mock('../../../../../InternalLog', () => ({ + InternalLog: { + log: jest.fn() + } +})); + +const mockLog = InternalLog.log as jest.Mock; + +beforeEach(() => { + mockLog.mockClear(); +}); + +describe('compileHeaderCaptureConfig', () => { + describe('disabled / null output', () => { + it('returns null for undefined', () => { + expect(compileHeaderCaptureConfig(undefined)).toBeNull(); + }); + + it('returns null for empty array and logs WARN', () => { + const result = compileHeaderCaptureConfig([]); + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + 'headerCaptureRules is empty, no headers will be captured' + ), + SdkVerbosity.WARN + ); + }); + + it('returns null for totally invalid value and logs WARN', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = compileHeaderCaptureConfig( + 'totally-invalid-value' as any + ); + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.any(String), + SdkVerbosity.WARN + ); + }); + }); + + describe('"defaults" string shortcut', () => { + it('returns an array with exactly 1 rule', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + }); + + it('has requestHeaderNames equal to DEFAULT_REQUEST_HEADERS', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].requestHeaderNames).toEqual( + new Set(DEFAULT_REQUEST_HEADERS) + ); + }); + + it('has responseHeaderNames equal to DEFAULT_RESPONSE_HEADERS', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].responseHeaderNames).toEqual( + new Set(DEFAULT_RESPONSE_HEADERS) + ); + }); + + it('has urlRegex matching any URL', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].urlRegex.test('https://anything.com/path')).toBe( + true + ); + }); + + it('has isScoped equal to false', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].isScoped).toBe(false); + }); + }); + + describe('array input — rule variants', () => { + it('compiles a defaults rule with default header sets', () => { + const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual( + new Set(DEFAULT_REQUEST_HEADERS) + ); + expect(rule.responseHeaderNames).toEqual( + new Set(DEFAULT_RESPONSE_HEADERS) + ); + expect(rule.urlRegex.test('https://anything.com/path')).toBe(true); + expect(rule.isScoped).toBe(false); + }); + + it('compiles a matchHeaders rule with both request and response sets', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchHeaders', headers: ['Content-Type', 'X-Custom'] } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual( + new Set(['content-type', 'x-custom']) + ); + expect(rule.responseHeaderNames).toEqual( + new Set(['content-type', 'x-custom']) + ); + }); + + it('compiles a matchRequestHeaders rule with request set only', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchRequestHeaders', + headers: ['X-Request-ID', 'Authorization'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual( + new Set(['x-request-id', 'authorization']) + ); + expect(rule.responseHeaderNames).toEqual(new Set()); + }); + + it('compiles a matchResponseHeaders rule with response set only', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['ETag', 'X-RateLimit-Remaining'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual(new Set()); + expect(rule.responseHeaderNames).toEqual( + new Set(['etag', 'x-ratelimit-remaining']) + ); + }); + + it('lowercases header names regardless of input casing', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['X-Request-ID', 'AUTHORIZATION', 'Cache-Control'] + } + ]); + expect(result).not.toBeNull(); + expect(result![0].requestHeaderNames).toEqual( + new Set(['x-request-id', 'authorization', 'cache-control']) + ); + expect(result![0].responseHeaderNames).toEqual( + new Set(['x-request-id', 'authorization', 'cache-control']) + ); + }); + + it('{ type: "defaults" } in array produces rule with isScoped: false', () => { + const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); + expect(result).not.toBeNull(); + expect(result![0].isScoped).toBe(false); + }); + + it('matchHeaders with specific forURLs produces rule with isScoped: true', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['etag'], + forURLs: ['api.example.com'] + } + ]); + expect(result).not.toBeNull(); + expect(result![0].isScoped).toBe(true); + }); + }); + + describe('forURLs handling', () => { + it('omitting forURLs produces catch-all regex and isScoped: false', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchResponseHeaders', headers: ['etag'] } + ]); + expect(result).not.toBeNull(); + const { urlRegex, isScoped } = result![0]; + expect(urlRegex.test('https://anything.com/path')).toBe(true); + expect(urlRegex.test('http://other.io')).toBe(true); + expect(isScoped).toBe(false); + }); + + it('forURLs: ["*"] produces catch-all regex and isScoped: false', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['*'] + } + ]); + expect(result).not.toBeNull(); + const { urlRegex, isScoped } = result![0]; + expect(urlRegex.test('https://anything.com/path')).toBe(true); + expect(urlRegex.test('')).toBe(true); + expect(isScoped).toBe(false); + }); + + it('specific forURLs patterns produce isScoped: true', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com'] + } + ]); + expect(result).not.toBeNull(); + const { urlRegex, isScoped } = result![0]; + expect(urlRegex.test('https://api.example.com/any')).toBe(true); + expect(urlRegex.test('https://api.example.com')).toBe(true); + expect(urlRegex.test('https://other.com/api')).toBe(false); + expect(isScoped).toBe(true); + }); + + it('forURLs: ["api.example.com/v2"] matches /v2 paths but not /v1', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2'] + } + ]); + expect(result).not.toBeNull(); + const { urlRegex } = result![0]; + expect(urlRegex.test('https://api.example.com/v2/users')).toBe( + true + ); + expect(urlRegex.test('https://api.example.com/v1/users')).toBe( + false + ); + }); + + it('forURLs: [] (empty) skips the rule with WARN log', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: [] + } + ]); + // With only one rule that gets skipped, all rules fail -> null + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('empty forURLs'), + SdkVerbosity.WARN + ); + }); + + it('forURLs with invalid pattern skips the rule with WARN log', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['[invalid'] + } + ]); + // With only one rule that gets skipped, all rules fail -> null + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.any(String), + SdkVerbosity.WARN + ); + }); + + it('forURLs with multiple patterns produces combined regex', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com', 'cdn.example.com'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const { urlRegex } = result![0]; + expect(urlRegex.test('https://api.example.com/v2')).toBe(true); + expect(urlRegex.test('https://cdn.example.com/assets')).toBe(true); + expect(urlRegex.test('https://other.com')).toBe(false); + }); + }); + + describe('multiple rules', () => { + it('compiles multiple rules of different types', () => { + const result = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchHeaders', + headers: ['X-Custom'], + forURLs: ['api.example.com'] + }, + { type: 'matchRequestHeaders', headers: ['Authorization'] }, + { type: 'matchResponseHeaders', headers: ['ETag'] } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(4); + }); + + it('keeps valid rules and skips invalid ones', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchResponseHeaders', headers: ['etag'] }, + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: [] + } // skipped + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + expect(mockLog).toHaveBeenCalledTimes(1); + }); + }); + + describe('casing maps', () => { + it('"defaults" shortcut produces canonical casing maps', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map(CANONICAL_REQUEST_HEADERS) + ); + expect(rule.responseHeaderCasing).toEqual( + new Map(CANONICAL_RESPONSE_HEADERS) + ); + }); + + it('{ type: "defaults" } rule produces canonical casing maps', () => { + const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map(CANONICAL_REQUEST_HEADERS) + ); + expect(rule.responseHeaderCasing).toEqual( + new Map(CANONICAL_RESPONSE_HEADERS) + ); + }); + + it('matchHeaders preserves user casing in both maps', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['Content-Type', 'X-Custom'] + } + ]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map([ + ['content-type', 'Content-Type'], + ['x-custom', 'X-Custom'] + ]) + ); + expect(rule.responseHeaderCasing).toEqual( + new Map([ + ['content-type', 'Content-Type'], + ['x-custom', 'X-Custom'] + ]) + ); + }); + + it('matchRequestHeaders populates requestHeaderCasing only', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchRequestHeaders', headers: ['X-Api-Key'] } + ]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map([['x-api-key', 'X-Api-Key']]) + ); + expect(rule.responseHeaderCasing).toEqual(new Map()); + }); + + it('matchResponseHeaders populates responseHeaderCasing only', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchResponseHeaders', headers: ['X-RateLimit'] } + ]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual(new Map()); + expect(rule.responseHeaderCasing).toEqual( + new Map([['x-ratelimit', 'X-RateLimit']]) + ); + }); + + it('each rule carries its own casing map independently', () => { + const result = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchResponseHeaders', + headers: ['content-type'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(2); + + // Defaults rule has canonical Title-Case + expect(result![0].responseHeaderCasing.get('content-type')).toBe( + 'Content-Type' + ); + + // Custom rule preserves user-provided lowercase + expect(result![1].responseHeaderCasing.get('content-type')).toBe( + 'content-type' + ); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts new file mode 100644 index 000000000..54e75e0e8 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts @@ -0,0 +1,183 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { NativeModules } from 'react-native'; + +import { BufferSingleton } from '../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; +import { XMLHttpRequestMock } from '../../__tests__/__utils__/XMLHttpRequestMock'; +import { firstPartyHostsRegexMapBuilder } from '../../distributedTracing/firstPartyHosts'; +import { ResourceReporter } from '../../requestProxy/common/ResourceReporter'; +import { XHRProxy } from '../../requestProxy/XHRProxy/XHRProxy'; +import { + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS +} from '../captureHeaders'; +import { MAX_HEADER_VALUE_BYTES } from '../enforceSizeLimits'; +import type { CompiledHeaderCaptureConfig } from '../types'; + +jest.useFakeTimers(); + +const DdNativeRum = NativeModules.DdRum; + +const flushPromises = () => + new Promise(jest.requireActual('timers').setImmediate); + +let xhrProxy: any; + +beforeEach(() => { + DdNativeRum.startResource.mockClear(); + DdNativeRum.stopResource.mockClear(); + BufferSingleton.onInitialization(); + + xhrProxy = new XHRProxy({ + xhrType: XMLHttpRequestMock, + resourceReporter: new ResourceReporter([]) + } as { + xhrType: typeof XMLHttpRequest; + resourceReporter: ResourceReporter; + }); + + let now = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => { + now += 5; + return now; + }); +}); + +afterEach(() => { + xhrProxy.onTrackingStop(); + (Date.now as jest.MockedFunction).mockClear(); +}); + +describe('enforceSizeLimits integration', () => { + it('truncates oversized header values before they reach stopResource', async () => { + // GIVEN - a value longer than MAX_HEADER_VALUE_BYTES + const longValue = 'x'.repeat(256); + const method = 'GET'; + const url = 'https://api.example.com/data'; + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', longValue); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + `content-type: ${longValue}\r\n` + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN - values are truncated to MAX_HEADER_VALUE_BYTES + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; + expect(stopContext['_dd.request_headers']['Content-Type'].length).toBe( + MAX_HEADER_VALUE_BYTES + ); + expect(stopContext['_dd.response_headers']['Content-Type'].length).toBe( + MAX_HEADER_VALUE_BYTES + ); + }); + + it('disabled config (null): enforceSizeLimits is never invoked', async () => { + // GIVEN + const enforceSizeLimitsModule = require('../enforceSizeLimits'); + const spy = jest.spyOn(enforceSizeLimitsModule, 'enforceSizeLimits'); + + const method = 'GET'; + const url = 'https://api.example.com/data'; + const disabledConfig: CompiledHeaderCaptureConfig = null; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: disabledConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN - enforceSizeLimits was NOT called (zero overhead) + expect(spy).not.toHaveBeenCalled(); + + // Also verify no headers in stopResource context + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; + expect(stopContext['_dd.request_headers']).toBeUndefined(); + expect(stopContext['_dd.response_headers']).toBeUndefined(); + + spy.mockRestore(); + }); + + it('silent operation: no console warnings or errors during enforcement', async () => { + // GIVEN - oversized headers that will trigger truncation + const longValue = 'y'.repeat(256); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + + const method = 'GET'; + const url = 'https://api.example.com/data'; + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', longValue); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + `content-type: ${longValue}\r\n` + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN - no console output during enforcement + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts new file mode 100644 index 000000000..a899652ae --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts @@ -0,0 +1,226 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + enforceSizeLimits, + MAX_HEADER_VALUE_BYTES, + MAX_HEADER_COUNT, + MAX_TOTAL_BYTES +} from '../enforceSizeLimits'; + +describe('enforceSizeLimits', () => { + describe('constants', () => { + it('exports MAX_HEADER_VALUE_BYTES as 128', () => { + expect(MAX_HEADER_VALUE_BYTES).toBe(128); + }); + + it('exports MAX_HEADER_COUNT as 100', () => { + expect(MAX_HEADER_COUNT).toBe(100); + }); + + it('exports MAX_TOTAL_BYTES as 2048', () => { + expect(MAX_TOTAL_BYTES).toBe(2048); + }); + }); + + describe('undefined passthrough', () => { + it('returns both undefined when both inputs are undefined', () => { + const result = enforceSizeLimits(undefined, undefined); + expect(result.requestHeaders).toBeUndefined(); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('passes through small request headers when response is undefined', () => { + const result = enforceSizeLimits({ a: 'b' }, undefined); + expect(result.requestHeaders).toEqual({ a: 'b' }); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('passes through small response headers when request is undefined', () => { + const result = enforceSizeLimits(undefined, { a: 'b' }); + expect(result.requestHeaders).toBeUndefined(); + expect(result.responseHeaders).toEqual({ a: 'b' }); + }); + }); + + describe('value truncation', () => { + it('truncates header values longer than 128 bytes', () => { + const longValue = 'a'.repeat(200); + const result = enforceSizeLimits({ x: longValue }, undefined); + expect(result.requestHeaders!['x']).toBe('a'.repeat(128)); + expect(result.requestHeaders!['x'].length).toBe(128); + }); + + it('does not truncate values exactly at 128 bytes', () => { + const exactValue = 'a'.repeat(128); + const result = enforceSizeLimits({ x: exactValue }, undefined); + expect(result.requestHeaders!['x']).toBe(exactValue); + }); + + it('truncates response header values as well', () => { + const longValue = 'b'.repeat(200); + const result = enforceSizeLimits(undefined, { + y: longValue + }); + expect(result.responseHeaders!['y']).toBe('b'.repeat(128)); + }); + }); + + describe('header count cap', () => { + it('keeps all headers when combined count is at 100', () => { + const req: Record = {}; + const res: Record = {}; + for (let i = 0; i < 50; i++) { + req[`rq${i}`] = 'v'; + res[`rs${i}`] = 'v'; + } + const result = enforceSizeLimits(req, res); + expect( + Object.keys(result.requestHeaders!).length + + Object.keys(result.responseHeaders!).length + ).toBe(100); + }); + + it('caps total to 100 with request headers taking priority', () => { + const req: Record = {}; + const res: Record = {}; + for (let i = 0; i < 60; i++) { + req[`rq${i}`] = 'v'; + res[`rs${i}`] = 'v'; + } + const result = enforceSizeLimits(req, res); + expect(Object.keys(result.requestHeaders!).length).toBe(60); + expect(Object.keys(result.responseHeaders!).length).toBe(40); + }); + + it('caps request-only headers to 100', () => { + const req: Record = {}; + for (let i = 0; i < 110; i++) { + req[`h${i}`] = 'v'; + } + const result = enforceSizeLimits(req, undefined); + expect(Object.keys(result.requestHeaders!).length).toBe(100); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('returns undefined for response when all its headers are dropped by count cap', () => { + const req: Record = {}; + for (let i = 0; i < 100; i++) { + req[`h${i}`] = 'v'; + } + const res: Record = { a: 'b' }; + const result = enforceSizeLimits(req, res); + expect(Object.keys(result.requestHeaders!).length).toBe(100); + expect(result.responseHeaders).toBeUndefined(); + }); + }); + + describe('total size budget', () => { + it('drops response headers first when total exceeds 2048 bytes', () => { + // Each header: name(4) + value(100) = 104 bytes + // 20 headers = 2080 bytes > 2048 + const req: Record = {}; + const res: Record = {}; + for (let i = 0; i < 10; i++) { + req[`rq${String(i).padStart(2, '0')}`] = 'x'.repeat(100); + res[`rs${String(i).padStart(2, '0')}`] = 'x'.repeat(100); + } + const result = enforceSizeLimits(req, res); + // Request headers should be preserved; response headers dropped from end + expect(Object.keys(result.requestHeaders!).length).toBe(10); + // Some response headers should be dropped + const totalBytes = computeTotalBytes( + result.requestHeaders, + result.responseHeaders + ); + expect(totalBytes).toBeLessThanOrEqual(2048); + }); + + it('drops request headers from end if response fully dropped and still over budget', () => { + // 25 request headers each with name(3) + value(100) = 103 bytes + // 25 * 103 = 2575 > 2048 + const req: Record = {}; + for (let i = 0; i < 25; i++) { + req[`h${String(i).padStart(1, '0')}`] = 'y'.repeat(100); + } + const result = enforceSizeLimits(req, undefined); + const totalBytes = computeTotalBytes( + result.requestHeaders, + result.responseHeaders + ); + expect(totalBytes).toBeLessThanOrEqual(2048); + // Some request headers should be dropped + expect(Object.keys(result.requestHeaders!).length).toBeLessThan(25); + }); + + it('returns undefined for response when all response headers dropped for budget', () => { + // 19 request headers: name(4) + value(100) = 104 each = 1976 + // 1 response header: name(4) + value(100) = 104 -> total 2080 > 2048 + const req: Record = {}; + for (let i = 0; i < 19; i++) { + req[`rq${String(i).padStart(2, '0')}`] = 'x'.repeat(100); + } + const res: Record = { + rs00: 'x'.repeat(100) + }; + const result = enforceSizeLimits(req, res); + expect(result.requestHeaders).toBeDefined(); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('returns undefined for request when all headers dropped for budget', () => { + // Build a scenario where request has one giant-name header + // After truncation, still over budget -> request dropped entirely + const req: Record = {}; + // Create headers with long names that blow the budget + for (let i = 0; i < 30; i++) { + req['h'.repeat(70) + i] = 'v'.repeat(128); + } + const result = enforceSizeLimits(req, undefined); + const totalBytes = computeTotalBytes( + result.requestHeaders, + result.responseHeaders + ); + expect(totalBytes).toBeLessThanOrEqual(2048); + }); + }); + + describe('combined processing order', () => { + it('truncates values before counting bytes for budget', () => { + // 10 headers with 500-char values -> truncated to 128 each + // After truncation: name(2) + value(128) = 130 each -> 1300 total < 2048 + const req: Record = {}; + for (let i = 0; i < 10; i++) { + req[`h${i}`] = 'z'.repeat(500); + } + const result = enforceSizeLimits(req, undefined); + expect(Object.keys(result.requestHeaders!).length).toBe(10); + // All values truncated to 128 + for (const value of Object.values(result.requestHeaders!)) { + expect(value.length).toBeLessThanOrEqual(128); + } + }); + }); +}); + +/** Helper: compute total bytes for all headers */ +function computeTotalBytes( + reqHeaders: Record | undefined, + resHeaders: Record | undefined +): number { + let total = 0; + if (reqHeaders) { + for (const [name, value] of Object.entries(reqHeaders)) { + total += name.length + value.length; + } + } + if (resHeaders) { + for (const [name, value] of Object.entries(resHeaders)) { + total += name.length + value.length; + } + } + return total; +} diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts new file mode 100644 index 000000000..bff0dbe88 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts @@ -0,0 +1,35 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isHeaderAllowed } from '../isHeaderAllowed'; + +describe('isHeaderAllowed', () => { + it('blocks sensitive headers', () => { + expect(isHeaderAllowed('authorization')).toBe(false); + }); + + it('blocks tracing headers', () => { + expect(isHeaderAllowed('x-datadog-trace-id')).toBe(false); + }); + + it('allows normal headers', () => { + expect(isHeaderAllowed('content-type')).toBe(true); + }); + + describe('case-insensitive', () => { + it('blocks Authorization (capitalized)', () => { + expect(isHeaderAllowed('Authorization')).toBe(false); + }); + + it('blocks X-B3-TraceId (mixed case tracing header)', () => { + expect(isHeaderAllowed('X-B3-TraceId')).toBe(false); + }); + }); + + it('allows headers that pass both checks', () => { + expect(isHeaderAllowed('etag')).toBe(true); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts new file mode 100644 index 000000000..994025bf1 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts @@ -0,0 +1,105 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { parseResponseHeaders } from '../parseResponseHeaders'; + +describe('parseResponseHeaders', () => { + it('parses standard CRLF-delimited headers', () => { + const raw = 'content-type: text/html\r\ncache-control: no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('returns empty object for null input', () => { + expect(parseResponseHeaders(null)).toEqual({}); + }); + + it('returns empty object for undefined input', () => { + expect(parseResponseHeaders(undefined)).toEqual({}); + }); + + it('returns empty object for empty string input', () => { + expect(parseResponseHeaders('')).toEqual({}); + }); + + it('uses last-value-wins for duplicate header names', () => { + const raw = 'set-cookie: a=1\r\nset-cookie: b=2\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'set-cookie': 'b=2' + }); + }); + + it('normalizes header names to lowercase', () => { + const raw = 'Content-Type: text/html\r\nCache-Control: no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('trims whitespace from header values (HTTP OWS)', () => { + const raw = 'content-type: text/html \r\ncache-control:no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('skips malformed lines with no colon', () => { + const raw = + 'content-type: text/html\r\ngarbage line\r\ncache-control: no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('skips lines where colon is the first character (empty name)', () => { + const raw = ': some-value\r\ncontent-type: text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html' + }); + }); + + it('handles bare LF without CR (robustness)', () => { + const raw = 'content-type: text/html\ncache-control: no-cache\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('preserves colons in header values (only first colon is separator)', () => { + const raw = 'location: https://example.com:8080/path\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + location: 'https://example.com:8080/path' + }); + }); + + it('handles empty header values', () => { + const raw = 'x-custom: \r\ncontent-type: text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'x-custom': '', + 'content-type': 'text/html' + }); + }); + + it('does not produce empty key from trailing CRLF', () => { + const raw = 'content-type: text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html' + }); + }); + + it('trims whitespace from header names before lowercasing', () => { + const raw = ' Content-Type : text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html' + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts new file mode 100644 index 000000000..d7a8a66d0 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts @@ -0,0 +1,109 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isSensitiveHeader } from '../sensitiveHeaderBlocklist'; + +describe('isSensitiveHeader', () => { + describe('MUST BLOCK sensitive headers (returns true)', () => { + const sensitiveHeaders = [ + // Standard auth + 'authorization', + 'Authorization', + 'AUTHORIZATION', + 'proxy-authorization', + 'Proxy-Authorization', + // Cookies + 'cookie', + 'Cookie', + 'COOKIE', + 'set-cookie', + 'Set-Cookie', + // Token patterns + 'x-access-token', + 'X-Access-Token', + 'x-auth-token', + 'x-csrf-token', + 'x-xsrf-token', + 'x-amz-security-token', + // Secrets and passwords + 'secret', + 'x-secret-key', + 'password', + 'x-password', + // Credentials + 'credential', + 'grpc-credential', + 'x-amz-credential', + // Bearer + 'bearer', + // API/secret/access/app key variants + 'api-key', + 'apikey', + 'api_key', + 'api.key', + 'secret-key', + 'secretkey', + 'access-key', + 'accesskey', + 'app-key', + 'appkey', + // Forwarding / IP headers + 'forwarded', + 'x-forwarded-for', + 'x-real-ip', + 'x-connecting-ip', + 'cf-connecting-ip', + 'x-client-ip', + 'true-client-ip', + // gRPC sensitive metadata + 'grpc-metadata-authorization', + 'grpc-metadata-cookie' + ]; + + it.each(sensitiveHeaders)('blocks "%s"', (headerName: string) => { + expect(isSensitiveHeader(headerName)).toBe(true); + }); + }); + + describe('MUST ALLOW safe headers (returns false)', () => { + const safeHeaders = [ + 'content-type', + 'Content-Type', + 'cache-control', + 'Cache-Control', + 'etag', + 'x-request-id', + 'x-correlation-id', + 'x-custom-header', + 'accept', + 'accept-encoding', + 'accept-language', + 'content-length', + 'vary', + 'server-timing', + 'x-cache', + 'age', + 'expires', + 'content-encoding' + ]; + + it.each(safeHeaders)('allows "%s"', (headerName: string) => { + expect(isSensitiveHeader(headerName)).toBe(false); + }); + }); + + describe('case-insensitive matching', () => { + it('blocks both Authorization and authorization', () => { + expect(isSensitiveHeader('Authorization')).toBe(true); + expect(isSensitiveHeader('authorization')).toBe(true); + }); + + it('allows both Cache-Control and cache-control', () => { + expect(isSensitiveHeader('Cache-Control')).toBe(false); + expect(isSensitiveHeader('cache-control')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts new file mode 100644 index 000000000..dfa09e5ab --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts @@ -0,0 +1,58 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isTracingHeader } from '../tracingHeaderExclusion'; + +describe('isTracingHeader', () => { + describe('MUST EXCLUDE all 12 tracing headers (returns true)', () => { + const tracingHeaders = [ + 'x-datadog-sampling-priority', + 'x-datadog-origin', + 'x-datadog-trace-id', + 'x-datadog-parent-id', + 'x-datadog-tags', + 'traceparent', + 'tracestate', + 'baggage', + 'b3', + 'x-b3-traceid', + 'x-b3-spanid', + 'x-b3-sampled' + ]; + + it.each(tracingHeaders)('excludes "%s"', (headerName: string) => { + expect(isTracingHeader(headerName)).toBe(true); + }); + }); + + describe('case-insensitive matching', () => { + it('excludes X-Datadog-Trace-Id (mixed case)', () => { + expect(isTracingHeader('X-Datadog-Trace-Id')).toBe(true); + }); + + it('excludes Traceparent (capitalized)', () => { + expect(isTracingHeader('Traceparent')).toBe(true); + }); + + it('excludes X-B3-TraceId (original constant casing)', () => { + expect(isTracingHeader('X-B3-TraceId')).toBe(true); + }); + }); + + describe('MUST ALLOW non-tracing headers (returns false)', () => { + it('allows content-type', () => { + expect(isTracingHeader('content-type')).toBe(false); + }); + + it('allows x-custom-header', () => { + expect(isTracingHeader('x-custom-header')).toBe(false); + }); + + it('allows x-datadog-customer-id (not a tracing header - explicit list, not prefix match)', () => { + expect(isTracingHeader('x-datadog-customer-id')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts new file mode 100644 index 000000000..b1447405c --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -0,0 +1,328 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isHeaderAllowed } from './isHeaderAllowed'; +import { parseResponseHeaders } from './parseResponseHeaders'; +import type { + CompiledHeaderCaptureConfig, + CompiledHeaderCaptureRule +} from './types'; + +/** + * Default response header names captured in 'defaults' mode. + * Lowercased for direct comparison with parseResponseHeaders output. + */ +export const DEFAULT_RESPONSE_HEADERS: Set = new Set([ + 'cache-control', + 'etag', + 'age', + 'expires', + 'content-type', + 'content-encoding', + 'content-length', + 'vary', + 'server-timing', + 'x-cache' +]); + +/** + * Default request header names captured in 'defaults' mode. + * Lowercased for direct comparison with accumulated header names. + */ +export const DEFAULT_REQUEST_HEADERS: Set = new Set([ + 'cache-control', + 'content-type' +]); + +/** + * Canonical RFC Title-Case casing for default response headers. + * Static map -- explicit lookup, no auto-capitalization. + * Key: lowercased name, Value: Title-Case name. + */ +export const CANONICAL_RESPONSE_HEADERS: Map = new Map([ + ['cache-control', 'Cache-Control'], + ['etag', 'ETag'], + ['age', 'Age'], + ['expires', 'Expires'], + ['content-type', 'Content-Type'], + ['content-encoding', 'Content-Encoding'], + ['content-length', 'Content-Length'], + ['vary', 'Vary'], + ['server-timing', 'Server-Timing'], + ['x-cache', 'X-Cache'] +]); + +/** + * Canonical RFC Title-Case casing for default request headers. + * Key: lowercased name, Value: Title-Case name. + */ +export const CANONICAL_REQUEST_HEADERS: Map = new Map([ + ['cache-control', 'Cache-Control'], + ['content-type', 'Content-Type'] +]); + +/** + * Filters a header record to only entries in the allowed set, applying + * casing from the provided casing map. + * + * Output key resolution: + * - If the input key has non-lowered casing (e.g. from setRequestHeader), use it as-is + * - Otherwise, look up the casing map for config-provided casing + * - Falls back to lowered name if neither provides casing + * + * This handles both directions: + * - Response headers: input keys are lowered (from parseResponseHeaders), so casing map provides output casing + * - Request headers: input keys preserve setRequestHeader casing, which takes priority + * + * Returns undefined if no entries survive filtering (locked decision: no empty objects). + * + * @internal + */ +const filterWithCasing = ( + headers: Record, + allowedSet: Set, + casingMap: Map +): Record | undefined => { + const filtered: Record = {}; + let hasEntries = false; + + for (const [name, value] of Object.entries(headers)) { + const lowered = name.toLowerCase(); + if (allowedSet.has(lowered)) { + // Use original key casing if different from lowered (request headers), + // otherwise fall back to casing map, otherwise use lowered + const outputKey = + name !== lowered + ? name // original casing from accumulator + : casingMap.get(lowered) ?? lowered; + filtered[outputKey] = value; + hasEntries = true; + } + } + + return hasEntries ? filtered : undefined; +}; + +/** + * Loops over compiled rules, finds all that match the given URL, + * and merges their header name sets additively (union). + * + * Scoped-replaces-catch-all: if any rule with isScoped=true matches + * the URL, all catch-all (isScoped=false) rules are ignored. + * Multiple scoped rules matching the same URL merge additively. + * + * @param rules - The compiled rule array. + * @param url - The request URL to match against. + * @param field - Which header set to merge ('requestHeaderNames' or 'responseHeaderNames'). + * @returns The merged Set of header names, or an empty Set if no rules match. + */ +const mergeMatchingHeaderNames = ( + rules: CompiledHeaderCaptureRule[], + url: string, + field: 'requestHeaderNames' | 'responseHeaderNames' +): Set => { + const scopedMatches: Set = new Set(); + const catchAllMatches: Set = new Set(); + let hasScopedMatch = false; + + for (const rule of rules) { + if (rule.urlRegex.test(url)) { + if (rule.isScoped) { + hasScopedMatch = true; + for (const name of rule[field]) { + scopedMatches.add(name); + } + } else { + for (const name of rule[field]) { + catchAllMatches.add(name); + } + } + } + } + + return hasScopedMatch ? scopedMatches : catchAllMatches; +}; + +/** + * Merges casing maps from all matching rules into a single Map. + * Uses the same scoped-replaces-catch-all logic as mergeMatchingHeaderNames. + * + * Casing precedence: + * - Scoped rules: first-declared wins (among custom rules, first casing is kept) + * - Catch-all rules: last-declared wins (custom rules override defaults' casing + * since defaults is typically declared first in the config array) + * + * @param rules - The compiled rule array. + * @param url - The request URL to match against. + * @param field - Which casing map to merge ('requestHeaderCasing' or 'responseHeaderCasing'). + * @returns The merged casing Map, or an empty Map if no rules match. + */ +const mergeCasingMaps = ( + rules: CompiledHeaderCaptureRule[], + url: string, + field: 'requestHeaderCasing' | 'responseHeaderCasing' +): Map => { + const scopedMap = new Map(); + const catchAllMap = new Map(); + let hasScopedMatch = false; + + for (const rule of rules) { + if (rule.urlRegex.test(url)) { + if (rule.isScoped) { + hasScopedMatch = true; + // First-declared wins for scoped rules + for (const [lowered, original] of rule[field]) { + if (!scopedMap.has(lowered)) { + scopedMap.set(lowered, original); + } + } + } else { + // Last-declared wins for catch-all rules + // (ensures custom rules override defaults' casing) + for (const [lowered, original] of rule[field]) { + catchAllMap.set(lowered, original); + } + } + } + } + + return hasScopedMatch ? scopedMap : catchAllMap; +}; + +/** + * Accumulates a single request header into the capture store. + * Applies security filtering at capture time (locked decision: sensitive + * headers never stored in memory). + * + * Mutates the accumulator in place for performance — no allocation per call. + * Last-value-wins semantics for duplicate header names. + * + * @param accumulator - Mutable record to store captured headers. + * @param headerName - The header name as provided by setRequestHeader (any case). + * @param headerValue - The header value string. + */ +export const accumulateRequestHeader = ( + accumulator: Record, + headerName: string, + headerValue: string +): void => { + const lowered = headerName.toLowerCase(); + if (isHeaderAllowed(lowered)) { + // Remove any existing entry with different casing for same header + // (last-value-wins: both value and casing from latest call) + for (const existing of Object.keys(accumulator)) { + if (existing.toLowerCase() === lowered) { + delete accumulator[existing]; + break; + } + } + accumulator[headerName] = headerValue; + } +}; + +/** + * Captures response headers from a raw getAllResponseHeaders() string. + * Applies security filtering first (defense in depth), then filters by + * the union of all matching compiled rules' response header sets. + * + * Returns undefined if: + * - Config is null (disabled) + * - rawHeaders is null/undefined/empty + * - No headers survive security filtering + * - No compiled rules match the URL + * - No headers survive rule-based filtering + * + * (Locked decision: no empty objects — undefined or absent, not `{}`.) + * + * @param rawHeaders - The raw CRLF string from getAllResponseHeaders(). + * @param url - The full request URL for rule matching. + * @param config - The compiled header capture configuration. + * @returns Filtered response headers, or undefined. + */ +export const captureResponseHeaders = ( + rawHeaders: string | null | undefined, + url: string, + config: CompiledHeaderCaptureConfig +): Record | undefined => { + if (config === null) { + return undefined; + } + + const allHeaders = parseResponseHeaders(rawHeaders); + if (Object.keys(allHeaders).length === 0) { + return undefined; + } + + // Security filter first — defense in depth + const securityFiltered: Record = {}; + let hasSecurityFiltered = false; + + for (const [name, value] of Object.entries(allHeaders)) { + if (isHeaderAllowed(name)) { + securityFiltered[name] = value; + hasSecurityFiltered = true; + } + } + + if (!hasSecurityFiltered) { + return undefined; + } + + // Merge all matching rules' response header sets (union) + const allowedHeaders = mergeMatchingHeaderNames( + config, + url, + 'responseHeaderNames' + ); + + if (allowedHeaders.size === 0) { + return undefined; + } + + const casingMap = mergeCasingMaps(config, url, 'responseHeaderCasing'); + return filterWithCasing(securityFiltered, allowedHeaders, casingMap); +}; + +/** + * Filters accumulated request headers by compiled rule matching. + * + * Called at request completion (DONE state) when the URL is final. + * Request headers are accumulated during setRequestHeader calls and + * filtered here to the union of all matching rules' request header sets. + * + * Returns undefined if: + * - Config is null (disabled) + * - No compiled rules match the URL + * - No headers survive rule-based filtering + * + * @param headers - The accumulated request headers (already security-filtered). + * @param url - The final request URL for rule matching. + * @param config - The compiled header capture configuration. + * @returns Filtered request headers, or undefined. + */ +export const filterRequestHeadersByMode = ( + headers: Record, + url: string, + config: CompiledHeaderCaptureConfig +): Record | undefined => { + if (config === null) { + return undefined; + } + + // Merge all matching rules' request header sets (union) + const allowedHeaders = mergeMatchingHeaderNames( + config, + url, + 'requestHeaderNames' + ); + + if (allowedHeaders.size === 0) { + return undefined; + } + + const casingMap = mergeCasingMaps(config, url, 'requestHeaderCasing'); + return filterWithCasing(headers, allowedHeaders, casingMap); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts new file mode 100644 index 000000000..f0c92d2c3 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -0,0 +1,300 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../../../InternalLog'; +import type { HeaderCaptureRule } from '../../../../config/features/RumConfiguration.type'; +import { SdkVerbosity } from '../../../../config/types/SdkVerbosity'; +import { escapeRegExp } from '../distributedTracing/firstPartyHosts'; + +import { + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS +} from './captureHeaders'; +import type { + CompiledHeaderCaptureConfig, + CompiledHeaderCaptureRule +} from './types'; + +/** + * Builds a URL-matching RegExp from a user-supplied `match` string. + * + * The regex is applied to full URL strings (e.g. "https://api.example.com/v2/users"). + * Pattern: `^https?://(.*\.)*${escapedHost}(:\d+)?${pathSuffix}` + * where pathSuffix anchors at end-of-host when no path is given, or matches the path prefix. + * + * Returns null if the pattern is invalid (e.g. contains unescaped regex metacharacters that + * result in an invalid expression). + */ +const buildUrlMatchRegex = (match: string): RegExp | null => { + if (match === '*') { + return /.*/; + } + + // Split on first '/' to separate hostname from optional path prefix + const slashIndex = match.indexOf('/'); + const hostname = slashIndex === -1 ? match : match.slice(0, slashIndex); + const pathPrefix = slashIndex === -1 ? '' : match.slice(slashIndex); + + // Validate that the raw hostname is a valid literal string (no unmatched regex metacharacters + // like `[` without closing `]`). We do this by attempting to compile the raw hostname as + // a regex fragment. If it throws, the match string is invalid — return null before escaping. + try { + // eslint-disable-next-line no-new + new RegExp(hostname); + } catch (_e) { + return null; + } + + const escapedHost = escapeRegExp(hostname); + // If a path prefix is given, escape it and match as prefix. + // If hostname-only, require end-of-host (port or end of authority). + const pathSuffix = pathPrefix ? escapeRegExp(pathPrefix) : '(/|$)'; + + try { + // Regex matches full URL strings: scheme + optional subdomains + host + optional port + path + const regex = new RegExp( + `^https?://(.*\\.)*${escapedHost}(:\\d+)?${pathSuffix}`, + 'i' + ); + // Validate the final regex is well-formed + regex.test('validation_probe'); + return regex; + } catch (_e) { + return null; + } +}; + +/** + * Result from building a forURLs regex, carrying both the compiled regex + * and whether it represents a scoped (specific URL patterns) or catch-all match. + */ +type ForURLsResult = { regex: RegExp; isScoped: boolean } | null | 'skip'; + +/** + * Builds a combined URL-matching RegExp from an array of `forURLs` patterns. + * + * Returns an object with `regex` and `isScoped`, or `null` (all invalid), or `'skip'` (empty array). + * - `undefined` or `['*']` -> catch-all, isScoped=false + * - `[]` -> 'skip' + * - Specific patterns -> combined regex, isScoped=true + */ +const buildForURLsRegex = (forURLs: string[] | undefined): ForURLsResult => { + // Omitting forURLs = match all URLs (catch-all) + if (forURLs === undefined) { + return { regex: /.*/, isScoped: false }; + } + + // Empty array = no-op rule, skip with warning + if (forURLs.length === 0) { + return 'skip'; + } + + // ['*'] is equivalent to omitting forURLs (catch-all) + if (forURLs.length === 1 && forURLs[0] === '*') { + return { regex: /.*/, isScoped: false }; + } + + // Specific patterns = scoped + const patterns: string[] = []; + for (const pattern of forURLs) { + const regex = buildUrlMatchRegex(pattern); + if (regex === null) { + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Skipping invalid forURLs pattern "${pattern}".`, + SdkVerbosity.WARN + ); + continue; + } + // Extract the source from the compiled regex to combine + patterns.push(regex.source); + } + + if (patterns.length === 0) { + return null; + } + + try { + return { regex: new RegExp(patterns.join('|'), 'i'), isScoped: true }; + } catch (_e) { + return null; + } +}; + +/** + * Compiles an array of composable `HeaderCaptureRule` entries into compiled rules. + * Each rule variant type produces a different set of request/response header names. + * Each compiled rule carries an `isScoped` flag from its forURLs resolution. + */ +const compileRules = ( + rules: HeaderCaptureRule[] +): CompiledHeaderCaptureRule[] => { + const compiled: CompiledHeaderCaptureRule[] = []; + + for (const rule of rules) { + // Build URL regex from forURLs + const urlResult = buildForURLsRegex(rule.forURLs); + + if (urlResult === 'skip') { + InternalLog.log( + '[DatadogRUM] headerCaptureRules: Skipping rule with empty forURLs array (no-op).', + SdkVerbosity.WARN + ); + continue; + } + + if (urlResult === null) { + InternalLog.log( + '[DatadogRUM] headerCaptureRules: Skipping rule — all forURLs patterns are invalid.', + SdkVerbosity.WARN + ); + continue; + } + + const { regex: urlRegex, isScoped } = urlResult; + + switch (rule.type) { + case 'defaults': + compiled.push({ + urlRegex, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + }); + break; + + case 'matchHeaders': + compiled.push({ + urlRegex, + requestHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + responseHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + isScoped, + requestHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ), + responseHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ) + }); + break; + + case 'matchRequestHeaders': + compiled.push({ + urlRegex, + requestHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + responseHeaderNames: new Set(), + isScoped, + requestHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ), + responseHeaderCasing: new Map() + }); + break; + + case 'matchResponseHeaders': + compiled.push({ + urlRegex, + requestHeaderNames: new Set(), + responseHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + isScoped, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ) + }); + break; + + default: + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Skipping rule with unknown type "${ + (rule as { type: string }).type + }".`, + SdkVerbosity.WARN + ); + break; + } + } + + return compiled; +}; + +/** + * Converts a user-supplied header capture config into a runtime-efficient + * `CompiledHeaderCaptureConfig` (which is `CompiledHeaderCaptureRule[] | null`). + * + * Runs once at SDK initialization. Produces pre-built `RegExp` objects and `Set` + * lookups so that per-request header matching is O(1) with no config traversal. + * + * Input: `'defaults' | HeaderCaptureRule[] | undefined` + * - `undefined` (omitted) = disabled, returns `null` + * - `'defaults'` = string shortcut, returns single-element array with default headers and catch-all regex + * - `HeaderCaptureRule[]` = composable array of rules, compiled into `CompiledHeaderCaptureRule[]` + * - `[]` (empty array) = logs WARN, returns `null` + * - Invalid value = logs WARN, returns `null` + */ +export const compileHeaderCaptureConfig = ( + config: 'defaults' | HeaderCaptureRule[] | undefined +): CompiledHeaderCaptureConfig => { + if (config === undefined) { + return null; + } + + if (config === 'defaults') { + return [ + { + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + urlRegex: /.*/, + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + } + + if (Array.isArray(config)) { + if (config.length === 0) { + InternalLog.log( + '[DatadogRUM] headerCaptureRules is empty, no headers will be captured.', + SdkVerbosity.WARN + ); + return null; + } + + const compiled = compileRules(config); + if (compiled.length === 0) { + return null; + } + return compiled; + } + + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Unrecognized value "${JSON.stringify( + config + )}" — defaulting to disabled.`, + SdkVerbosity.WARN + ); + return null; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts new file mode 100644 index 000000000..ad477ed35 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -0,0 +1,131 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** Maximum byte length for any single header value. */ +export const MAX_HEADER_VALUE_BYTES = 128; + +/** Maximum combined header count (request + response). */ +export const MAX_HEADER_COUNT = 100; + +/** Maximum total bytes across all header names and values. */ +export const MAX_TOTAL_BYTES = 2048; + +/** + * Converts a non-empty record to undefined-or-record. + * Locked decision: no empty objects -- undefined or absent, not `{}`. + */ +const toUndefinedIfEmpty = ( + record: Record +): Record | undefined => { + return Object.keys(record).length > 0 ? record : undefined; +}; + +/** + * Enforces per-value truncation, header count cap, and total size budget + * on captured header records. + * + * Processing order: + * 1. Short-circuit if both inputs undefined + * 2. Truncate each value to MAX_HEADER_VALUE_BYTES + * 3. Cap combined count to MAX_HEADER_COUNT (request priority) + * 4. Drop headers from end until total bytes <= MAX_TOTAL_BYTES + * 5. Return undefined (not {}) for empty records + * + * Pure function -- no side effects, no logging. + */ +export const enforceSizeLimits = ( + requestHeaders: Record | undefined, + responseHeaders: Record | undefined +): { + requestHeaders: Record | undefined; + responseHeaders: Record | undefined; +} => { + // Step 1: Short-circuit + if (requestHeaders === undefined && responseHeaders === undefined) { + return { requestHeaders: undefined, responseHeaders: undefined }; + } + + // Step 2: Truncate values + const reqEntries = requestHeaders + ? Object.entries(requestHeaders).map(([name, value]): [ + string, + string + ] => [ + name, + value.length > MAX_HEADER_VALUE_BYTES + ? value.slice(0, MAX_HEADER_VALUE_BYTES) + : value + ]) + : []; + + const resEntries = responseHeaders + ? Object.entries(responseHeaders).map(([name, value]): [ + string, + string + ] => [ + name, + value.length > MAX_HEADER_VALUE_BYTES + ? value.slice(0, MAX_HEADER_VALUE_BYTES) + : value + ]) + : []; + + // Step 3: Count cap -- request headers take priority + const totalCount = reqEntries.length + resEntries.length; + let cappedReqEntries = reqEntries; + let cappedResEntries = resEntries; + + if (totalCount > MAX_HEADER_COUNT) { + const reqSlots = Math.min(reqEntries.length, MAX_HEADER_COUNT); + cappedReqEntries = reqEntries.slice(0, reqSlots); + const remainingSlots = MAX_HEADER_COUNT - cappedReqEntries.length; + cappedResEntries = resEntries.slice(0, remainingSlots); + } + + // Step 4: Total size budget -- drop from end (response first, then request) + let totalBytes = 0; + for (const [name, value] of cappedReqEntries) { + totalBytes += name.length + value.length; + } + for (const [name, value] of cappedResEntries) { + totalBytes += name.length + value.length; + } + + if (totalBytes > MAX_TOTAL_BYTES) { + // Drop response headers from end first + while (cappedResEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { + const [name, value] = cappedResEntries.pop()!; + totalBytes -= name.length + value.length; + } + // Then drop request headers from end + while (cappedReqEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { + const [name, value] = cappedReqEntries.pop()!; + totalBytes -= name.length + value.length; + } + } + + // Step 5: Build result records + const reqResult: Record = {}; + for (const [name, value] of cappedReqEntries) { + reqResult[name] = value; + } + + const resResult: Record = {}; + for (const [name, value] of cappedResEntries) { + resResult[name] = value; + } + + return { + requestHeaders: + requestHeaders !== undefined + ? toUndefinedIfEmpty(reqResult) + : undefined, + responseHeaders: + responseHeaders !== undefined + ? toUndefinedIfEmpty(resResult) + : undefined + }; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts new file mode 100644 index 000000000..186066d69 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts @@ -0,0 +1,29 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isSensitiveHeader } from './sensitiveHeaderBlocklist'; +import { isTracingHeader } from './tracingHeaderExclusion'; + +/** + * Composed check: returns true if a header is allowed for capture. + * A header is allowed only if it is NOT sensitive AND NOT a tracing header. + * + * This is the single entry point for Phase 3 XHR integration to call for + * every header considered for capture. + * + * @param headerName - The header name to check (case-insensitive). + * @returns `true` if the header may be captured, `false` if blocked. + */ +export const isHeaderAllowed = (headerName: string): boolean => { + const lowered = headerName.toLowerCase(); + if (isSensitiveHeader(lowered)) { + return false; + } + if (isTracingHeader(lowered)) { + return false; + } + return true; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts new file mode 100644 index 000000000..dee4427ae --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts @@ -0,0 +1,57 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Parses a CRLF-delimited `getAllResponseHeaders()` string into a + * `Record` of lowercase header names to trimmed values. + * + * Handles all edge cases defensively — SDK stability takes priority over + * strict compliance. Never throws on any input. + * + * - Splits on `\r?\n` to handle both CRLF and bare LF + * - Uses `indexOf(':')` so values containing colons (e.g. URLs) are preserved + * - Header names are trimmed and lowercased; values are trimmed (HTTP OWS) + * - Duplicate names resolve to last-value-wins + * - Malformed lines (no colon, empty name) are silently skipped + */ +export const parseResponseHeaders = ( + rawHeaders: string | null | undefined +): Record => { + if (!rawHeaders) { + return {}; + } + + const result: Record = {}; + const lines = rawHeaders.split(/\r?\n/); + + for (const line of lines) { + // Skip empty lines (trailing delimiter produces empty last element) + if (line === '') { + continue; + } + + const colonIndex = line.indexOf(':'); + + // No colon found, or colon is first character (empty name) — skip + if (colonIndex <= 0) { + continue; + } + + const name = line.slice(0, colonIndex).trim().toLowerCase(); + + // Name is empty after trimming — skip + if (name === '') { + continue; + } + + const value = line.slice(colonIndex + 1).trim(); + + // Last value wins for duplicate header names + result[name] = value; + } + + return result; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts new file mode 100644 index 000000000..c45649143 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts @@ -0,0 +1,31 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Compiled regex matching sensitive header name patterns. + * + * Matches headers containing: token, cookie, secret, authorization, password, + * credential, bearer, api/secret/access/app key variants, forwarding/IP headers. + * + * The `/i` flag ensures case-insensitive matching. Compiled once at module load + * time — no per-call allocation. + */ +const SENSITIVE_HEADER_PATTERN = /(?:token|cookie|secret|authorization|password|credential|bearer|(?:api|secret|access|app).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i; + +/** + * Checks whether a header name matches a known sensitive header pattern. + * Sensitive headers (authorization, cookies, tokens, API keys, etc.) must + * never be captured regardless of configuration mode. + * + * Silent filtering — no debug log for blocked headers (these are expected + * normal behavior, not warnings). + * + * @param headerName - The header name to check (case-insensitive via regex /i flag). + * @returns `true` if the header is sensitive and must be blocked. + */ +export const isSensitiveHeader = (headerName: string): boolean => { + return SENSITIVE_HEADER_PATTERN.test(headerName); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts new file mode 100644 index 000000000..62930af4b --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts @@ -0,0 +1,59 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + SAMPLING_PRIORITY_HEADER_KEY, + ORIGIN_HEADER_KEY, + TRACE_ID_HEADER_KEY, + PARENT_ID_HEADER_KEY, + TAGS_HEADER_KEY, + TRACECONTEXT_HEADER_KEY, + TRACESTATE_HEADER_KEY, + BAGGAGE_HEADER_KEY, + B3_HEADER_KEY, + B3_MULTI_TRACE_ID_HEADER_KEY, + B3_MULTI_SPAN_ID_HEADER_KEY, + B3_MULTI_SAMPLED_HEADER_KEY +} from '../distributedTracing/headers'; + +/** + * Set of all SDK-injected distributed tracing header names, lowercased. + * + * Built once at module load time from the canonical header constants in + * `distributedTracing/headers.ts`. The B3 multi-headers have mixed case + * (e.g. `X-B3-TraceId`), so all values are lowercased for uniform lookup. + */ +const TRACING_HEADERS: Set = new Set( + [ + SAMPLING_PRIORITY_HEADER_KEY, + ORIGIN_HEADER_KEY, + TRACE_ID_HEADER_KEY, + PARENT_ID_HEADER_KEY, + TAGS_HEADER_KEY, + TRACECONTEXT_HEADER_KEY, + TRACESTATE_HEADER_KEY, + BAGGAGE_HEADER_KEY, + B3_HEADER_KEY, + B3_MULTI_TRACE_ID_HEADER_KEY, + B3_MULTI_SPAN_ID_HEADER_KEY, + B3_MULTI_SAMPLED_HEADER_KEY + ].map(h => h.toLowerCase()) +); + +/** + * Checks whether a header name is an SDK-injected distributed tracing header. + * Tracing headers must never be captured because they are SDK internals, + * not application-level headers. + * + * Uses an explicit list (not prefix matching) — `x-datadog-customer-id` + * would NOT be excluded. + * + * @param headerName - The header name to check (lowercased internally for lookup). + * @returns `true` if the header is a tracing header and must be excluded. + */ +export const isTracingHeader = (headerName: string): boolean => { + return TRACING_HEADERS.has(headerName.toLowerCase()); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts new file mode 100644 index 000000000..5bb8eb9bd --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts @@ -0,0 +1,33 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Compiled runtime representation of the header capture configuration. + * Produced once at SDK initialization by `compileHeaderCaptureConfig`. + * + * - `null` means header capture is disabled (undefined input, empty array, or invalid value). + * - `CompiledHeaderCaptureRule[]` contains one or more compiled rules to evaluate per request. + */ +export type CompiledHeaderCaptureConfig = CompiledHeaderCaptureRule[] | null; + +/** + * A single compiled URL-scoped header capture rule. + * Pre-built for O(1) per-request matching — no config traversal at capture time. + */ +export type CompiledHeaderCaptureRule = { + /** Pre-built RegExp for URL matching (full URL string). */ + urlRegex: RegExp; + /** Lowercased request header names. Set for O(1) lookup. */ + requestHeaderNames: Set; + /** Lowercased response header names. Set for O(1) lookup. */ + responseHeaderNames: Set; + /** True if this rule was compiled from specific forURLs patterns (not catch-all). */ + isScoped: boolean; + /** Map from lowercased header name to original/config-provided casing for request headers. */ + requestHeaderCasing: Map; + /** Map from lowercased header name to original/config-provided casing for response headers. */ + responseHeaderCasing: Map; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts index 194944986..d88aecd05 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts @@ -83,4 +83,124 @@ describe('Resource reporter', () => { expect(DdRum.startResource).not.toHaveBeenCalled(); expect(DdRum.stopResource).not.toHaveBeenCalled(); }); + + describe('captured header attributes in stopResource context', () => { + it('includes _dd.request_headers in stopResource context when captured request headers are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedRequestHeaders: { + 'content-type': 'application/json', + 'cache-control': 'no-cache' + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.request_headers': { + 'content-type': 'application/json', + 'cache-control': 'no-cache' + } + }) + ); + }); + + it('includes _dd.response_headers in stopResource context when captured response headers are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedResponseHeaders: { + etag: '"abc123"', + 'cache-control': 'max-age=3600' + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.response_headers': { + etag: '"abc123"', + 'cache-control': 'max-age=3600' + } + }) + ); + }); + + it('includes both _dd.request_headers and _dd.response_headers when both are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedRequestHeaders: { + 'content-type': 'text/plain' + }, + capturedResponseHeaders: { 'x-cache': 'HIT' } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.request_headers': { + 'content-type': 'text/plain' + }, + '_dd.response_headers': { 'x-cache': 'HIT' } + }) + ); + }); + + it('does not include _dd.request_headers or _dd.response_headers when headers are undefined', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getBasicResource(); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).not.toHaveProperty('_dd.request_headers'); + expect(stopContext).not.toHaveProperty('_dd.response_headers'); + }); + + it('does not include _dd.request_headers when only response headers are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedResponseHeaders: { + 'x-request-id': '12345' + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.response_headers': { + 'x-request-id': '12345' + } + }) + ); + expect(stopContext).not.toHaveProperty('_dd.request_headers'); + }); + }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 54a67e774..2d2032da2 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -7,6 +7,14 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types'; import { extractGraphQLErrors } from '../../graphql/graphqlUtils'; +import { + accumulateRequestHeader, + captureResponseHeaders, + filterRequestHeadersByMode +} from '../../headerCapture/captureHeaders'; +import { enforceSizeLimits } from '../../headerCapture/enforceSizeLimits'; +import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; +import { isDatadogCustomHeader } from '../../headers'; import { isRunningWithinFetchProxy } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext, getResourceKey } from '../common/RequestContext'; @@ -30,6 +38,9 @@ interface DdRumXhr extends XMLHttpRequest { interface DdRumXhrContext extends RequestContext { reported: boolean; + headerCaptureConfig: CompiledHeaderCaptureConfig; + capturedRequestHeaders: Record | undefined; + capturedResponseHeaders?: Record; } interface XHRProxyProviders { @@ -113,7 +124,11 @@ const proxyOpen = ( // start time is tracked by the `send` method this._datadog_xhr = { ...createRequestContext({ method, url, options: context }), - reported: false + reported: false, + headerCaptureConfig: context.headerCaptureConfig, + capturedRequestHeaders: + context.headerCaptureConfig != null ? {} : undefined, + capturedResponseHeaders: undefined }; // eslint-disable-next-line prefer-rest-params return originalXhrOpen.apply(this, arguments as any); @@ -155,6 +170,41 @@ const proxyOnReadyStateChange = ( xhrProxy.onreadystatechange = function onreadystatechange() { if (xhrProxy.readyState === xhrType.DONE) { + // Capture response headers (only if capture enabled and not aborted/network-error) + if ( + requestContext.headerCaptureConfig != null && + xhrProxy.status !== 0 + ) { + requestContext.capturedResponseHeaders = captureResponseHeaders( + xhrProxy.getAllResponseHeaders(), + requestContext.url, + requestContext.headerCaptureConfig + ); + } + + // Filter accumulated request headers by mode now that URL is final + if (requestContext.capturedRequestHeaders !== undefined) { + requestContext.capturedRequestHeaders = filterRequestHeadersByMode( + requestContext.capturedRequestHeaders, + requestContext.url, + requestContext.headerCaptureConfig + ); + } + + // Enforce size limits after security/mode filtering, before reporting + if ( + requestContext.capturedRequestHeaders !== undefined || + requestContext.capturedResponseHeaders !== undefined + ) { + const limited = enforceSizeLimits( + requestContext.capturedRequestHeaders, + requestContext.capturedResponseHeaders + ); + requestContext.capturedRequestHeaders = limited.requestHeaders; + requestContext.capturedResponseHeaders = + limited.responseHeaders; + } + if (!requestContext.reported) { reportXhr(xhrProxy, providers.resourceReporter).catch(error => { const errorData = getErrorData(error); @@ -223,6 +273,8 @@ const reportXhr = async ( }, graphqlAttributes: context.graphql, tracingAttributes: context.tracingAttributes, + capturedRequestHeaders: context.capturedRequestHeaders, + capturedResponseHeaders: context.capturedResponseHeaders, response: { statusCode: xhrProxy.status, size: responseSize @@ -258,10 +310,21 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { }); if (processedHeader.type === 'send') { - return originalXhrSetRequestHeader.apply(this, [ + originalXhrSetRequestHeader.apply(this, [ processedHeader.header, processedHeader.value ]); + // Accumulate for header capture (only user-set, non-Datadog headers) + if ( + this._datadog_xhr.capturedRequestHeaders !== undefined && + !isDatadogCustomHeader(header.toLowerCase()) + ) { + accumulateRequestHeader( + this._datadog_xhr.capturedRequestHeaders, + header, + value + ); + } } }; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index c8b5bd7dc..78a620f3d 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -71,6 +71,42 @@ const hexToDecimal = (hex: string): string => { return BigInt(hex, 16).toString(10); }; +const defaultsConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(['cache-control', 'content-type']), + responseHeaderNames: new Set([ + 'cache-control', + 'etag', + 'age', + 'expires', + 'content-type', + 'content-encoding', + 'content-length', + 'vary', + 'server-timing', + 'x-cache' + ]), + isScoped: false, + requestHeaderCasing: new Map([ + ['cache-control', 'Cache-Control'], + ['content-type', 'Content-Type'] + ]), + responseHeaderCasing: new Map([ + ['cache-control', 'Cache-Control'], + ['etag', 'ETag'], + ['age', 'Age'], + ['expires', 'Expires'], + ['content-type', 'Content-Type'], + ['content-encoding', 'Content-Encoding'], + ['content-length', 'Content-Length'], + ['vary', 'Vary'], + ['server-timing', 'Server-Timing'], + ['x-cache', 'X-Cache'] + ]) + } +]; + beforeEach(() => { DdNativeRum.startResource.mockClear(); DdNativeRum.stopResource.mockClear(); @@ -2580,4 +2616,424 @@ describe('XHRProxy', () => { }); }); }); + + describe('header capture', () => { + it('disabled mode: does not accumulate request headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect( + (xhr as any)._datadog_xhr.capturedRequestHeaders + ).toBeUndefined(); + }); + + it('disabled mode: does not capture response headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect( + (xhr as any)._datadog_xhr.capturedResponseHeaders + ).toBeUndefined(); + }); + + it('defaults mode: accumulates and filters allowed request headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('X-Custom', 'val'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — content-type is in defaults, x-custom is not + expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + }); + + it('defaults mode: captures default response headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\ncache-control: no-cache\r\nx-custom: val\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — content-type, cache-control in defaults; x-custom not + expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + }); + }); + + it('defaults mode: skips response capture on aborted request (status 0)', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\n' + ); + xhr.abort(); + xhr.complete(0, undefined); + await flushPromises(); + + // THEN — status 0 means response capture is skipped + expect( + (xhr as any)._datadog_xhr.capturedResponseHeaders + ).toBeUndefined(); + }); + + it('custom mode: captures only rule-matched headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/data'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: [ + { + urlRegex: /.*example\.com.*/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(['etag', 'x-cache']), + isScoped: false, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map([ + ['etag', 'etag'], + ['x-cache', 'x-cache'] + ]) + } + ] + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('X-Request-Id', 'abc'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'etag: abc123\r\ncontent-type: text/html\r\nx-cache: HIT\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — only rule-matched headers appear + expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + 'X-Request-Id': 'abc' + }); + expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + etag: 'abc123', + 'x-cache': 'HIT' + }); + }); + + it('security: sensitive request headers are never accumulated', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Authorization', 'Bearer token'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — authorization blocked by security filter + expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + }); + + it('security: sensitive response headers are filtered out', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set([ + 'set-cookie', + 'cache-control' + ]), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['set-cookie', 'set-cookie'], + ['cache-control', 'cache-control'] + ]) + } + ] + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'set-cookie: session=abc\r\ncache-control: no-cache\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — set-cookie blocked by isSensitiveHeader; only cache-control appears + expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + 'cache-control': 'no-cache' + }); + }); + }); + + describe('header capture integration', () => { + it('disabled mode: no header attributes in stopResource context', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Accept', 'text/html'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\ncache-control: no-cache\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + expect(stopContext['_dd.request_headers']).toBeUndefined(); + expect(stopContext['_dd.response_headers']).toBeUndefined(); + }); + + it('defaults mode: correct default headers captured, sensitive headers excluded', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Authorization', 'Bearer secret'); + xhr.setRequestHeader('X-Custom', 'val'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\ncache-control: no-cache\r\netag: abc\r\nset-cookie: session=xyz\r\nx-custom-resp: val\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + // Only default, non-sensitive request headers + expect(stopContext['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + // Only default response headers; set-cookie is sensitive, x-custom-resp not in defaults + expect(stopContext['_dd.response_headers']).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache', + ETag: 'abc' + }); + }); + + it('custom mode: only rule-matched headers captured', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/data'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(['x-correlation-id']), + isScoped: false, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map([ + ['x-correlation-id', 'x-correlation-id'] + ]) + } + ] + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('X-Request-Id', 'test-123'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'x-correlation-id: abc123\r\ncontent-type: text/html\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + // Only the custom rule match + expect(stopContext['_dd.request_headers']).toEqual({ + 'X-Request-Id': 'test-123' + }); + expect(stopContext['_dd.response_headers']).toEqual({ + 'x-correlation-id': 'abc123' + }); + }); + + it('defaults mode: duplicate setRequestHeader uses last value (last-wins)', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'text/plain'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + expect(stopContext['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + }); + + it('network failure: resource reported without throwing, no response headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + // Status 0 = network error/abort; XHRProxy skips response header capture by design + xhr.getAllResponseHeaders.mockReturnValue(null); + xhr.complete(0, undefined); + await flushPromises(); + + // THEN + expect(DdNativeRum.stopResource).toHaveBeenCalledTimes(1); + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + expect(stopContext['_dd.response_headers']).toBeUndefined(); + // Test completing without throwing is proof no exception occurred + }); + }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts index ba0f2f293..f44aade6a 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts @@ -52,7 +52,9 @@ const formatResourceStartContext = ( const formatResourceStopContext = ( timings: RUMResource['timings'], - graphqlAttributes: RUMResource['graphqlAttributes'] + graphqlAttributes: RUMResource['graphqlAttributes'], + capturedRequestHeaders?: Record, + capturedResponseHeaders?: Record ): Record => { const attributes: Record = {}; @@ -86,6 +88,13 @@ const formatResourceStopContext = ( } } + if (capturedRequestHeaders !== undefined) { + attributes['_dd.request_headers'] = capturedRequestHeaders; + } + if (capturedResponseHeaders !== undefined) { + attributes['_dd.response_headers'] = capturedResponseHeaders; + } + return attributes; }; @@ -103,7 +112,12 @@ const reportResource = async (resource: RUMResource) => { resource.response.statusCode, resource.request.kind, resource.response.size, - formatResourceStopContext(resource.timings, resource.graphqlAttributes), + formatResourceStopContext( + resource.timings, + resource.graphqlAttributes, + resource.capturedRequestHeaders, + resource.capturedResponseHeaders + ), resource.timings.stopTime, resource.resourceContext ); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts index 37abc1eec..fa5d500fc 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts @@ -5,10 +5,12 @@ */ import type { PropagatorType } from '../../../../types'; +import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; export interface RequestProxyOptions { tracingSamplingRate: number; firstPartyHostsRegexMap: RegexMap; + headerCaptureConfig: CompiledHeaderCaptureConfig; } export type RegexMap = { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts index 0e4fd1030..7c8e1fc97 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts @@ -26,6 +26,8 @@ export interface RUMResource { responseStartTime?: number; }; resourceContext?: XMLHttpRequest; + capturedRequestHeaders?: Record; + capturedResponseHeaders?: Record; } export type DdRumResourceGraphqlError = { diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 13ebe6b9e..a49925553 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -66,7 +66,7 @@ describe('DatadogProvider', () => { expect(receivedConfiguration).toMatchInlineSnapshot(` DdSdkNativeConfiguration { "additionalConfiguration": { - "_dd.react_native_version": "${reactNativeVersion}", + "_dd.react_native_version": "0.76.9", "_dd.source": "react-native", }, "attributeEncoders": [], @@ -92,6 +92,7 @@ describe('DatadogProvider', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, From 3c7953e276f537cd49f86bd50745514bb9c15662 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 8 Jun 2026 12:59:07 +0200 Subject: [PATCH 02/26] pr-review(tests): improved unit tests and fixed lint issues --- .../__tests__/compileHeaderConfig.test.ts | 30 ++++++++++++++----- .../enforceSizeLimits.integration.test.ts | 2 +- .../headerCapture/compileHeaderConfig.ts | 10 ------- .../__tests__/initialization.test.tsx | 1 - 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index cae73e9e6..e401956fa 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -232,6 +232,8 @@ describe('compileHeaderCaptureConfig', () => { expect(urlRegex.test('https://api.example.com/any')).toBe(true); expect(urlRegex.test('https://api.example.com')).toBe(true); expect(urlRegex.test('https://other.com/api')).toBe(false); + // Dots in the hostname are escaped (literal), not wildcards + expect(urlRegex.test('https://apiXexampleYcom/any')).toBe(false); expect(isScoped).toBe(true); }); @@ -251,6 +253,10 @@ describe('compileHeaderCaptureConfig', () => { expect(urlRegex.test('https://api.example.com/v1/users')).toBe( false ); + // Dots in hostname and path are escaped (literal), not wildcards + expect(urlRegex.test('https://apiXexampleYcom/v2/users')).toBe( + false + ); }); it('forURLs: [] (empty) skips the rule with WARN log', () => { @@ -269,20 +275,25 @@ describe('compileHeaderCaptureConfig', () => { ); }); - it('forURLs with invalid pattern skips the rule with WARN log', () => { + it('forURLs with special regex characters treats them as literal hostname characters', () => { + // Metacharacters like '[' are escaped by escapeRegExp so they become literal matches const result = compileHeaderCaptureConfig([ { type: 'matchResponseHeaders', headers: ['etag'], - forURLs: ['[invalid'] + forURLs: ['[special].example.com'] } ]); - // With only one rule that gets skipped, all rules fail -> null - expect(result).toBeNull(); - expect(mockLog).toHaveBeenCalledWith( - expect.any(String), - SdkVerbosity.WARN - ); + expect(result).not.toBeNull(); + expect(result![0].isScoped).toBe(true); + // The pattern is treated literally: it matches the host '[special].example.com' + expect( + result![0].urlRegex.test('https://[special].example.com/path') + ).toBe(true); + // It does NOT match hosts that would match the unescaped regex interpretation + expect( + result![0].urlRegex.test('https://xspecialx.example.com/path') + ).toBe(false); }); it('forURLs with multiple patterns produces combined regex', () => { @@ -299,6 +310,9 @@ describe('compileHeaderCaptureConfig', () => { expect(urlRegex.test('https://api.example.com/v2')).toBe(true); expect(urlRegex.test('https://cdn.example.com/assets')).toBe(true); expect(urlRegex.test('https://other.com')).toBe(false); + // Dots in hostnames are escaped (literal), not wildcards + expect(urlRegex.test('https://apiXexampleYcom/v2')).toBe(false); + expect(urlRegex.test('https://cdnXexampleYcom/assets')).toBe(false); }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts index 54e75e0e8..9413ee631 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts @@ -17,6 +17,7 @@ import { CANONICAL_REQUEST_HEADERS, CANONICAL_RESPONSE_HEADERS } from '../captureHeaders'; +import * as enforceSizeLimitsModule from '../enforceSizeLimits'; import { MAX_HEADER_VALUE_BYTES } from '../enforceSizeLimits'; import type { CompiledHeaderCaptureConfig } from '../types'; @@ -100,7 +101,6 @@ describe('enforceSizeLimits integration', () => { it('disabled config (null): enforceSizeLimits is never invoked', async () => { // GIVEN - const enforceSizeLimitsModule = require('../enforceSizeLimits'); const spy = jest.spyOn(enforceSizeLimitsModule, 'enforceSizeLimits'); const method = 'GET'; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index f0c92d2c3..fc18ba662 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -40,16 +40,6 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { const hostname = slashIndex === -1 ? match : match.slice(0, slashIndex); const pathPrefix = slashIndex === -1 ? '' : match.slice(slashIndex); - // Validate that the raw hostname is a valid literal string (no unmatched regex metacharacters - // like `[` without closing `]`). We do this by attempting to compile the raw hostname as - // a regex fragment. If it throws, the match string is invalid — return null before escaping. - try { - // eslint-disable-next-line no-new - new RegExp(hostname); - } catch (_e) { - return null; - } - const escapedHost = escapeRegExp(hostname); // If a path prefix is given, escape it and match as prefix. // If hostname-only, require end-of-host (port or end of authority). diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index a49925553..685053cde 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -4,7 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ -import { version as reactNativeVersion } from 'react-native/package.json'; import { NativeModules } from 'react-native'; import { InitializationMode } from '../../../config/types'; From 41e2771d278c461b28fce217b66f0e0ee184e1e6 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 8 Jun 2026 16:17:43 +0200 Subject: [PATCH 03/26] chore(docs): update header capture readme --- .../resourceTracking/headerCapture/README.md | 131 ++++++++++-------- 1 file changed, 70 insertions(+), 61 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index cd77f76bc..496d82b65 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -4,107 +4,124 @@ The Datadog React Native SDK can capture HTTP request and response headers on RU ## Configuration -Header capture is **disabled by default**. Enable it via the `trackResourceHeaders` option in your RUM configuration: +Header capture is **disabled by default**. Enable it via the `headerCaptureRules` option in your RUM configuration: ```typescript -// Capture a predefined set of caching/content headers -trackResourceHeaders: 'defaults' - -// Capture specific headers for specific URLs -trackResourceHeaders: { - custom: [ - { - match: 'api.example.com', - requestHeaderNames: ['x-request-id'], - responseHeaderNames: ['etag', 'cache-control'] - } - ] -} - -// Disabled (default behavior) -trackResourceHeaders: 'disabled' +// Capture a predefined set of caching/content headers across all URLs +headerCaptureRules: 'defaults' + +// Composable rules — mix defaults and custom header/URL targets +headerCaptureRules: [ + { type: 'defaults' }, + { + type: 'matchResponseHeaders', + headers: ['x-request-id', 'x-trace-id'], + forURLs: ['api.example.com'] + } +] + +// Disabled (default behavior — omit the option entirely) +// headerCaptureRules: undefined ``` -### Custom Rules +### Rule types -Each rule specifies a URL pattern and the header names to capture: +`headerCaptureRules` accepts either the string shortcut `'defaults'` or an array of `HeaderCaptureRule` objects. Each rule is discriminated by its `type` field: + +| `type` | Headers captured | +|---|---| +| `'defaults'` | A predefined set of caching and content headers (request + response) | +| `'matchHeaders'` | The specified header names from both request and response | +| `'matchRequestHeaders'` | The specified header names from requests only | +| `'matchResponseHeaders'` | The specified header names from responses only | ```typescript -type HeaderCaptureRule = { - match: string; // URL pattern (same format as firstPartyHosts) - requestHeaderNames?: string[]; // Request headers to capture - responseHeaderNames?: string[]; // Response headers to capture -}; +type HeaderCaptureRule = + | { type: 'defaults'; forURLs?: string[] } + | { type: 'matchHeaders'; headers: string[]; forURLs?: string[] } + | { type: 'matchRequestHeaders'; headers: string[]; forURLs?: string[] } + | { type: 'matchResponseHeaders'; headers: string[]; forURLs?: string[] } ``` -The `match` field supports hostname-only (`api.example.com`), hostname+path prefix (`api.example.com/v2`), or wildcard (`*`) to match all URLs. This is the same format used by `firstPartyHosts`. +The optional `forURLs` field scopes the rule to specific URL patterns. Supports hostname-only (`api.example.com`), hostname + path prefix (`api.example.com/v2`), or wildcard (`*`) to match all URLs. This is the same format used by `firstPartyHosts`. Omitting `forURLs` is equivalent to `['*']`. -When multiple rules could match a URL, **the first matching rule wins**. +When multiple rules match a URL, their header sets are **merged additively** (union of all matching headers). -### Default Headers +### Default headers -When using `'defaults'` mode, the SDK captures: +When using `'defaults'` (or `{ type: 'defaults' }`), the SDK captures: **10 response headers:** -`cache-control`, `etag`, `age`, `expires`, `content-type`, `content-encoding`, `content-length`, `vary`, `server-timing`, `x-cache` +`Cache-Control`, `ETag`, `Age`, `Expires`, `Content-Type`, `Content-Encoding`, `Content-Length`, `Vary`, `Server-Timing`, `X-Cache` **2 request headers:** -`cache-control`, `content-type` +`Cache-Control`, `Content-Type` -## Output Format +## Output format Captured headers appear as context attributes on `stopResource` events: - `_dd.request_headers` — `Record` of captured request headers - `_dd.response_headers` — `Record` of captured response headers -When no headers are captured (disabled mode, no matching URL rules, or empty result), these attributes are **omitted entirely** rather than set to empty objects. +When no headers are captured (disabled mode, no matching URL rules, or empty result after filtering), these attributes are **omitted entirely** rather than set to empty objects. + +### Header name casing + +- **Default headers** use RFC Title-Case: `Cache-Control`, `ETag`, `Content-Type`, etc. +- **Custom headers** (`matchHeaders`, `matchRequestHeaders`, `matchResponseHeaders`) use the casing exactly as specified in the `headers` array of the rule. Request headers additionally preserve the casing used in the original `setRequestHeader` call. -## Constraints and Behavior Details +## Constraints and behavior details -### Header Name Normalization +### Size limits -All header names in the output are **lowercased**, regardless of how the server or application originally cased them. `Content-Type` and `content-type` both appear as `content-type` in the captured output. +To prevent runaway event sizes, the SDK enforces the following hard limits after all filtering: -### Duplicate Headers +- **Per-value cap:** 128 bytes per header value (values exceeding this are truncated) +- **Header count cap:** 100 headers total (request + response combined) +- **Total size cap:** 2 048 bytes across all header names and values -- **Response headers:** If a response contains duplicate header names (e.g., multiple `Set-Cookie`), the **last value wins**. +### Duplicate headers + +- **Response headers:** If a response contains duplicate header names, the **last value wins**. - **Request headers:** If `setRequestHeader` is called multiple times with the same header name, the **last value wins**. -### Whitespace Handling +### Whitespace handling Leading and trailing whitespace is **trimmed** from header values. For example, `" text/html "` becomes `"text/html"`. -### Malformed Response Headers +### Malformed response headers Lines from `getAllResponseHeaders()` that lack a colon separator, have an empty name, or are otherwise malformed are **silently skipped**. The SDK never throws on malformed header input. -### Null or Empty Responses +### Null or empty responses -If `getAllResponseHeaders()` returns `null` or an empty string (e.g., due to a network error or CORS restriction), the result is an empty object. Since empty results are omitted, no `_dd.response_headers` attribute will appear on the event. +If `getAllResponseHeaders()` returns `null` or an empty string (e.g. due to a network error or CORS restriction), the result is treated as an empty set. Since empty results are omitted, no `_dd.response_headers` attribute will appear on the event. -### Aborted Requests +### Aborted requests -Response header capture is **skipped entirely** on aborted requests (XHR status 0). Request headers that were accumulated before the abort are also discarded. +Response header capture is **skipped entirely** on aborted requests (XHR status 0). Request headers accumulated before the abort are also discarded. -### Security Filtering +## Security filtering -The SDK enforces two layers of security filtering that **cannot be overridden** by configuration: +Two layers of non-overridable filtering are always applied, regardless of configuration. Security filtering runs **before** any URL rule matching. -#### Sensitive Header Blocklist +### Sensitive header blocklist -Headers matching the following pattern are **always blocked**, even if explicitly listed in a custom rule: +Headers whose names match the following pattern are **always blocked**, even if explicitly listed in a custom rule: ``` /(?:token|cookie|secret|authorization|password|credential|bearer| (?:api|secret|access|app).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i ``` -This blocks headers like `Authorization`, `Cookie`, `Set-Cookie`, `x-access-token`, `x-amz-security-token`, `x-api-key`, `X-Forwarded-For`, `X-Real-IP`, and any header containing `password`, `secret`, `credential`, or `bearer` in its name. +This blocks headers such as `Authorization`, `Cookie`, `Set-Cookie`, `X-Access-Token`, `X-Amz-Security-Token`, `X-Api-Key`, `X-Forwarded-For`, `X-Real-IP`, and any header whose name contains `password`, `secret`, `credential`, or `bearer`. + +The match is **case-insensitive** and applies to **partial name matches** — a header named `x-custom-token-id` would be blocked because it contains `token`. -The match is **case-insensitive** and applies to partial name matches (a header named `x-custom-token-id` would be blocked because it contains `token`). +Sensitive headers are filtered at capture time and are **never stored in memory**, even briefly. -#### SDK Tracing Header Exclusion +### SDK tracing header exclusion The 12 headers injected by the SDK's distributed tracing system are **always excluded** from capture: @@ -123,20 +140,12 @@ The 12 headers injected by the SDK's distributed tracing system are **always exc | `x-b3-spanid` | B3 Multi | | `x-b3-sampled` | B3 Multi | -#### Filter Order - -Security filtering is applied **before** URL rule matching. The pipeline is: capture raw header -> security filter (blocklist + tracing exclusion) -> mode/rule filter (defaults set or custom rule). - -### Request Header Capture Timing - -Request headers are filtered by the security blocklist **at capture time** — sensitive headers are never stored in memory, even briefly. However, URL-based rule filtering is applied **at request completion** (when the final URL is known), since the URL may change between `setRequestHeader` calls and request completion. - -### Configuration Lifecycle +## Configuration lifecycle - The compiled header capture configuration is **snapshotted per request** at `open()` time. In-flight requests are unaffected by configuration changes. - Configuration changes take effect on the **next request** — no SDK restart required. -- When disabled, no interception hooks are installed for header capture — **zero overhead** on the network hot path. +- When `headerCaptureRules` is omitted, the capture path is not entered at all — **zero overhead** on the network hot path. -### Fetch Support +## Fetch support React Native's `fetch` API uses `XMLHttpRequest` under the hood. All fetch requests go through the same XHR interception pipeline and benefit from header capture identically to direct XHR usage. From 3878412dd60e099f8c5be9a52edf1e266a7ae75e Mon Sep 17 00:00:00 2001 From: Marco Saia <164892343+marco-saia-datadog@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:27:03 +0200 Subject: [PATCH 04/26] fix(pr, docs): code doc improvements --- .../src/config/features/RumConfiguration.type.ts | 12 +++++++++--- .../resourceTracking/headerCapture/README.md | 8 +++----- .../headerCapture/enforceSizeLimits.ts | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index fad8ff76d..db33b392f 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -129,7 +129,9 @@ export type MatchResponseHeadersRule = { * A composable header capture rule. * * Discriminated union on the `type` field. Multiple rules can be combined - * in an array -- matching rules are merged additively (union of headers). + * in an array. Matching rules are merged additively (union of headers). + * If at least one scoped rule (explicit `forURLs` patterns) matches a URL, + * catch-all rules (omitted `forURLs` / `['*']`) are ignored for that URL. */ export type HeaderCaptureRule = | DefaultsRule @@ -185,11 +187,15 @@ export interface RumConfigurationOptions { * - **Omitted** (default): No headers are captured. * - `'defaults'`: Shortcut equivalent to `[{ type: 'defaults' }]`. * Captures a predefined set of caching and content headers. - * - `HeaderCaptureRule[]`: An array of composable rules. Multiple rules - * are merged additively -- matching rules contribute their headers. + * - `HeaderCaptureRule[]`: An array of composable rules. Matching rules are + * merged additively (union of headers). If at least one scoped rule (explicit + * `forURLs` patterns) matches a URL, catch-all rules (omitted `forURLs` / `['*']`) + * are ignored for that URL. * * Requires `trackResources: true` to take effect. */ + * Requires `trackResources: true` to take effect. + */ headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; /** diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index 496d82b65..9a760871e 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -45,8 +45,7 @@ type HeaderCaptureRule = The optional `forURLs` field scopes the rule to specific URL patterns. Supports hostname-only (`api.example.com`), hostname + path prefix (`api.example.com/v2`), or wildcard (`*`) to match all URLs. This is the same format used by `firstPartyHosts`. Omitting `forURLs` is equivalent to `['*']`. -When multiple rules match a URL, their header sets are **merged additively** (union of all matching headers). - +When multiple rules match a URL, their header sets are **merged additively** (union of all matching headers). If at least one scoped rule matches, catch-all rules (omitted `forURLs` / `['*']`) are ignored for that URL. ### Default headers When using `'defaults'` (or `{ type: 'defaults' }`), the SDK captures: @@ -77,10 +76,9 @@ When no headers are captured (disabled mode, no matching URL rules, or empty res To prevent runaway event sizes, the SDK enforces the following hard limits after all filtering: -- **Per-value cap:** 128 bytes per header value (values exceeding this are truncated) +- **Per-value cap:** 128 characters per header value (values exceeding this are truncated) - **Header count cap:** 100 headers total (request + response combined) -- **Total size cap:** 2 048 bytes across all header names and values - +- **Total size cap:** 2 048 characters across all header names and values ### Duplicate headers - **Response headers:** If a response contains duplicate header names, the **last value wins**. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts index ad477ed35..6b3ce9cd6 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -4,13 +4,13 @@ * Copyright 2016-Present Datadog, Inc. */ -/** Maximum byte length for any single header value. */ +/** Maximum character length (UTF-16 code units) for any single header value. */ export const MAX_HEADER_VALUE_BYTES = 128; /** Maximum combined header count (request + response). */ export const MAX_HEADER_COUNT = 100; -/** Maximum total bytes across all header names and values. */ +/** Maximum total character count (UTF-16 code units) across all header names and values. */ export const MAX_TOTAL_BYTES = 2048; /** From 74cf4d1d68670ba137dc6824e2047172a87f2173 Mon Sep 17 00:00:00 2001 From: Marco Saia <164892343+marco-saia-datadog@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:33:01 +0200 Subject: [PATCH 05/26] feat(header-capture): discard headers on status == 0 requests Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../resourceTracking/requestProxy/XHRProxy/XHRProxy.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 2d2032da2..eb91b5ee4 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -182,6 +182,11 @@ const proxyOnReadyStateChange = ( ); } + // Discard accumulated request headers on aborted/network-error requests (status 0) + if (xhrProxy.status === 0) { + xhrProxy._datadog_xhr.capturedRequestHeaders = undefined; + } + // Filter accumulated request headers by mode now that URL is final if (requestContext.capturedRequestHeaders !== undefined) { requestContext.capturedRequestHeaders = filterRequestHeadersByMode( From 80dd1f9bcab6da857e0a42c178735567ca3dda9b Mon Sep 17 00:00:00 2001 From: Marco Saia <164892343+marco-saia-datadog@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:37:24 +0200 Subject: [PATCH 06/26] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../resourceTracking/requestProxy/XHRProxy/XHRProxy.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index eb91b5ee4..34c781523 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -170,6 +170,11 @@ const proxyOnReadyStateChange = ( xhrProxy.onreadystatechange = function onreadystatechange() { if (xhrProxy.readyState === xhrType.DONE) { + // If aborted/network-error (status 0), discard any accumulated request headers + if (xhrProxy.status === 0) { + xhrProxy._datadog_xhr.capturedRequestHeaders = undefined; + } + // Capture response headers (only if capture enabled and not aborted/network-error) if ( requestContext.headerCaptureConfig != null && From 512a5a3188c11c762af98cba2c41ffe33aed976c Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Jun 2026 15:20:04 +0200 Subject: [PATCH 07/26] fix(pr, header-capture): bytes length enforcing + lint fixes --- .../config/features/RumConfiguration.type.ts | 18 +- .../resourceTracking/headerCapture/README.md | 174 +++++++----------- .../__tests__/compileHeaderConfig.test.ts | 55 +++--- .../__tests__/enforceSizeLimits.test.ts | 30 +-- .../headerCapture/compileHeaderConfig.ts | 4 +- .../headerCapture/enforceSizeLimits.ts | 60 +++--- .../requestProxy/XHRProxy/XHRProxy.ts | 7 +- .../XHRProxy/__tests__/XHRProxy.test.ts | 4 +- .../XHRProxy/baggageHeaderUtils.ts | 35 +--- .../__tests__/initialization.test.tsx | 5 +- packages/core/src/utils/stringUtils.ts | 29 +++ 11 files changed, 193 insertions(+), 228 deletions(-) create mode 100644 packages/core/src/utils/stringUtils.ts diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index db33b392f..e7bc26df8 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -62,7 +62,7 @@ export type DefaultsRule = { * Captures the specified headers from both request and response. * * Use this when the same header names should be captured regardless - * of direction (e.g. 'content-type', 'authorization'). + * of direction (e.g. 'content-type', 'x-request-id'). * Optionally scoped to specific URLs via `forURLs`. */ export type MatchHeadersRule = { @@ -84,7 +84,7 @@ export type MatchHeadersRule = { * Captures the specified headers from requests only. * * Use this when you need to capture request-specific headers - * (e.g. 'authorization', 'x-api-key') without capturing response headers. + * (e.g. 'x-request-id', 'if-none-match') without capturing response headers. * Optionally scoped to specific URLs via `forURLs`. */ export type MatchRequestHeadersRule = { @@ -185,17 +185,15 @@ export interface RumConfigurationOptions { * Controls which resource headers the SDK captures on network requests. * * - **Omitted** (default): No headers are captured. - * - `'defaults'`: Shortcut equivalent to `[{ type: 'defaults' }]`. - * Captures a predefined set of caching and content headers. - * - `HeaderCaptureRule[]`: An array of composable rules. Matching rules are - * merged additively (union of headers). If at least one scoped rule (explicit - * `forURLs` patterns) matches a URL, catch-all rules (omitted `forURLs` / `['*']`) - * are ignored for that URL. + * - `'defaults'`: Captures a predefined set of caching and content headers + * across all URLs. + * - `HeaderCaptureRule[]`: An array of composable rules. When multiple rules + * match a URL their header sets are merged. If at least one scoped rule + * (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs` + * or `['*']`) are ignored for that URL. * * Requires `trackResources: true` to take effect. */ - * Requires `trackResources: true` to take effect. - */ headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; /** diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index 9a760871e..c9cb088db 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -1,149 +1,101 @@ # Resource Header Capture -The Datadog React Native SDK can capture HTTP request and response headers on RUM resource events. This helps debug caching, CDN, and content negotiation issues without manual instrumentation. +The Datadog React Native SDK can capture HTTP request and response headers on RUM resource events. This helps debug caching, CDN, and content negotiation issues without extra instrumentation. + +Header capture is **disabled by default** and requires `trackResources: true`. ## Configuration -Header capture is **disabled by default**. Enable it via the `headerCaptureRules` option in your RUM configuration: +Set `headerCaptureRules` in your RUM configuration: ```typescript -// Capture a predefined set of caching/content headers across all URLs -headerCaptureRules: 'defaults' - -// Composable rules — mix defaults and custom header/URL targets -headerCaptureRules: [ - { type: 'defaults' }, - { - type: 'matchResponseHeaders', - headers: ['x-request-id', 'x-trace-id'], - forURLs: ['api.example.com'] - } -] +import { DatadogProvider } from '@datadog/mobile-react-native'; -// Disabled (default behavior — omit the option entirely) -// headerCaptureRules: undefined +DatadogProvider.initialize({ + // ... + trackResources: true, + headerCaptureRules: 'defaults', +}); ``` -### Rule types - -`headerCaptureRules` accepts either the string shortcut `'defaults'` or an array of `HeaderCaptureRule` objects. Each rule is discriminated by its `type` field: +### Modes -| `type` | Headers captured | +| Value | What gets captured | |---|---| -| `'defaults'` | A predefined set of caching and content headers (request + response) | -| `'matchHeaders'` | The specified header names from both request and response | -| `'matchRequestHeaders'` | The specified header names from requests only | -| `'matchResponseHeaders'` | The specified header names from responses only | +| Omitted (default) | Nothing | +| `'defaults'` | A predefined set of caching and content headers across all URLs | +| `HeaderCaptureRule[]` | Custom rules — see below | -```typescript -type HeaderCaptureRule = - | { type: 'defaults'; forURLs?: string[] } - | { type: 'matchHeaders'; headers: string[]; forURLs?: string[] } - | { type: 'matchRequestHeaders'; headers: string[]; forURLs?: string[] } - | { type: 'matchResponseHeaders'; headers: string[]; forURLs?: string[] } -``` - -The optional `forURLs` field scopes the rule to specific URL patterns. Supports hostname-only (`api.example.com`), hostname + path prefix (`api.example.com/v2`), or wildcard (`*`) to match all URLs. This is the same format used by `firstPartyHosts`. Omitting `forURLs` is equivalent to `['*']`. - -When multiple rules match a URL, their header sets are **merged additively** (union of all matching headers). If at least one scoped rule matches, catch-all rules (omitted `forURLs` / `['*']`) are ignored for that URL. ### Default headers -When using `'defaults'` (or `{ type: 'defaults' }`), the SDK captures: - -**10 response headers:** -`Cache-Control`, `ETag`, `Age`, `Expires`, `Content-Type`, `Content-Encoding`, `Content-Length`, `Vary`, `Server-Timing`, `X-Cache` - -**2 request headers:** -`Cache-Control`, `Content-Type` - -## Output format - -Captured headers appear as context attributes on `stopResource` events: - -- `_dd.request_headers` — `Record` of captured request headers -- `_dd.response_headers` — `Record` of captured response headers - -When no headers are captured (disabled mode, no matching URL rules, or empty result after filtering), these attributes are **omitted entirely** rather than set to empty objects. +`'defaults'` captures: -### Header name casing +- **Response:** `Cache-Control`, `ETag`, `Age`, `Expires`, `Content-Type`, `Content-Encoding`, `Content-Length`, `Vary`, `Server-Timing`, `X-Cache` +- **Request:** `Cache-Control`, `Content-Type` -- **Default headers** use RFC Title-Case: `Cache-Control`, `ETag`, `Content-Type`, etc. -- **Custom headers** (`matchHeaders`, `matchRequestHeaders`, `matchResponseHeaders`) use the casing exactly as specified in the `headers` array of the rule. Request headers additionally preserve the casing used in the original `setRequestHeader` call. +### Custom rules -## Constraints and behavior details +Use an array of rules to control exactly which headers are captured and for which URLs: -### Size limits - -To prevent runaway event sizes, the SDK enforces the following hard limits after all filtering: - -- **Per-value cap:** 128 characters per header value (values exceeding this are truncated) -- **Header count cap:** 100 headers total (request + response combined) -- **Total size cap:** 2 048 characters across all header names and values -### Duplicate headers - -- **Response headers:** If a response contains duplicate header names, the **last value wins**. -- **Request headers:** If `setRequestHeader` is called multiple times with the same header name, the **last value wins**. - -### Whitespace handling - -Leading and trailing whitespace is **trimmed** from header values. For example, `" text/html "` becomes `"text/html"`. - -### Malformed response headers - -Lines from `getAllResponseHeaders()` that lack a colon separator, have an empty name, or are otherwise malformed are **silently skipped**. The SDK never throws on malformed header input. +```typescript +headerCaptureRules: [ + // Capture default headers everywhere + { type: 'defaults' }, -### Null or empty responses + // Capture a custom response header on a specific API + { + type: 'matchResponseHeaders', + headers: ['x-request-id', 'x-ratelimit-remaining'], + forURLs: ['api.example.com'] + }, -If `getAllResponseHeaders()` returns `null` or an empty string (e.g. due to a network error or CORS restriction), the result is treated as an empty set. Since empty results are omitted, no `_dd.response_headers` attribute will appear on the event. + // Capture a custom request header on a path prefix + { + type: 'matchRequestHeaders', + headers: ['x-device-id'], + forURLs: ['api.example.com/v2'] + } +] +``` -### Aborted requests +Each rule has a `type`: -Response header capture is **skipped entirely** on aborted requests (XHR status 0). Request headers accumulated before the abort are also discarded. +| `type` | Captures from | +|---|---| +| `'defaults'` | Request + response (predefined set) | +| `'matchHeaders'` | Both request and response (named headers) | +| `'matchRequestHeaders'` | Request only | +| `'matchResponseHeaders'` | Response only | -## Security filtering +The optional `forURLs` field scopes a rule to specific URL patterns. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit it (or use `['*']`) to match all URLs. -Two layers of non-overridable filtering are always applied, regardless of configuration. Security filtering runs **before** any URL rule matching. +**Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs` / `['*']`) are ignored for that URL. -### Sensitive header blocklist +## Output -Headers whose names match the following pattern are **always blocked**, even if explicitly listed in a custom rule: +Captured headers appear as attributes on RUM resource events: -``` -/(?:token|cookie|secret|authorization|password|credential|bearer| -(?:api|secret|access|app).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i -``` +- `_dd.request_headers` — captured request headers +- `_dd.response_headers` — captured response headers -This blocks headers such as `Authorization`, `Cookie`, `Set-Cookie`, `X-Access-Token`, `X-Amz-Security-Token`, `X-Api-Key`, `X-Forwarded-For`, `X-Real-IP`, and any header whose name contains `password`, `secret`, `credential`, or `bearer`. +Header names use RFC Title-Case for default headers (`Cache-Control`, `ETag`, …) and preserve the casing from your rule's `headers` array for custom headers. -The match is **case-insensitive** and applies to **partial name matches** — a header named `x-custom-token-id` would be blocked because it contains `token`. +When nothing is captured these attributes are omitted entirely. -Sensitive headers are filtered at capture time and are **never stored in memory**, even briefly. +## Security -### SDK tracing header exclusion +Two filters are always enforced, regardless of configuration: -The 12 headers injected by the SDK's distributed tracing system are **always excluded** from capture: +**Sensitive header blocklist** — headers matching patterns like `token`, `cookie`, `secret`, `authorization`, `password`, `bearer`, `api-key`, `x-forwarded-for`, etc. are always blocked and never stored. -| Header | Protocol | -|--------|----------| -| `x-datadog-sampling-priority` | Datadog | -| `x-datadog-origin` | Datadog | -| `x-datadog-trace-id` | Datadog | -| `x-datadog-parent-id` | Datadog | -| `x-datadog-tags` | Datadog | -| `traceparent` | W3C / OpenTelemetry | -| `tracestate` | W3C / OpenTelemetry | -| `baggage` | W3C / OpenTelemetry | -| `b3` | B3 Single | -| `x-b3-traceid` | B3 Multi | -| `x-b3-spanid` | B3 Multi | -| `x-b3-sampled` | B3 Multi | +**SDK tracing headers** — the Datadog and W3C/B3 tracing headers injected by the SDK itself (`x-datadog-trace-id`, `traceparent`, `b3`, etc.) are always excluded. -## Configuration lifecycle +## Size limits -- The compiled header capture configuration is **snapshotted per request** at `open()` time. In-flight requests are unaffected by configuration changes. -- Configuration changes take effect on the **next request** — no SDK restart required. -- When `headerCaptureRules` is omitted, the capture path is not entered at all — **zero overhead** on the network hot path. +To protect event size, the SDK enforces: -## Fetch support +- **128 bytes** max per header value (values are truncated) +- **100 headers** max total (request + response combined) +- **2 048 bytes** max total across all header names and values -React Native's `fetch` API uses `XMLHttpRequest` under the hood. All fetch requests go through the same XHR interception pipeline and benefit from header capture identically to direct XHR usage. +All limits are measured in UTF-8 bytes. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index e401956fa..0ddb65ad9 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -66,7 +66,7 @@ describe('compileHeaderCaptureConfig', () => { it('has requestHeaderNames equal to DEFAULT_REQUEST_HEADERS', () => { const result = compileHeaderCaptureConfig('defaults'); expect(result).not.toBeNull(); - expect(result![0].requestHeaderNames).toEqual( + expect(result?.[0].requestHeaderNames).toEqual( new Set(DEFAULT_REQUEST_HEADERS) ); }); @@ -74,7 +74,7 @@ describe('compileHeaderCaptureConfig', () => { it('has responseHeaderNames equal to DEFAULT_RESPONSE_HEADERS', () => { const result = compileHeaderCaptureConfig('defaults'); expect(result).not.toBeNull(); - expect(result![0].responseHeaderNames).toEqual( + expect(result?.[0].responseHeaderNames).toEqual( new Set(DEFAULT_RESPONSE_HEADERS) ); }); @@ -82,7 +82,7 @@ describe('compileHeaderCaptureConfig', () => { it('has urlRegex matching any URL', () => { const result = compileHeaderCaptureConfig('defaults'); expect(result).not.toBeNull(); - expect(result![0].urlRegex.test('https://anything.com/path')).toBe( + expect(result?.[0].urlRegex.test('https://anything.com/path')).toBe( true ); }); @@ -90,7 +90,7 @@ describe('compileHeaderCaptureConfig', () => { it('has isScoped equal to false', () => { const result = compileHeaderCaptureConfig('defaults'); expect(result).not.toBeNull(); - expect(result![0].isScoped).toBe(false); + expect(result?.[0].isScoped).toBe(false); }); }); @@ -99,7 +99,7 @@ describe('compileHeaderCaptureConfig', () => { const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); expect(result).not.toBeNull(); expect(result).toHaveLength(1); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderNames).toEqual( new Set(DEFAULT_REQUEST_HEADERS) ); @@ -116,7 +116,7 @@ describe('compileHeaderCaptureConfig', () => { ]); expect(result).not.toBeNull(); expect(result).toHaveLength(1); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderNames).toEqual( new Set(['content-type', 'x-custom']) ); @@ -134,7 +134,7 @@ describe('compileHeaderCaptureConfig', () => { ]); expect(result).not.toBeNull(); expect(result).toHaveLength(1); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderNames).toEqual( new Set(['x-request-id', 'authorization']) ); @@ -150,7 +150,7 @@ describe('compileHeaderCaptureConfig', () => { ]); expect(result).not.toBeNull(); expect(result).toHaveLength(1); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderNames).toEqual(new Set()); expect(rule.responseHeaderNames).toEqual( new Set(['etag', 'x-ratelimit-remaining']) @@ -165,10 +165,10 @@ describe('compileHeaderCaptureConfig', () => { } ]); expect(result).not.toBeNull(); - expect(result![0].requestHeaderNames).toEqual( + expect(result?.[0].requestHeaderNames).toEqual( new Set(['x-request-id', 'authorization', 'cache-control']) ); - expect(result![0].responseHeaderNames).toEqual( + expect(result?.[0].responseHeaderNames).toEqual( new Set(['x-request-id', 'authorization', 'cache-control']) ); }); @@ -176,7 +176,7 @@ describe('compileHeaderCaptureConfig', () => { it('{ type: "defaults" } in array produces rule with isScoped: false', () => { const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); expect(result).not.toBeNull(); - expect(result![0].isScoped).toBe(false); + expect(result?.[0].isScoped).toBe(false); }); it('matchHeaders with specific forURLs produces rule with isScoped: true', () => { @@ -188,7 +188,7 @@ describe('compileHeaderCaptureConfig', () => { } ]); expect(result).not.toBeNull(); - expect(result![0].isScoped).toBe(true); + expect(result?.[0].isScoped).toBe(true); }); }); @@ -198,7 +198,7 @@ describe('compileHeaderCaptureConfig', () => { { type: 'matchResponseHeaders', headers: ['etag'] } ]); expect(result).not.toBeNull(); - const { urlRegex, isScoped } = result![0]; + const { urlRegex, isScoped } = (result ?? [])[0]; expect(urlRegex.test('https://anything.com/path')).toBe(true); expect(urlRegex.test('http://other.io')).toBe(true); expect(isScoped).toBe(false); @@ -213,7 +213,7 @@ describe('compileHeaderCaptureConfig', () => { } ]); expect(result).not.toBeNull(); - const { urlRegex, isScoped } = result![0]; + const { urlRegex, isScoped } = (result ?? [])[0]; expect(urlRegex.test('https://anything.com/path')).toBe(true); expect(urlRegex.test('')).toBe(true); expect(isScoped).toBe(false); @@ -228,7 +228,7 @@ describe('compileHeaderCaptureConfig', () => { } ]); expect(result).not.toBeNull(); - const { urlRegex, isScoped } = result![0]; + const { urlRegex, isScoped } = (result ?? [])[0]; expect(urlRegex.test('https://api.example.com/any')).toBe(true); expect(urlRegex.test('https://api.example.com')).toBe(true); expect(urlRegex.test('https://other.com/api')).toBe(false); @@ -246,7 +246,7 @@ describe('compileHeaderCaptureConfig', () => { } ]); expect(result).not.toBeNull(); - const { urlRegex } = result![0]; + const { urlRegex } = (result ?? [])[0]; expect(urlRegex.test('https://api.example.com/v2/users')).toBe( true ); @@ -285,14 +285,15 @@ describe('compileHeaderCaptureConfig', () => { } ]); expect(result).not.toBeNull(); - expect(result![0].isScoped).toBe(true); + const rule0 = (result ?? [])[0]; + expect(rule0.isScoped).toBe(true); // The pattern is treated literally: it matches the host '[special].example.com' expect( - result![0].urlRegex.test('https://[special].example.com/path') + rule0.urlRegex.test('https://[special].example.com/path') ).toBe(true); // It does NOT match hosts that would match the unescaped regex interpretation expect( - result![0].urlRegex.test('https://xspecialx.example.com/path') + rule0.urlRegex.test('https://xspecialx.example.com/path') ).toBe(false); }); @@ -306,7 +307,7 @@ describe('compileHeaderCaptureConfig', () => { ]); expect(result).not.toBeNull(); expect(result).toHaveLength(1); - const { urlRegex } = result![0]; + const { urlRegex } = (result ?? [])[0]; expect(urlRegex.test('https://api.example.com/v2')).toBe(true); expect(urlRegex.test('https://cdn.example.com/assets')).toBe(true); expect(urlRegex.test('https://other.com')).toBe(false); @@ -351,7 +352,7 @@ describe('compileHeaderCaptureConfig', () => { it('"defaults" shortcut produces canonical casing maps', () => { const result = compileHeaderCaptureConfig('defaults'); expect(result).not.toBeNull(); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderCasing).toEqual( new Map(CANONICAL_REQUEST_HEADERS) ); @@ -363,7 +364,7 @@ describe('compileHeaderCaptureConfig', () => { it('{ type: "defaults" } rule produces canonical casing maps', () => { const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); expect(result).not.toBeNull(); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderCasing).toEqual( new Map(CANONICAL_REQUEST_HEADERS) ); @@ -380,7 +381,7 @@ describe('compileHeaderCaptureConfig', () => { } ]); expect(result).not.toBeNull(); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderCasing).toEqual( new Map([ ['content-type', 'Content-Type'], @@ -400,7 +401,7 @@ describe('compileHeaderCaptureConfig', () => { { type: 'matchRequestHeaders', headers: ['X-Api-Key'] } ]); expect(result).not.toBeNull(); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderCasing).toEqual( new Map([['x-api-key', 'X-Api-Key']]) ); @@ -412,7 +413,7 @@ describe('compileHeaderCaptureConfig', () => { { type: 'matchResponseHeaders', headers: ['X-RateLimit'] } ]); expect(result).not.toBeNull(); - const rule = result![0]; + const rule = (result ?? [])[0]; expect(rule.requestHeaderCasing).toEqual(new Map()); expect(rule.responseHeaderCasing).toEqual( new Map([['x-ratelimit', 'X-RateLimit']]) @@ -431,12 +432,12 @@ describe('compileHeaderCaptureConfig', () => { expect(result).toHaveLength(2); // Defaults rule has canonical Title-Case - expect(result![0].responseHeaderCasing.get('content-type')).toBe( + expect(result?.[0].responseHeaderCasing.get('content-type')).toBe( 'Content-Type' ); // Custom rule preserves user-provided lowercase - expect(result![1].responseHeaderCasing.get('content-type')).toBe( + expect(result?.[1].responseHeaderCasing.get('content-type')).toBe( 'content-type' ); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts index a899652ae..2cd840819 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts @@ -50,14 +50,14 @@ describe('enforceSizeLimits', () => { it('truncates header values longer than 128 bytes', () => { const longValue = 'a'.repeat(200); const result = enforceSizeLimits({ x: longValue }, undefined); - expect(result.requestHeaders!['x']).toBe('a'.repeat(128)); - expect(result.requestHeaders!['x'].length).toBe(128); + expect(result.requestHeaders?.['x']).toBe('a'.repeat(128)); + expect(result.requestHeaders?.['x'].length).toBe(128); }); it('does not truncate values exactly at 128 bytes', () => { const exactValue = 'a'.repeat(128); const result = enforceSizeLimits({ x: exactValue }, undefined); - expect(result.requestHeaders!['x']).toBe(exactValue); + expect(result.requestHeaders?.['x']).toBe(exactValue); }); it('truncates response header values as well', () => { @@ -65,7 +65,7 @@ describe('enforceSizeLimits', () => { const result = enforceSizeLimits(undefined, { y: longValue }); - expect(result.responseHeaders!['y']).toBe('b'.repeat(128)); + expect(result.responseHeaders?.['y']).toBe('b'.repeat(128)); }); }); @@ -79,8 +79,8 @@ describe('enforceSizeLimits', () => { } const result = enforceSizeLimits(req, res); expect( - Object.keys(result.requestHeaders!).length + - Object.keys(result.responseHeaders!).length + Object.keys(result.requestHeaders ?? {}).length + + Object.keys(result.responseHeaders ?? {}).length ).toBe(100); }); @@ -92,8 +92,8 @@ describe('enforceSizeLimits', () => { res[`rs${i}`] = 'v'; } const result = enforceSizeLimits(req, res); - expect(Object.keys(result.requestHeaders!).length).toBe(60); - expect(Object.keys(result.responseHeaders!).length).toBe(40); + expect(Object.keys(result.requestHeaders ?? {}).length).toBe(60); + expect(Object.keys(result.responseHeaders ?? {}).length).toBe(40); }); it('caps request-only headers to 100', () => { @@ -102,7 +102,7 @@ describe('enforceSizeLimits', () => { req[`h${i}`] = 'v'; } const result = enforceSizeLimits(req, undefined); - expect(Object.keys(result.requestHeaders!).length).toBe(100); + expect(Object.keys(result.requestHeaders ?? {}).length).toBe(100); expect(result.responseHeaders).toBeUndefined(); }); @@ -113,7 +113,7 @@ describe('enforceSizeLimits', () => { } const res: Record = { a: 'b' }; const result = enforceSizeLimits(req, res); - expect(Object.keys(result.requestHeaders!).length).toBe(100); + expect(Object.keys(result.requestHeaders ?? {}).length).toBe(100); expect(result.responseHeaders).toBeUndefined(); }); }); @@ -130,7 +130,7 @@ describe('enforceSizeLimits', () => { } const result = enforceSizeLimits(req, res); // Request headers should be preserved; response headers dropped from end - expect(Object.keys(result.requestHeaders!).length).toBe(10); + expect(Object.keys(result.requestHeaders ?? {}).length).toBe(10); // Some response headers should be dropped const totalBytes = computeTotalBytes( result.requestHeaders, @@ -153,7 +153,9 @@ describe('enforceSizeLimits', () => { ); expect(totalBytes).toBeLessThanOrEqual(2048); // Some request headers should be dropped - expect(Object.keys(result.requestHeaders!).length).toBeLessThan(25); + expect( + Object.keys(result.requestHeaders ?? {}).length + ).toBeLessThan(25); }); it('returns undefined for response when all response headers dropped for budget', () => { @@ -197,9 +199,9 @@ describe('enforceSizeLimits', () => { req[`h${i}`] = 'z'.repeat(500); } const result = enforceSizeLimits(req, undefined); - expect(Object.keys(result.requestHeaders!).length).toBe(10); + expect(Object.keys(result.requestHeaders ?? {}).length).toBe(10); // All values truncated to 128 - for (const value of Object.values(result.requestHeaders!)) { + for (const value of Object.values(result.requestHeaders ?? {})) { expect(value.length).toBeLessThanOrEqual(128); } }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index fc18ba662..d5136e546 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -42,8 +42,8 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { const escapedHost = escapeRegExp(hostname); // If a path prefix is given, escape it and match as prefix. - // If hostname-only, require end-of-host (port or end of authority). - const pathSuffix = pathPrefix ? escapeRegExp(pathPrefix) : '(/|$)'; + // If hostname-only, accept /, ?, # or end-of-string as valid URL terminators. + const pathSuffix = pathPrefix ? escapeRegExp(pathPrefix) : '(/|\\?|#|$)'; try { // Regex matches full URL strings: scheme + optional subdomains + host + optional port + path diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts index 6b3ce9cd6..1fd01bbc1 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -4,15 +4,33 @@ * Copyright 2016-Present Datadog, Inc. */ -/** Maximum character length (UTF-16 code units) for any single header value. */ +/** Maximum UTF-8 byte length for any single header value. */ +import { utf8ByteLength } from '../../../../utils/stringUtils'; + export const MAX_HEADER_VALUE_BYTES = 128; /** Maximum combined header count (request + response). */ export const MAX_HEADER_COUNT = 100; -/** Maximum total character count (UTF-16 code units) across all header names and values. */ +/** Maximum total UTF-8 bytes across all header names and values. */ export const MAX_TOTAL_BYTES = 2048; +/** + * Truncates a string so its UTF-8 byte length does not exceed maxBytes. + * Slices by character and re-checks, because multi-byte chars mean + * character count < byte count. + */ +const truncateToBytes = (str: string, maxBytes: number): string => { + if (utf8ByteLength(str) <= maxBytes) { + return str; + } + let result = str; + while (result.length > 0 && utf8ByteLength(result) > maxBytes) { + result = result.slice(0, result.length - 1); + } + return result; +}; + /** * Converts a non-empty record to undefined-or-record. * Locked decision: no empty objects -- undefined or absent, not `{}`. @@ -25,7 +43,7 @@ const toUndefinedIfEmpty = ( /** * Enforces per-value truncation, header count cap, and total size budget - * on captured header records. + * on captured header records. All limits are in UTF-8 bytes. * * Processing order: * 1. Short-circuit if both inputs undefined @@ -48,29 +66,19 @@ export const enforceSizeLimits = ( return { requestHeaders: undefined, responseHeaders: undefined }; } - // Step 2: Truncate values + // Step 2: Truncate values to MAX_HEADER_VALUE_BYTES (UTF-8 bytes) const reqEntries = requestHeaders ? Object.entries(requestHeaders).map(([name, value]): [ string, string - ] => [ - name, - value.length > MAX_HEADER_VALUE_BYTES - ? value.slice(0, MAX_HEADER_VALUE_BYTES) - : value - ]) + ] => [name, truncateToBytes(value, MAX_HEADER_VALUE_BYTES)]) : []; const resEntries = responseHeaders ? Object.entries(responseHeaders).map(([name, value]): [ string, string - ] => [ - name, - value.length > MAX_HEADER_VALUE_BYTES - ? value.slice(0, MAX_HEADER_VALUE_BYTES) - : value - ]) + ] => [name, truncateToBytes(value, MAX_HEADER_VALUE_BYTES)]) : []; // Step 3: Count cap -- request headers take priority @@ -85,25 +93,31 @@ export const enforceSizeLimits = ( cappedResEntries = resEntries.slice(0, remainingSlots); } - // Step 4: Total size budget -- drop from end (response first, then request) + // Step 4: Total byte budget -- drop from end (response first, then request) let totalBytes = 0; for (const [name, value] of cappedReqEntries) { - totalBytes += name.length + value.length; + totalBytes += utf8ByteLength(name) + utf8ByteLength(value); } for (const [name, value] of cappedResEntries) { - totalBytes += name.length + value.length; + totalBytes += utf8ByteLength(name) + utf8ByteLength(value); } if (totalBytes > MAX_TOTAL_BYTES) { // Drop response headers from end first while (cappedResEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { - const [name, value] = cappedResEntries.pop()!; - totalBytes -= name.length + value.length; + const entry = cappedResEntries.pop(); + if (entry === undefined) { + break; + } + totalBytes -= utf8ByteLength(entry[0]) + utf8ByteLength(entry[1]); } // Then drop request headers from end while (cappedReqEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { - const [name, value] = cappedReqEntries.pop()!; - totalBytes -= name.length + value.length; + const entry = cappedReqEntries.pop(); + if (entry === undefined) { + break; + } + totalBytes -= utf8ByteLength(entry[0]) + utf8ByteLength(entry[1]); } } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 34c781523..9da0064f6 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -172,7 +172,7 @@ const proxyOnReadyStateChange = ( if (xhrProxy.readyState === xhrType.DONE) { // If aborted/network-error (status 0), discard any accumulated request headers if (xhrProxy.status === 0) { - xhrProxy._datadog_xhr.capturedRequestHeaders = undefined; + requestContext.capturedRequestHeaders = undefined; } // Capture response headers (only if capture enabled and not aborted/network-error) @@ -189,7 +189,7 @@ const proxyOnReadyStateChange = ( // Discard accumulated request headers on aborted/network-error requests (status 0) if (xhrProxy.status === 0) { - xhrProxy._datadog_xhr.capturedRequestHeaders = undefined; + requestContext.capturedRequestHeaders = undefined; } // Filter accumulated request headers by mode now that URL is final @@ -320,7 +320,7 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { }); if (processedHeader.type === 'send') { - originalXhrSetRequestHeader.apply(this, [ + const result = originalXhrSetRequestHeader.apply(this, [ processedHeader.header, processedHeader.value ]); @@ -335,6 +335,7 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { value ); } + return result; } }; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 78a620f3d..379696457 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -2373,7 +2373,7 @@ describe('XHRProxy', () => { // THEN - Application successfully read the response expect(applicationReadResponse).toBe(responseBody); - const parsedByApp = JSON.parse(applicationReadResponse!); + const parsedByApp = JSON.parse(applicationReadResponse ?? ''); expect(parsedByApp.errors).toHaveLength(1); expect(parsedByApp.errors[0].message).toBe('User not found'); @@ -2587,7 +2587,7 @@ describe('XHRProxy', () => { // THEN - Application got complete response with all error details expect(applicationReadResponse).toBe(responseBody); - const parsedByApp = JSON.parse(applicationReadResponse!); + const parsedByApp = JSON.parse(applicationReadResponse ?? ''); expect(parsedByApp.errors).toHaveLength(2); expect(parsedByApp.errors[0].extensions.details).toBe( 'filtered' diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts index 540c68455..a5657ba3c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts @@ -6,6 +6,7 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types/SdkVerbosity'; +import { utf8ByteLength } from '../../../../../utils/stringUtils'; import { DD_RUM_ACCOUNT_ID_TAG, DD_RUM_SESSION_ID_TAG, @@ -138,40 +139,6 @@ export function formatBaggageHeader(entries: Set): string | null { return headerValue; } -/** - * Returns the number of bytes needed to encode a string in UTF-8. - * - * Useful as a lightweight alternative to Node.js `Buffer.byteLength()` - * for older environments that do not support it. - * - * @param text - The input string. - * @returns The UTF-8 byte length of the string. - */ -function utf8ByteLength(text: string): number { - let byteLength = text.length; - for (let i = text.length - 1; i >= 0; i--) { - const code = text.charCodeAt(i); - - // 2-byte characters (U+0080 to U+07FF) - if (code > 0x7f && code <= 0x7ff) { - byteLength++; - } - // 3-byte characters (U+0800 to U+FFFF) - else if (code > 0x7ff && code <= 0xffff) { - byteLength += 2; - } - - // Handle surrogate pairs (4-byte characters, e.g. emoji) - // These characters already count as 2 in the initial length - // Encountering the low surrogate already accounts for the full 4 bytes - // (2 from the initial length + 2 for the 3-byte characters logic above) - if (code >= 0xdc00 && code <= 0xdfff) { - i--; // prevents double counting the same character by skipping high surrogate - } - } - return byteLength; -} - /** * Returns a set of valid baggage header characters. */ diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 685053cde..92eecf523 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import { version as reactNativeVersion } from 'react-native/package.json'; import { NativeModules } from 'react-native'; import { InitializationMode } from '../../../config/types'; @@ -65,7 +66,7 @@ describe('DatadogProvider', () => { expect(receivedConfiguration).toMatchInlineSnapshot(` DdSdkNativeConfiguration { "additionalConfiguration": { - "_dd.react_native_version": "0.76.9", + "_dd.react_native_version": "${reactNativeVersion}", "_dd.source": "react-native", }, "attributeEncoders": [], @@ -74,7 +75,7 @@ describe('DatadogProvider', () => { "clientToken": "fakeToken", "configurationForTelemetry": { "initializationType": "SYNC", - "reactNativeVersion": "0.76.9", + "reactNativeVersion": "${reactNativeVersion}", "reactVersion": "18.3.1", "trackErrors": true, "trackInteractions": true, diff --git a/packages/core/src/utils/stringUtils.ts b/packages/core/src/utils/stringUtils.ts new file mode 100644 index 000000000..92376c3bf --- /dev/null +++ b/packages/core/src/utils/stringUtils.ts @@ -0,0 +1,29 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Returns the number of bytes needed to encode a string in UTF-8. + * + * Works in all JS environments (Hermes, JSC, Node.js) — no TextEncoder or + * deprecated APIs required. + */ +export function utf8ByteLength(str: string): number { + let bytes = 0; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code < 0xd800 || code >= 0xe000) { + bytes += 3; + } else { + i += 1; // surrogate pair — consume low surrogate, emit 4 bytes + bytes += 4; + } + } + return bytes; +} From 48cf474eef5543df4249268cb3b1884dfd08f4a0 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Jun 2026 16:56:56 +0200 Subject: [PATCH 08/26] chore(header-capture): add debug internal logs --- .../headerCapture/captureHeaders.ts | 91 +++++++++++++++++-- .../headerCapture/enforceSizeLimits.ts | 49 +++++++++- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts index b1447405c..c59e81426 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -4,6 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../../../InternalLog'; +import { SdkVerbosity } from '../../../../config/types'; + import { isHeaderAllowed } from './isHeaderAllowed'; import { parseResponseHeaders } from './parseResponseHeaders'; import type { @@ -11,6 +14,8 @@ import type { CompiledHeaderCaptureRule } from './types'; +const TAG = '[DatadogRUM][HeaderCapture]'; + /** * Default response header names captured in 'defaults' mode. * Lowercased for direct comparison with parseResponseHeaders output. @@ -220,6 +225,15 @@ export const accumulateRequestHeader = ( } } accumulator[headerName] = headerValue; + InternalLog.log( + `${TAG} Accumulated request header "${headerName}"`, + SdkVerbosity.DEBUG + ); + } else { + InternalLog.log( + `${TAG} Request header "${headerName}" blocked by security filter`, + SdkVerbosity.DEBUG + ); } }; @@ -252,22 +266,37 @@ export const captureResponseHeaders = ( } const allHeaders = parseResponseHeaders(rawHeaders); - if (Object.keys(allHeaders).length === 0) { + const allCount = Object.keys(allHeaders).length; + if (allCount === 0) { + InternalLog.log( + `${TAG} No response headers to capture for ${url}`, + SdkVerbosity.DEBUG + ); return undefined; } // Security filter first — defense in depth const securityFiltered: Record = {}; - let hasSecurityFiltered = false; + const blocked: string[] = []; for (const [name, value] of Object.entries(allHeaders)) { if (isHeaderAllowed(name)) { securityFiltered[name] = value; - hasSecurityFiltered = true; + } else { + blocked.push(name); } } - if (!hasSecurityFiltered) { + if (blocked.length > 0) { + InternalLog.log( + `${TAG} Response headers blocked by security filter for ${url}: [${blocked.join( + ', ' + )}]`, + SdkVerbosity.DEBUG + ); + } + + if (Object.keys(securityFiltered).length === 0) { return undefined; } @@ -279,11 +308,37 @@ export const captureResponseHeaders = ( ); if (allowedHeaders.size === 0) { + InternalLog.log( + `${TAG} No rules matched ${url} for response headers — nothing captured`, + SdkVerbosity.DEBUG + ); return undefined; } const casingMap = mergeCasingMaps(config, url, 'responseHeaderCasing'); - return filterWithCasing(securityFiltered, allowedHeaders, casingMap); + const result = filterWithCasing( + securityFiltered, + allowedHeaders, + casingMap + ); + + if (result === undefined) { + InternalLog.log( + `${TAG} No response headers survived rule filtering for ${url}`, + SdkVerbosity.DEBUG + ); + } else { + InternalLog.log( + `${TAG} Captured ${ + Object.keys(result).length + }/${allCount} response headers for ${url}: [${Object.keys( + result + ).join(', ')}]`, + SdkVerbosity.DEBUG + ); + } + + return result; }; /** @@ -312,6 +367,8 @@ export const filterRequestHeadersByMode = ( return undefined; } + const accumulatedCount = Object.keys(headers).length; + // Merge all matching rules' request header sets (union) const allowedHeaders = mergeMatchingHeaderNames( config, @@ -320,9 +377,31 @@ export const filterRequestHeadersByMode = ( ); if (allowedHeaders.size === 0) { + InternalLog.log( + `${TAG} No rules matched ${url} for request headers — nothing captured`, + SdkVerbosity.DEBUG + ); return undefined; } const casingMap = mergeCasingMaps(config, url, 'requestHeaderCasing'); - return filterWithCasing(headers, allowedHeaders, casingMap); + const result = filterWithCasing(headers, allowedHeaders, casingMap); + + if (result === undefined) { + InternalLog.log( + `${TAG} No request headers survived rule filtering for ${url} (accumulated: ${accumulatedCount})`, + SdkVerbosity.DEBUG + ); + } else { + InternalLog.log( + `${TAG} Captured ${ + Object.keys(result).length + }/${accumulatedCount} request headers for ${url}: [${Object.keys( + result + ).join(', ')}]`, + SdkVerbosity.DEBUG + ); + } + + return result; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts index 1fd01bbc1..a1127f1a1 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -4,9 +4,14 @@ * Copyright 2016-Present Datadog, Inc. */ -/** Maximum UTF-8 byte length for any single header value. */ +import { InternalLog } from '../../../../InternalLog'; +import { SdkVerbosity } from '../../../../config/types'; import { utf8ByteLength } from '../../../../utils/stringUtils'; +const TAG = '[DatadogRUM][HeaderCapture]'; + +/** Maximum UTF-8 byte length for any single header value. */ + export const MAX_HEADER_VALUE_BYTES = 128; /** Maximum combined header count (request + response). */ @@ -71,14 +76,36 @@ export const enforceSizeLimits = ( ? Object.entries(requestHeaders).map(([name, value]): [ string, string - ] => [name, truncateToBytes(value, MAX_HEADER_VALUE_BYTES)]) + ] => { + const truncated = truncateToBytes(value, MAX_HEADER_VALUE_BYTES); + if (truncated.length < value.length) { + InternalLog.log( + `${TAG} Truncated request header "${name}" value from ${utf8ByteLength( + value + )} to ${MAX_HEADER_VALUE_BYTES} bytes`, + SdkVerbosity.DEBUG + ); + } + return [name, truncated]; + }) : []; const resEntries = responseHeaders ? Object.entries(responseHeaders).map(([name, value]): [ string, string - ] => [name, truncateToBytes(value, MAX_HEADER_VALUE_BYTES)]) + ] => { + const truncated = truncateToBytes(value, MAX_HEADER_VALUE_BYTES); + if (truncated.length < value.length) { + InternalLog.log( + `${TAG} Truncated response header "${name}" value from ${utf8ByteLength( + value + )} to ${MAX_HEADER_VALUE_BYTES} bytes`, + SdkVerbosity.DEBUG + ); + } + return [name, truncated]; + }) : []; // Step 3: Count cap -- request headers take priority @@ -91,6 +118,10 @@ export const enforceSizeLimits = ( cappedReqEntries = reqEntries.slice(0, reqSlots); const remainingSlots = MAX_HEADER_COUNT - cappedReqEntries.length; cappedResEntries = resEntries.slice(0, remainingSlots); + InternalLog.log( + `${TAG} Header count cap hit (${totalCount} > ${MAX_HEADER_COUNT}): keeping ${cappedReqEntries.length} request + ${cappedResEntries.length} response headers`, + SdkVerbosity.DEBUG + ); } // Step 4: Total byte budget -- drop from end (response first, then request) @@ -103,12 +134,20 @@ export const enforceSizeLimits = ( } if (totalBytes > MAX_TOTAL_BYTES) { + InternalLog.log( + `${TAG} Total byte budget exceeded (${totalBytes} > ${MAX_TOTAL_BYTES}): dropping headers from end`, + SdkVerbosity.DEBUG + ); // Drop response headers from end first while (cappedResEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { const entry = cappedResEntries.pop(); if (entry === undefined) { break; } + InternalLog.log( + `${TAG} Dropped response header "${entry[0]}" to fit byte budget`, + SdkVerbosity.DEBUG + ); totalBytes -= utf8ByteLength(entry[0]) + utf8ByteLength(entry[1]); } // Then drop request headers from end @@ -117,6 +156,10 @@ export const enforceSizeLimits = ( if (entry === undefined) { break; } + InternalLog.log( + `${TAG} Dropped request header "${entry[0]}" to fit byte budget`, + SdkVerbosity.DEBUG + ); totalBytes -= utf8ByteLength(entry[0]) + utf8ByteLength(entry[1]); } } From 922660405a4db7e848cea3f836f5894ffd6e4e98 Mon Sep 17 00:00:00 2001 From: Marco Saia <164892343+marco-saia-datadog@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:03:23 +0200 Subject: [PATCH 09/26] chore(pr, minor): remove duplicate logic and comment improvement --- .../resourceTracking/headerCapture/enforceSizeLimits.ts | 2 +- .../resourceTracking/requestProxy/XHRProxy/XHRProxy.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts index a1127f1a1..287fbee9b 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -57,7 +57,7 @@ const toUndefinedIfEmpty = ( * 4. Drop headers from end until total bytes <= MAX_TOTAL_BYTES * 5. Return undefined (not {}) for empty records * - * Pure function -- no side effects, no logging. + * Pure function with no stateful side effects (emits DEBUG logs when limits are hit). */ export const enforceSizeLimits = ( requestHeaders: Record | undefined, diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 9da0064f6..2b95f103f 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -170,11 +170,6 @@ const proxyOnReadyStateChange = ( xhrProxy.onreadystatechange = function onreadystatechange() { if (xhrProxy.readyState === xhrType.DONE) { - // If aborted/network-error (status 0), discard any accumulated request headers - if (xhrProxy.status === 0) { - requestContext.capturedRequestHeaders = undefined; - } - // Capture response headers (only if capture enabled and not aborted/network-error) if ( requestContext.headerCaptureConfig != null && From b7149cddbaa178e54a520724c2ba895263bc679c Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Jun 2026 17:11:51 +0200 Subject: [PATCH 10/26] fix(pr, header-capture): lowered complexity of enforceSizeLimits --- .../headerCapture/enforceSizeLimits.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts index 287fbee9b..c3c89a4c4 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -22,18 +22,38 @@ export const MAX_TOTAL_BYTES = 2048; /** * Truncates a string so its UTF-8 byte length does not exceed maxBytes. - * Slices by character and re-checks, because multi-byte chars mean - * character count < byte count. + * Single forward pass — O(n). Mirrors the surrogate-pair logic in utf8ByteLength. */ const truncateToBytes = (str: string, maxBytes: number): string => { - if (utf8ByteLength(str) <= maxBytes) { - return str; - } - let result = str; - while (result.length > 0 && utf8ByteLength(result) > maxBytes) { - result = result.slice(0, result.length - 1); + let bytes = 0; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + let charBytes: number; + let advance = 1; + if (code < 0x80) { + charBytes = 1; + } else if (code < 0x800) { + charBytes = 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = i + 1 < str.length ? str.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next <= 0xdfff) { + charBytes = 4; + advance = 2; + } else { + charBytes = 3; // lone high surrogate + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + charBytes = 3; // lone low surrogate + } else { + charBytes = 3; + } + if (bytes + charBytes > maxBytes) { + return str.slice(0, i); + } + bytes += charBytes; + i += advance - 1; // -1 because the for-loop will also increment i } - return result; + return str; }; /** From 1efd1bd75016710c58207f639d867b878b9af010 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Jun 2026 17:11:57 +0200 Subject: [PATCH 11/26] fix(pr, header-capture): improved accuracy of utf8ByteLength --- .../src/utils/__tests__/stringUtils.test.ts | 90 +++++++++++++++++++ packages/core/src/utils/stringUtils.ts | 20 ++++- 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/utils/__tests__/stringUtils.test.ts diff --git a/packages/core/src/utils/__tests__/stringUtils.test.ts b/packages/core/src/utils/__tests__/stringUtils.test.ts new file mode 100644 index 000000000..d7dd3eac9 --- /dev/null +++ b/packages/core/src/utils/__tests__/stringUtils.test.ts @@ -0,0 +1,90 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { utf8ByteLength } from '../stringUtils'; + +describe('utf8ByteLength', () => { + describe('ASCII strings (1 byte per char)', () => { + it('returns 0 for empty string', () => { + expect(utf8ByteLength('')).toBe(0); + }); + + it('counts single ASCII character as 1 byte', () => { + expect(utf8ByteLength('a')).toBe(1); + }); + + it('counts ASCII string correctly', () => { + expect(utf8ByteLength('hello')).toBe(5); + }); + }); + + describe('2-byte characters (U+0080 – U+07FF)', () => { + it('counts a 2-byte character correctly', () => { + // 'é' is U+00E9 → 2 bytes in UTF-8 + expect(utf8ByteLength('é')).toBe(2); + }); + + it('counts mixed ASCII and 2-byte characters', () => { + // 'café' → c(1) + a(1) + f(1) + é(2) = 5 + expect(utf8ByteLength('café')).toBe(5); + }); + }); + + describe('3-byte characters (U+0800 – U+FFFF, excluding surrogates)', () => { + it('counts a 3-byte CJK character correctly', () => { + // '中' is U+4E2D → 3 bytes in UTF-8 + expect(utf8ByteLength('中')).toBe(3); + }); + + it('counts a string of 3-byte characters correctly', () => { + // '中文' → 3 + 3 = 6 + expect(utf8ByteLength('中文')).toBe(6); + }); + }); + + describe('4-byte characters — valid surrogate pairs', () => { + it('counts a valid surrogate pair (emoji) as 4 bytes', () => { + // '😀' is U+1F600, encoded as surrogate pair 😀 + expect(utf8ByteLength('😀')).toBe(4); + }); + + it('counts multiple emoji correctly', () => { + expect(utf8ByteLength('😀😀')).toBe(8); + }); + + it('counts mixed ASCII and emoji', () => { + // 'a😀b' → 1 + 4 + 1 = 6 + expect(utf8ByteLength('a😀b')).toBe(6); + }); + }); + + describe('unpaired surrogates — malformed strings', () => { + it('counts a lone high surrogate as 3 bytes', () => { + // \uD83D is a high surrogate not followed by a low surrogate + const loneHigh = '\uD83D'; + expect(utf8ByteLength(loneHigh)).toBe(3); + }); + + it('counts a lone low surrogate as 3 bytes', () => { + // \uDE00 is a low surrogate not preceded by a high surrogate + const loneLow = '\uDE00'; + expect(utf8ByteLength(loneLow)).toBe(3); + }); + + it('does not under-count when a high surrogate is followed by a non-low-surrogate', () => { + // \uD83D (high surrogate) + 'a' (not a low surrogate) + // Should be 3 + 1 = 4, not 4 (treating as pair) + const highPlusAscii = '\uD83Da'; + expect(utf8ByteLength(highPlusAscii)).toBe(4); + }); + + it('counts two consecutive lone surrogates as 6 bytes', () => { + // \uD83D\uD83D — two high surrogates, each lone + const twoHighSurrogates = '\uD83D\uD83D'; + expect(utf8ByteLength(twoHighSurrogates)).toBe(6); + }); + }); +}); diff --git a/packages/core/src/utils/stringUtils.ts b/packages/core/src/utils/stringUtils.ts index 92376c3bf..ed16b6160 100644 --- a/packages/core/src/utils/stringUtils.ts +++ b/packages/core/src/utils/stringUtils.ts @@ -9,6 +9,10 @@ * * Works in all JS environments (Hermes, JSC, Node.js) — no TextEncoder or * deprecated APIs required. + * + * Handles surrogate pairs correctly: a valid high+low pair counts as 4 bytes. + * Lone surrogates (unpaired high or low) each count as 3 bytes, matching the + * WTF-8 / WHATWG encoding convention for malformed strings. */ export function utf8ByteLength(str: string): number { let bytes = 0; @@ -18,11 +22,19 @@ export function utf8ByteLength(str: string): number { bytes += 1; } else if (code < 0x800) { bytes += 2; - } else if (code < 0xd800 || code >= 0xe000) { - bytes += 3; + } else if (code >= 0xd800 && code <= 0xdbff) { + // High surrogate — check for a following low surrogate + const next = i + 1 < str.length ? str.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next <= 0xdfff) { + i += 1; // consume the low surrogate + bytes += 4; + } else { + bytes += 3; // lone high surrogate + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes += 3; // lone low surrogate } else { - i += 1; // surrogate pair — consume low surrogate, emit 4 bytes - bytes += 4; + bytes += 3; } } return bytes; From d904f21e0b8d2d772f37067518cec10f1c208c85 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Jun 2026 19:21:08 +0200 Subject: [PATCH 12/26] feat(pr, header-capture): minor improvement to null check --- .../resourceTracking/headerCapture/compileHeaderConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index d5136e546..b2174a547 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -247,7 +247,7 @@ const compileRules = ( export const compileHeaderCaptureConfig = ( config: 'defaults' | HeaderCaptureRule[] | undefined ): CompiledHeaderCaptureConfig => { - if (config === undefined) { + if (config == null) { return null; } From 3c60eef9a636c95938db711a4967018c2631bd2a Mon Sep 17 00:00:00 2001 From: Marco Saia <164892343+marco-saia-datadog@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:29:44 +0200 Subject: [PATCH 13/26] Minor null check improvement Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/core/src/DdSdkReactNative.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 1c69e3036..394c02433 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -555,7 +555,7 @@ export class DdSdkReactNative { }); } - if (!trackResources && headerCaptureRules !== undefined) { + if (!trackResources && headerCaptureRules != null) { InternalLog.log( 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', SdkVerbosity.WARN From e1f97c2aceae3c9b63eda86de3dfb71bb5a7fc67 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Wed, 8 Jul 2026 10:35:24 +0200 Subject: [PATCH 14/26] feat(pr, header-capture): trackResourceHeaders parameter --- packages/core/src/DdSdkReactNative.tsx | 14 +- .../src/__tests__/DdSdkReactNative.test.tsx | 125 ++++++++++++++++++ .../DdSdkReactNativeConfiguration.test.ts | 9 +- .../__tests__/FileBasedConfiguration.test.ts | 9 +- .../async/AutoInstrumentationConfiguration.ts | 7 +- .../src/config/features/RumConfiguration.ts | 6 +- .../config/features/RumConfiguration.type.ts | 19 ++- .../resourceTracking/headerCapture/README.md | 21 ++- .../__tests__/initialization.test.tsx | 3 +- 9 files changed, 191 insertions(+), 22 deletions(-) diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 394c02433..8f08e5ca4 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -518,8 +518,12 @@ export class DdSdkReactNative { const resourceTraceSampleRate = configuration.rumConfiguration?.resourceTraceSampleRate || RUM_DEFAULTS.resourceTraceSampleRate; + const trackResourceHeaders = + configuration.rumConfiguration?.trackResourceHeaders || + RUM_DEFAULTS.trackResourceHeaders; const headerCaptureRules = - configuration.rumConfiguration?.headerCaptureRules; + configuration.rumConfiguration?.headerCaptureRules || + RUM_DEFAULTS.headerCaptureRules; const logEventMapper = configuration.logsConfiguration?.logEventMapper; const errorEventMapper = configuration.rumConfiguration?.errorEventMapper; @@ -555,9 +559,9 @@ export class DdSdkReactNative { }); } - if (!trackResources && headerCaptureRules != null) { + if (trackResourceHeaders && !trackResources) { InternalLog.log( - 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', + 'trackResourceHeaders is set but trackResources is false. Header capture will be disabled.', SdkVerbosity.WARN ); } @@ -567,7 +571,9 @@ export class DdSdkReactNative { resourceTraceSampleRate, firstPartyHosts, trackFetchResources, - headerCaptureRules + headerCaptureRules: trackResourceHeaders + ? headerCaptureRules + : undefined }); } diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 8dc090fa7..af72dfad7 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -8,6 +8,7 @@ import { version as reactNativeVersion } from 'react-native/package.json'; import { NativeModules } from 'react-native'; import { DdSdkReactNative } from '../DdSdkReactNative'; +import { InternalLog } from '../InternalLog'; import type { DdSdkNativeConfiguration } from '../config/features/CoreConfigurationNative'; import { CoreConfiguration } from '../config/features/CoreConfiguration'; import { LogsConfiguration } from '../config/features/LogsConfiguration'; @@ -818,6 +819,130 @@ describe('DdSdkReactNative', () => { }); }); + it('enables header capture with the default headerCaptureRules when trackResourceHeaders is true', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + true + ); + configuration.rumConfiguration.trackResourceHeaders = true; + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( + expect.objectContaining({ + headerCaptureRules: 'defaults' + }) + ); + }); + + it('uses the custom headerCaptureRules when trackResourceHeaders is true', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + true + ); + configuration.rumConfiguration.trackResourceHeaders = true; + configuration.rumConfiguration.headerCaptureRules = [ + { type: 'matchHeaders', headers: ['x-request-id'] } + ]; + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( + expect.objectContaining({ + headerCaptureRules: [ + { type: 'matchHeaders', headers: ['x-request-id'] } + ] + }) + ); + }); + + it('ignores headerCaptureRules when trackResourceHeaders is false', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + true + ); + configuration.rumConfiguration.trackResourceHeaders = false; + configuration.rumConfiguration.headerCaptureRules = [ + { type: 'matchHeaders', headers: ['x-request-id'] } + ]; + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( + expect.objectContaining({ + headerCaptureRules: undefined + }) + ); + }); + + it('logs a warning and disables header capture when trackResourceHeaders is true but trackResources is false', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + false + ); + configuration.rumConfiguration.trackResourceHeaders = true; + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).not.toHaveBeenCalled(); + expect(InternalLog.log).toHaveBeenCalledWith( + 'trackResourceHeaders is set but trackResources is false. Header capture will be disabled.', + SdkVerbosity.WARN + ); + }); + it('enables error tracking feature when initialize { error tracking config enabled }', async () => { // GIVEN const fakeAppId = '1'; diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 5f6285767..1bf9509a0 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -61,7 +61,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": undefined, + "headerCaptureRules": "defaults", "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -81,6 +81,7 @@ describe('DdSdkReactNativeConfiguration', () => { "trackInteractions": false, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, + "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, @@ -213,7 +214,7 @@ describe('DdSdkReactNativeConfiguration', () => { ], }, ], - "headerCaptureRules": undefined, + "headerCaptureRules": "defaults", "initialResourceThreshold": 0.123, "longTaskThresholdMs": 567, "nativeCrashReportEnabled": true, @@ -235,6 +236,7 @@ describe('DdSdkReactNativeConfiguration', () => { "trackInteractions": true, "trackMemoryWarnings": true, "trackNonFatalAnrs": true, + "trackResourceHeaders": false, "trackResources": true, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, @@ -322,7 +324,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": undefined, + "headerCaptureRules": "defaults", "initialResourceThreshold": 0, "longTaskThresholdMs": false, "nativeCrashReportEnabled": false, @@ -342,6 +344,7 @@ describe('DdSdkReactNativeConfiguration', () => { "trackInteractions": false, "trackMemoryWarnings": false, "trackNonFatalAnrs": false, + "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": false, diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index 223b91dad..a519f6114 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -55,7 +55,7 @@ describe('FileBasedConfiguration', () => { ], }, ], - "headerCaptureRules": undefined, + "headerCaptureRules": "defaults", "initialResourceThreshold": 456, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": true, @@ -77,6 +77,7 @@ describe('FileBasedConfiguration', () => { "trackInteractions": true, "trackMemoryWarnings": false, "trackNonFatalAnrs": true, + "trackResourceHeaders": false, "trackResources": true, "trackWatchdogTerminations": true, "useAccessibilityLabel": false, @@ -173,7 +174,7 @@ describe('FileBasedConfiguration', () => { ], }, ], - "headerCaptureRules": undefined, + "headerCaptureRules": "defaults", "initialResourceThreshold": undefined, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": false, @@ -193,6 +194,7 @@ describe('FileBasedConfiguration', () => { "trackInteractions": true, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, + "trackResourceHeaders": false, "trackResources": true, "trackWatchdogTerminations": false, "useAccessibilityLabel": false, @@ -241,7 +243,7 @@ describe('FileBasedConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": undefined, + "headerCaptureRules": "defaults", "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -261,6 +263,7 @@ describe('FileBasedConfiguration', () => { "trackInteractions": false, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, + "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, diff --git a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts index ee636a460..32911a612 100644 --- a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts +++ b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts @@ -27,6 +27,7 @@ export type AutoInstrumentationConfiguration = { readonly actionNameAttribute?: string; readonly resourceTraceSampleRate?: number; readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; + readonly trackResourceHeaders?: boolean; readonly nativeCrashReportEnabled?: boolean; readonly nativeLongTaskThresholdMs?: number; readonly nativeViewTracking?: boolean; @@ -64,6 +65,7 @@ export type AutoInstrumentationParameters = { readonly resourceEventMapper: ResourceEventMapper | null; readonly firstPartyHosts: FirstPartyHost[]; readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; + readonly trackResourceHeaders: boolean; }; readonly logsConfiguration?: { readonly logEventMapper: LogEventMapper | null; @@ -132,7 +134,10 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( firstPartyHosts: features.rumConfiguration.firstPartyHosts || RUM_DEFAULTS.getFirstPartyHosts(), - headerCaptureRules: features.rumConfiguration.headerCaptureRules + headerCaptureRules: features.rumConfiguration.headerCaptureRules, + trackResourceHeaders: + features.rumConfiguration.trackResourceHeaders ?? + RUM_DEFAULTS.trackResourceHeaders }, logsConfiguration: { logEventMapper: diff --git a/packages/core/src/config/features/RumConfiguration.ts b/packages/core/src/config/features/RumConfiguration.ts index 41d389626..b530d214b 100644 --- a/packages/core/src/config/features/RumConfiguration.ts +++ b/packages/core/src/config/features/RumConfiguration.ts @@ -41,10 +41,11 @@ const DEFAULTS = { trackInteractions: false, trackMemoryWarnings: true, trackNonFatalAnrs: undefined, - headerCaptureRules: undefined as + headerCaptureRules: 'defaults' as | 'defaults' | HeaderCaptureRule[] | undefined, + trackResourceHeaders: false, trackResources: false, trackWatchdogTerminations: false, useAccessibilityLabel: true, @@ -135,6 +136,9 @@ export class RumConfiguration implements RumConfigurationType { public headerCaptureRules: 'defaults' | HeaderCaptureRule[] | undefined = DEFAULTS.headerCaptureRules; + // Track Resource Headers enabled + public trackResourceHeaders: boolean = DEFAULTS.trackResourceHeaders; + // Track Watchdog Terminations enabled public trackWatchdogTerminations: boolean = DEFAULTS.trackWatchdogTerminations; diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index e7bc26df8..52a2a7261 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -183,16 +183,15 @@ export interface RumConfigurationOptions { /** * Controls which resource headers the SDK captures on network requests. + * Only takes effect when {@link RumConfigurationOptions.trackResourceHeaders} + * is `true`. * - * - **Omitted** (default): No headers are captured. - * - `'defaults'`: Captures a predefined set of caching and content headers - * across all URLs. + * - `'defaults'` (default): Captures a predefined set of caching and content + * headers across all URLs. * - `HeaderCaptureRule[]`: An array of composable rules. When multiple rules * match a URL their header sets are merged. If at least one scoped rule * (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs` * or `['*']`) are ignored for that URL. - * - * Requires `trackResources: true` to take effect. */ headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; @@ -279,6 +278,16 @@ export interface RumConfigurationOptions { */ trackNonFatalAnrs?: boolean; + /** + * Enables capturing of HTTP request and response headers on RUM resource + * events. Defaults to `false`. + * + * When enabled, the headers that get captured are controlled by + * {@link RumConfigurationOptions.headerCaptureRules} (defaults to + * `'defaults'`). Requires `trackResources: true` to take effect. + */ + trackResourceHeaders?: boolean; + /** * Enables tracking of app termination by the iOS watchdog. */ diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index c9cb088db..919ba6ba2 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -6,7 +6,7 @@ Header capture is **disabled by default** and requires `trackResources: true`. ## Configuration -Set `headerCaptureRules` in your RUM configuration: +Set `trackResourceHeaders: true` in your RUM configuration: ```typescript import { DatadogProvider } from '@datadog/mobile-react-native'; @@ -14,17 +14,30 @@ import { DatadogProvider } from '@datadog/mobile-react-native'; DatadogProvider.initialize({ // ... trackResources: true, + trackResourceHeaders: true, +}); +``` + +By default this captures the predefined set of caching and content headers (`'defaults'`). To customize which headers get captured, set `headerCaptureRules`: + +```typescript +DatadogProvider.initialize({ + // ... + trackResources: true, + trackResourceHeaders: true, headerCaptureRules: 'defaults', }); ``` +`headerCaptureRules` only takes effect when `trackResourceHeaders` is `true`. + ### Modes | Value | What gets captured | |---|---| -| Omitted (default) | Nothing | -| `'defaults'` | A predefined set of caching and content headers across all URLs | -| `HeaderCaptureRule[]` | Custom rules — see below | +| `trackResourceHeaders: false` (default) | Nothing | +| `trackResourceHeaders: true` + `headerCaptureRules: 'defaults'` (default) | A predefined set of caching and content headers across all URLs | +| `trackResourceHeaders: true` + `headerCaptureRules: HeaderCaptureRule[]` | Custom rules — see below | ### Default headers diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 92eecf523..bd7424660 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -92,7 +92,7 @@ describe('DatadogProvider', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": undefined, + "headerCaptureRules": "defaults", "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -112,6 +112,7 @@ describe('DatadogProvider', () => { "trackInteractions": true, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, + "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, From 519dbcf25f563713850f43bf6d89ba0f4f3f3afd Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 09:33:45 +0100 Subject: [PATCH 15/26] feat(header-capture): use a single headerCaptureRules option Drop the trackResourceHeaders flag and go back to headerCaptureRules as the only switch: omitted (default) disables header capture, 'defaults' or a HeaderCaptureRule[] enables it. Also points the size-limits integration test at the relocated common/ResourceReporter after the rebase on develop. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/DdSdkReactNative.tsx | 14 +++------ .../src/__tests__/DdSdkReactNative.test.tsx | 29 ++++++++----------- .../DdSdkReactNativeConfiguration.test.ts | 9 ++---- .../__tests__/FileBasedConfiguration.test.ts | 9 ++---- .../async/AutoInstrumentationConfiguration.ts | 7 +---- .../src/config/features/RumConfiguration.ts | 6 +--- .../config/features/RumConfiguration.type.ts | 19 ++++-------- .../resourceTracking/headerCapture/README.md | 21 +++----------- .../enforceSizeLimits.integration.test.ts | 2 +- .../__tests__/initialization.test.tsx | 3 +- 10 files changed, 35 insertions(+), 84 deletions(-) diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 8f08e5ca4..394c02433 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -518,12 +518,8 @@ export class DdSdkReactNative { const resourceTraceSampleRate = configuration.rumConfiguration?.resourceTraceSampleRate || RUM_DEFAULTS.resourceTraceSampleRate; - const trackResourceHeaders = - configuration.rumConfiguration?.trackResourceHeaders || - RUM_DEFAULTS.trackResourceHeaders; const headerCaptureRules = - configuration.rumConfiguration?.headerCaptureRules || - RUM_DEFAULTS.headerCaptureRules; + configuration.rumConfiguration?.headerCaptureRules; const logEventMapper = configuration.logsConfiguration?.logEventMapper; const errorEventMapper = configuration.rumConfiguration?.errorEventMapper; @@ -559,9 +555,9 @@ export class DdSdkReactNative { }); } - if (trackResourceHeaders && !trackResources) { + if (!trackResources && headerCaptureRules != null) { InternalLog.log( - 'trackResourceHeaders is set but trackResources is false. Header capture will be disabled.', + 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', SdkVerbosity.WARN ); } @@ -571,9 +567,7 @@ export class DdSdkReactNative { resourceTraceSampleRate, firstPartyHosts, trackFetchResources, - headerCaptureRules: trackResourceHeaders - ? headerCaptureRules - : undefined + headerCaptureRules }); } diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index af72dfad7..d11c28526 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -819,7 +819,7 @@ describe('DdSdkReactNative', () => { }); }); - it('enables header capture with the default headerCaptureRules when trackResourceHeaders is true', async () => { + it('does not enable header capture when headerCaptureRules is omitted', async () => { // GIVEN const fakeAppId = '1'; const fakeClientToken = '2'; @@ -833,7 +833,6 @@ describe('DdSdkReactNative', () => { false, true ); - configuration.rumConfiguration.trackResourceHeaders = true; NativeModules.DdSdk.initialize.mockResolvedValue(null); @@ -843,12 +842,12 @@ describe('DdSdkReactNative', () => { // THEN expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( expect.objectContaining({ - headerCaptureRules: 'defaults' + headerCaptureRules: undefined }) ); }); - it('uses the custom headerCaptureRules when trackResourceHeaders is true', async () => { + it('enables header capture with the default headerCaptureRules preset', async () => { // GIVEN const fakeAppId = '1'; const fakeClientToken = '2'; @@ -862,10 +861,7 @@ describe('DdSdkReactNative', () => { false, true ); - configuration.rumConfiguration.trackResourceHeaders = true; - configuration.rumConfiguration.headerCaptureRules = [ - { type: 'matchHeaders', headers: ['x-request-id'] } - ]; + configuration.rumConfiguration.headerCaptureRules = 'defaults'; NativeModules.DdSdk.initialize.mockResolvedValue(null); @@ -875,14 +871,12 @@ describe('DdSdkReactNative', () => { // THEN expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( expect.objectContaining({ - headerCaptureRules: [ - { type: 'matchHeaders', headers: ['x-request-id'] } - ] + headerCaptureRules: 'defaults' }) ); }); - it('ignores headerCaptureRules when trackResourceHeaders is false', async () => { + it('enables header capture with custom headerCaptureRules', async () => { // GIVEN const fakeAppId = '1'; const fakeClientToken = '2'; @@ -896,7 +890,6 @@ describe('DdSdkReactNative', () => { false, true ); - configuration.rumConfiguration.trackResourceHeaders = false; configuration.rumConfiguration.headerCaptureRules = [ { type: 'matchHeaders', headers: ['x-request-id'] } ]; @@ -909,12 +902,14 @@ describe('DdSdkReactNative', () => { // THEN expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( expect.objectContaining({ - headerCaptureRules: undefined + headerCaptureRules: [ + { type: 'matchHeaders', headers: ['x-request-id'] } + ] }) ); }); - it('logs a warning and disables header capture when trackResourceHeaders is true but trackResources is false', async () => { + it('logs a warning and disables header capture when headerCaptureRules is set but trackResources is false', async () => { // GIVEN const fakeAppId = '1'; const fakeClientToken = '2'; @@ -928,7 +923,7 @@ describe('DdSdkReactNative', () => { false, false ); - configuration.rumConfiguration.trackResourceHeaders = true; + configuration.rumConfiguration.headerCaptureRules = 'defaults'; NativeModules.DdSdk.initialize.mockResolvedValue(null); @@ -938,7 +933,7 @@ describe('DdSdkReactNative', () => { // THEN expect(DdRumResourceTracking.startTracking).not.toHaveBeenCalled(); expect(InternalLog.log).toHaveBeenCalledWith( - 'trackResourceHeaders is set but trackResources is false. Header capture will be disabled.', + 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', SdkVerbosity.WARN ); }); diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 1bf9509a0..5f6285767 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -61,7 +61,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": "defaults", + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -81,7 +81,6 @@ describe('DdSdkReactNativeConfiguration', () => { "trackInteractions": false, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, - "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, @@ -214,7 +213,7 @@ describe('DdSdkReactNativeConfiguration', () => { ], }, ], - "headerCaptureRules": "defaults", + "headerCaptureRules": undefined, "initialResourceThreshold": 0.123, "longTaskThresholdMs": 567, "nativeCrashReportEnabled": true, @@ -236,7 +235,6 @@ describe('DdSdkReactNativeConfiguration', () => { "trackInteractions": true, "trackMemoryWarnings": true, "trackNonFatalAnrs": true, - "trackResourceHeaders": false, "trackResources": true, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, @@ -324,7 +322,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": "defaults", + "headerCaptureRules": undefined, "initialResourceThreshold": 0, "longTaskThresholdMs": false, "nativeCrashReportEnabled": false, @@ -344,7 +342,6 @@ describe('DdSdkReactNativeConfiguration', () => { "trackInteractions": false, "trackMemoryWarnings": false, "trackNonFatalAnrs": false, - "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": false, diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index a519f6114..223b91dad 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -55,7 +55,7 @@ describe('FileBasedConfiguration', () => { ], }, ], - "headerCaptureRules": "defaults", + "headerCaptureRules": undefined, "initialResourceThreshold": 456, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": true, @@ -77,7 +77,6 @@ describe('FileBasedConfiguration', () => { "trackInteractions": true, "trackMemoryWarnings": false, "trackNonFatalAnrs": true, - "trackResourceHeaders": false, "trackResources": true, "trackWatchdogTerminations": true, "useAccessibilityLabel": false, @@ -174,7 +173,7 @@ describe('FileBasedConfiguration', () => { ], }, ], - "headerCaptureRules": "defaults", + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": false, @@ -194,7 +193,6 @@ describe('FileBasedConfiguration', () => { "trackInteractions": true, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, - "trackResourceHeaders": false, "trackResources": true, "trackWatchdogTerminations": false, "useAccessibilityLabel": false, @@ -243,7 +241,7 @@ describe('FileBasedConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": "defaults", + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -263,7 +261,6 @@ describe('FileBasedConfiguration', () => { "trackInteractions": false, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, - "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, diff --git a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts index 32911a612..ee636a460 100644 --- a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts +++ b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts @@ -27,7 +27,6 @@ export type AutoInstrumentationConfiguration = { readonly actionNameAttribute?: string; readonly resourceTraceSampleRate?: number; readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; - readonly trackResourceHeaders?: boolean; readonly nativeCrashReportEnabled?: boolean; readonly nativeLongTaskThresholdMs?: number; readonly nativeViewTracking?: boolean; @@ -65,7 +64,6 @@ export type AutoInstrumentationParameters = { readonly resourceEventMapper: ResourceEventMapper | null; readonly firstPartyHosts: FirstPartyHost[]; readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; - readonly trackResourceHeaders: boolean; }; readonly logsConfiguration?: { readonly logEventMapper: LogEventMapper | null; @@ -134,10 +132,7 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( firstPartyHosts: features.rumConfiguration.firstPartyHosts || RUM_DEFAULTS.getFirstPartyHosts(), - headerCaptureRules: features.rumConfiguration.headerCaptureRules, - trackResourceHeaders: - features.rumConfiguration.trackResourceHeaders ?? - RUM_DEFAULTS.trackResourceHeaders + headerCaptureRules: features.rumConfiguration.headerCaptureRules }, logsConfiguration: { logEventMapper: diff --git a/packages/core/src/config/features/RumConfiguration.ts b/packages/core/src/config/features/RumConfiguration.ts index b530d214b..41d389626 100644 --- a/packages/core/src/config/features/RumConfiguration.ts +++ b/packages/core/src/config/features/RumConfiguration.ts @@ -41,11 +41,10 @@ const DEFAULTS = { trackInteractions: false, trackMemoryWarnings: true, trackNonFatalAnrs: undefined, - headerCaptureRules: 'defaults' as + headerCaptureRules: undefined as | 'defaults' | HeaderCaptureRule[] | undefined, - trackResourceHeaders: false, trackResources: false, trackWatchdogTerminations: false, useAccessibilityLabel: true, @@ -136,9 +135,6 @@ export class RumConfiguration implements RumConfigurationType { public headerCaptureRules: 'defaults' | HeaderCaptureRule[] | undefined = DEFAULTS.headerCaptureRules; - // Track Resource Headers enabled - public trackResourceHeaders: boolean = DEFAULTS.trackResourceHeaders; - // Track Watchdog Terminations enabled public trackWatchdogTerminations: boolean = DEFAULTS.trackWatchdogTerminations; diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index 52a2a7261..e7bc26df8 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -183,15 +183,16 @@ export interface RumConfigurationOptions { /** * Controls which resource headers the SDK captures on network requests. - * Only takes effect when {@link RumConfigurationOptions.trackResourceHeaders} - * is `true`. * - * - `'defaults'` (default): Captures a predefined set of caching and content - * headers across all URLs. + * - **Omitted** (default): No headers are captured. + * - `'defaults'`: Captures a predefined set of caching and content headers + * across all URLs. * - `HeaderCaptureRule[]`: An array of composable rules. When multiple rules * match a URL their header sets are merged. If at least one scoped rule * (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs` * or `['*']`) are ignored for that URL. + * + * Requires `trackResources: true` to take effect. */ headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; @@ -278,16 +279,6 @@ export interface RumConfigurationOptions { */ trackNonFatalAnrs?: boolean; - /** - * Enables capturing of HTTP request and response headers on RUM resource - * events. Defaults to `false`. - * - * When enabled, the headers that get captured are controlled by - * {@link RumConfigurationOptions.headerCaptureRules} (defaults to - * `'defaults'`). Requires `trackResources: true` to take effect. - */ - trackResourceHeaders?: boolean; - /** * Enables tracking of app termination by the iOS watchdog. */ diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index 919ba6ba2..c9cb088db 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -6,7 +6,7 @@ Header capture is **disabled by default** and requires `trackResources: true`. ## Configuration -Set `trackResourceHeaders: true` in your RUM configuration: +Set `headerCaptureRules` in your RUM configuration: ```typescript import { DatadogProvider } from '@datadog/mobile-react-native'; @@ -14,30 +14,17 @@ import { DatadogProvider } from '@datadog/mobile-react-native'; DatadogProvider.initialize({ // ... trackResources: true, - trackResourceHeaders: true, -}); -``` - -By default this captures the predefined set of caching and content headers (`'defaults'`). To customize which headers get captured, set `headerCaptureRules`: - -```typescript -DatadogProvider.initialize({ - // ... - trackResources: true, - trackResourceHeaders: true, headerCaptureRules: 'defaults', }); ``` -`headerCaptureRules` only takes effect when `trackResourceHeaders` is `true`. - ### Modes | Value | What gets captured | |---|---| -| `trackResourceHeaders: false` (default) | Nothing | -| `trackResourceHeaders: true` + `headerCaptureRules: 'defaults'` (default) | A predefined set of caching and content headers across all URLs | -| `trackResourceHeaders: true` + `headerCaptureRules: HeaderCaptureRule[]` | Custom rules — see below | +| Omitted (default) | Nothing | +| `'defaults'` | A predefined set of caching and content headers across all URLs | +| `HeaderCaptureRule[]` | Custom rules — see below | ### Default headers diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts index 9413ee631..22464bd08 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts @@ -9,8 +9,8 @@ import { NativeModules } from 'react-native'; import { BufferSingleton } from '../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; import { XMLHttpRequestMock } from '../../__tests__/__utils__/XMLHttpRequestMock'; import { firstPartyHostsRegexMapBuilder } from '../../distributedTracing/firstPartyHosts'; -import { ResourceReporter } from '../../requestProxy/common/ResourceReporter'; import { XHRProxy } from '../../requestProxy/XHRProxy/XHRProxy'; +import { ResourceReporter } from '../../requestProxy/common/ResourceReporter'; import { DEFAULT_REQUEST_HEADERS, DEFAULT_RESPONSE_HEADERS, diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index bd7424660..92eecf523 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -92,7 +92,7 @@ describe('DatadogProvider', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], - "headerCaptureRules": "defaults", + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -112,7 +112,6 @@ describe('DatadogProvider', () => { "trackInteractions": true, "trackMemoryWarnings": true, "trackNonFatalAnrs": undefined, - "trackResourceHeaders": false, "trackResources": false, "trackWatchdogTerminations": false, "useAccessibilityLabel": true, From 3837e829c6652911a7672431b121b2c76b0b86ab Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 10:05:44 +0100 Subject: [PATCH 16/26] feat(header-capture): capture headers on Fetch resources Move per-request header capture state into the shared RequestContext and add a common helper used by both the XHR and Fetch proxies. The Fetch proxy now records user-provided request headers and reads response headers from the Response, so header capture keeps working when trackFetchResources is enabled (including XHR-backed Fetch, where the XHR proxy skips the inner request). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/README.md | 3 +- .../__tests__/DdRumResourceTracking.test.ts | 46 ++++ .../resourceTracking/headerCapture/README.md | 11 + .../headerCapture/captureHeaders.ts | 23 +- .../requestProxy/FetchProxy/FetchProxy.ts | 19 +- .../FetchProxy/__tests__/FetchProxy.test.ts | 230 ++++++++++++++++++ .../requestProxy/XHRProxy/XHRProxy.ts | 83 ++----- .../XHRProxy/__tests__/XHRProxy.test.ts | 46 +++- .../requestProxy/common/RequestContext.ts | 7 +- .../common/__tests__/headerCapture.test.ts | 132 ++++++++++ .../requestProxy/common/headerCapture.ts | 107 ++++++++ 11 files changed, 621 insertions(+), 86 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts diff --git a/packages/core/README.md b/packages/core/README.md index 6b61e7510..e8a246014 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -83,7 +83,8 @@ export default function App() { `trackFetchResources` tracks calls made through the Expo-installed global `fetch`. Direct imports retained from `expo/fetch` are not intercepted. XHR -tracking remains enabled for clients such as axios. +tracking remains enabled for clients such as axios. Resource header capture +(`headerCaptureRules`) applies to both XHR and Fetch resources. GraphQL error extraction (via `DatadogLink({ trackErrors: true })`) for requests made through `expo/fetch` requires Expo SDK 56 or later, as older diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts index d578fbb57..013213a26 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts @@ -193,6 +193,52 @@ describe('DdRumResourceTracking', () => { ); }); + it('captures headers once on an XHR-backed Fetch when header capture is enabled', async () => { + const fetchResponse = ({ + status: 200, + headers: new Headers({ 'cache-control': 'max-age=60' }) + } as unknown) as Response; + const xhrBackedFetch = jest.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const xhr = new XMLHttpRequestMock(); + xhr.open(init?.method ?? 'GET', String(input)); + new Headers(init?.headers).forEach((value, header) => { + xhr.setRequestHeader(header, value); + }); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'cache-control: max-age=60\r\n' + ); + xhr.complete(200, 'ok'); + return Promise.resolve(fetchResponse); + } + ) as jest.MockedFunction; + global.fetch = xhrBackedFetch; + + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [], + trackFetchResources: true, + headerCaptureRules: 'defaults' + }); + + await global.fetch('https://api.example.com/fetch', { + headers: { 'Content-Type': 'application/json' } + }); + await flushPromises(); + + expect(DdRum.stopResource).toHaveBeenCalledTimes(1); + const [, , kind, , context] = DdRum.stopResource.mock.calls[0]; + expect(kind).toBe('fetch'); + expect(context['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + expect(context['_dd.response_headers']).toEqual({ + 'Cache-Control': 'max-age=60' + }); + }); + describe('updateTrackingContext', () => { beforeEach(() => { DdRumResourceTracking.stopTracking(); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index c9cb088db..d36621ff6 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -82,6 +82,17 @@ Header names use RFC Title-Case for default headers (`Cache-Control`, `ETag`, When nothing is captured these attributes are omitted entirely. +## Supported clients + +Headers are captured for every request tracked by the SDK: + +- **XHR** (`trackResources: true`) — covers React Native's built-in `fetch`, axios and any other XMLHttpRequest-based client. +- **Global Fetch** (`trackFetchResources: true`) — covers the Expo-installed global `fetch`. When the global `fetch` is backed by XHR, headers are reported once, on the Fetch resource. Direct imports retained from `expo/fetch` are not intercepted. + +Fetch `Headers` lowercase header names, so on Fetch resources custom request headers use the casing from your rule's `headers` array rather than the casing passed to `fetch`. + +Requests that fail without a response (aborted, network error) never report headers. + ## Security Two filters are always enforced, regardless of configuration: diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts index c59e81426..571861336 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -265,7 +265,28 @@ export const captureResponseHeaders = ( return undefined; } - const allHeaders = parseResponseHeaders(rawHeaders); + return filterResponseHeaders(parseResponseHeaders(rawHeaders), url, config); +}; + +/** + * Filters already-parsed response headers (lowercased names) the same way + * as {@link captureResponseHeaders}. Used by request proxies whose response + * headers are not exposed as a raw string (e.g. Fetch `Headers`). + * + * @param allHeaders - Response headers keyed by lowercased name. + * @param url - The full request URL for rule matching. + * @param config - The compiled header capture configuration. + * @returns Filtered response headers, or undefined. + */ +export const filterResponseHeaders = ( + allHeaders: Record, + url: string, + config: CompiledHeaderCaptureConfig +): Record | undefined => { + if (config === null) { + return undefined; + } + const allCount = Object.keys(allHeaders).length; if (allCount === 0) { InternalLog.log( diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts index 670717881..fc39f84ba 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -12,6 +12,11 @@ import { callOriginalFetch } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext, getResourceKey } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; +import { + fetchHeadersToRecord, + finalizeCapturedHeaders, + recordRequestHeader +} from '../common/headerCapture'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { getInstrumentationHeaders, @@ -202,7 +207,9 @@ const buildHeaders = ({ originalHeaders.forEach(({ header }) => headers.delete(header)); originalHeaders.forEach(({ header, value }) => { - applyHeader({ headers, context, header, value }); + if (applyHeader({ headers, context, header, value })) { + recordRequestHeader(context.headerCapture, header, value); + } }); getInstrumentationHeaders(context).forEach(({ header, value }) => { applyHeader({ headers, context, header, value }); @@ -221,11 +228,13 @@ const applyHeader = ({ context: RequestContext; header: string; value: string; -}) => { +}): boolean => { const processedHeader = processRequestHeader({ context, header, value }); if (processedHeader.type === 'send') { headers.set(processedHeader.header, processedHeader.value); + return true; } + return false; }; const reportFetch = async ({ @@ -267,6 +276,12 @@ const reportFetch = async ({ }, graphqlAttributes: context.graphql, tracingAttributes: context.tracingAttributes, + ...finalizeCapturedHeaders({ + state: context.headerCapture, + url: context.url, + statusCode: response.status, + responseHeaders: () => fetchHeadersToRecord(response.headers) + }), response: { statusCode: response.status, size: getResponseSize(response) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts index 10e37fe16..2ee33b094 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts @@ -16,6 +16,7 @@ import { DATADOG_GRAPH_QL_ERROR_HEADER, DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER } from '../../../graphql/graphqlHeaders'; +import { compileHeaderCaptureConfig } from '../../../headerCapture/compileHeaderConfig'; import type { ResourceReporter } from '../../common/ResourceReporter'; import type { RUMResource } from '../../interfaces/RumResource'; import { FetchProxy } from '../FetchProxy'; @@ -419,4 +420,233 @@ describe('FetchProxy', () => { expect(resource.graphqlAttributes?.errors).toBeUndefined(); }); }); + + describe('header capture', () => { + const getHeaderCaptureOptions = ( + rules: Parameters[0] + ) => ({ + ...getOptions(), + headerCaptureConfig: compileHeaderCaptureConfig(rules) + }); + + it('does not report headers when header capture is disabled', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue( + createResponse({ headers: { 'cache-control': 'no-cache' } }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + await fetchGlobal.fetch('https://api.example.com/users', { + headers: { 'Content-Type': 'application/json' } + }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toBeUndefined(); + expect(resource.capturedResponseHeaders).toBeUndefined(); + }); + + it('captures default request and response headers', async () => { + const originalFetch = jest.fn().mockResolvedValue( + createResponse({ + headers: { + 'cache-control': 'max-age=60', + etag: '"abc"', + 'set-cookie': 'session=secret', + 'x-custom': 'value' + } + }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch('https://api.example.com/users', { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer secret', + 'X-Custom': 'value' + } + }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + expect(resource.capturedResponseHeaders).toEqual({ + 'Cache-Control': 'max-age=60', + ETag: '"abc"' + }); + }); + + it('does not capture headers injected by the SDK', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue(createResponse()) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart( + getHeaderCaptureOptions([ + { + type: 'matchRequestHeaders', + headers: [ + SAMPLING_PRIORITY_HEADER_KEY, + PARENT_ID_HEADER_KEY, + TRACKED_BY_HEADER_KEY, + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, + 'x-request-id' + ] + } + ]) + ); + + await fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query', + 'X-Request-Id': 'abc' + } + }); + await flushPromises(); + + // Fetch Headers lowercase names, so the casing comes from the rule + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'x-request-id': 'abc' + }); + }); + + it('captures headers set on a Request-like input', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue( + createResponse({ headers: { 'x-cache': 'HIT' } }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch(({ + url: 'https://api.example.com/users', + method: 'GET', + headers: new HeadersMock({ 'cache-control': 'no-cache' }) + } as unknown) as Request); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'Cache-Control': 'no-cache' + }); + expect(resource.capturedResponseHeaders).toEqual({ + 'X-Cache': 'HIT' + }); + }); + + it('applies URL-scoped rules', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue( + createResponse({ headers: { 'x-request-id': 'abc' } }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart( + getHeaderCaptureOptions([ + { + type: 'matchResponseHeaders', + headers: ['x-request-id'], + forURLs: ['api.example.com'] + } + ]) + ); + + await fetchGlobal.fetch('https://api.example.com/users'); + await fetchGlobal.fetch('https://other.example.org/users'); + await flushPromises(); + + const [matched, unmatched] = reportResource.mock.calls.map( + call => call[0] as RUMResource + ); + expect(matched.capturedResponseHeaders).toEqual({ + 'x-request-id': 'abc' + }); + expect(unmatched.capturedResponseHeaders).toBeUndefined(); + }); + + it('does not report headers for failed requests', async () => { + const originalFetch = jest + .fn() + .mockRejectedValue( + new Error('network failed') + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await expect( + fetchGlobal.fetch('https://api.example.com/users', { + headers: { 'Content-Type': 'application/json' } + }) + ).rejects.toThrow('network failed'); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toBeUndefined(); + expect(resource.capturedResponseHeaders).toBeUndefined(); + }); + + it('still reports request headers when response headers cannot be iterated', async () => { + const response = ({ + status: 200, + headers: { get: () => null }, + clone() { + return response; + } + } as unknown) as Response; + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch('https://api.example.com/users', { + headers: { 'Content-Type': 'application/json' } + }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + expect(resource.capturedResponseHeaders).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 2b95f103f..a55593137 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -7,18 +7,15 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types'; import { extractGraphQLErrors } from '../../graphql/graphqlUtils'; -import { - accumulateRequestHeader, - captureResponseHeaders, - filterRequestHeadersByMode -} from '../../headerCapture/captureHeaders'; -import { enforceSizeLimits } from '../../headerCapture/enforceSizeLimits'; -import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; -import { isDatadogCustomHeader } from '../../headers'; +import { parseResponseHeaders } from '../../headerCapture/parseResponseHeaders'; import { isRunningWithinFetchProxy } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext, getResourceKey } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; +import { + finalizeCapturedHeaders, + recordRequestHeader +} from '../common/headerCapture'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { getInstrumentationHeaders, @@ -38,9 +35,6 @@ interface DdRumXhr extends XMLHttpRequest { interface DdRumXhrContext extends RequestContext { reported: boolean; - headerCaptureConfig: CompiledHeaderCaptureConfig; - capturedRequestHeaders: Record | undefined; - capturedResponseHeaders?: Record; } interface XHRProxyProviders { @@ -124,11 +118,7 @@ const proxyOpen = ( // start time is tracked by the `send` method this._datadog_xhr = { ...createRequestContext({ method, url, options: context }), - reported: false, - headerCaptureConfig: context.headerCaptureConfig, - capturedRequestHeaders: - context.headerCaptureConfig != null ? {} : undefined, - capturedResponseHeaders: undefined + reported: false }; // eslint-disable-next-line prefer-rest-params return originalXhrOpen.apply(this, arguments as any); @@ -170,46 +160,6 @@ const proxyOnReadyStateChange = ( xhrProxy.onreadystatechange = function onreadystatechange() { if (xhrProxy.readyState === xhrType.DONE) { - // Capture response headers (only if capture enabled and not aborted/network-error) - if ( - requestContext.headerCaptureConfig != null && - xhrProxy.status !== 0 - ) { - requestContext.capturedResponseHeaders = captureResponseHeaders( - xhrProxy.getAllResponseHeaders(), - requestContext.url, - requestContext.headerCaptureConfig - ); - } - - // Discard accumulated request headers on aborted/network-error requests (status 0) - if (xhrProxy.status === 0) { - requestContext.capturedRequestHeaders = undefined; - } - - // Filter accumulated request headers by mode now that URL is final - if (requestContext.capturedRequestHeaders !== undefined) { - requestContext.capturedRequestHeaders = filterRequestHeadersByMode( - requestContext.capturedRequestHeaders, - requestContext.url, - requestContext.headerCaptureConfig - ); - } - - // Enforce size limits after security/mode filtering, before reporting - if ( - requestContext.capturedRequestHeaders !== undefined || - requestContext.capturedResponseHeaders !== undefined - ) { - const limited = enforceSizeLimits( - requestContext.capturedRequestHeaders, - requestContext.capturedResponseHeaders - ); - requestContext.capturedRequestHeaders = limited.requestHeaders; - requestContext.capturedResponseHeaders = - limited.responseHeaders; - } - if (!requestContext.reported) { reportXhr(xhrProxy, providers.resourceReporter).catch(error => { const errorData = getErrorData(error); @@ -278,8 +228,13 @@ const reportXhr = async ( }, graphqlAttributes: context.graphql, tracingAttributes: context.tracingAttributes, - capturedRequestHeaders: context.capturedRequestHeaders, - capturedResponseHeaders: context.capturedResponseHeaders, + ...finalizeCapturedHeaders({ + state: context.headerCapture, + url: context.url, + statusCode: xhrProxy.status, + responseHeaders: () => + parseResponseHeaders(xhrProxy.getAllResponseHeaders()) + }), response: { statusCode: xhrProxy.status, size: responseSize @@ -319,17 +274,7 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { processedHeader.header, processedHeader.value ]); - // Accumulate for header capture (only user-set, non-Datadog headers) - if ( - this._datadog_xhr.capturedRequestHeaders !== undefined && - !isDatadogCustomHeader(header.toLowerCase()) - ) { - accumulateRequestHeader( - this._datadog_xhr.capturedRequestHeaders, - header, - value - ); - } + recordRequestHeader(this._datadog_xhr.headerCapture, header, value); return result; } }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 379696457..73dfef0b7 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -2618,7 +2618,7 @@ describe('XHRProxy', () => { }); describe('header capture', () => { - it('disabled mode: does not accumulate request headers', async () => { + it('disabled mode: does not report request headers', async () => { // GIVEN const method = 'GET'; const url = 'https://api.example.com/v2/user'; @@ -2639,7 +2639,7 @@ describe('XHRProxy', () => { // THEN expect( - (xhr as any)._datadog_xhr.capturedRequestHeaders + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] ).toBeUndefined(); }); @@ -2666,7 +2666,9 @@ describe('XHRProxy', () => { // THEN expect( - (xhr as any)._datadog_xhr.capturedResponseHeaders + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] ).toBeUndefined(); }); @@ -2691,7 +2693,9 @@ describe('XHRProxy', () => { await flushPromises(); // THEN — content-type is in defaults, x-custom is not - expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + expect( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ 'Content-Type': 'application/json' }); }); @@ -2718,7 +2722,11 @@ describe('XHRProxy', () => { await flushPromises(); // THEN — content-type, cache-control in defaults; x-custom not - expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + expect( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).toEqual({ 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' }); @@ -2745,9 +2753,11 @@ describe('XHRProxy', () => { xhr.complete(0, undefined); await flushPromises(); - // THEN — status 0 means response capture is skipped + // THEN — status 0 means header capture is skipped expect( - (xhr as any)._datadog_xhr.capturedResponseHeaders + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] ).toBeUndefined(); }); @@ -2789,16 +2799,22 @@ describe('XHRProxy', () => { await flushPromises(); // THEN — only rule-matched headers appear - expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + expect( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ 'X-Request-Id': 'abc' }); - expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + expect( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).toEqual({ etag: 'abc123', 'x-cache': 'HIT' }); }); - it('security: sensitive request headers are never accumulated', async () => { + it('security: sensitive request headers are never reported', async () => { // GIVEN const method = 'GET'; const url = 'https://api.example.com/v2/user'; @@ -2819,7 +2835,9 @@ describe('XHRProxy', () => { await flushPromises(); // THEN — authorization blocked by security filter - expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + expect( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ 'Content-Type': 'application/json' }); }); @@ -2861,7 +2879,11 @@ describe('XHRProxy', () => { await flushPromises(); // THEN — set-cookie blocked by isSensitiveHeader; only cache-control appears - expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + expect( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).toEqual({ 'cache-control': 'no-cache' }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts index c292c7f8f..9a8bdb675 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts @@ -16,6 +16,9 @@ import { URLHostParser } from '../XHRProxy/URLHostParser'; import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import type { DdRumResourceGraphqlAttributes } from '../interfaces/RumResource'; +import type { HeaderCaptureState } from './headerCapture'; +import { createHeaderCaptureState } from './headerCapture'; + export interface RequestContext { resourceId: number; graphql: DdRumResourceGraphqlAttributes & { @@ -26,6 +29,7 @@ export interface RequestContext { timer: Timer; tracingAttributes: DdRumResourceTracingAttributes; baggageHeaderEntries: Set; + headerCapture: HeaderCaptureState; } // Disambiguates resources sharing the same start millisecond and method, @@ -55,7 +59,8 @@ export const createRequestContext = ({ userId: getCachedUserId(), accountId: getCachedAccountId() }), - baggageHeaderEntries: new Set() + baggageHeaderEntries: new Set(), + headerCapture: createHeaderCaptureState(options.headerCaptureConfig) }; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts new file mode 100644 index 000000000..125ce8b55 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts @@ -0,0 +1,132 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER } from '../../../graphql/graphqlHeaders'; +import { compileHeaderCaptureConfig } from '../../../headerCapture/compileHeaderConfig'; +import { + createHeaderCaptureState, + fetchHeadersToRecord, + finalizeCapturedHeaders, + recordRequestHeader +} from '../headerCapture'; + +jest.mock('../../../../../../InternalLog'); + +const URL = 'https://api.example.com/users'; + +describe('headerCapture', () => { + describe('createHeaderCaptureState', () => { + it('does not accumulate request headers when capture is disabled', () => { + const state = createHeaderCaptureState(null); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + expect(state.requestHeaders).toBeUndefined(); + }); + + it('treats a missing config as disabled', () => { + expect(createHeaderCaptureState(undefined)).toEqual({ + config: null, + requestHeaders: undefined + }); + }); + }); + + describe('recordRequestHeader', () => { + it('ignores Datadog internal and sensitive headers', () => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader( + state, + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, + 'query' + ); + recordRequestHeader(state, 'Authorization', 'Bearer secret'); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + expect(state.requestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + }); + }); + + describe('finalizeCapturedHeaders', () => { + it('returns no headers when capture is disabled', () => { + const responseHeaders = jest.fn(() => ({ etag: 'abc' })); + + expect( + finalizeCapturedHeaders({ + state: createHeaderCaptureState(null), + url: URL, + statusCode: 200, + responseHeaders + }) + ).toEqual({}); + expect(responseHeaders).not.toHaveBeenCalled(); + }); + + it('returns no headers for status 0 requests', () => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + expect( + finalizeCapturedHeaders({ + state, + url: URL, + statusCode: 0, + responseHeaders: () => ({ etag: 'abc' }) + }) + ).toEqual({}); + }); + + it('filters by rules and enforces size limits', () => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader(state, 'Content-Type', 'application/json'); + recordRequestHeader(state, 'X-Custom', 'value'); + + const result = finalizeCapturedHeaders({ + state, + url: URL, + statusCode: 200, + responseHeaders: () => ({ + etag: 'x'.repeat(500), + 'x-custom': 'value' + }) + }); + + expect(result.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + expect(result.capturedResponseHeaders?.ETag).toHaveLength(128); + expect( + result.capturedResponseHeaders?.['x-custom'] + ).toBeUndefined(); + }); + }); + + describe('fetchHeadersToRecord', () => { + it('lowercases header names', () => { + expect( + fetchHeadersToRecord( + new Headers({ 'Cache-Control': 'no-cache' }) + ) + ).toEqual({ 'cache-control': 'no-cache' }); + }); + + it('returns an empty record when headers cannot be iterated', () => { + expect(fetchHeadersToRecord(undefined)).toEqual({}); + expect( + fetchHeadersToRecord(({ + get: () => null + } as unknown) as Headers) + ).toEqual({}); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts new file mode 100644 index 000000000..31046d1d7 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts @@ -0,0 +1,107 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + accumulateRequestHeader, + filterRequestHeadersByMode, + filterResponseHeaders +} from '../../headerCapture/captureHeaders'; +import { enforceSizeLimits } from '../../headerCapture/enforceSizeLimits'; +import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; +import { isDatadogCustomHeader } from '../../headers'; + +/** + * Per-request header capture state, shared by all request proxies. + */ +export interface HeaderCaptureState { + config: CompiledHeaderCaptureConfig; + /** `undefined` when header capture is disabled. */ + requestHeaders: Record | undefined; +} + +export type CapturedHeaders = { + capturedRequestHeaders?: Record; + capturedResponseHeaders?: Record; +}; + +export const createHeaderCaptureState = ( + config: CompiledHeaderCaptureConfig | undefined +): HeaderCaptureState => ({ + config: config ?? null, + requestHeaders: config != null ? {} : undefined +}); + +/** + * Records a user-set request header. Datadog internal headers are ignored, + * sensitive headers are dropped before being stored. + */ +export const recordRequestHeader = ( + state: HeaderCaptureState, + header: string, + value: string +): void => { + if ( + state.requestHeaders === undefined || + isDatadogCustomHeader(header.toLowerCase()) + ) { + return; + } + accumulateRequestHeader(state.requestHeaders, header, value); +}; + +/** + * Applies rule filtering and size limits to the headers of a completed request. + * Nothing is reported for aborted or network-error requests (status 0). + * + * @param responseHeaders - Response headers keyed by lowercased name. + */ +export const finalizeCapturedHeaders = ({ + state, + url, + statusCode, + responseHeaders +}: { + state: HeaderCaptureState; + url: string; + statusCode: number; + responseHeaders: () => Record; +}): CapturedHeaders => { + if ( + state.config === null || + state.requestHeaders === undefined || + statusCode === 0 + ) { + return {}; + } + + const limited = enforceSizeLimits( + filterRequestHeadersByMode(state.requestHeaders, url, state.config), + filterResponseHeaders(responseHeaders(), url, state.config) + ); + + return { + capturedRequestHeaders: limited.requestHeaders, + capturedResponseHeaders: limited.responseHeaders + }; +}; + +/** + * Converts a Fetch `Headers` object into a record keyed by lowercased name. + * Some Fetch implementations (e.g. older `expo/fetch`) may not implement + * `forEach`, in which case no headers are returned. + */ +export const fetchHeadersToRecord = ( + headers: Headers | null | undefined +): Record => { + const result: Record = {}; + if (!headers || typeof headers.forEach !== 'function') { + return result; + } + headers.forEach((value: string, name: string) => { + result[name.toLowerCase()] = value; + }); + return result; +}; From cfb89d6a048075d2d24665138efa1068c1647663 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 10:07:49 +0100 Subject: [PATCH 17/26] feat(header-capture): support headerCaptureRules in file-based configuration Read headerCaptureRules from the JSON configuration and describe it in datadog-configuration.schema.json ('defaults' or an array of rules). Values are validated when compiled at resource tracking start, same as the programmatic configuration. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/datadog-configuration.schema.json | 119 ++++++++++++++++++ .../core/src/config/FileBasedConfiguration.ts | 3 + .../src/config/FileBasedConfiguration.type.ts | 2 + .../__tests__/FileBasedConfiguration.test.ts | 65 +++++++++- .../configuration-all-fields.json | 8 ++ .../resourceTracking/headerCapture/README.md | 13 ++ 6 files changed, 209 insertions(+), 1 deletion(-) diff --git a/packages/core/datadog-configuration.schema.json b/packages/core/datadog-configuration.schema.json index c06bc5de4..5a7c112b0 100644 --- a/packages/core/datadog-configuration.schema.json +++ b/packages/core/datadog-configuration.schema.json @@ -192,6 +192,125 @@ "description": "The app hang threshold in seconds for non-fatal app hangs on iOS.", "type": "number" }, + "headerCaptureRules": { + "description": "Controls which HTTP request and response headers are captured on RUM resource events. Omit to disable header capture (default). Use `\"defaults\"` to capture a predefined set of caching and content headers on all URLs, or an array of composable rules. Sensitive headers (cookies, authorization, tokens, ...) and SDK tracing headers are never captured. Only takes effect when resource tracking is enabled.", + "oneOf": [ + { + "const": "defaults" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "description": "Captures the predefined set of caching and content headers from requests and responses.", + "type": "object", + "properties": { + "type": { + "const": "defaults" + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Captures the given headers from both requests and responses.", + "type": "object", + "properties": { + "type": { + "const": "matchHeaders" + }, + "headers": { + "description": "Header names to capture. Compared case-insensitively; the casing provided here is used in the reported attributes.", + "type": "array", + "items": { + "type": "string" + } + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "headers" + ], + "additionalProperties": false + }, + { + "description": "Captures the given headers from requests only.", + "type": "object", + "properties": { + "type": { + "const": "matchRequestHeaders" + }, + "headers": { + "description": "Header names to capture. Compared case-insensitively; the casing provided here is used in the reported attributes.", + "type": "array", + "items": { + "type": "string" + } + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "headers" + ], + "additionalProperties": false + }, + { + "description": "Captures the given headers from responses only.", + "type": "object", + "properties": { + "type": { + "const": "matchResponseHeaders" + }, + "headers": { + "description": "Header names to capture. Compared case-insensitively; the casing provided here is used in the reported attributes.", + "type": "array", + "items": { + "type": "string" + } + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "headers" + ], + "additionalProperties": false + } + ] + } + } + ] + }, "initialResourceThreshold": { "description": "The amount of time after a view starts where a Resource should be considered when calculating Time to Network-Settled (TNS).", "type": "number" diff --git a/packages/core/src/config/FileBasedConfiguration.ts b/packages/core/src/config/FileBasedConfiguration.ts index 508261580..5e63dcb45 100644 --- a/packages/core/src/config/FileBasedConfiguration.ts +++ b/packages/core/src/config/FileBasedConfiguration.ts @@ -165,6 +165,9 @@ export const getJSONConfiguration = ( buildFirstPartyHosts( configuration.rumConfiguration.firstPartyHosts ) || RUM_DEFAULTS.getFirstPartyHosts(), + // Validated when compiled at resource tracking start + headerCaptureRules: + configuration.rumConfiguration.headerCaptureRules, useAccessibilityLabel: configuration.rumConfiguration.useAccessibilityLabel, trackInteractions: diff --git a/packages/core/src/config/FileBasedConfiguration.type.ts b/packages/core/src/config/FileBasedConfiguration.type.ts index 1fb8cff33..8e82283eb 100644 --- a/packages/core/src/config/FileBasedConfiguration.type.ts +++ b/packages/core/src/config/FileBasedConfiguration.type.ts @@ -6,6 +6,7 @@ import type { FirstPartyHost } from '../rum/types'; import type { CoreConfigurationOptions } from './features/CoreConfiguration.type'; +import type { HeaderCaptureRule } from './features/RumConfiguration.type'; import type { BatchProcessingLevel, BatchSize, @@ -40,6 +41,7 @@ export interface JsonConfiguration extends CoreConfigurationOptions { actionNameAttribute?: string; appHangThreshold?: number; firstPartyHosts?: FirstPartyHost[]; + headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; initialResourceThreshold?: number; trackMemoryWarnings?: boolean; nativeCrashReportEnabled?: boolean; diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index 223b91dad..f0aa030bc 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -55,7 +55,20 @@ describe('FileBasedConfiguration', () => { ], }, ], - "headerCaptureRules": undefined, + "headerCaptureRules": [ + { + "type": "defaults", + }, + { + "forURLs": [ + "api.example.com", + ], + "headers": [ + "x-request-id", + ], + "type": "matchResponseHeaders", + }, + ], "initialResourceThreshold": 456, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": true, @@ -349,6 +362,56 @@ describe('FileBasedConfiguration', () => { ).toEqual(disallowList); }); + it('leaves headerCaptureRules undefined when not set', () => { + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id' + } + } + }); + expect(config.rumConfiguration?.headerCaptureRules).toBeUndefined(); + }); + + it('passes the headerCaptureRules defaults preset through', () => { + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id', + headerCaptureRules: 'defaults' + } + } + }); + expect(config.rumConfiguration?.headerCaptureRules).toBe( + 'defaults' + ); + }); + + it('passes headerCaptureRules rules through', () => { + const rules = [ + { + type: 'matchRequestHeaders', + headers: ['x-device-id'], + forURLs: ['api.example.com/v2'] + } + ]; + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id', + headerCaptureRules: rules + } + } + }); + expect(config.rumConfiguration?.headerCaptureRules).toEqual(rules); + }); + it('applies event mappers to configuration when provided', () => { const actionEventMapper = () => null; const errorEventMapper = () => null; diff --git a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json index c9f4a434e..d566506e1 100644 --- a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json +++ b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json @@ -25,6 +25,14 @@ ] } ], + "headerCaptureRules": [ + { "type": "defaults" }, + { + "type": "matchResponseHeaders", + "headers": ["x-request-id"], + "forURLs": ["api.example.com"] + } + ], "initialResourceThreshold": 456, "nativeViewTracking": true, "nativeInteractionTracking": true, diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index d36621ff6..35be55998 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -18,6 +18,19 @@ DatadogProvider.initialize({ }); ``` +With `FileBasedConfiguration`, set the same value under `rumConfiguration` in your JSON configuration file: + +```json +{ + "configuration": { + "rumConfiguration": { + "trackResources": true, + "headerCaptureRules": "defaults" + } + } +} +``` + ### Modes | Value | What gets captured | From d841489a27990c631183d240e9aabfd59135588d Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 10:27:47 +0100 Subject: [PATCH 18/26] fix(header-capture): address review feedback - Restrict forURLs subdomain matching to hostname labels so hostnames in the path, query or userinfo of other URLs no longer match - Require path prefixes to end on a path segment boundary - Validate rules at runtime and skip malformed entries with a warning instead of throwing (file-based configuration is not type-checked) - Release the request header store once a request is finalized - Avoid allocating a keys array in accumulateRequestHeader - Fix the README configuration example and the blocklist doc comment Co-Authored-By: Claude Opus 5.5 (1M context) --- .../resourceTracking/headerCapture/README.md | 28 +++- .../__tests__/compileHeaderConfig.test.ts | 121 ++++++++++++++++++ .../headerCapture/captureHeaders.ts | 4 +- .../headerCapture/compileHeaderConfig.ts | 71 +++++++--- .../headerCapture/sensitiveHeaderBlocklist.ts | 4 +- .../common/__tests__/headerCapture.test.ts | 19 +++ .../requestProxy/common/headerCapture.ts | 9 +- 7 files changed, 229 insertions(+), 27 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index 35be55998..9ab3fc7f3 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -9,13 +9,26 @@ Header capture is **disabled by default** and requires `trackResources: true`. Set `headerCaptureRules` in your RUM configuration: ```typescript -import { DatadogProvider } from '@datadog/mobile-react-native'; +import { + DatadogProvider, + DatadogProviderConfiguration, + TrackingConsent +} from '@datadog/mobile-react-native'; + +const config = new DatadogProviderConfiguration( + '', + '', + TrackingConsent.GRANTED, + { + rumConfiguration: { + applicationId: '', + trackResources: true, + headerCaptureRules: 'defaults' + } + } +); -DatadogProvider.initialize({ - // ... - trackResources: true, - headerCaptureRules: 'defaults', -}); +// ... ``` With `FileBasedConfiguration`, set the same value under `rumConfiguration` in your JSON configuration file: @@ -82,6 +95,9 @@ Each rule has a `type`: The optional `forURLs` field scopes a rule to specific URL patterns. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit it (or use `['*']`) to match all URLs. +- A hostname matches itself and its subdomains (`api.example.com` matches `cdn.api.example.com`), but never a hostname that only appears in the path or query of another URL. +- A path prefix matches whole path segments: `api.example.com/v2` matches `/v2` and `/v2/users`, but not `/v22`. + **Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs` / `['*']`) are ignored for that URL. ## Output diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index 0ddb65ad9..5750e419c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -259,6 +259,80 @@ describe('compileHeaderCaptureConfig', () => { ); }); + it('forURLs path prefix ends on a path segment boundary', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect(urlRegex.test('https://api.example.com/v2')).toBe(true); + expect(urlRegex.test('https://api.example.com/v2?page=1')).toBe( + true + ); + expect(urlRegex.test('https://api.example.com/v2#top')).toBe(true); + expect(urlRegex.test('https://api.example.com/v22/users')).toBe( + false + ); + expect(urlRegex.test('https://api.example.com/v2-beta')).toBe( + false + ); + }); + + it('forURLs path prefix ending with a slash matches nested paths', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2/'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect(urlRegex.test('https://api.example.com/v2/users')).toBe( + true + ); + expect(urlRegex.test('https://api.example.com/v2')).toBe(false); + }); + + it('forURLs matches subdomains but not hostnames in the path, query or userinfo', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect(urlRegex.test('https://cdn.api.example.com/users')).toBe( + true + ); + expect(urlRegex.test('https://a.b.api.example.com:8443/x')).toBe( + true + ); + expect( + urlRegex.test( + 'https://unrelated.test/assets.api.example.com/users' + ) + ).toBe(false); + expect( + urlRegex.test( + 'https://unrelated.test/?next=cdn.api.example.com/users' + ) + ).toBe(false); + expect( + urlRegex.test('https://unrelated.test#x.api.example.com') + ).toBe(false); + expect( + urlRegex.test('https://user.api.example.com@unrelated.test/') + ).toBe(false); + expect(urlRegex.test('https://api.example.com.evil.test/')).toBe( + false + ); + expect(urlRegex.test('https://notapi.example.com/')).toBe(false); + }); + it('forURLs: [] (empty) skips the rule with WARN log', () => { const result = compileHeaderCaptureConfig([ { @@ -348,6 +422,53 @@ describe('compileHeaderCaptureConfig', () => { }); }); + describe('malformed rules', () => { + it.each([ + ['null', null], + ['a string', 'defaults'], + ['a rule without type', { headers: ['etag'] }], + ['a rule with an unknown type', { type: 'all', headers: ['etag'] }], + ['a match rule without headers', { type: 'matchHeaders' }], + [ + 'a match rule with non-string headers', + { type: 'matchRequestHeaders', headers: ['etag', 42] } + ], + [ + 'a rule with null forURLs', + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: null + } + ], + [ + 'a rule with non-array forURLs', + { type: 'defaults', forURLs: 'api.example.com' } + ] + ])('skips %s with a WARN log instead of throwing', (_, rule) => { + const result = compileHeaderCaptureConfig([ + rule, + { type: 'matchResponseHeaders', headers: ['etag'] } + ] as any); + + expect(result).toHaveLength(1); + expect(result?.[0].responseHeaderNames).toEqual(new Set(['etag'])); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Skipping invalid rule'), + SdkVerbosity.WARN + ); + }); + + it('returns null when every rule is malformed', () => { + expect( + compileHeaderCaptureConfig([ + null, + { type: 'matchHeaders' } + ] as any) + ).toBeNull(); + }); + }); + describe('casing maps', () => { it('"defaults" shortcut produces canonical casing maps', () => { const result = compileHeaderCaptureConfig('defaults'); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts index 571861336..d5cf767d4 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -218,7 +218,9 @@ export const accumulateRequestHeader = ( if (isHeaderAllowed(lowered)) { // Remove any existing entry with different casing for same header // (last-value-wins: both value and casing from latest call) - for (const existing of Object.keys(accumulator)) { + // for...in avoids allocating a keys array on the request hot path + // eslint-disable-next-line guard-for-in + for (const existing in accumulator) { if (existing.toLowerCase() === lowered) { delete accumulator[existing]; break; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index b2174a547..961f77e5c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -24,8 +24,11 @@ import type { * Builds a URL-matching RegExp from a user-supplied `match` string. * * The regex is applied to full URL strings (e.g. "https://api.example.com/v2/users"). - * Pattern: `^https?://(.*\.)*${escapedHost}(:\d+)?${pathSuffix}` - * where pathSuffix anchors at end-of-host when no path is given, or matches the path prefix. + * Pattern: `^https?://([^/?#@:]+\.)?${escapedHost}(:\d+)?${pathSuffix}` + * Subdomains are restricted to hostname characters, so a hostname appearing in + * the path or query of another URL never matches. The path prefix (if any) must + * end on a path segment boundary: `api.example.com/v2` matches `/v2` and `/v2/users`, + * but not `/v22`. * * Returns null if the pattern is invalid (e.g. contains unescaped regex metacharacters that * result in an invalid expression). @@ -41,14 +44,17 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { const pathPrefix = slashIndex === -1 ? '' : match.slice(slashIndex); const escapedHost = escapeRegExp(hostname); - // If a path prefix is given, escape it and match as prefix. - // If hostname-only, accept /, ?, # or end-of-string as valid URL terminators. - const pathSuffix = pathPrefix ? escapeRegExp(pathPrefix) : '(/|\\?|#|$)'; + // Host and path prefix must be followed by /, ?, # or end-of-string. + // A path prefix ending with '/' is already on a segment boundary. + const boundary = '(/|\\?|#|$)'; + const pathSuffix = pathPrefix.endsWith('/') + ? escapeRegExp(pathPrefix) + : `${escapeRegExp(pathPrefix)}${boundary}`; try { // Regex matches full URL strings: scheme + optional subdomains + host + optional port + path const regex = new RegExp( - `^https?://(.*\\.)*${escapedHost}(:\\d+)?${pathSuffix}`, + `^https?://([^/?#@:]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}`, 'i' ); // Validate the final regex is well-formed @@ -115,17 +121,55 @@ const buildForURLsRegex = (forURLs: string[] | undefined): ForURLsResult => { } }; +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every(item => typeof item === 'string'); + +/** + * Checks the shape of a user-supplied rule at runtime. Rules coming from + * file-based configuration are not type-checked, so malformed entries must + * be skipped instead of throwing during compilation. + */ +const getRuleValidationError = (rule: unknown): string | null => { + if (typeof rule !== 'object' || rule === null) { + return 'rule is not an object'; + } + const { type, headers, forURLs } = rule as Record; + if (forURLs !== undefined && !isStringArray(forURLs)) { + return '`forURLs` must be an array of strings'; + } + switch (type) { + case 'defaults': + return null; + case 'matchHeaders': + case 'matchRequestHeaders': + case 'matchResponseHeaders': + return isStringArray(headers) + ? null + : '`headers` must be an array of strings'; + default: + return `unknown type "${String(type)}"`; + } +}; + /** * Compiles an array of composable `HeaderCaptureRule` entries into compiled rules. * Each rule variant type produces a different set of request/response header names. * Each compiled rule carries an `isScoped` flag from its forURLs resolution. */ -const compileRules = ( - rules: HeaderCaptureRule[] -): CompiledHeaderCaptureRule[] => { +const compileRules = (rules: unknown[]): CompiledHeaderCaptureRule[] => { const compiled: CompiledHeaderCaptureRule[] = []; - for (const rule of rules) { + for (const candidate of rules) { + const validationError = getRuleValidationError(candidate); + if (validationError !== null) { + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Skipping invalid rule (${validationError}).`, + SdkVerbosity.WARN + ); + continue; + } + const rule = candidate as HeaderCaptureRule; + // Build URL regex from forURLs const urlResult = buildForURLsRegex(rule.forURLs); @@ -217,12 +261,7 @@ const compileRules = ( break; default: - InternalLog.log( - `[DatadogRUM] headerCaptureRules: Skipping rule with unknown type "${ - (rule as { type: string }).type - }".`, - SdkVerbosity.WARN - ); + // Unknown types are rejected by getRuleValidationError break; } } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts index c45649143..ce3275450 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts @@ -20,8 +20,8 @@ const SENSITIVE_HEADER_PATTERN = /(?:token|cookie|secret|authorization|password| * Sensitive headers (authorization, cookies, tokens, API keys, etc.) must * never be captured regardless of configuration mode. * - * Silent filtering — no debug log for blocked headers (these are expected - * normal behavior, not warnings). + * Blocked headers are expected behavior, so they never produce WARN or ERROR + * logs; the capture pipeline only reports them at DEBUG verbosity. * * @param headerName - The header name to check (case-insensitive via regex /i flag). * @returns `true` if the header is sensitive and must be blocked. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts index 125ce8b55..5ff3b657c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts @@ -84,6 +84,25 @@ describe('headerCapture', () => { ).toEqual({}); }); + it.each([200, 0])( + 'releases the request header store (status %s)', + statusCode => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + finalizeCapturedHeaders({ + state, + url: URL, + statusCode, + responseHeaders: () => ({}) + }); + + expect(state.requestHeaders).toBeUndefined(); + } + ); + it('filters by rules and enforces size limits', () => { const state = createHeaderCaptureState( compileHeaderCaptureConfig('defaults') diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts index 31046d1d7..46e8bb0b9 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts @@ -55,6 +55,8 @@ export const recordRequestHeader = ( /** * Applies rule filtering and size limits to the headers of a completed request. * Nothing is reported for aborted or network-error requests (status 0). + * The request header store is released, so a retained request object does not + * keep unreported or untruncated header values alive. * * @param responseHeaders - Response headers keyed by lowercased name. */ @@ -69,16 +71,19 @@ export const finalizeCapturedHeaders = ({ statusCode: number; responseHeaders: () => Record; }): CapturedHeaders => { + const requestHeaders = state.requestHeaders; + state.requestHeaders = undefined; + if ( state.config === null || - state.requestHeaders === undefined || + requestHeaders === undefined || statusCode === 0 ) { return {}; } const limited = enforceSizeLimits( - filterRequestHeadersByMode(state.requestHeaders, url, state.config), + filterRequestHeadersByMode(requestHeaders, url, state.config), filterResponseHeaders(responseHeaders(), url, state.config) ); From 1c5da8318ba0965caea7b63b5e8323973e262554 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 10:44:28 +0100 Subject: [PATCH 19/26] fix(header-capture): address second round of review feedback - Snapshot XHR status and captured headers before awaiting GraphQL body extraction, so a reused XHR cannot alter the reported headers - Make RequestProxyOptions.headerCaptureConfig optional again - Match forURLs hosts case-insensitively but path prefixes case-sensitively - Never capture the SDK x-datadog-tracked-by header Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/captureHeaders.test.ts | 34 ++++++++ .../__tests__/compileHeaderConfig.test.ts | 19 +++++ .../__tests__/normalizeUrlForMatching.test.ts | 28 +++++++ .../__tests__/tracingHeaderExclusion.test.ts | 5 +- .../headerCapture/captureHeaders.ts | 11 ++- .../headerCapture/compileHeaderConfig.ts | 12 +-- .../headerCapture/normalizeUrlForMatching.ts | 31 +++++++ .../headerCapture/tracingHeaderExclusion.ts | 8 +- .../requestProxy/XHRProxy/XHRProxy.ts | 20 +++-- .../XHRProxy/__tests__/XHRProxy.test.ts | 82 +++++++++++++++++++ .../requestProxy/interfaces/RequestProxy.ts | 2 +- 11 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts index 43ab426ba..e879a86a7 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts @@ -13,6 +13,7 @@ import { captureResponseHeaders, filterRequestHeadersByMode } from '../captureHeaders'; +import { compileHeaderCaptureConfig } from '../compileHeaderConfig'; import type { CompiledHeaderCaptureConfig } from '../types'; describe('captureHeaders', () => { @@ -222,6 +223,39 @@ describe('captureHeaders', () => { }); describe('scoped rules', () => { + it('matches forURLs hosts case-insensitively and paths case-sensitively', () => { + const config = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/Public'] + } + ]); + const raw = 'etag: abc\r\n'; + + expect( + captureResponseHeaders( + raw, + 'HTTPS://API.EXAMPLE.COM/Public/users', + config + ) + ).toEqual({ etag: 'abc' }); + expect( + captureResponseHeaders( + raw, + 'https://api.example.com/public/users', + config + ) + ).toBeUndefined(); + expect( + filterRequestHeadersByMode( + { etag: 'abc' }, + 'https://api.example.com/public/users', + config + ) + ).toBeUndefined(); + }); + it('captures only headers from matching rule', () => { const config: CompiledHeaderCaptureConfig = [ { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index 5750e419c..c4d45666d 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -13,6 +13,7 @@ import { CANONICAL_RESPONSE_HEADERS } from '../captureHeaders'; import { compileHeaderCaptureConfig } from '../compileHeaderConfig'; +import { normalizeUrlForMatching } from '../normalizeUrlForMatching'; jest.mock('../../../../../InternalLog', () => ({ InternalLog: { @@ -281,6 +282,24 @@ describe('compileHeaderCaptureConfig', () => { ); }); + it('forURLs matches the host case-insensitively and the path case-sensitively', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['API.Example.com/Public'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + const matches = (url: string) => + urlRegex.test(normalizeUrlForMatching(url)); + + expect(matches('https://api.example.com/Public/users')).toBe(true); + expect(matches('HTTPS://API.EXAMPLE.COM/Public/users')).toBe(true); + expect(matches('https://api.example.com/public/users')).toBe(false); + expect(matches('https://api.example.com/PUBLIC/users')).toBe(false); + }); + it('forURLs path prefix ending with a slash matches nested paths', () => { const result = compileHeaderCaptureConfig([ { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts new file mode 100644 index 000000000..1560fc50d --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts @@ -0,0 +1,28 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { normalizeUrlForMatching } from '../normalizeUrlForMatching'; + +describe('normalizeUrlForMatching', () => { + it.each([ + [ + 'HTTPS://API.Example.com/Public/Users?Q=A#Top', + 'https://api.example.com/Public/Users?Q=A#Top' + ], + ['https://API.example.com:8443', 'https://api.example.com:8443'], + ['https://API.example.com?Q=A', 'https://api.example.com?Q=A'], + ['https://API.example.com#Top', 'https://api.example.com#Top'], + ['https://api.example.com/v2', 'https://api.example.com/v2'] + ])('lowercases only scheme and authority of %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + + it('returns URLs without a scheme unchanged', () => { + expect(normalizeUrlForMatching('/Relative/Path')).toBe( + '/Relative/Path' + ); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts index dfa09e5ab..928dfbec3 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts @@ -7,7 +7,7 @@ import { isTracingHeader } from '../tracingHeaderExclusion'; describe('isTracingHeader', () => { - describe('MUST EXCLUDE all 12 tracing headers (returns true)', () => { + describe('MUST EXCLUDE all SDK-injected tracing headers (returns true)', () => { const tracingHeaders = [ 'x-datadog-sampling-priority', 'x-datadog-origin', @@ -20,7 +20,8 @@ describe('isTracingHeader', () => { 'b3', 'x-b3-traceid', 'x-b3-spanid', - 'x-b3-sampled' + 'x-b3-sampled', + 'x-datadog-tracked-by' ]; it.each(tracingHeaders)('excludes "%s"', (headerName: string) => { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts index d5cf767d4..ea88a9ee3 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -8,6 +8,7 @@ import { InternalLog } from '../../../../InternalLog'; import { SdkVerbosity } from '../../../../config/types'; import { isHeaderAllowed } from './isHeaderAllowed'; +import { normalizeUrlForMatching } from './normalizeUrlForMatching'; import { parseResponseHeaders } from './parseResponseHeaders'; import type { CompiledHeaderCaptureConfig, @@ -324,9 +325,10 @@ export const filterResponseHeaders = ( } // Merge all matching rules' response header sets (union) + const matchUrl = normalizeUrlForMatching(url); const allowedHeaders = mergeMatchingHeaderNames( config, - url, + matchUrl, 'responseHeaderNames' ); @@ -338,7 +340,7 @@ export const filterResponseHeaders = ( return undefined; } - const casingMap = mergeCasingMaps(config, url, 'responseHeaderCasing'); + const casingMap = mergeCasingMaps(config, matchUrl, 'responseHeaderCasing'); const result = filterWithCasing( securityFiltered, allowedHeaders, @@ -393,9 +395,10 @@ export const filterRequestHeadersByMode = ( const accumulatedCount = Object.keys(headers).length; // Merge all matching rules' request header sets (union) + const matchUrl = normalizeUrlForMatching(url); const allowedHeaders = mergeMatchingHeaderNames( config, - url, + matchUrl, 'requestHeaderNames' ); @@ -407,7 +410,7 @@ export const filterRequestHeadersByMode = ( return undefined; } - const casingMap = mergeCasingMaps(config, url, 'requestHeaderCasing'); + const casingMap = mergeCasingMaps(config, matchUrl, 'requestHeaderCasing'); const result = filterWithCasing(headers, allowedHeaders, casingMap); if (result === undefined) { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index 961f77e5c..623caba9a 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -28,7 +28,8 @@ import type { * Subdomains are restricted to hostname characters, so a hostname appearing in * the path or query of another URL never matches. The path prefix (if any) must * end on a path segment boundary: `api.example.com/v2` matches `/v2` and `/v2/users`, - * but not `/v22`. + * but not `/v22`. The host is matched case-insensitively (URLs go through + * normalizeUrlForMatching first), the path prefix case-sensitively. * * Returns null if the pattern is invalid (e.g. contains unescaped regex metacharacters that * result in an invalid expression). @@ -43,7 +44,9 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { const hostname = slashIndex === -1 ? match : match.slice(0, slashIndex); const pathPrefix = slashIndex === -1 ? '' : match.slice(slashIndex); - const escapedHost = escapeRegExp(hostname); + // Hostnames are case-insensitive: the pattern host is lowercased and URLs are + // normalized with normalizeUrlForMatching. Paths stay case-sensitive. + const escapedHost = escapeRegExp(hostname.toLowerCase()); // Host and path prefix must be followed by /, ?, # or end-of-string. // A path prefix ending with '/' is already on a segment boundary. const boundary = '(/|\\?|#|$)'; @@ -54,8 +57,7 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { try { // Regex matches full URL strings: scheme + optional subdomains + host + optional port + path const regex = new RegExp( - `^https?://([^/?#@:]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}`, - 'i' + `^https?://([^/?#@:]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}` ); // Validate the final regex is well-formed regex.test('validation_probe'); @@ -115,7 +117,7 @@ const buildForURLsRegex = (forURLs: string[] | undefined): ForURLsResult => { } try { - return { regex: new RegExp(patterns.join('|'), 'i'), isScoped: true }; + return { regex: new RegExp(patterns.join('|')), isScoped: true }; } catch (_e) { return null; } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts new file mode 100644 index 000000000..13754b060 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts @@ -0,0 +1,31 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Lowercases the scheme and authority of a URL, leaving path, query and + * fragment untouched. Compiled `forURLs` regexes are case-sensitive with a + * lowercased host, so hosts match case-insensitively while paths keep their case. + * + * Example: `HTTPS://API.Example.com/Public?Q=1` -> `https://api.example.com/Public?Q=1` + */ +export const normalizeUrlForMatching = (url: string): string => { + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1) { + return url; + } + + const authorityStart = schemeEnd + 3; + let authorityEnd = url.length; + for (let i = authorityStart; i < url.length; i++) { + const char = url[i]; + if (char === '/' || char === '?' || char === '#') { + authorityEnd = i; + break; + } + } + + return url.slice(0, authorityEnd).toLowerCase() + url.slice(authorityEnd); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts index 62930af4b..a85c80f65 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts @@ -16,11 +16,12 @@ import { B3_HEADER_KEY, B3_MULTI_TRACE_ID_HEADER_KEY, B3_MULTI_SPAN_ID_HEADER_KEY, - B3_MULTI_SAMPLED_HEADER_KEY + B3_MULTI_SAMPLED_HEADER_KEY, + TRACKED_BY_HEADER_KEY } from '../distributedTracing/headers'; /** - * Set of all SDK-injected distributed tracing header names, lowercased. + * Set of all SDK-injected distributed tracing (and tracking) header names, lowercased. * * Built once at module load time from the canonical header constants in * `distributedTracing/headers.ts`. The B3 multi-headers have mixed case @@ -39,7 +40,8 @@ const TRACING_HEADERS: Set = new Set( B3_HEADER_KEY, B3_MULTI_TRACE_ID_HEADER_KEY, B3_MULTI_SPAN_ID_HEADER_KEY, - B3_MULTI_SAMPLED_HEADER_KEY + B3_MULTI_SAMPLED_HEADER_KEY, + TRACKED_BY_HEADER_KEY ].map(h => h.toLowerCase()) ); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index a55593137..1441176ed 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -195,6 +195,16 @@ const reportXhr = async ( } const key = getResourceKey(context); + // Snapshot before any await: the app may reopen this XHR from its + // completion handler while the GraphQL body is being read. + const statusCode = xhrProxy.status; + const capturedHeaders = finalizeCapturedHeaders({ + state: context.headerCapture, + url: context.url, + statusCode, + responseHeaders: () => + parseResponseHeaders(xhrProxy.getAllResponseHeaders()) + }); context.timer.stop(); @@ -228,15 +238,9 @@ const reportXhr = async ( }, graphqlAttributes: context.graphql, tracingAttributes: context.tracingAttributes, - ...finalizeCapturedHeaders({ - state: context.headerCapture, - url: context.url, - statusCode: xhrProxy.status, - responseHeaders: () => - parseResponseHeaders(xhrProxy.getAllResponseHeaders()) - }), + ...capturedHeaders, response: { - statusCode: xhrProxy.status, + statusCode, size: responseSize }, timings: { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 73dfef0b7..3e10f1314 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -43,6 +43,7 @@ import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../../../graphql/graphqlHeaders'; +import { compileHeaderCaptureConfig } from '../../../headerCapture/compileHeaderConfig'; import { ResourceReporter } from '../../common/ResourceReporter'; import { XHRProxy } from '../XHRProxy'; import { @@ -2889,6 +2890,87 @@ describe('XHRProxy', () => { }); }); + describe('header capture with request reuse', () => { + it('reports the headers of the completed request when the XHR is reused during GraphQL body extraction', async () => { + // GIVEN + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig('defaults') + }); + + const xhr = new XMLHttpRequestMock(); + xhr.open('POST', 'https://api.example.com/graphql'); + xhr.setRequestHeader( + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, + 'query' + ); + xhr.setRequestHeader(DATADOG_GRAPH_QL_ERROR_HEADER, 'true'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'cache-control: max-age=60\r\n' + ); + const body = JSON.stringify({ errors: [{ message: 'boom' }] }); + xhr.responseText = body; + + // WHEN the request completes, then the app reuses the XHR + // before the GraphQL body has been read + xhr.complete(200, body); + xhr.status = 0; + xhr.getAllResponseHeaders.mockReturnValue('etag: "other"\r\n'); + await flushPromises(); + + // THEN + const [ + , + statusCode, + , + , + context + ] = DdNativeRum.stopResource.mock.calls[0]; + expect(statusCode).toBe(200); + expect(context['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + expect(context['_dd.response_headers']).toEqual({ + 'Cache-Control': 'max-age=60' + }); + }); + + it('does not capture the SDK tracked-by header even when a rule names it', async () => { + // GIVEN + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig([ + { + type: 'matchRequestHeaders', + headers: [TRACKED_BY_HEADER_KEY, 'x-request-id'] + } + ]) + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open('GET', 'https://api.example.com/v2/user'); + xhr.setRequestHeader('X-Request-Id', 'abc'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN the header was sent but not captured + expect(xhr.requestHeaders.get(TRACKED_BY_HEADER_KEY)).toBe( + TRACKED_BY_HEADER_VALUE + ); + expect( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ 'X-Request-Id': 'abc' }); + }); + }); + describe('header capture integration', () => { it('disabled mode: no header attributes in stopResource context', async () => { // GIVEN diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts index fa5d500fc..efaae51f5 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts @@ -10,7 +10,7 @@ import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; export interface RequestProxyOptions { tracingSamplingRate: number; firstPartyHostsRegexMap: RegexMap; - headerCaptureConfig: CompiledHeaderCaptureConfig; + headerCaptureConfig?: CompiledHeaderCaptureConfig; } export type RegexMap = { From 1f6045dad1a307a141c3100be0cc340a128f7ee8 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 11:07:18 +0100 Subject: [PATCH 20/26] fix(header-capture): address previously missed review issues - Exclude backslashes from forURLs subdomain matching, since URL parsers treat them as path separators (host-scope bypass) - Treat any forURLs array containing '*' as catch-all - Snapshot Fetch captured headers before awaiting GraphQL body parsing - Stringify request header values before capture so non-string values no longer drop the resource event Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/compileHeaderConfig.test.ts | 17 +++++++ .../__tests__/normalizeUrlForMatching.test.ts | 3 +- .../headerCapture/compileHeaderConfig.ts | 12 ++--- .../headerCapture/normalizeUrlForMatching.ts | 2 +- .../requestProxy/FetchProxy/FetchProxy.ts | 16 ++++--- .../FetchProxy/__tests__/FetchProxy.test.ts | 44 +++++++++++++++++++ .../XHRProxy/__tests__/XHRProxy.test.ts | 30 +++++++++++++ .../requestProxy/common/headerCapture.ts | 4 +- 8 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index c4d45666d..1bdd02039 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -220,6 +220,19 @@ describe('compileHeaderCaptureConfig', () => { expect(isScoped).toBe(false); }); + it('forURLs containing "*" alongside other patterns is catch-all', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['x-foo'], + forURLs: ['*', 'api.example.com'] + } + ]); + const { urlRegex, isScoped } = (result ?? [])[0]; + expect(isScoped).toBe(false); + expect(urlRegex.test('https://other.com/path')).toBe(true); + }); + it('specific forURLs patterns produce isScoped: true', () => { const result = compileHeaderCaptureConfig([ { @@ -350,6 +363,10 @@ describe('compileHeaderCaptureConfig', () => { false ); expect(urlRegex.test('https://notapi.example.com/')).toBe(false); + // URL parsers treat '\\' as '/', so this request goes to unrelated.test + expect( + urlRegex.test('https://unrelated.test\\.api.example.com/users') + ).toBe(false); }); it('forURLs: [] (empty) skips the rule with WARN log', () => { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts index 1560fc50d..d03083898 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts @@ -15,7 +15,8 @@ describe('normalizeUrlForMatching', () => { ['https://API.example.com:8443', 'https://api.example.com:8443'], ['https://API.example.com?Q=A', 'https://api.example.com?Q=A'], ['https://API.example.com#Top', 'https://api.example.com#Top'], - ['https://api.example.com/v2', 'https://api.example.com/v2'] + ['https://api.example.com/v2', 'https://api.example.com/v2'], + ['https://API.example.com\\Path', 'https://api.example.com\\Path'] ])('lowercases only scheme and authority of %s', (url, expected) => { expect(normalizeUrlForMatching(url)).toBe(expected); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index 623caba9a..99dd6b7d9 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -24,9 +24,10 @@ import type { * Builds a URL-matching RegExp from a user-supplied `match` string. * * The regex is applied to full URL strings (e.g. "https://api.example.com/v2/users"). - * Pattern: `^https?://([^/?#@:]+\.)?${escapedHost}(:\d+)?${pathSuffix}` + * Pattern: `^https?://([^/?#@:\\]+\.)?${escapedHost}(:\d+)?${pathSuffix}` * Subdomains are restricted to hostname characters, so a hostname appearing in - * the path or query of another URL never matches. The path prefix (if any) must + * the path or query of another URL never matches. Backslashes are excluded too, + * as URL parsers treat them as path separators. The path prefix (if any) must * end on a path segment boundary: `api.example.com/v2` matches `/v2` and `/v2/users`, * but not `/v22`. The host is matched case-insensitively (URLs go through * normalizeUrlForMatching first), the path prefix case-sensitively. @@ -57,7 +58,7 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { try { // Regex matches full URL strings: scheme + optional subdomains + host + optional port + path const regex = new RegExp( - `^https?://([^/?#@:]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}` + `^https?://([^/?#@:\\\\]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}` ); // Validate the final regex is well-formed regex.test('validation_probe'); @@ -92,8 +93,9 @@ const buildForURLsRegex = (forURLs: string[] | undefined): ForURLsResult => { return 'skip'; } - // ['*'] is equivalent to omitting forURLs (catch-all) - if (forURLs.length === 1 && forURLs[0] === '*') { + // Any array containing '*' matches all URLs, so it is equivalent to + // omitting forURLs (catch-all) + if (forURLs.includes('*')) { return { regex: /.*/, isScoped: false }; } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts index 13754b060..4c48fc723 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts @@ -21,7 +21,7 @@ export const normalizeUrlForMatching = (url: string): string => { let authorityEnd = url.length; for (let i = authorityStart; i < url.length; i++) { const char = url[i]; - if (char === '/' || char === '?' || char === '#') { + if (char === '/' || char === '\\' || char === '?' || char === '#') { authorityEnd = i; break; } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts index fc39f84ba..79b09d009 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -246,6 +246,15 @@ const reportFetch = async ({ response: Response; resourceReporter: ResourceReporter; }): Promise => { + // Snapshot before any await: some Fetch implementations expose mutable + // response headers that the app could change while the body is parsed. + const capturedHeaders = finalizeCapturedHeaders({ + state: context.headerCapture, + url: context.url, + statusCode: response.status, + responseHeaders: () => fetchHeadersToRecord(response.headers) + }); + // Only extract GraphQL errors if operationType is set AND error tracking is enabled if (context.graphql.operationType && context.graphql.trackErrors) { try { @@ -276,12 +285,7 @@ const reportFetch = async ({ }, graphqlAttributes: context.graphql, tracingAttributes: context.tracingAttributes, - ...finalizeCapturedHeaders({ - state: context.headerCapture, - url: context.url, - statusCode: response.status, - responseHeaders: () => fetchHeadersToRecord(response.headers) - }), + ...capturedHeaders, response: { statusCode: response.status, size: getResponseSize(response) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts index 2ee33b094..0027f944c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts @@ -617,6 +617,50 @@ describe('FetchProxy', () => { expect(resource.capturedResponseHeaders).toBeUndefined(); }); + it('captures response headers before the GraphQL body is parsed', async () => { + const headers = new HeadersMock({ 'cache-control': 'max-age=60' }); + let resolveBody: (body: unknown) => void = () => {}; + const response = ({ + status: 200, + headers, + clone() { + return response; + }, + json: () => + new Promise(resolve => { + resolveBody = resolve; + }) + } as unknown) as Response; + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query', + [DATADOG_GRAPH_QL_ERROR_HEADER]: 'true' + } + }); + // The app mutates the response headers while the body is parsed + headers.delete('cache-control'); + headers.set('etag', '"later"'); + resolveBody({ data: {} }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedResponseHeaders).toEqual({ + 'Cache-Control': 'max-age=60' + }); + }); + it('still reports request headers when response headers cannot be iterated', async () => { const response = ({ status: 200, diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 3e10f1314..3964974bf 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -2939,6 +2939,36 @@ describe('XHRProxy', () => { }); }); + it('stringifies non-string request header values and still reports the resource', async () => { + // GIVEN + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig([ + { + type: 'matchRequestHeaders', + headers: ['x-count', 'x-empty'] + } + ]) + }); + + // WHEN untyped callers pass non-string values + const xhr = new XMLHttpRequestMock(); + xhr.open('GET', 'https://api.example.com/v2/user'); + xhr.setRequestHeader('x-count', (42 as unknown) as string); + xhr.setRequestHeader('x-empty', (null as unknown) as string); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(DdNativeRum.stopResource).toHaveBeenCalledTimes(1); + expect( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ 'x-count': '42', 'x-empty': 'null' }); + }); + it('does not capture the SDK tracked-by header even when a rule names it', async () => { // GIVEN xhrProxy.onTrackingStart({ diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts index 46e8bb0b9..fc6b9ae5f 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts @@ -37,6 +37,8 @@ export const createHeaderCaptureState = ( /** * Records a user-set request header. Datadog internal headers are ignored, * sensitive headers are dropped before being stored. + * Values are stringified the same way XHR and Fetch coerce them, since + * untyped callers can pass `null`, numbers, etc. */ export const recordRequestHeader = ( state: HeaderCaptureState, @@ -49,7 +51,7 @@ export const recordRequestHeader = ( ) { return; } - accumulateRequestHeader(state.requestHeaders, header, value); + accumulateRequestHeader(state.requestHeaders, header, String(value)); }; /** From 76d3ef31e39c7b254ace81ee77440917476418fe Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 11:19:21 +0100 Subject: [PATCH 21/26] fix(header-capture): exclude header processing from XHR timings - Stop the XHR resource timer before finalizing captured headers, so header parsing and size enforcement do not inflate the duration - Stringify XHR header values once and use the same string for sending and capture Co-Authored-By: Claude Opus 5.5 (1M context) --- .../resourceTracking/headerCapture/README.md | 2 +- .../requestProxy/XHRProxy/XHRProxy.ts | 17 +++++++--- .../XHRProxy/__tests__/XHRProxy.test.ts | 34 ++++++++++++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index 9ab3fc7f3..9b61e0ba6 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -98,7 +98,7 @@ The optional `forURLs` field scopes a rule to specific URL patterns. Supports ho - A hostname matches itself and its subdomains (`api.example.com` matches `cdn.api.example.com`), but never a hostname that only appears in the path or query of another URL. - A path prefix matches whole path segments: `api.example.com/v2` matches `/v2` and `/v2/users`, but not `/v22`. -**Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs` / `['*']`) are ignored for that URL. +**Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs`, or a `forURLs` list containing `'*'`) are ignored for that URL. For example, `[{ type: 'defaults' }, { type: 'matchResponseHeaders', headers: ['x-request-id'], forURLs: ['api.example.com'] }]` captures only `x-request-id` on `api.example.com`, and the default headers everywhere else. ## Output diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 1441176ed..bba2cb07a 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -195,6 +195,10 @@ const reportXhr = async ( } const key = getResourceKey(context); + + // Stop before header processing so it is not counted in the resource duration + context.timer.stop(); + // Snapshot before any await: the app may reopen this XHR from its // completion handler while the GraphQL body is being read. const statusCode = xhrProxy.status; @@ -206,8 +210,6 @@ const reportXhr = async ( parseResponseHeaders(xhrProxy.getAllResponseHeaders()) }); - context.timer.stop(); - // Only extract GraphQL errors if operationType is set AND error tracking is enabled if (context.graphql.operationType && context.graphql.trackErrors) { try { @@ -267,10 +269,13 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { return originalXhrSetRequestHeader.apply(this, [header, value]); } + // XHR coerces values to strings: do it once so the sent and the + // captured values are identical + const stringValue = String(value); const processedHeader = processRequestHeader({ context: this._datadog_xhr, header, - value + value: stringValue }); if (processedHeader.type === 'send') { @@ -278,7 +283,11 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { processedHeader.header, processedHeader.value ]); - recordRequestHeader(this._datadog_xhr.headerCapture, header, value); + recordRequestHeader( + this._datadog_xhr.headerCapture, + header, + stringValue + ); return result; } }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 3964974bf..343bf7a43 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -10,6 +10,7 @@ import { Platform, NativeModules } from 'react-native'; import { InternalLog } from '../../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../../config/types'; import { BufferSingleton } from '../../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; +import { Timer } from '../../../../../../utils/Timer'; import { DdRum } from '../../../../../DdRum'; import { setCachedSessionId, @@ -2962,13 +2963,44 @@ describe('XHRProxy', () => { xhr.complete(200, 'ok'); await flushPromises(); - // THEN + // THEN the same string is sent and captured + expect(xhr.requestHeaders.get('x-count')).toBe('42'); + expect(xhr.requestHeaders.get('x-empty')).toBe('null'); expect(DdNativeRum.stopResource).toHaveBeenCalledTimes(1); expect( DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] ).toEqual({ 'x-count': '42', 'x-empty': 'null' }); }); + it('stops the resource timer before processing captured headers', async () => { + // GIVEN + const timerStop = jest.spyOn(Timer.prototype, 'stop'); + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig('defaults') + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open('GET', 'https://api.example.com/v2/user'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'cache-control: no-cache\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(timerStop).toHaveBeenCalledTimes(1); + expect(xhr.getAllResponseHeaders).toHaveBeenCalled(); + expect(timerStop.mock.invocationCallOrder[0]).toBeLessThan( + xhr.getAllResponseHeaders.mock.invocationCallOrder[0] + ); + timerStop.mockRestore(); + }); + it('does not capture the SDK tracked-by header even when a rule names it', async () => { // GIVEN xhrProxy.onTrackingStart({ From 2f625604754bddd7954dc03918b685d4379d6471 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 11:35:57 +0100 Subject: [PATCH 22/26] fix(header-capture): harden URL scope matching and sensitive headers - Block application-key credentials (e.g. DD-APPLICATION-KEY) - Allow userinfo before the host in forURLs matching, while still rejecting hostnames that only appear in the userinfo - Resolve dot segments (including %2e encodings) and backslashes in the path before scope matching - Skip rules with a non-string type instead of throwing while building the warning message Co-Authored-By: Claude Opus 5.5 (1M context) --- .vscode/settings.json | 13 ++++ .../resourceTracking/headerCapture/README.md | 1 + .../__tests__/compileHeaderConfig.test.ts | 56 ++++++++++++++++ .../__tests__/normalizeUrlForMatching.test.ts | 52 ++++++++++++++- .../sensitiveHeaderBlocklist.test.ts | 3 + .../headerCapture/compileHeaderConfig.ts | 13 ++-- .../headerCapture/normalizeUrlForMatching.ts | 65 +++++++++++++++---- .../headerCapture/sensitiveHeaderBlocklist.ts | 2 +- 8 files changed, 187 insertions(+), 18 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a25f1934..aebae3196 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,4 +36,17 @@ "source.fixAll.eslint": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib", + "workbench.colorCustomizations": { + "activityBar.background": "#580F33", + "titleBar.activeBackground": "#7C1648", + "titleBar.activeForeground": "#FEFCFD", + "titleBar.inactiveBackground": "#580F33", + "titleBar.inactiveForeground": "#FEFCFD", + "statusBar.background": "#580F33", + "statusBar.foreground": "#FEFCFD", + "statusBar.debuggingBackground": "#580F33", + "statusBar.debuggingForeground": "#FEFCFD", + "statusBar.noFolderBackground": "#580F33", + "statusBar.noFolderForeground": "#FEFCFD" + }, } \ No newline at end of file diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index 9b61e0ba6..f8b0b198e 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -97,6 +97,7 @@ The optional `forURLs` field scopes a rule to specific URL patterns. Supports ho - A hostname matches itself and its subdomains (`api.example.com` matches `cdn.api.example.com`), but never a hostname that only appears in the path or query of another URL. - A path prefix matches whole path segments: `api.example.com/v2` matches `/v2` and `/v2/users`, but not `/v22`. +- URLs are matched the way HTTP clients resolve them: `..` and `.` segments (including `%2e` encodings) are resolved first, so `api.example.com/public/../private` does not match `api.example.com/public`. Hostnames match case-insensitively, paths case-sensitively. **Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs`, or a `forURLs` list containing `'*'`) are ignored for that URL. For example, `[{ type: 'defaults' }, { type: 'matchResponseHeaders', headers: ['x-request-id'], forURLs: ['api.example.com'] }]` captures only `x-request-id` on `api.example.com`, and the default headers everywhere else. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index 1bdd02039..1838706da 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -295,6 +295,49 @@ describe('compileHeaderCaptureConfig', () => { ); }); + it('forURLs matches URLs with userinfo on the configured host', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect( + urlRegex.test('https://user:pass@api.example.com/v2/users') + ).toBe(true); + expect(urlRegex.test('https://user@cdn.api.example.com/v2')).toBe( + true + ); + expect( + urlRegex.test('https://user:pass@api.example.com/v1/users') + ).toBe(false); + }); + + it('forURLs path scope applies to the resolved path', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/public'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + const matches = (url: string) => + urlRegex.test(normalizeUrlForMatching(url)); + + expect(matches('https://api.example.com/public/../private')).toBe( + false + ); + expect( + matches('https://api.example.com/public/%2e%2e/private') + ).toBe(false); + expect(matches('https://api.example.com/other/../public/x')).toBe( + true + ); + }); + it('forURLs matches the host case-insensitively and the path case-sensitively', () => { const result = compileHeaderCaptureConfig([ { @@ -363,6 +406,14 @@ describe('compileHeaderCaptureConfig', () => { false ); expect(urlRegex.test('https://notapi.example.com/')).toBe(false); + expect( + urlRegex.test('https://api.example.com@unrelated.test/users') + ).toBe(false); + expect( + urlRegex.test( + 'https://user:pass@unrelated.test/api.example.com' + ) + ).toBe(false); // URL parsers treat '\\' as '/', so this request goes to unrelated.test expect( urlRegex.test('https://unrelated.test\\.api.example.com/users') @@ -480,6 +531,11 @@ describe('compileHeaderCaptureConfig', () => { [ 'a rule with non-array forURLs', { type: 'defaults', forURLs: 'api.example.com' } + ], + ['a rule with a non-string type', { type: ['defaults'] }], + [ + 'a rule whose type cannot be converted to a string', + { type: { toString: null } } ] ])('skips %s with a WARN log instead of throwing', (_, rule) => { const result = compileHeaderCaptureConfig([ diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts index d03083898..043406e81 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts @@ -16,11 +16,61 @@ describe('normalizeUrlForMatching', () => { ['https://API.example.com?Q=A', 'https://api.example.com?Q=A'], ['https://API.example.com#Top', 'https://api.example.com#Top'], ['https://api.example.com/v2', 'https://api.example.com/v2'], - ['https://API.example.com\\Path', 'https://api.example.com\\Path'] + [ + 'https://User:Pass@API.example.com/Path', + 'https://user:pass@api.example.com/Path' + ] ])('lowercases only scheme and authority of %s', (url, expected) => { expect(normalizeUrlForMatching(url)).toBe(expected); }); + it.each([ + [ + 'https://api.example.com/public/../private', + 'https://api.example.com/private' + ], + [ + 'https://api.example.com/public/%2e%2e/private', + 'https://api.example.com/private' + ], + [ + 'https://api.example.com/public/%2E./private', + 'https://api.example.com/private' + ], + [ + 'https://api.example.com/v1/../v2/users', + 'https://api.example.com/v2/users' + ], + [ + 'https://api.example.com/a/./b/%2e/c', + 'https://api.example.com/a/b/c' + ], + ['https://api.example.com/a/..', 'https://api.example.com/'], + ['https://api.example.com/a/.', 'https://api.example.com/a/'], + ['https://api.example.com/../../a', 'https://api.example.com/a'], + [ + 'https://api.example.com\\Path\\To', + 'https://api.example.com/Path/To' + ], + [ + 'https://api.example.com/a/../b?x=/../y#/..', + 'https://api.example.com/b?x=/../y#/..' + ], + [ + 'https://api.example.com/v1.2/file.json', + 'https://api.example.com/v1.2/file.json' + ], + [ + 'https://api.example.com/a%20b/..c', + 'https://api.example.com/a%20b/..c' + ] + ])( + 'resolves dot segments and backslashes in the path of %s', + (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + } + ); + it('returns URLs without a scheme unchanged', () => { expect(normalizeUrlForMatching('/Relative/Path')).toBe( '/Relative/Path' diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts index d7a8a66d0..6bc5583d6 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts @@ -50,6 +50,9 @@ describe('isSensitiveHeader', () => { 'accesskey', 'app-key', 'appkey', + 'dd-application-key', + 'x-application-key', + 'application_key', // Forwarding / IP headers 'forwarded', 'x-forwarded-for', diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index 99dd6b7d9..6eddac0dd 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -24,7 +24,9 @@ import type { * Builds a URL-matching RegExp from a user-supplied `match` string. * * The regex is applied to full URL strings (e.g. "https://api.example.com/v2/users"). - * Pattern: `^https?://([^/?#@:\\]+\.)?${escapedHost}(:\d+)?${pathSuffix}` + * Pattern: `^https?://([^/?#\\]*@)?([^/?#@:\\]+\.)?${escapedHost}(:\d+)?${pathSuffix}` + * Optional userinfo (`user:pass@`) is allowed before the host; a hostname that + * only appears in the userinfo never matches. * Subdomains are restricted to hostname characters, so a hostname appearing in * the path or query of another URL never matches. Backslashes are excluded too, * as URL parsers treat them as path separators. The path prefix (if any) must @@ -56,9 +58,9 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { : `${escapeRegExp(pathPrefix)}${boundary}`; try { - // Regex matches full URL strings: scheme + optional subdomains + host + optional port + path + // Regex matches full URL strings: scheme + optional userinfo + optional subdomains + host + optional port + path const regex = new RegExp( - `^https?://([^/?#@:\\\\]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}` + `^https?://([^/?#\\\\]*@)?([^/?#@:\\\\]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}` ); // Validate the final regex is well-formed regex.test('validation_probe'); @@ -151,7 +153,10 @@ const getRuleValidationError = (rule: unknown): string | null => { ? null : '`headers` must be an array of strings'; default: - return `unknown type "${String(type)}"`; + // Only interpolate strings: arbitrary objects may not be convertible + return typeof type === 'string' + ? `unknown type "${type}"` + : '`type` must be a string'; } }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts index 4c48fc723..c26591d86 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts @@ -4,12 +4,18 @@ * Copyright 2016-Present Datadog, Inc. */ +const SINGLE_DOT_SEGMENTS = new Set(['.', '%2e']); +const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); + /** - * Lowercases the scheme and authority of a URL, leaving path, query and - * fragment untouched. Compiled `forURLs` regexes are case-sensitive with a - * lowercased host, so hosts match case-insensitively while paths keep their case. + * Normalizes a URL for `forURLs` scope matching, following the URL parsing + * semantics of HTTP clients: + * - the scheme and authority are lowercased (hosts match case-insensitively); + * - in the path, backslashes are treated as slashes and dot segments + * (`.`, `..`, and their `%2e` encodings) are resolved; + * - path case, query and fragment are left untouched. * - * Example: `HTTPS://API.Example.com/Public?Q=1` -> `https://api.example.com/Public?Q=1` + * Example: `HTTPS://API.Example.com/Public/../Private?Q=1` -> `https://api.example.com/Private?Q=1` */ export const normalizeUrlForMatching = (url: string): string => { const schemeEnd = url.indexOf('://'); @@ -17,15 +23,50 @@ export const normalizeUrlForMatching = (url: string): string => { return url; } - const authorityStart = schemeEnd + 3; - let authorityEnd = url.length; - for (let i = authorityStart; i < url.length; i++) { - const char = url[i]; - if (char === '/' || char === '\\' || char === '?' || char === '#') { - authorityEnd = i; - break; + const authorityEnd = findFirstOf(url, schemeEnd + 3, '/\\?#'); + const pathEnd = findFirstOf(url, authorityEnd, '?#'); + + return ( + url.slice(0, authorityEnd).toLowerCase() + + normalizePath(url.slice(authorityEnd, pathEnd)) + + url.slice(pathEnd) + ); +}; + +const findFirstOf = (url: string, from: number, chars: string): number => { + for (let i = from; i < url.length; i++) { + if (chars.includes(url[i])) { + return i; } } + return url.length; +}; + +const normalizePath = (path: string): string => { + // Fast path: nothing to resolve + if (!/[.\\%]/.test(path)) { + return path; + } + + const segments = path.replace(/\\/g, '/').slice(1).split('/'); + const output: string[] = []; + + segments.forEach((segment, index) => { + const isLast = index === segments.length - 1; + const lowered = segment.toLowerCase(); + if (DOUBLE_DOT_SEGMENTS.has(lowered)) { + output.pop(); + if (isLast) { + output.push(''); + } + } else if (SINGLE_DOT_SEGMENTS.has(lowered)) { + if (isLast) { + output.push(''); + } + } else { + output.push(segment); + } + }); - return url.slice(0, authorityEnd).toLowerCase() + url.slice(authorityEnd); + return `/${output.join('/')}`; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts index ce3275450..e10ea2757 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts @@ -13,7 +13,7 @@ * The `/i` flag ensures case-insensitive matching. Compiled once at module load * time — no per-call allocation. */ -const SENSITIVE_HEADER_PATTERN = /(?:token|cookie|secret|authorization|password|credential|bearer|(?:api|secret|access|app).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i; +const SENSITIVE_HEADER_PATTERN = /(?:token|cookie|secret|authorization|password|credential|bearer|(?:api|secret|access|app|application).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i; /** * Checks whether a header name matches a known sensitive header pattern. From eba139aec26e19a01176168f2345a71fc10a371f Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 11:55:29 +0100 Subject: [PATCH 23/26] fix(header-capture): strip tabs and newlines before URL scope matching - Remove ASCII tabs, CRs and LFs and trim leading/trailing control characters and spaces before normalizing URLs, as URL parsers do - Add UTF-8 boundary tests for value truncation (2-, 3- and 4-byte characters, lone surrogates) and for the total byte budget Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/compileHeaderConfig.test.ts | 3 + .../__tests__/enforceSizeLimits.test.ts | 109 ++++++++++++++++++ .../__tests__/normalizeUrlForMatching.test.ts | 21 ++++ .../headerCapture/normalizeUrlForMatching.ts | 13 ++- 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index 1838706da..cfc704039 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -333,6 +333,9 @@ describe('compileHeaderCaptureConfig', () => { expect( matches('https://api.example.com/public/%2e%2e/private') ).toBe(false); + expect(matches('https://api.example.com/public/\t../private')).toBe( + false + ); expect(matches('https://api.example.com/other/../public/x')).toBe( true ); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts index 2cd840819..61c4f4a2c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts @@ -11,6 +11,12 @@ import { MAX_TOTAL_BYTES } from '../enforceSizeLimits'; +// Node's encoder is the reference: lone surrogates are encoded as U+FFFD (3 bytes) +const bytesOf = (value: string) => Buffer.byteLength(value, 'utf8'); + +const truncateValue = (value: string): string => + enforceSizeLimits({ h: value }, undefined).requestHeaders?.h ?? ''; + describe('enforceSizeLimits', () => { describe('constants', () => { it('exports MAX_HEADER_VALUE_BYTES as 128', () => { @@ -69,6 +75,84 @@ describe('enforceSizeLimits', () => { }); }); + describe('UTF-8 value truncation', () => { + it.each([ + ['2-byte characters exactly at the limit', 'é'.repeat(64), 64], + ['3-byte characters below the limit', '€'.repeat(42), 42], + [ + 'an emoji ending exactly at the limit', + `${'a'.repeat(124)}😀`, + 126 + ], + [ + 'a lone high surrogate ending at the limit', + `${'a'.repeat(125)}\uD800`, + 126 + ], + [ + 'a lone low surrogate ending at the limit', + `${'a'.repeat(125)}\uDC00`, + 126 + ] + ])('keeps %s intact', (_, value, expectedLength) => { + expect(bytesOf(value)).toBeLessThanOrEqual(MAX_HEADER_VALUE_BYTES); + expect(truncateValue(value)).toBe(value); + expect(value).toHaveLength(expectedLength); + }); + + it.each([ + [ + 'a 2-byte character crossing the limit', + `a${'é'.repeat(64)}`, + `a${'é'.repeat(63)}` + ], + [ + 'a 3-byte character crossing the limit', + '€'.repeat(43), + '€'.repeat(42) + ], + [ + 'an emoji crossing the limit', + `${'a'.repeat(126)}😀`, + 'a'.repeat(126) + ], + [ + 'an emoji starting right after the limit', + `${'a'.repeat(128)}😀`, + 'a'.repeat(128) + ], + [ + 'a lone high surrogate crossing the limit', + `${'a'.repeat(126)}\uD800`, + 'a'.repeat(126) + ], + [ + 'a lone low surrogate crossing the limit', + `${'a'.repeat(126)}\uDC00`, + 'a'.repeat(126) + ] + ])('drops %s without splitting it', (_, value, expected) => { + const truncated = truncateValue(value); + expect(truncated).toBe(expected); + expect(bytesOf(truncated)).toBeLessThanOrEqual( + MAX_HEADER_VALUE_BYTES + ); + }); + + it('never leaves a dangling high surrogate after truncation', () => { + for (let prefix = 120; prefix <= 128; prefix++) { + const truncated = truncateValue( + `${'a'.repeat(prefix)}${'😀'.repeat(4)}` + ); + const lastCode = truncated.charCodeAt(truncated.length - 1); + expect(lastCode >= 0xd800 && lastCode <= 0xdbff).toBe(false); + expect(bytesOf(truncated)).toBeLessThanOrEqual( + MAX_HEADER_VALUE_BYTES + ); + } + }); + }); + describe('header count cap', () => { it('keeps all headers when combined count is at 100', () => { const req: Record = {}; @@ -190,6 +274,31 @@ describe('enforceSizeLimits', () => { }); }); + describe('total size budget with multi-byte values', () => { + it('measures the budget in UTF-8 bytes, not UTF-16 code units', () => { + // 20 headers of 3 + 126 = 129 UTF-8 bytes (45 UTF-16 code units each): + // 900 code units in total, but 2580 bytes + const responseHeaders: Record = {}; + for (let i = 0; i < 20; i++) { + responseHeaders[`h${String(i).padStart(2, '0')}`] = '€'.repeat( + 42 + ); + } + + const result = enforceSizeLimits(undefined, responseHeaders); + const kept = Object.entries(result.responseHeaders ?? {}); + const keptBytes = kept.reduce( + (total, [name, value]) => + total + bytesOf(name) + bytesOf(value), + 0 + ); + + expect(kept).toHaveLength(15); + expect(keptBytes).toBe(15 * 129); + expect(keptBytes).toBeLessThanOrEqual(MAX_TOTAL_BYTES); + }); + }); + describe('combined processing order', () => { it('truncates values before counting bytes for budget', () => { // 10 headers with 500-char values -> truncated to 128 each diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts index 043406e81..5714ee12f 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts @@ -71,6 +71,27 @@ describe('normalizeUrlForMatching', () => { } ); + it.each([ + ['a tab', '\t'], + ['a carriage return', '\r'], + ['a line feed', '\n'] + ])('removes %s before resolving dot segments', (_, char) => { + expect( + normalizeUrlForMatching( + `https://api.example.com/public/${char}../private` + ) + ).toBe('https://api.example.com/private'); + expect( + normalizeUrlForMatching(`https://api.exa${char}mple.com/v2`) + ).toBe('https://api.example.com/v2'); + }); + + it('trims leading and trailing control characters and spaces', () => { + expect( + normalizeUrlForMatching(' \u0000https://API.example.com/v2 \u001f') + ).toBe('https://api.example.com/v2'); + }); + it('returns URLs without a scheme unchanged', () => { expect(normalizeUrlForMatching('/Relative/Path')).toBe( '/Relative/Path' diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts index c26591d86..212ab37c9 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts @@ -4,12 +4,20 @@ * Copyright 2016-Present Datadog, Inc. */ +// URL parsers ignore leading/trailing C0 control characters and spaces, and +// remove ASCII tabs and newlines anywhere in the URL. +// eslint-disable-next-line no-control-regex +const LEADING_TRAILING_C0_OR_SPACE = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g; +const TAB_OR_NEWLINE = /[\t\n\r]/g; + const SINGLE_DOT_SEGMENTS = new Set(['.', '%2e']); const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); /** * Normalizes a URL for `forURLs` scope matching, following the URL parsing * semantics of HTTP clients: + * - leading/trailing control characters and spaces are trimmed, and tabs, + * CRs and LFs are removed; * - the scheme and authority are lowercased (hosts match case-insensitively); * - in the path, backslashes are treated as slashes and dot segments * (`.`, `..`, and their `%2e` encodings) are resolved; @@ -17,7 +25,10 @@ const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); * * Example: `HTTPS://API.Example.com/Public/../Private?Q=1` -> `https://api.example.com/Private?Q=1` */ -export const normalizeUrlForMatching = (url: string): string => { +export const normalizeUrlForMatching = (rawUrl: string): string => { + const url = rawUrl + .replace(LEADING_TRAILING_C0_OR_SPACE, '') + .replace(TAB_OR_NEWLINE, ''); const schemeEnd = url.indexOf('://'); if (schemeEnd === -1) { return url; From aa10a1c0bd939d832ea087ab0377987aa3e01dc4 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 12:22:37 +0100 Subject: [PATCH 24/26] fix(header-capture): match root scopes and encoded hostnames - Treat an omitted URL path as '/', so root scopes such as 'api.example.com/' match 'https://api.example.com?view=home' - Decode percent-encoded hostname characters before scope matching, so scoped rules are not bypassed in favor of catch-all rules Co-Authored-By: Claude Opus 5.5 (1M context) --- .../resourceTracking/headerCapture/README.md | 2 +- .../__tests__/captureHeaders.test.ts | 20 ++++++++++ .../__tests__/compileHeaderConfig.test.ts | 19 ++++++++++ .../__tests__/normalizeUrlForMatching.test.ts | 30 +++++++++++++-- .../headerCapture/normalizeUrlForMatching.ts | 37 ++++++++++++++++++- 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index f8b0b198e..24f22911e 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -97,7 +97,7 @@ The optional `forURLs` field scopes a rule to specific URL patterns. Supports ho - A hostname matches itself and its subdomains (`api.example.com` matches `cdn.api.example.com`), but never a hostname that only appears in the path or query of another URL. - A path prefix matches whole path segments: `api.example.com/v2` matches `/v2` and `/v2/users`, but not `/v22`. -- URLs are matched the way HTTP clients resolve them: `..` and `.` segments (including `%2e` encodings) are resolved first, so `api.example.com/public/../private` does not match `api.example.com/public`. Hostnames match case-insensitively, paths case-sensitively. +- URLs are matched the way HTTP clients resolve them: `..` and `.` segments (including `%2e` encodings) are resolved first, so `api.example.com/public/../private` does not match `api.example.com/public`. Percent-encoded hostname characters are decoded, and a URL without a path (`https://api.example.com?x=1`) matches as `/`. Hostnames match case-insensitively, paths case-sensitively. **Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs`, or a `forURLs` list containing `'*'`) are ignored for that URL. For example, `[{ type: 'defaults' }, { type: 'matchResponseHeaders', headers: ['x-request-id'], forURLs: ['api.example.com'] }]` captures only `x-request-id` on `api.example.com`, and the default headers everywhere else. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts index e879a86a7..33938aac0 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts @@ -223,6 +223,26 @@ describe('captureHeaders', () => { }); describe('scoped rules', () => { + it('applies a scoped rule over catch-all rules for percent-encoded hosts', () => { + const config = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchResponseHeaders', + headers: ['x-request-id'], + forURLs: ['api.example.com/v2'] + } + ]); + const raw = 'cache-control: no-cache\r\nx-request-id: abc\r\n'; + + expect( + captureResponseHeaders( + raw, + 'https://%61pi.example.com/v2/users', + config + ) + ).toEqual({ 'x-request-id': 'abc' }); + }); + it('matches forURLs hosts case-insensitively and paths case-sensitively', () => { const config = compileHeaderCaptureConfig([ { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index cfc704039..4b325e1da 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -359,6 +359,25 @@ describe('compileHeaderCaptureConfig', () => { expect(matches('https://api.example.com/PUBLIC/users')).toBe(false); }); + it('forURLs root path scope matches requests without a path', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + const matches = (url: string) => + urlRegex.test(normalizeUrlForMatching(url)); + + expect(matches('https://api.example.com')).toBe(true); + expect(matches('https://api.example.com?view=home')).toBe(true); + expect(matches('https://api.example.com#top')).toBe(true); + expect(matches('https://api.example.com/users')).toBe(true); + expect(matches('https://other.example.com')).toBe(false); + }); + it('forURLs path prefix ending with a slash matches nested paths', () => { const result = compileHeaderCaptureConfig([ { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts index 5714ee12f..0f33b0fb5 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts @@ -12,9 +12,9 @@ describe('normalizeUrlForMatching', () => { 'HTTPS://API.Example.com/Public/Users?Q=A#Top', 'https://api.example.com/Public/Users?Q=A#Top' ], - ['https://API.example.com:8443', 'https://api.example.com:8443'], - ['https://API.example.com?Q=A', 'https://api.example.com?Q=A'], - ['https://API.example.com#Top', 'https://api.example.com#Top'], + ['https://API.example.com:8443', 'https://api.example.com:8443/'], + ['https://API.example.com?Q=A', 'https://api.example.com/?Q=A'], + ['https://API.example.com#Top', 'https://api.example.com/#Top'], ['https://api.example.com/v2', 'https://api.example.com/v2'], [ 'https://User:Pass@API.example.com/Path', @@ -92,6 +92,30 @@ describe('normalizeUrlForMatching', () => { ).toBe('https://api.example.com/v2'); }); + it.each([ + ['https://%61pi.example.com/v2', 'https://api.example.com/v2'], + ['https://API.%45xample.com/V2', 'https://api.example.com/V2'], + [ + 'https://api%2Eexample%2ecom:8443/x', + 'https://api.example.com:8443/x' + ], + [ + 'https://us%65r@%61pi.example.com/x', + 'https://us%65r@api.example.com/x' + ], + [ + 'https://api.example.com%2f.evil/x', + 'https://api.example.com%2f.evil/x' + ], + [ + 'https://api.example.com%40evil.test/x', + 'https://api.example.com%40evil.test/x' + ], + ['https://api.ex%zzample.com/x', 'https://api.ex%zzample.com/x'] + ])('decodes percent-encoded hostname characters in %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + it('returns URLs without a scheme unchanged', () => { expect(normalizeUrlForMatching('/Relative/Path')).toBe( '/Relative/Path' diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts index 212ab37c9..17021946b 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts @@ -10,6 +10,10 @@ const LEADING_TRAILING_C0_OR_SPACE = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g; const TAB_OR_NEWLINE = /[\t\n\r]/g; +// Percent-encoded characters that can appear in a valid ASCII hostname +const ENCODED_HOST_CHARACTER = /%([0-9a-fA-F]{2})/g; +const HOST_CHARACTER = /^[A-Za-z0-9.-]$/; + const SINGLE_DOT_SEGMENTS = new Set(['.', '%2e']); const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); @@ -18,7 +22,9 @@ const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); * semantics of HTTP clients: * - leading/trailing control characters and spaces are trimmed, and tabs, * CRs and LFs are removed; - * - the scheme and authority are lowercased (hosts match case-insensitively); + * - the scheme and authority are lowercased (hosts match case-insensitively), + * and percent-encoded hostname characters are decoded (`%61pi` -> `api`); + * - an omitted path is treated as `/`; * - in the path, backslashes are treated as slashes and dot segments * (`.`, `..`, and their `%2e` encodings) are resolved; * - path case, query and fragment are left untouched. @@ -38,12 +44,34 @@ export const normalizeUrlForMatching = (rawUrl: string): string => { const pathEnd = findFirstOf(url, authorityEnd, '?#'); return ( - url.slice(0, authorityEnd).toLowerCase() + + normalizeAuthority(url.slice(0, authorityEnd)) + normalizePath(url.slice(authorityEnd, pathEnd)) + url.slice(pathEnd) ); }; +/** + * Lowercases the scheme and authority and decodes percent-encoded hostname + * characters. Userinfo (before the last `@`) is lowercased but not decoded, so + * an encoded `@` can never move the host boundary. Encodings that would not be + * valid in a hostname are left as-is. + */ +const normalizeAuthority = (schemeAndAuthority: string): string => { + const userinfoEnd = schemeAndAuthority.lastIndexOf('@'); + const hostStart = + userinfoEnd === -1 + ? schemeAndAuthority.indexOf('://') + 3 + : userinfoEnd + 1; + const host = schemeAndAuthority + .slice(hostStart) + .replace(ENCODED_HOST_CHARACTER, (encoded, hex: string) => { + const char = String.fromCharCode(parseInt(hex, 16)); + return HOST_CHARACTER.test(char) ? char : encoded; + }); + + return `${schemeAndAuthority.slice(0, hostStart)}${host}`.toLowerCase(); +}; + const findFirstOf = (url: string, from: number, chars: string): number => { for (let i = from; i < url.length; i++) { if (chars.includes(url[i])) { @@ -54,6 +82,11 @@ const findFirstOf = (url: string, from: number, chars: string): number => { }; const normalizePath = (path: string): string => { + // HTTP requests without a path are sent to `/` + if (path === '') { + return '/'; + } + // Fast path: nothing to resolve if (!/[.\\%]/.test(path)) { return path; From ab1c073154d26742aa15f4c5304fe76de0175759 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 12:57:57 +0100 Subject: [PATCH 25/26] fix(header-capture): canonicalize equivalent URLs and filter before storage - Normalize hostnames like HTTP clients do before scope matching: decode percent-encoded UTF-8, apply NFKC, map IDNA label separators (U+3002, U+FF0E, U+FF61) to '.', and apply the same normalization to configured forURLs hostnames - Collapse extra slashes after http(s): before matching - Drop blocked headers while parsing XHR response headers and copying Fetch headers, so they never enter SDK-owned records Co-Authored-By: Claude Opus 5.5 (1M context) --- .../resourceTracking/headerCapture/README.md | 2 +- .../__tests__/captureHeaders.test.ts | 24 +++++ .../__tests__/compileHeaderConfig.test.ts | 17 ++++ .../__tests__/normalizeUrlForMatching.test.ts | 54 ++++++++++- .../__tests__/parseResponseHeaders.test.ts | 9 ++ .../headerCapture/captureHeaders.ts | 6 +- .../headerCapture/compileHeaderConfig.ts | 7 +- .../headerCapture/normalizeUrlForMatching.ts | 95 ++++++++++++++++--- .../headerCapture/parseResponseHeaders.ts | 8 +- .../requestProxy/XHRProxy/XHRProxy.ts | 6 +- .../common/__tests__/headerCapture.test.ts | 13 +++ .../requestProxy/common/headerCapture.ts | 7 +- 12 files changed, 222 insertions(+), 26 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md index 24f22911e..de47b8c72 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -97,7 +97,7 @@ The optional `forURLs` field scopes a rule to specific URL patterns. Supports ho - A hostname matches itself and its subdomains (`api.example.com` matches `cdn.api.example.com`), but never a hostname that only appears in the path or query of another URL. - A path prefix matches whole path segments: `api.example.com/v2` matches `/v2` and `/v2/users`, but not `/v22`. -- URLs are matched the way HTTP clients resolve them: `..` and `.` segments (including `%2e` encodings) are resolved first, so `api.example.com/public/../private` does not match `api.example.com/public`. Percent-encoded hostname characters are decoded, and a URL without a path (`https://api.example.com?x=1`) matches as `/`. Hostnames match case-insensitively, paths case-sensitively. +- URLs are matched the way HTTP clients resolve them: `..` and `.` segments (including `%2e` encodings) are resolved first, so `api.example.com/public/../private` does not match `api.example.com/public`. Equivalent hostname spellings are normalized (percent-encodings, fullwidth characters, `。`/`.`/`。` label separators, extra slashes after `https:`), and a URL without a path (`https://api.example.com?x=1`) matches as `/`. Hostnames in `forURLs` are normalized the same way. Hostnames match case-insensitively, paths case-sensitively. **Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs`, or a `forURLs` list containing `'*'`) are ignored for that URL. For example, `[{ type: 'defaults' }, { type: 'matchResponseHeaders', headers: ['x-request-id'], forURLs: ['api.example.com'] }]` captures only `x-request-id` on `api.example.com`, and the default headers everywhere else. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts index 33938aac0..44bb997bc 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts @@ -243,6 +243,30 @@ describe('captureHeaders', () => { ).toEqual({ 'x-request-id': 'abc' }); }); + it.each([ + 'https://api\uFF0Eexample.com/v2/users', + 'https://api%EF%BC%8Eexample.com/v2/users', + 'https:///api.example.com/v2/users' + ])( + 'applies a scoped rule over catch-all rules for the equivalent spelling %s', + url => { + const config = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchResponseHeaders', + headers: ['x-request-id'], + forURLs: ['api.example.com/v2'] + } + ]); + const raw = + 'cache-control: no-cache\r\nx-request-id: abc\r\n'; + + expect(captureResponseHeaders(raw, url, config)).toEqual({ + 'x-request-id': 'abc' + }); + } + ); + it('matches forURLs hosts case-insensitively and paths case-sensitively', () => { const config = compileHeaderCaptureConfig([ { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts index 4b325e1da..261e086f4 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -359,6 +359,23 @@ describe('compileHeaderCaptureConfig', () => { expect(matches('https://api.example.com/PUBLIC/users')).toBe(false); }); + it('forURLs normalizes configured hostnames like request hostnames', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['API\uFF0EExample.com/v2'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + + expect( + urlRegex.test( + normalizeUrlForMatching('https://api.example.com/v2/users') + ) + ).toBe(true); + }); + it('forURLs root path scope matches requests without a path', () => { const result = compileHeaderCaptureConfig([ { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts index 0f33b0fb5..a251eab66 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts @@ -4,7 +4,10 @@ * Copyright 2016-Present Datadog, Inc. */ -import { normalizeUrlForMatching } from '../normalizeUrlForMatching'; +import { + normalizeHostname, + normalizeUrlForMatching +} from '../normalizeUrlForMatching'; describe('normalizeUrlForMatching', () => { it.each([ @@ -116,6 +119,55 @@ describe('normalizeUrlForMatching', () => { expect(normalizeUrlForMatching(url)).toBe(expected); }); + it.each([ + [ + 'https:///api.example.com/v2/users', + 'https://api.example.com/v2/users' + ], + ['https:////api.example.com/v2', 'https://api.example.com/v2'], + ['https:\\\\api.example.com/v2', 'https://api.example.com/v2'], + ['HTTPS:api.example.com/v2', 'https://api.example.com/v2'], + ['http:/api.example.com', 'http://api.example.com/'] + ])('collapses the slashes after the scheme of %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + + it.each([ + [ + 'https://api\uFF0Eexample.com/v2/users', + 'https://api.example.com/v2/users' + ], + [ + 'https://api%EF%BC%8Eexample.com/v2/users', + 'https://api.example.com/v2/users' + ], + ['https://api\u3002example\uFF61com/v2', 'https://api.example.com/v2'], + [ + 'https://\uFF41\uFF50\uFF49.example.com/v2', + 'https://api.example.com/v2' + ], + [ + 'https://api\uFF0Eexample.com:8443/v2', + 'https://api.example.com:8443/v2' + ], + ['https://[::1]:8080/v2', 'https://[::1]:8080/v2'] + ])('maps Unicode hostname equivalents in %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + + describe('normalizeHostname', () => { + it.each([ + ['API.Example.com', 'api.example.com'], + ['api\uFF0Eexample.com', 'api.example.com'], + ['%61pi.example.com', 'api.example.com'], + // Decoding must not introduce characters that change the host + ['api.example.com%2fevil', 'api.example.com%2fevil'], + ['api.example.com%E0%A4%A', 'api.example.com%e0%a4%a'] + ])('normalizes %s', (hostname, expected) => { + expect(normalizeHostname(hostname)).toBe(expected); + }); + }); + it('returns URLs without a scheme unchanged', () => { expect(normalizeUrlForMatching('/Relative/Path')).toBe( '/Relative/Path' diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts index 994025bf1..cfd27ae62 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts @@ -4,9 +4,18 @@ * Copyright 2016-Present Datadog, Inc. */ +import { isHeaderAllowed } from '../isHeaderAllowed'; import { parseResponseHeaders } from '../parseResponseHeaders'; describe('parseResponseHeaders', () => { + it('never stores headers rejected by the isAllowed filter', () => { + const raw = + 'set-cookie: session=secret\r\nauthorization: Bearer x\r\nx-datadog-trace-id: 1\r\ncache-control: no-cache\r\n'; + expect(parseResponseHeaders(raw, isHeaderAllowed)).toEqual({ + 'cache-control': 'no-cache' + }); + }); + it('parses standard CRLF-delimited headers', () => { const raw = 'content-type: text/html\r\ncache-control: no-cache\r\n'; expect(parseResponseHeaders(raw)).toEqual({ diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts index ea88a9ee3..6c80255ad 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -268,7 +268,11 @@ export const captureResponseHeaders = ( return undefined; } - return filterResponseHeaders(parseResponseHeaders(rawHeaders), url, config); + return filterResponseHeaders( + parseResponseHeaders(rawHeaders, isHeaderAllowed), + url, + config + ); }; /** diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts index 6eddac0dd..78235004a 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -15,6 +15,7 @@ import { CANONICAL_REQUEST_HEADERS, CANONICAL_RESPONSE_HEADERS } from './captureHeaders'; +import { normalizeHostname } from './normalizeUrlForMatching'; import type { CompiledHeaderCaptureConfig, CompiledHeaderCaptureRule @@ -47,9 +48,9 @@ const buildUrlMatchRegex = (match: string): RegExp | null => { const hostname = slashIndex === -1 ? match : match.slice(0, slashIndex); const pathPrefix = slashIndex === -1 ? '' : match.slice(slashIndex); - // Hostnames are case-insensitive: the pattern host is lowercased and URLs are - // normalized with normalizeUrlForMatching. Paths stay case-sensitive. - const escapedHost = escapeRegExp(hostname.toLowerCase()); + // The pattern host goes through the same normalizeHostname as request URLs + // (normalizeUrlForMatching), so hosts match case-insensitively. Paths stay case-sensitive. + const escapedHost = escapeRegExp(normalizeHostname(hostname)); // Host and path prefix must be followed by /, ?, # or end-of-string. // A path prefix ending with '/' is already on a segment boundary. const boundary = '(/|\\?|#|$)'; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts index 17021946b..c4b2b7a39 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts @@ -10,9 +10,17 @@ const LEADING_TRAILING_C0_OR_SPACE = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g; const TAB_OR_NEWLINE = /[\t\n\r]/g; -// Percent-encoded characters that can appear in a valid ASCII hostname +// HTTP(S) URL parsers accept any number of slashes (or backslashes) after the scheme +const HTTP_SCHEME_AND_SLASHES = /^(https?):[/\\]*/i; + const ENCODED_HOST_CHARACTER = /%([0-9a-fA-F]{2})/g; const HOST_CHARACTER = /^[A-Za-z0-9.-]$/; +// Label separators that IDNA maps to '.' +const ALTERNATIVE_DOTS = /[\u3002\uFF0E\uFF61]/g; +// Characters that cannot appear in a hostname: decoding must never produce them, +// otherwise an encoded character could move the host boundary +// eslint-disable-next-line no-control-regex +const FORBIDDEN_HOST_CHARACTER = /[\u0000-\u0020/\\?#@:%<>[\]^|]/; const SINGLE_DOT_SEGMENTS = new Set(['.', '%2e']); const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); @@ -22,8 +30,9 @@ const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); * semantics of HTTP clients: * - leading/trailing control characters and spaces are trimmed, and tabs, * CRs and LFs are removed; - * - the scheme and authority are lowercased (hosts match case-insensitively), - * and percent-encoded hostname characters are decoded (`%61pi` -> `api`); + * - extra slashes after `http(s):` are collapsed (`https:///host` -> `https://host`); + * - the scheme and authority are lowercased, and the hostname is normalized + * with {@link normalizeHostname}; * - an omitted path is treated as `/`; * - in the path, backslashes are treated as slashes and dot segments * (`.`, `..`, and their `%2e` encodings) are resolved; @@ -34,7 +43,8 @@ const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); export const normalizeUrlForMatching = (rawUrl: string): string => { const url = rawUrl .replace(LEADING_TRAILING_C0_OR_SPACE, '') - .replace(TAB_OR_NEWLINE, ''); + .replace(TAB_OR_NEWLINE, '') + .replace(HTTP_SCHEME_AND_SLASHES, '$1://'); const schemeEnd = url.indexOf('://'); if (schemeEnd === -1) { return url; @@ -51,10 +61,60 @@ export const normalizeUrlForMatching = (rawUrl: string): string => { }; /** - * Lowercases the scheme and authority and decodes percent-encoded hostname - * characters. Userinfo (before the last `@`) is lowercased but not decoded, so - * an encoded `@` can never move the host boundary. Encodings that would not be - * valid in a hostname are left as-is. + * Normalizes a hostname the way HTTP clients resolve it before connecting: + * percent-encodings are decoded, Unicode compatibility characters are mapped + * (NFKC, e.g. fullwidth letters), IDNA label separators (`。`, `.`, `。`) + * become `.`, and the result is lowercased. + * Used for both request URLs and configured `forURLs` hostnames. + * + * If decoding would produce a character that is not allowed in a hostname, + * only ASCII hostname characters are decoded. + */ +export const normalizeHostname = (hostname: string): string => { + if (/^[A-Za-z0-9.-]*$/.test(hostname)) { + return hostname.toLowerCase(); + } + + const normalized = mapUnicodeHostname(decodeUtf8(hostname)); + if (!FORBIDDEN_HOST_CHARACTER.test(normalized)) { + return normalized; + } + + return mapUnicodeHostname( + hostname.replace(ENCODED_HOST_CHARACTER, (encoded, hex: string) => { + const char = String.fromCharCode(parseInt(hex, 16)); + return HOST_CHARACTER.test(char) ? char : encoded; + }) + ); +}; + +const decodeUtf8 = (value: string): string => { + try { + return decodeURIComponent(value); + } catch (_e) { + // Malformed percent-encoding: leave it to the ASCII-only fallback + return value; + } +}; + +const mapUnicodeHostname = (hostname: string): string => { + let mapped = hostname; + try { + // Not available on every JS engine configuration + if (typeof mapped.normalize === 'function') { + mapped = mapped.normalize('NFKC'); + } + } catch (_e) { + // Keep the unmapped hostname + } + return mapped.replace(ALTERNATIVE_DOTS, '.').toLowerCase(); +}; + +/** + * Lowercases the scheme and authority and normalizes the hostname. + * Userinfo (before the last `@`) is lowercased but not decoded, and the port + * is kept as-is, so encoded characters can never move the host boundaries. + * IPv6 literals (`[...]`) are only lowercased. */ const normalizeAuthority = (schemeAndAuthority: string): string => { const userinfoEnd = schemeAndAuthority.lastIndexOf('@'); @@ -62,14 +122,19 @@ const normalizeAuthority = (schemeAndAuthority: string): string => { userinfoEnd === -1 ? schemeAndAuthority.indexOf('://') + 3 : userinfoEnd + 1; - const host = schemeAndAuthority - .slice(hostStart) - .replace(ENCODED_HOST_CHARACTER, (encoded, hex: string) => { - const char = String.fromCharCode(parseInt(hex, 16)); - return HOST_CHARACTER.test(char) ? char : encoded; - }); + const hostAndPort = schemeAndAuthority.slice(hostStart); + const portMatch = /:\d*$/.exec(hostAndPort); + const host = portMatch + ? hostAndPort.slice(0, portMatch.index) + : hostAndPort; + const port = portMatch ? portMatch[0] : ''; + const normalizedHost = host.startsWith('[') + ? host.toLowerCase() + : normalizeHostname(host); - return `${schemeAndAuthority.slice(0, hostStart)}${host}`.toLowerCase(); + return `${schemeAndAuthority + .slice(0, hostStart) + .toLowerCase()}${normalizedHost}${port}`; }; const findFirstOf = (url: string, from: number, chars: string): number => { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts index dee4427ae..4b3792e0c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts @@ -16,9 +16,11 @@ * - Header names are trimmed and lowercased; values are trimmed (HTTP OWS) * - Duplicate names resolve to last-value-wins * - Malformed lines (no colon, empty name) are silently skipped + * - Headers rejected by `isAllowed` (if provided) are never stored */ export const parseResponseHeaders = ( - rawHeaders: string | null | undefined + rawHeaders: string | null | undefined, + isAllowed?: (headerName: string) => boolean ): Record => { if (!rawHeaders) { return {}; @@ -42,8 +44,8 @@ export const parseResponseHeaders = ( const name = line.slice(0, colonIndex).trim().toLowerCase(); - // Name is empty after trimming — skip - if (name === '') { + // Name is empty after trimming, or the header is not allowed — skip + if (name === '' || (isAllowed !== undefined && !isAllowed(name))) { continue; } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index bba2cb07a..8d8429264 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -7,6 +7,7 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types'; import { extractGraphQLErrors } from '../../graphql/graphqlUtils'; +import { isHeaderAllowed } from '../../headerCapture/isHeaderAllowed'; import { parseResponseHeaders } from '../../headerCapture/parseResponseHeaders'; import { isRunningWithinFetchProxy } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; @@ -207,7 +208,10 @@ const reportXhr = async ( url: context.url, statusCode, responseHeaders: () => - parseResponseHeaders(xhrProxy.getAllResponseHeaders()) + parseResponseHeaders( + xhrProxy.getAllResponseHeaders(), + isHeaderAllowed + ) }); // Only extract GraphQL errors if operationType is set AND error tracking is enabled diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts index 5ff3b657c..d9edc720d 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts @@ -131,6 +131,19 @@ describe('headerCapture', () => { }); describe('fetchHeadersToRecord', () => { + it('never copies blocked headers', () => { + expect( + fetchHeadersToRecord( + new Headers({ + 'Set-Cookie': 'session=secret', + Authorization: 'Bearer secret', + 'x-datadog-tracked-by': 'react-native', + 'Cache-Control': 'no-cache' + }) + ) + ).toEqual({ 'cache-control': 'no-cache' }); + }); + it('lowercases header names', () => { expect( fetchHeadersToRecord( diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts index fc6b9ae5f..b8ebcbc3a 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts @@ -10,6 +10,7 @@ import { filterResponseHeaders } from '../../headerCapture/captureHeaders'; import { enforceSizeLimits } from '../../headerCapture/enforceSizeLimits'; +import { isHeaderAllowed } from '../../headerCapture/isHeaderAllowed'; import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; import { isDatadogCustomHeader } from '../../headers'; @@ -97,6 +98,7 @@ export const finalizeCapturedHeaders = ({ /** * Converts a Fetch `Headers` object into a record keyed by lowercased name. + * Blocked headers (sensitive or SDK-injected) are never copied. * Some Fetch implementations (e.g. older `expo/fetch`) may not implement * `forEach`, in which case no headers are returned. */ @@ -108,7 +110,10 @@ export const fetchHeadersToRecord = ( return result; } headers.forEach((value: string, name: string) => { - result[name.toLowerCase()] = value; + const lowered = name.toLowerCase(); + if (isHeaderAllowed(lowered)) { + result[lowered] = value; + } }); return result; }; From b2f134418ea26614e10f320885d95394c7d821b5 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 26 Oct 2026 13:10:24 +0100 Subject: [PATCH 26/26] chore: remove accidentally committed .vscode/settings.json changes Co-Authored-By: Claude Opus 5.5 (1M context) --- .vscode/settings.json | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index aebae3196..7a25f1934 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,17 +36,4 @@ "source.fixAll.eslint": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib", - "workbench.colorCustomizations": { - "activityBar.background": "#580F33", - "titleBar.activeBackground": "#7C1648", - "titleBar.activeForeground": "#FEFCFD", - "titleBar.inactiveBackground": "#580F33", - "titleBar.inactiveForeground": "#FEFCFD", - "statusBar.background": "#580F33", - "statusBar.foreground": "#FEFCFD", - "statusBar.debuggingBackground": "#580F33", - "statusBar.debuggingForeground": "#FEFCFD", - "statusBar.noFolderBackground": "#580F33", - "statusBar.noFolderForeground": "#FEFCFD" - }, } \ No newline at end of file