import { type Span } from '@opentelemetry/api' import { MULTIPLAYER_TRACE_DEBUG_PREFIX, MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX, MULTIPLAYER_TRACE_SESSION_CACHE_PREFIX, ATTR_MULTIPLAYER_HTTP_REQUEST_BODY, ATTR_MULTIPLAYER_HTTP_REQUEST_HEADERS, ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY, ATTR_MULTIPLAYER_HTTP_RESPONSE_HEADERS, } from '@multiplayer-app/session-recorder-common' import { logger } from '../utils' import { type TracerReactNativeConfig } from '../types' export interface HttpPayloadData { requestBody?: any responseBody?: any requestHeaders?: Record responseHeaders?: Record } export interface ProcessedHttpPayload { requestBody?: string responseBody?: string requestHeaders?: string responseHeaders?: string } /** * Checks if the trace should be processed based on trace ID prefixes */ export function shouldProcessTrace(traceId: string): boolean { return ( traceId.startsWith(MULTIPLAYER_TRACE_DEBUG_PREFIX) || traceId.startsWith(MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX) || traceId.startsWith(MULTIPLAYER_TRACE_SESSION_CACHE_PREFIX) ) } /** * Processes request and response body based on trace type and configuration */ export function processBody( payload: HttpPayloadData, config: TracerReactNativeConfig, span: Span, ): { requestBody?: string; responseBody?: string } { const { captureBody, masking } = config const traceId = span.spanContext().traceId if (!captureBody) { return {} } let { requestBody, responseBody } = payload if (requestBody !== undefined && requestBody !== null) { requestBody = JSON.parse(JSON.stringify(requestBody)) } if (responseBody !== undefined && responseBody !== null) { responseBody = JSON.parse(JSON.stringify(responseBody)) } // Apply masking for debug traces if ( traceId.startsWith(MULTIPLAYER_TRACE_DEBUG_PREFIX) || traceId.startsWith(MULTIPLAYER_TRACE_CONTINUOUS_DEBUG_PREFIX) || traceId.startsWith(MULTIPLAYER_TRACE_SESSION_CACHE_PREFIX) ) { if (masking.isContentMaskingEnabled) { requestBody = requestBody && masking.maskBody?.(requestBody, span) responseBody = responseBody && masking.maskBody?.(responseBody, span) } } // Convert to string if needed if (typeof requestBody !== 'string') { requestBody = JSON.stringify(requestBody) } if (typeof responseBody !== 'string') { responseBody = JSON.stringify(responseBody) } return { requestBody: requestBody?.length ? requestBody : undefined, responseBody: responseBody?.length ? responseBody : undefined, } } /** * Processes request and response headers based on configuration */ export function processHeaders( payload: HttpPayloadData, config: TracerReactNativeConfig, span: Span, ): { requestHeaders?: string; responseHeaders?: string } { const { captureHeaders, masking } = config if (!captureHeaders) { return {} } let { requestHeaders = {}, responseHeaders = {} } = payload // Handle header filtering if (!masking.headersToInclude?.length && !masking.headersToExclude?.length) { // Add null checks to prevent JSON.parse error when headers is undefined if (requestHeaders !== undefined && requestHeaders !== null) { requestHeaders = JSON.parse(JSON.stringify(requestHeaders)) } if (responseHeaders !== undefined && responseHeaders !== null) { responseHeaders = JSON.parse(JSON.stringify(responseHeaders)) } } else { if (masking.headersToInclude) { const _requestHeaders: Record = {} const _responseHeaders: Record = {} for (const headerName of masking.headersToInclude) { if (requestHeaders[headerName]) { _requestHeaders[headerName] = requestHeaders[headerName] } if (responseHeaders[headerName]) { _responseHeaders[headerName] = responseHeaders[headerName] } } requestHeaders = _requestHeaders responseHeaders = _responseHeaders } if (masking.headersToExclude?.length) { for (const headerName of masking.headersToExclude) { delete requestHeaders[headerName] delete responseHeaders[headerName] } } } // Apply masking const maskedRequestHeaders = masking.maskHeaders?.(requestHeaders, span) || requestHeaders const maskedResponseHeaders = masking.maskHeaders?.(responseHeaders, span) || responseHeaders // Convert to string const requestHeadersStr = typeof maskedRequestHeaders === 'string' ? maskedRequestHeaders : JSON.stringify(maskedRequestHeaders) const responseHeadersStr = typeof maskedResponseHeaders === 'string' ? maskedResponseHeaders : JSON.stringify(maskedResponseHeaders) return { requestHeaders: requestHeadersStr?.length ? requestHeadersStr : undefined, responseHeaders: responseHeadersStr?.length ? responseHeadersStr : undefined, } } /** * Processes HTTP payload (body and headers) and sets span attributes */ export function processHttpPayload( payload: HttpPayloadData, config: TracerReactNativeConfig, span: Span, ): void { const traceId = span.spanContext().traceId if (!shouldProcessTrace(traceId)) { return } const { requestBody, responseBody } = processBody(payload, config, span) const { requestHeaders, responseHeaders } = processHeaders( payload, config, span, ) // Set span attributes if (requestBody) { span.setAttribute(ATTR_MULTIPLAYER_HTTP_REQUEST_BODY, requestBody) } if (responseBody) { span.setAttribute(ATTR_MULTIPLAYER_HTTP_RESPONSE_BODY, responseBody) } if (requestHeaders) { span.setAttribute(ATTR_MULTIPLAYER_HTTP_REQUEST_HEADERS, requestHeaders) } if (responseHeaders) { span.setAttribute(ATTR_MULTIPLAYER_HTTP_RESPONSE_HEADERS, responseHeaders) } } /** * Converts Headers object to plain object */ export function headersToObject( headers: | Headers | Record | Record | string[][] | undefined, ): Record { const result: Record = {} if (!headers) { return result } if (headers instanceof Headers) { headers.forEach((value: string, key: string) => { result[key] = value }) } else if (Array.isArray(headers)) { // Handle array of [key, value] pairs for (const [key, value] of headers) { if (typeof key === 'string' && typeof value === 'string') { result[key] = value } } } else if (typeof headers === 'object' && !Array.isArray(headers)) { for (const [key, value] of Object.entries(headers)) { if (typeof key === 'string' && typeof value === 'string') { result[key] = value } } } return result } /** * Extracts response body as string from Response object */ export async function extractResponseBody( response: Response, ): Promise { if (!response.body) { return null } try { if (response.body instanceof ReadableStream) { // Check if response body is already consumed if (response.bodyUsed) { return null } const responseClone = response.clone() return responseClone.text() } else { return JSON.stringify(response.body) } } catch (error) { // If cloning fails (body already consumed), return null logger.warn( 'MULTIPLAYER_SESSION_RECORDER', 'Failed to extract response body', error, ) return null } } export const getExporterEndpoint = (exporterEndpoint: string): string => { const hasPath = exporterEndpoint && (() => { try { const url = new URL(exporterEndpoint) return url.pathname !== '/' && url.pathname !== '' } catch { return false } })() if (hasPath) { return exporterEndpoint } const trimmedExporterEndpoint = new URL(exporterEndpoint).origin return `${trimmedExporterEndpoint}/v1/traces` }