import { combine, generateUUID, toServerDuration, relativeToClocks, createTaskQueue, mockable, runOnReadyState, matchList, safeTruncate, display, addTelemetryDebug, RequestType, } from '@datadog/browser-core' import type { MatchHeader, RumConfiguration } from '../configuration' import { RumPerformanceEntryType, createPerformanceObservable } from '../../browser/performanceObservable' import type { RumResourceEventDomainContext } from '../../domainContext.types' import type { NetworkHeaders, RawRumResourceEvent, ResourceRequest, ResourceResponse } from '../../rawRumEvent.types' import { RumEventType } from '../../rawRumEvent.types' import type { RawRumEventCollectedData, LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' import type { RequestCompleteEvent } from '../requestCollection' import { createSpanIdentifier } from '../tracing/identifier' import { getDocumentTraceId } from '../tracing/getDocumentTraceId' import { getNavigationEntry } from '../../browser/performanceUtils' import { startEventTracker } from '../eventTracker' import { extractRegexMatch } from '../extractRegexMatch' import { computeResourceEntryDetails, computeResourceEntryDuration, computeResourceEntryType, computeResourceEntrySize, computeResourceEntryProtocol, computeResourceEntryDeliveryType, isResourceEntryRequestType, sanitizeIfLongDataUrl, } from './resourceUtils' import type { ResourceLikeEntry } from './resourceUtils' import type { RequestRegistry } from './requestRegistry' import { createRequestRegistry } from './requestRegistry' import type { GraphQlMetadata } from './graphql' import { extractGraphQlMetadata, findGraphQlConfiguration } from './graphql' import type { ManualResourceData } from './trackManualResources' import { trackManualResources } from './trackManualResources' export function startResourceCollection(lifeCycle: LifeCycle, configuration: RumConfiguration) { const taskQueue = mockable(createTaskQueue)() const requestRegistry = createRequestRegistry(lifeCycle) const performanceResourceSubscription = createPerformanceObservable(configuration, { type: RumPerformanceEntryType.RESOURCE, buffered: true, }).subscribe((entries) => { for (const entry of entries) { handleResource(() => assembleResource(entry, requestRegistry, configuration)) } }) const { stop: stopRunOnReadyState } = runOnReadyState(configuration, 'interactive', () => { handleResource(() => assembleResource(getNavigationEntry(), requestRegistry, configuration)) }) function handleResource(computeRawEvent: () => RawRumEventCollectedData | undefined) { taskQueue.push(() => { const rawEvent = computeRawEvent() if (rawEvent) { lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, rawEvent) } }) } const resourceTracker = startEventTracker(lifeCycle) const manualResources = trackManualResources(lifeCycle, resourceTracker) return { startResource: manualResources.startResource, stopResource: manualResources.stopResource, stop: () => { stopRunOnReadyState() taskQueue.stop() performanceResourceSubscription.unsubscribe() resourceTracker.stopAll() }, } } function assembleResource( entry: ResourceLikeEntry, requestRegistry: RequestRegistry, configuration: RumConfiguration ): RawRumEventCollectedData | undefined { const request = isResourceEntryRequestType(entry) ? requestRegistry.getMatchingRequest(entry) : undefined const tracingInfo = request ? computeRequestTracingInfo(request, configuration) : computeResourceEntryTracingInfo(entry, configuration) if (!configuration.trackResources && !tracingInfo) { return } const startClocks = relativeToClocks(entry.startTime) const duration = computeResourceEntryDuration(entry) const networkHeaders = computeNetworkHeaders(request, configuration) const resourceEvent = combine( { date: startClocks.timeStamp, resource: { id: generateUUID(), duration: toServerDuration(duration), type: computeResourceEntryType(entry), method: request?.method, status_code: request ? request.status : discardZeroStatus(entry.responseStatus), url: request ? sanitizeIfLongDataUrl(request.url) : entry.name, protocol: computeResourceEntryProtocol(entry), delivery_type: computeResourceEntryDeliveryType(entry), graphql: request && computeGraphQlMetaData(request, configuration), render_blocking_status: entry.renderBlockingStatus, ...computeResourceEntrySize(entry), ...computeResourceEntryDetails(entry), }, type: RumEventType.RESOURCE, _dd: { discarded: !configuration.trackResources, }, }, tracingInfo, computeContentTypeFromPerformanceEntry(entry), networkHeaders ) return { startClocks, duration, rawRumEvent: resourceEvent, domainContext: getResourceDomainContext(entry, request), } } function computeGraphQlMetaData( request: RequestCompleteEvent, configuration: RumConfiguration ): GraphQlMetadata | undefined { const graphQlConfig = findGraphQlConfiguration(request.url, configuration) if (!graphQlConfig) { return } return extractGraphQlMetadata(request, graphQlConfig) } function computeContentTypeFromPerformanceEntry( entry: ResourceLikeEntry ): { resource: Pick } | undefined { const contentType = entry.contentType if (contentType) { return { resource: { response: { headers: { 'content-type': contentType, }, }, }, } } return undefined } function getResourceDomainContext( entry: ResourceLikeEntry, request: RequestCompleteEvent | undefined ): RumResourceEventDomainContext { return { performanceEntry: entry as unknown as PerformanceResourceTiming | PerformanceNavigationTiming, isManual: false, isAborted: request ? request.isAborted : false, handlingStack: request?.handlingStack, requestInit: request?.init, requestInput: request?.input as RequestInfo | undefined, response: request?.response, error: request?.error, xhr: request?.xhr, } } function computeRequestTracingInfo(request: RequestCompleteEvent, configuration: RumConfiguration) { const hasBeenTraced = request.traceSampled && request.traceId && request.spanId if (!hasBeenTraced) { return undefined } return { _dd: { span_id: request.spanId!.toString(), trace_id: request.traceId!.toString(), rule_psr: configuration.rulePsr, }, } } function computeResourceEntryTracingInfo(entry: ResourceLikeEntry, configuration: RumConfiguration) { if (entry.initiatorType !== 'navigation') { return undefined } const traceId = mockable(getDocumentTraceId)(document) if (!traceId) { return undefined } return { _dd: { trace_id: traceId, span_id: createSpanIdentifier().toString(), rule_psr: configuration.rulePsr, }, } } /** * The status is 0 for cross-origin resources without CORS headers, so the status is meaningless, and we shouldn't report it * https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus#cross-origin_response_status_codes */ function discardZeroStatus(statusCode: number | undefined): number | undefined { return statusCode === 0 ? undefined : statusCode } function computeNetworkHeaders( request: RequestCompleteEvent | undefined, configuration: RumConfiguration ): { resource: { request?: ResourceRequest; response?: ResourceResponse } } | undefined { const matchers = configuration.trackResourceHeaders if (matchers.length === 0 || !request) { return undefined } const urlMatchers = matchers.filter((m) => (m.url !== undefined ? matchList([m.url], request.url, true) : true)) if (urlMatchers.length === 0) { return undefined } const responseMatchers = urlMatchers.filter( (m) => m.location === undefined || m.location === 'any' || m.location === 'response' ) const requestMatchers = urlMatchers.filter( (m) => m.location === undefined || m.location === 'any' || m.location === 'request' ) const responseHeaders = responseMatchers.length > 0 ? getResponseHeaders(request, responseMatchers) : undefined const requestHeaders = requestMatchers.length > 0 ? getRequestHeaders(request, requestMatchers) : undefined if (!responseHeaders && !requestHeaders) { return undefined } return { resource: { request: requestHeaders ? { headers: requestHeaders } : undefined, response: responseHeaders ? { headers: responseHeaders } : undefined, }, } } function getResponseHeaders(request: RequestCompleteEvent, matchers: MatchHeader[]): NetworkHeaders | undefined { if (request.type === RequestType.FETCH && request.response) { return filterHeaders(request.response.headers, matchers) } if (request.type === RequestType.XHR && request.xhr) { const rawXhrHeaders = request.xhr.getAllResponseHeaders() if (rawXhrHeaders) { try { return filterHeaders(new Headers(parseRawXhrHeaders(rawXhrHeaders)), matchers) } catch { // Ignore parsing errors } } } return undefined } function getRequestHeaders(request: RequestCompleteEvent, matchers: MatchHeader[]): NetworkHeaders | undefined { if (request.type !== RequestType.FETCH) { return undefined } let headers: Headers | undefined if (request.init?.headers) { headers = new Headers(request.init.headers) } else if (request.input instanceof Request) { headers = request.input.headers } return headers ? filterHeaders(headers, matchers) : undefined } const FORBIDDEN_HEADER_PATTERN = /(token|cookie|secret|authorization|(api|secret|access|app).?key|(client|connecting|real).?ip|forwarded)/ const MAX_HEADER_COUNT = 100 const MAX_HEADER_VALUE_LENGTH = 128 function filterHeaders(headers: Headers, matchers: MatchHeader[]): NetworkHeaders | undefined { const result: NetworkHeaders = {} let collectedHeaderCount = 0 let totalHeaderCount = 0 let hasReachedMaxHeaderCount = false headers.forEach((value, name) => { totalHeaderCount++ if (collectedHeaderCount >= MAX_HEADER_COUNT) { if (!hasReachedMaxHeaderCount) { display.warn(`Maximum number of headers (${MAX_HEADER_COUNT}) has been reached. Further headers are dropped.`) hasReachedMaxHeaderCount = true } return } const lowerName = name.toLowerCase() if (FORBIDDEN_HEADER_PATTERN.test(lowerName)) { return } const matchHeader = matchers.find((m) => matchList([m.name], lowerName)) if (!matchHeader) { return } const { extractor } = matchHeader const capturedValue = extractor ? extractRegexMatch(value, extractor) : value if (capturedValue === undefined) { return } if (capturedValue.length > MAX_HEADER_VALUE_LENGTH) { display.warn( `Header "${lowerName}" value was truncated from ${capturedValue.length} to ${MAX_HEADER_VALUE_LENGTH} characters.` ) // monitor-until: 2026-05-23 addTelemetryDebug('Resource header value was truncated', { header_name: lowerName, original_length: capturedValue.length, limit: MAX_HEADER_VALUE_LENGTH, }) } result[lowerName] = safeTruncate(capturedValue, MAX_HEADER_VALUE_LENGTH) collectedHeaderCount++ }) if (hasReachedMaxHeaderCount) { // monitor-until: 2026-05-23 addTelemetryDebug('Maximum number of resource headers reached', { collectedHeaderCount, totalHeaderCount, }) } return collectedHeaderCount > 0 ? result : undefined } // Input: "content-type: application/json\r\ncache-control: no-cache" // Output: [["content-type", "application/json"], ["cache-control", "no-cache"]] function parseRawXhrHeaders(rawXhrheaders: string): Array<[string, string]> { const pairs: Array<[string, string]> = [] const lines = rawXhrheaders.trim().split(/\r\n/) for (const line of lines) { const colonIndex = line.indexOf(':') if (colonIndex > 0) { pairs.push([line.substring(0, colonIndex).trim(), line.substring(colonIndex + 1).trim()]) } } return pairs }