diff --git a/e2e/react-router/src/main.tsx b/e2e/react-router/src/main.tsx index cd5516c39..32ec8094e 100644 --- a/e2e/react-router/src/main.tsx +++ b/e2e/react-router/src/main.tsx @@ -11,6 +11,7 @@ import { import Root from './routes/root' import Welcome from './routes/welcome' import PrivacyDemo from './routes/privacy-demo' +import HttpTest from './routes/http-test' function rootAction() { const contact = { name: 'hello' } @@ -57,6 +58,7 @@ const router = createBrowserRouter( } /> } /> + } /> , ), ) diff --git a/e2e/react-router/src/routes/http-test.tsx b/e2e/react-router/src/routes/http-test.tsx new file mode 100644 index 000000000..8bd16f885 --- /dev/null +++ b/e2e/react-router/src/routes/http-test.tsx @@ -0,0 +1,651 @@ +import { useEffect, useState } from 'react' +import { recordObservability } from '../ldclientLazy' + +interface TestButtonProps { + title: string + description: string + onClick: () => Promise | void +} + +function TestButton({ title, description, onClick }: TestButtonProps) { + return ( + + ) +} + +interface TestSectionProps { + title: string + description: string + children: React.ReactNode +} + +function TestSection({ title, description, children }: TestSectionProps) { + return ( +
+

{title}

+

{description}

+
+ {children} +
+
+ ) +} + +export default function HttpTest() { + const [isRecording, setIsRecording] = useState(false) + + useEffect(() => { + // Auto-start observability recording when page loads + const startRecording = async () => { + await recordObservability() + setIsRecording(true) + console.log('Observability recording started automatically') + } + startRecording() + }, []) + + return ( +
+ +

HTTP Request Testing

+

+ {isRecording + ? '✓ Observability recording active' + : '⏳ Starting recording...'} +

+ + + { + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/1?test=authorization-header', + { + headers: { + Authorization: + 'Bearer super-secret-token-12345', + }, + }, + ) + const data = await response.json() + console.log('Authorization header response:', data) + } catch (e) { + console.error('Request error:', e) + } + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/2?test=cookie-header', + { + headers: { + Cookie: 'sessionid=abc123; user_token=xyz789; preferences=dark_mode', + }, + }, + ) + const data = await response.json() + console.log('Cookie header response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/3?test=proxy-auth-header', + { + headers: { + 'Proxy-Authorization': + 'Basic dXNlcjpwYXNzd29yZA==', + }, + }, + ) + const data = await response.json() + console.log('Proxy-Authorization response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/4?test=token-header', + { + headers: { + Token: 'my-custom-token-value', + }, + }, + ) + const data = await response.json() + console.log('Token header response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/5?test=safe-headers', + { + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'safe-value', + 'User-Agent': 'TestApp/1.0', + }, + }, + ) + const data = await response.json() + console.log('Safe headers response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/6?test=mixed-headers', + { + headers: { + Authorization: 'Bearer should-be-redacted', + Cookie: 'session=should-be-redacted', + 'Content-Type': 'application/json', + 'X-Request-ID': '12345', + }, + }, + ) + const data = await response.json() + console.log('Mixed headers response:', data) + }} + /> + + + + { + // Note: This will fail due to invalid credentials, but we're testing URL sanitization + try { + await fetch( + 'https://user:password@jsonplaceholder.typicode.com/posts/10?test=url-with-credentials', + ) + } catch (e) { + console.log('Expected error:', e) + } + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/11?test=sig-param&sig=secret123', + ) + const data = await response.json() + console.log('Query param (sig) response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/12?test=aws-key&awsAccessKeyId=AKIAIOSFODNN7EXAMPLE', + ) + const data = await response.json() + console.log('AWS access key response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/13?test=multiple-sigs&signature=abc123&x-goog-signature=xyz789', + ) + const data = await response.json() + console.log('Multiple signatures response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts?userId=1&test=safe-params&_limit=5', + ) + const data = await response.json() + console.log('Safe query params response:', data) + }} + /> + + + + { + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts?test=post-json-body', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: 'Test Post', + body: 'This is a test post body', + userId: 1, + }), + }, + ) + const data = await response.json() + console.log('POST response:', data) + } catch (e) { + console.error('POST request error:', e) + } + }} + /> + + { + try { + const response = await fetch( + 'https://api.github.com/graphql?test=graphql-request', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + operationName: 'GetViewer', + query: 'query GetViewer { viewer { login name } }', + variables: { id: '123' }, + }), + }, + ) + const data = await response.json() + console.log('GraphQL response:', data) + } catch (e) { + console.error('GraphQL request error:', e) + } + }} + /> + + { + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/20?test=put-with-auth', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer secret-token', + }, + body: JSON.stringify({ + id: 20, + title: 'Updated title', + body: 'Updated body', + userId: 1, + }), + }, + ) + const data = await response.json() + console.log('PUT response:', data) + } catch (e) { + console.error('PUT request error:', e) + } + }} + /> + + { + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/21?test=patch-request', + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: 'Patched title', + }), + }, + ) + const data = await response.json() + console.log('PATCH response:', data) + } catch (e) { + console.error('PATCH request error:', e) + } + }} + /> + + + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/60?test=single-value-headers', + { + headers: { + 'Content-Type': 'application/json', + 'X-Request-ID': 'req-12345', + 'X-Api-Version': 'v1', + 'User-Agent': 'TestApp/1.0', + }, + }, + ) + const data = await response.json() + console.log('Single-value headers response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/61?test=multi-value-accept', + { + headers: { + Accept: 'application/json, text/plain, */*', + }, + }, + ) + const data = await response.json() + console.log('Multi-value Accept response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/62?test=multi-value-cache', + { + headers: { + 'Cache-Control': + 'no-cache, no-store, must-revalidate', + }, + }, + ) + const data = await response.json() + console.log('Multi-value Cache-Control response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/63?test=mixed-header-values', + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, application/xml, text/html', + 'X-Custom-Single': 'single-value', + 'X-Custom-Multi': 'value1, value2, value3', + 'Accept-Language': + 'en-US, en;q=0.9, es;q=0.8', + }, + }, + ) + const data = await response.json() + console.log('Mixed header values response:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/64?test=response-headers-check', + ) + console.log('Response headers:') + response.headers.forEach((value, key) => { + console.log(` ${key}: ${value}`) + }) + const data = await response.json() + console.log('Response data:', data) + }} + /> + + { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts?test=post-with-headers', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/plain', + 'X-Request-ID': 'post-req-789', + 'X-Client-Version': '2.0.0', + }, + body: JSON.stringify({ + title: 'Header Test Post', + body: 'Testing header attributes', + userId: 1, + }), + }, + ) + console.log('POST response headers:') + response.headers.forEach((value, key) => { + console.log(` ${key}: ${value}`) + }) + const data = await response.json() + console.log('POST response data:', data) + }} + /> + + + + { + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/30?test=successful-get', + ) + console.log('200 response status:', response.status) + const data = await response.json() + console.log('Response data:', data) + } catch (e) { + console.error('Request error:', e) + } + }} + /> + + { + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/999999?test=not-found', + ) + console.log('404 response status:', response.status) + const data = await response.json() + console.log('Response data:', data) + } catch (e) { + console.error('Request error:', e) + } + }} + /> + + { + try { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts/40?test=delete-request', + { + method: 'DELETE', + }, + ) + console.log( + 'DELETE response status:', + response.status, + ) + const data = await response.json() + console.log('DELETE response:', data) + } catch (e) { + console.error('Request error:', e) + } + }} + /> + + { + try { + const promises = [] + for (let i = 51; i <= 55; i++) { + promises.push( + fetch( + `https://jsonplaceholder.typicode.com/posts/${i}?test=batch-request-${i}`, + ), + ) + } + const responses = await Promise.all(promises) + console.log( + 'All responses completed:', + responses.length, + ) + const data = await Promise.all( + responses.map((r) => r.json()), + ) + console.log('All response data:', data) + } catch (e) { + console.error('Request error:', e) + } + }} + /> + + +
+

Testing Instructions

+
    +
  1. Click any button to trigger an HTTP request
  2. +
  3. + Check the browser console for request confirmation (some + may fail due to CORS, that's okay) +
  4. +
  5. + Verify in the observability backend that: +
      +
    • + Sensitive headers (Authorization, Cookie, etc.) + show [REDACTED] +
    • +
    • + URLs with credentials are sanitized to + REDACTED:REDACTED@domain +
    • +
    • + Query params like sig, signature, awsAccessKeyId + show REDACTED +
    • +
    • Safe headers and query params are preserved
    • +
    • + Request/response bodies are recorded as + configured +
    • +
    +
  6. +
+
+
+ ) +} diff --git a/e2e/react-router/src/routes/root.tsx b/e2e/react-router/src/routes/root.tsx index af17a61c3..39c55771e 100644 --- a/e2e/react-router/src/routes/root.tsx +++ b/e2e/react-router/src/routes/root.tsx @@ -42,6 +42,7 @@ export default function Root() {

{flags}

diff --git a/sdk/highlight-run/src/client/listeners/network-listener/utils/network-sanitizer.ts b/sdk/highlight-run/src/client/listeners/network-listener/utils/network-sanitizer.ts index ca16a8718..15dbe9de4 100644 --- a/sdk/highlight-run/src/client/listeners/network-listener/utils/network-sanitizer.ts +++ b/sdk/highlight-run/src/client/listeners/network-listener/utils/network-sanitizer.ts @@ -63,3 +63,100 @@ export const DEFAULT_URL_BLOCKLIST = [ 'https://www.googleapis.com/identitytoolkit', 'https://securetoken.googleapis.com', ] + +/** + * Sensitive query parameter keys that should be redacted according to + * OpenTelemetry semantic conventions for HTTP spans. + * @see https://opentelemetry.io/docs/specs/semconv/http/http-spans/ + */ +const SENSITIVE_QUERY_PARAMS = [ + 'awsaccesskeyid', + 'signature', + 'sig', + 'x-goog-signature', +] + +/** + * Safely parses a URL, handling both absolute and relative URLs. + * For relative URLs, resolves against globalThis.location.origin (browser/worker) + * or a placeholder base (non-browser environments). + */ +export const safeParseUrl = (url: string): URL => { + try { + return new URL(url) + } catch { + // For relative URLs, we need a base to parse. The base doesn't affect + // the output since sanitizeUrl strips it for relative URLs. + // Use globalThis for broader environment support (window, workers, etc.) + return new URL(url, globalThis.location?.origin ?? 'http://example.com') + } +} + +/** + * Sanitizes a URL according to OpenTelemetry semantic conventions. + * - Redacts credentials (username:password) in the URL + * - Redacts sensitive query parameter values while preserving keys + * - Handles absolute, relative, and protocol-relative URLs + * + * @param url - The URL string to sanitize + * @returns Sanitized URL string + * + * @example + * sanitizeUrl('https://user:pass@example.com/path') + * // Returns: 'https://REDACTED:REDACTED@example.com/path' + * + * @example + * sanitizeUrl('https://example.com/path?color=blue&sig=secret123') + * // Returns: 'https://example.com/path?color=blue&sig=REDACTED' + * + * @example + * sanitizeUrl('/api?sig=secret123') + * // Returns: '/api?sig=REDACTED' + * + * @example + * sanitizeUrl('//example.com/path?sig=secret123') + * // Returns: '//example.com/path?sig=REDACTED' + */ +export const sanitizeUrl = (url: string): string => { + try { + const urlObject = safeParseUrl(url) + + if (urlObject.username || urlObject.password) { + urlObject.username = 'REDACTED' + urlObject.password = 'REDACTED' + } + + const searchParams = urlObject.searchParams + SENSITIVE_QUERY_PARAMS.forEach((sensitiveParam) => { + for (const key of Array.from(searchParams.keys())) { + if (key.toLowerCase() === sensitiveParam) { + searchParams.set(key, 'REDACTED') + } + } + }) + + // If the URL is relative (but not protocol-relative), return only the pathname + search + hash + if (!url.includes('://') && !url.startsWith('//')) { + return urlObject.pathname + urlObject.search + urlObject.hash + } + + // For protocol-relative URLs, preserve the //host format + if (url.startsWith('//')) { + let result = '//' + // Include credentials if present (they will be redacted) + if (urlObject.username || urlObject.password) { + result += urlObject.username + ':' + urlObject.password + '@' + } + result += + urlObject.host + + urlObject.pathname + + urlObject.search + + urlObject.hash + return result + } + + return urlObject.toString() + } catch { + return url + } +} diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index 00b1a8ee6..abd21b43d 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -27,7 +27,9 @@ import * as SemanticAttributes from '@opentelemetry/semantic-conventions' import { getResponseBody } from '../listeners/network-listener/utils/fetch-listener' import { DEFAULT_URL_BLOCKLIST, + safeParseUrl, sanitizeHeaders, + sanitizeUrl, } from '../listeners/network-listener/utils/network-sanitizer' import { shouldNetworkRequestBeRecorded, @@ -232,14 +234,6 @@ export const setupBrowserTracing = ( return } - if (!(response instanceof Response)) { - span.setAttributes({ - 'http.response.error': response.message, - 'http.response.status': response.status, - }) - return - } - enhanceSpanWithHttpRequestAttributes( span, request.body, @@ -247,13 +241,36 @@ export const setupBrowserTracing = ( config.networkRecordingOptions, ) - const body = await getResponseBody( - response, - config.networkRecordingOptions?.bodyKeysToRecord, - config.networkRecordingOptions - ?.networkBodyKeysToRedact, - ) - span.setAttribute('http.response.body', body) + if (!(response instanceof Response)) { + span.setAttributes({ + 'http.response.error': response.message, + [SemanticAttributes.ATTR_HTTP_RESPONSE_STATUS_CODE]: + response.status, + }) + return + } + + if ( + config.networkRecordingOptions?.recordHeadersAndBody + ) { + const responseBody = await getResponseBody( + response, + config.networkRecordingOptions + ?.bodyKeysToRecord, + config.networkRecordingOptions + ?.networkBodyKeysToRedact, + ) + const responseHeaders = Object.fromEntries( + response.headers.entries(), + ) + + enhanceSpanWithHttpResponseAttributes( + span, + responseHeaders, + responseBody, + config.networkRecordingOptions, + ) + } }, }), ) @@ -288,14 +305,35 @@ export const setupBrowserTracing = ( config.networkRecordingOptions, ) - const recordedBody = getBodyThatShouldBeRecorded( - browserXhr._body, - config.networkRecordingOptions - ?.networkBodyKeysToRedact, - config.networkRecordingOptions?.bodyKeysToRecord, - browserXhr._requestHeaders as Headers, - ) - span.setAttribute('http.request.body', recordedBody) + if ( + config.networkRecordingOptions?.recordHeadersAndBody + ) { + let responseBody = '' + if ( + xhr.responseType === '' || + xhr.responseType === 'text' + ) { + responseBody = getBodyThatShouldBeRecorded( + xhr.responseText, + config.networkRecordingOptions + ?.networkBodyKeysToRedact, + config.networkRecordingOptions + ?.bodyKeysToRecord, + undefined, + ) + } + + const responseHeaders = parseXhrResponseHeaders( + xhr.getAllResponseHeaders(), + ) + + enhanceSpanWithHttpResponseAttributes( + span, + responseHeaders, + responseBody, + config.networkRecordingOptions, + ) + } }, }), ) @@ -375,7 +413,7 @@ class CustomTraceContextPropagator extends W3CTraceContextPropagator { return } - const url = (span as unknown as ReadableSpan).attributes['http.url'] + const url = getUrlFromSpan(span as unknown as ReadableSpan) if (typeof url === 'string') { const shouldRecord = shouldRecordRequest( url, @@ -453,51 +491,213 @@ const enhanceSpanWithHttpRequestAttributes = ( | ReturnType, networkRecordingOptions?: NetworkRecordingOptions, ) => { - const stringBody = typeof body === 'string' ? body : String(body) if (!(span as any).attributes) { return } const readableSpan = span as unknown as ReadableSpan - const url = readableSpan.attributes['http.url'] as string - const urlObject = new URL(url) + const url = getUrlFromSpan(readableSpan) + const sanitizedUrl = sanitizeUrl(url) + const sanitizedUrlObject = safeParseUrl(sanitizedUrl) - let parsedBody + const stringBody = typeof body === 'string' ? body : String(body) try { - parsedBody = body ? JSON.parse(stringBody) : undefined - - if (parsedBody.operationName) { + const parsedBody = body ? JSON.parse(stringBody) : undefined + if (parsedBody?.operationName) { span.setAttribute( 'graphql.operation.name', parsedBody.operationName, ) } } catch { - // Ignore + // Ignore parsing errors } - const sanitizedHeaders = sanitizeHeaders( - networkRecordingOptions?.networkHeadersToRedact ?? [], - headers as Headers, - networkRecordingOptions?.headerKeysToRecord, - ) - span.setAttributes({ 'highlight.type': 'http.request', - 'http.request.headers': JSON.stringify(sanitizedHeaders), - 'http.request.body': stringBody, - [SemanticAttributes.ATTR_URL_FULL]: url, - [SemanticAttributes.ATTR_URL_PATH]: urlObject.pathname, - [SemanticAttributes.ATTR_URL_QUERY]: urlObject.search, + [SemanticAttributes.SEMATTRS_HTTP_URL]: sanitizedUrl, // overwrite with sanitized version + [SemanticAttributes.ATTR_URL_FULL]: sanitizedUrl, + [SemanticAttributes.ATTR_URL_PATH]: sanitizedUrlObject.pathname, + [SemanticAttributes.ATTR_URL_QUERY]: sanitizedUrlObject.search, }) - if (urlObject.searchParams.size > 0) { - span.setAttributes({ - // Custom attribute that displays query string params as an object. - ['url.query_params']: JSON.stringify( - Object.fromEntries(urlObject.searchParams), - ), - }) + // Set sanitized query params as JSON object for easier querying + const searchParamsEntries = Array.from( + sanitizedUrlObject.searchParams.entries(), + ) + if (searchParamsEntries.length > 0) { + span.setAttribute( + 'url.query_params', + JSON.stringify(Object.fromEntries(searchParamsEntries)), + ) } + + if (networkRecordingOptions?.recordHeadersAndBody) { + const requestBody = getBodyThatShouldBeRecorded( + body, + networkRecordingOptions.networkBodyKeysToRedact, + networkRecordingOptions.bodyKeysToRecord, + headers as Headers, + ) + span.setAttribute('http.request.body', requestBody) + + const sanitizedHeaders = sanitizeHeaders( + networkRecordingOptions.networkHeadersToRedact ?? [], + headers as Headers, + networkRecordingOptions.headerKeysToRecord, + ) + + const headerAttributes = convertHeadersToOtelAttributes( + sanitizedHeaders, + 'http.request.header', + ) + span.setAttributes(headerAttributes) + } +} + +export const parseXhrResponseHeaders = ( + headerString: string, +): { [key: string]: string } => { + const headers: { [key: string]: string } = {} + if (headerString) { + headerString + .trim() + .split(/[\r\n]+/) + .forEach((line) => { + const parts = line.split(': ') + const header = parts.shift() + if (header) { + headers[header] = parts.join(': ') + } + }) + } + return headers +} + +/** + * Converts headers object to OpenTelemetry semantic convention format. + * Headers are set as individual attributes with the pattern: + * - http.request.header.: value (or [value1, value2] if multiple) + * - http.response.header.: value (or [value1, value2] if multiple) + * + * According to OTel spec, header values should be arrays when they contain + * comma-separated values. Single values remain as strings for simpler querying. + * + * @param headers - Object with header key-value pairs + * @param prefix - Either 'http.request.header' or 'http.response.header' + * @returns Object with OTel semantic convention attribute names + */ +export const convertHeadersToOtelAttributes = ( + headers: { [key: string]: string }, + prefix: 'http.request.header' | 'http.response.header', +): { [key: string]: string | string[] } => { + const attributes: { [key: string]: string | string[] } = {} + + Object.entries(headers).forEach(([key, value]) => { + const normalizedKey = key.toLowerCase().replace(/_/g, '-') + const attributeName = `${prefix}.${normalizedKey}` + const values = splitHeaderValue(normalizedKey, value) + + // Handle duplicate header keys (same header appearing multiple times) + if (attributes[attributeName]) { + const existing = attributes[attributeName] + + if (Array.isArray(existing)) { + attributes[attributeName] = [...existing, ...values] + } else { + attributes[attributeName] = [existing, ...values] + } + } else { + attributes[attributeName] = values.length === 1 ? values[0] : values + } + }) + + return attributes +} + +/** + * HTTP headers that are explicitly defined as comma-separated lists + * per RFC 7231 and related specifications. Only these headers should + * be split by comma. Other headers (especially date headers like + * Date, Last-Modified, Expires) contain commas as part of their value + * and should NOT be split. + * + * @see https://www.rfc-editor.org/rfc/rfc7231 + * @see https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6 + */ +const COMMA_SEPARATED_HEADERS = new Set([ + 'accept', + 'accept-charset', + 'accept-encoding', + 'accept-language', + 'accept-ranges', + 'allow', + 'cache-control', + 'connection', + 'content-encoding', + 'content-language', + 'expect', + 'if-match', + 'if-none-match', + 'pragma', + 'proxy-authenticate', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'vary', + 'via', + 'warning', + 'www-authenticate', + 'access-control-allow-headers', + 'access-control-allow-methods', + 'access-control-expose-headers', + 'access-control-request-headers', +]) + +/** + * Splits a header value by commas if the header is defined as comma-separated. + * Headers like Date, Last-Modified, Expires contain commas in RFC 7231 date + * format (e.g., "Mon, 01 Jan 2024 12:00:00 GMT") and should NOT be split. + * + * @param headerName - The lowercase header name + * @param value - The header value string + * @returns Array of values (single element for non-comma-separated headers) + */ +export const splitHeaderValue = ( + headerName: string, + value: string, +): string[] => { + // Only split headers that are explicitly defined as comma-separated lists + if (!COMMA_SEPARATED_HEADERS.has(headerName)) { + return [value] + } + + // Split by comma and trim whitespace from each value + return value + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0) +} + +const enhanceSpanWithHttpResponseAttributes = ( + span: api.Span, + responseHeaders: { [key: string]: string }, + responseBody: string, + networkRecordingOptions: NetworkRecordingOptions, +) => { + span.setAttribute('http.response.body', responseBody) + + const sanitizedResponseHeaders = sanitizeHeaders( + networkRecordingOptions.networkHeadersToRedact ?? [], + responseHeaders, + networkRecordingOptions.headerKeysToRecord, + ) + + const headerAttributes = convertHeadersToOtelAttributes( + sanitizedResponseHeaders, + 'http.response.header', + ) + span.setAttributes(headerAttributes) } const shouldRecordRequest = ( @@ -660,3 +860,11 @@ export const getCorsUrlsPattern = ( return /^$/ // Match nothing if tracingOrigins is false or undefined } + +const getUrlFromSpan = (span: ReadableSpan) => { + if (span.attributes[SemanticAttributes.ATTR_URL_FULL]) { + return span.attributes[SemanticAttributes.ATTR_URL_FULL] as string + } + + return span.attributes[SemanticAttributes.SEMATTRS_HTTP_URL] as string +} diff --git a/sdk/highlight-run/src/client/otel/instrumentation.test.ts b/sdk/highlight-run/src/client/otel/instrumentation.test.ts new file mode 100644 index 000000000..31072ff8b --- /dev/null +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -0,0 +1,1396 @@ +import { describe, it, expect } from 'vitest' +import { + safeParseUrl, + sanitizeHeaders, + sanitizeUrl, +} from '../listeners/network-listener/utils/network-sanitizer' +import { + parseXhrResponseHeaders, + splitHeaderValue, + convertHeadersToOtelAttributes, +} from './index' + +describe('Network Instrumentation Custom Attributes', () => { + describe('splitHeaderValue', () => { + it('should split comma-separated headers like accept', () => { + const result = splitHeaderValue( + 'accept', + 'application/json, text/plain, */*', + ) + expect(result).toEqual(['application/json', 'text/plain', '*/*']) + }) + + it('should split cache-control directives', () => { + const result = splitHeaderValue( + 'cache-control', + 'no-cache, no-store, must-revalidate', + ) + expect(result).toEqual(['no-cache', 'no-store', 'must-revalidate']) + }) + + it('should NOT split date headers (RFC 7231 date format)', () => { + const result = splitHeaderValue( + 'date', + 'Mon, 01 Jan 2024 12:00:00 GMT', + ) + expect(result).toEqual(['Mon, 01 Jan 2024 12:00:00 GMT']) + }) + + it('should NOT split last-modified headers', () => { + const result = splitHeaderValue( + 'last-modified', + 'Sun, 31 Dec 2023 23:59:59 GMT', + ) + expect(result).toEqual(['Sun, 31 Dec 2023 23:59:59 GMT']) + }) + + it('should NOT split expires headers', () => { + const result = splitHeaderValue( + 'expires', + 'Tue, 02 Jan 2024 12:00:00 GMT', + ) + expect(result).toEqual(['Tue, 02 Jan 2024 12:00:00 GMT']) + }) + + it('should NOT split content-type headers', () => { + const result = splitHeaderValue( + 'content-type', + 'text/html; charset=utf-8', + ) + expect(result).toEqual(['text/html; charset=utf-8']) + }) + + it('should NOT split custom headers with commas', () => { + const result = splitHeaderValue( + 'x-custom-header', + 'value1, value2, value3', + ) + expect(result).toEqual(['value1, value2, value3']) + }) + + it('should split vary header', () => { + const result = splitHeaderValue( + 'vary', + 'Accept-Encoding, User-Agent', + ) + expect(result).toEqual(['Accept-Encoding', 'User-Agent']) + }) + + it('should split accept-language with quality values', () => { + const result = splitHeaderValue( + 'accept-language', + 'en-US, en;q=0.9, es;q=0.8', + ) + expect(result).toEqual(['en-US', 'en;q=0.9', 'es;q=0.8']) + }) + + it('should trim whitespace from split values', () => { + const result = splitHeaderValue( + 'accept', + ' application/json , text/plain ', + ) + expect(result).toEqual(['application/json', 'text/plain']) + }) + }) + + describe('convertHeadersToOtelAttributes', () => { + it('should convert headers to OTel semantic convention format', () => { + const headers = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + 'Cache-Control': 'no-cache', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + expect(result).toEqual({ + 'http.request.header.content-type': 'application/json', + 'http.request.header.x-request-id': 'abc-123', + 'http.request.header.cache-control': 'no-cache', + }) + }) + + it('should normalize header names to lowercase', () => { + const headers = { + 'Content-Type': 'text/html', + 'X-Custom-Header': 'value', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.response.header', + ) + + expect(result).toEqual({ + 'http.response.header.content-type': 'text/html', + 'http.response.header.x-custom-header': 'value', + }) + }) + + it('should replace underscores with dashes in header names', () => { + const headers = { + x_custom_header: 'value', + another_header: 'test', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + expect(result).toEqual({ + 'http.request.header.x-custom-header': 'value', + 'http.request.header.another-header': 'test', + }) + }) + + it('should store single values as strings, not arrays', () => { + const headers = { + 'content-type': 'application/json', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + expect(result['http.request.header.content-type']).toBe( + 'application/json', + ) + expect( + result['http.request.header.content-type'], + ).not.toBeInstanceOf(Array) + }) + + it('should split comma-separated header values into arrays', () => { + const headers = { + accept: 'application/json, text/plain, */*', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + expect(result['http.request.header.accept']).toEqual([ + 'application/json', + 'text/plain', + '*/*', + ]) + }) + + it('should split accept-language with quality values into arrays', () => { + const headers = { + 'accept-language': 'en-US, en;q=0.9, es;q=0.8', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + expect(result['http.request.header.accept-language']).toEqual([ + 'en-US', + 'en;q=0.9', + 'es;q=0.8', + ]) + }) + + it('should split cache-control directives into arrays', () => { + const headers = { + 'cache-control': 'no-cache, no-store, must-revalidate', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.response.header', + ) + + expect(result['http.response.header.cache-control']).toEqual([ + 'no-cache', + 'no-store', + 'must-revalidate', + ]) + }) + + it('should handle mixed single and multi-value headers', () => { + const headers = { + 'content-type': 'application/json', + accept: 'application/json, application/xml, text/html', + 'x-custom-single': 'single-value', + 'cache-control': 'no-cache, no-store, max-age=0', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + expect(result['http.request.header.content-type']).toBe( + 'application/json', + ) + expect(result['http.request.header.accept']).toEqual([ + 'application/json', + 'application/xml', + 'text/html', + ]) + expect(result['http.request.header.x-custom-single']).toBe( + 'single-value', + ) + // cache-control is defined as a comma-separated header + expect(result['http.request.header.cache-control']).toEqual([ + 'no-cache', + 'no-store', + 'max-age=0', + ]) + }) + + it('should NOT split date headers containing commas (RFC 7231 date format)', () => { + const headers = { + date: 'Mon, 01 Jan 2024 12:00:00 GMT', + 'last-modified': 'Sun, 31 Dec 2023 23:59:59 GMT', + expires: 'Tue, 02 Jan 2024 12:00:00 GMT', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.response.header', + ) + + // Date headers should be preserved as strings, NOT split into arrays + expect(result['http.response.header.date']).toBe( + 'Mon, 01 Jan 2024 12:00:00 GMT', + ) + expect(result['http.response.header.last-modified']).toBe( + 'Sun, 31 Dec 2023 23:59:59 GMT', + ) + expect(result['http.response.header.expires']).toBe( + 'Tue, 02 Jan 2024 12:00:00 GMT', + ) + }) + + it('should NOT split custom headers with commas', () => { + const headers = { + 'x-custom-header': 'value1, value2, value3', + 'x-timestamp': 'Mon, 01 Jan 2024 12:00:00 GMT', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + // Custom headers should NOT be split + expect(result['http.request.header.x-custom-header']).toBe( + 'value1, value2, value3', + ) + expect(result['http.request.header.x-timestamp']).toBe( + 'Mon, 01 Jan 2024 12:00:00 GMT', + ) + }) + + it('should split known comma-separated headers but not others', () => { + const headers = { + accept: 'text/html, application/json', + 'accept-language': 'en-US, en;q=0.9', + vary: 'Accept-Encoding, User-Agent', + date: 'Mon, 01 Jan 2024 12:00:00 GMT', + 'content-type': 'text/html; charset=utf-8', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.response.header', + ) + + // These should be split (comma-separated by spec) + expect(result['http.response.header.accept']).toEqual([ + 'text/html', + 'application/json', + ]) + expect(result['http.response.header.accept-language']).toEqual([ + 'en-US', + 'en;q=0.9', + ]) + expect(result['http.response.header.vary']).toEqual([ + 'Accept-Encoding', + 'User-Agent', + ]) + + // These should NOT be split + expect(result['http.response.header.date']).toBe( + 'Mon, 01 Jan 2024 12:00:00 GMT', + ) + expect(result['http.response.header.content-type']).toBe( + 'text/html; charset=utf-8', + ) + }) + + it('should trim whitespace from split values', () => { + const headers = { + accept: 'application/json, text/plain , */*', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.request.header', + ) + + expect(result['http.request.header.accept']).toEqual([ + 'application/json', + 'text/plain', + '*/*', + ]) + }) + + it('should use arrays only when multiple values exist for the same header', () => { + // Simulate duplicate headers by calling with same header key twice + const headers = { + 'set-cookie': 'session=abc123', + } + + const result = convertHeadersToOtelAttributes( + headers, + 'http.response.header', + ) + + // First, it should be a string + expect(result['http.response.header.set-cookie']).toBe( + 'session=abc123', + ) + + // Now simulate adding a second value + const attributeName = 'http.response.header.set-cookie' + const existing = result[attributeName] + result[attributeName] = Array.isArray(existing) + ? [...existing, 'token=xyz789'] + : [existing, 'token=xyz789'] + + // Now it should be an array + expect(result['http.response.header.set-cookie']).toEqual([ + 'session=abc123', + 'token=xyz789', + ]) + }) + + it('should handle empty headers object', () => { + const result = convertHeadersToOtelAttributes( + {}, + 'http.request.header', + ) + expect(result).toEqual({}) + }) + }) + + describe('parseXhrResponseHeaders', () => { + it('should parse XHR header string correctly', () => { + const headerString = + 'content-type: application/json\r\nx-request-id: abc-123\r\ncache-control: no-cache' + + const parsed = parseXhrResponseHeaders(headerString) + + expect(parsed).toEqual({ + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + 'cache-control': 'no-cache', + }) + }) + + it('should handle header values with colons', () => { + const headerString = + 'content-type: application/json; charset=utf-8\r\ndate: Mon, 01 Jan 2024 12:00:00 GMT' + + const parsed = parseXhrResponseHeaders(headerString) + + expect(parsed).toEqual({ + 'content-type': 'application/json; charset=utf-8', + date: 'Mon, 01 Jan 2024 12:00:00 GMT', + }) + }) + + it('should handle empty header string', () => { + const parsed = parseXhrResponseHeaders('') + expect(parsed).toEqual({}) + }) + + it('should handle whitespace-only header string', () => { + const parsed = parseXhrResponseHeaders(' \n\r\n ') + expect(parsed).toEqual({}) + }) + + it('should handle newline-only separators', () => { + const headerString = + 'content-type: application/json\nx-request-id: abc-123' + + const parsed = parseXhrResponseHeaders(headerString) + + expect(parsed).toEqual({ + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + }) + }) + }) + + describe('sanitizeHeaders', () => { + describe('basic sanitization', () => { + it('should return all headers when no redaction is configured', () => { + const headers = { + 'content-type': 'application/json', + 'x-request-id': '12345', + 'cache-control': 'no-cache', + } + + const result = sanitizeHeaders([], headers) + + expect(result).toEqual({ + 'content-type': 'application/json', + 'x-request-id': '12345', + 'cache-control': 'no-cache', + }) + }) + + it('should handle empty headers object', () => { + const result = sanitizeHeaders([], {}) + expect(result).toEqual({}) + }) + + it('should handle undefined headers', () => { + const result = sanitizeHeaders([], undefined) + expect(result).toEqual({}) + }) + }) + + describe('networkHeadersToRedact', () => { + it('should redact specified headers', () => { + const headers = { + 'content-type': 'application/json', + 'x-secret': 'sensitive-value', + 'x-api-key': 'my-api-key', + } + + const result = sanitizeHeaders( + ['x-secret', 'x-api-key'], + headers, + ) + + expect(result).toEqual({ + 'content-type': 'application/json', + 'x-secret': '[REDACTED]', + 'x-api-key': '[REDACTED]', + }) + }) + + it('should be case-insensitive for redaction', () => { + const headers = { + 'X-Secret': 'sensitive-value', + 'X-API-KEY': 'my-api-key', + } + + const result = sanitizeHeaders( + ['x-secret', 'x-api-key'], + headers, + ) + + expect(result).toEqual({ + 'X-Secret': '[REDACTED]', + 'X-API-KEY': '[REDACTED]', + }) + }) + }) + + describe('default sensitive headers', () => { + it('should automatically redact authorization header', () => { + const headers = { + 'content-type': 'application/json', + authorization: 'Bearer token123', + } + + const result = sanitizeHeaders([], headers) + + expect(result).toEqual({ + 'content-type': 'application/json', + authorization: '[REDACTED]', + }) + }) + + it('should automatically redact cookie header', () => { + const headers = { + 'content-type': 'application/json', + cookie: 'session=abc123', + } + + const result = sanitizeHeaders([], headers) + + expect(result).toEqual({ + 'content-type': 'application/json', + cookie: '[REDACTED]', + }) + }) + + it('should automatically redact proxy-authorization header', () => { + const headers = { + 'content-type': 'application/json', + 'proxy-authorization': 'Basic abc123', + } + + const result = sanitizeHeaders([], headers) + + expect(result).toEqual({ + 'content-type': 'application/json', + 'proxy-authorization': '[REDACTED]', + }) + }) + + it('should automatically redact token header', () => { + const headers = { + 'content-type': 'application/json', + token: 'secret-token', + } + + const result = sanitizeHeaders([], headers) + + expect(result).toEqual({ + 'content-type': 'application/json', + token: '[REDACTED]', + }) + }) + + it('should redact multiple sensitive headers at once', () => { + const headers = { + 'content-type': 'application/json', + authorization: 'Bearer token123', + cookie: 'session=abc123', + 'proxy-authorization': 'Basic abc123', + token: 'secret-token', + } + + const result = sanitizeHeaders([], headers) + + expect(result).toEqual({ + 'content-type': 'application/json', + authorization: '[REDACTED]', + cookie: '[REDACTED]', + 'proxy-authorization': '[REDACTED]', + token: '[REDACTED]', + }) + }) + }) + + describe('headerKeysToRecord (allowlist)', () => { + it('should only keep specified headers when allowlist is set', () => { + const headers = { + 'content-type': 'application/json', + 'x-request-id': '12345', + 'x-secret': 'sensitive', + 'cache-control': 'no-cache', + } + + const result = sanitizeHeaders([], headers, ['x-request-id']) + + expect(result).toEqual({ + 'content-type': '[REDACTED]', + 'x-request-id': '12345', + 'x-secret': '[REDACTED]', + 'cache-control': '[REDACTED]', + }) + }) + + it('should be case-insensitive for allowlist', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Request-ID': '12345', + } + + const result = sanitizeHeaders([], headers, [ + 'content-type', + 'x-request-id', + ]) + + expect(result).toEqual({ + 'Content-Type': 'application/json', + 'X-Request-ID': '12345', + }) + }) + + it('should override headersToRedact when allowlist is set', () => { + const headers = { + 'content-type': 'application/json', + 'x-request-id': '12345', + authorization: 'Bearer token', + } + + // Even though authorization is in sensitive headers, + // allowlist takes precedence + const result = sanitizeHeaders(['x-request-id'], headers, [ + 'content-type', + 'authorization', + ]) + + expect(result).toEqual({ + 'content-type': 'application/json', + 'x-request-id': '[REDACTED]', + authorization: 'Bearer token', + }) + }) + }) + }) + + describe('Response Header Capture Integration', () => { + const captureResponseAttributes = ( + responseHeaders: { [key: string]: string }, + responseBody: string, + networkRecordingOptions?: { + recordHeadersAndBody?: boolean + networkHeadersToRedact?: string[] + headerKeysToRecord?: string[] + }, + ): { headers?: string; body?: string } | null => { + if (!networkRecordingOptions?.recordHeadersAndBody) { + return null + } + + const sanitizedResponseHeaders = sanitizeHeaders( + networkRecordingOptions?.networkHeadersToRedact ?? [], + responseHeaders, + networkRecordingOptions?.headerKeysToRecord, + ) + + return { + headers: JSON.stringify(sanitizedResponseHeaders), + body: responseBody, + } + } + + describe('recordHeadersAndBody conditional', () => { + it('should NOT capture headers or body when recordHeadersAndBody is false', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + } + + const result = captureResponseAttributes( + responseHeaders, + '{"data": "test"}', + { recordHeadersAndBody: false }, + ) + + expect(result).toBeNull() + }) + + it('should NOT capture headers or body when recordHeadersAndBody is undefined', () => { + const responseHeaders = { + 'content-type': 'application/json', + } + + const result = captureResponseAttributes( + responseHeaders, + 'body content', + {}, // recordHeadersAndBody not set + ) + + expect(result).toBeNull() + }) + + it('should capture headers and body when recordHeadersAndBody is true', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + } + const responseBody = '{"data": "test"}' + + const result = captureResponseAttributes( + responseHeaders, + responseBody, + { recordHeadersAndBody: true }, + ) + + expect(result).not.toBeNull() + expect(result?.body).toBe(responseBody) + const parsedHeaders = JSON.parse(result?.headers ?? '{}') + expect(parsedHeaders['content-type']).toBe('application/json') + expect(parsedHeaders['x-request-id']).toBe('abc-123') + }) + + it('should apply sanitization when recordHeadersAndBody is true', () => { + const responseHeaders = { + 'content-type': 'application/json', + authorization: 'Bearer secret-token', + 'x-request-id': 'abc-123', + } + + const result = captureResponseAttributes( + responseHeaders, + 'body', + { recordHeadersAndBody: true }, + ) + + expect(result).not.toBeNull() + const parsedHeaders = JSON.parse(result?.headers ?? '{}') + expect(parsedHeaders['authorization']).toBe('[REDACTED]') + expect(parsedHeaders['x-request-id']).toBe('abc-123') + }) + + it('should apply networkHeadersToRedact when recordHeadersAndBody is true', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-internal-secret': 'secret-value', + 'x-public': 'public-value', + } + + const result = captureResponseAttributes( + responseHeaders, + 'body', + { + recordHeadersAndBody: true, + networkHeadersToRedact: ['x-internal-secret'], + }, + ) + + expect(result).not.toBeNull() + const parsedHeaders = JSON.parse(result?.headers ?? '{}') + expect(parsedHeaders['x-internal-secret']).toBe('[REDACTED]') + expect(parsedHeaders['x-public']).toBe('public-value') + }) + + it('should apply headerKeysToRecord when recordHeadersAndBody is true', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + 'x-other': 'other-value', + } + + const result = captureResponseAttributes( + responseHeaders, + 'body', + { + recordHeadersAndBody: true, + headerKeysToRecord: ['x-request-id'], + }, + ) + + expect(result).not.toBeNull() + const parsedHeaders = JSON.parse(result?.headers ?? '{}') + expect(parsedHeaders['x-request-id']).toBe('abc-123') + expect(parsedHeaders['content-type']).toBe('[REDACTED]') + expect(parsedHeaders['x-other']).toBe('[REDACTED]') + }) + }) + + describe('Fetch Response Headers', () => { + it('should capture all response headers', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + 'cache-control': 'max-age=3600', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed).toEqual({ + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + 'cache-control': 'max-age=3600', + }) + }) + + it('should sanitize sensitive headers in response', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'set-cookie': 'session=secret', + authorization: 'Bearer token', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['content-type']).toBe('application/json') + expect(parsed['authorization']).toBe('[REDACTED]') + }) + + it('should apply networkHeadersToRedact to response headers', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-internal-id': 'internal-123', + 'x-public-id': 'public-456', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + networkHeadersToRedact: ['x-internal-id'], + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed).toEqual({ + 'content-type': 'application/json', + 'x-internal-id': '[REDACTED]', + 'x-public-id': 'public-456', + }) + }) + + it('should redact content-type when headerKeysToRecord is set and excludes it', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + 'x-trace-id': 'trace-789', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + headerKeysToRecord: ['x-request-id', 'x-trace-id'], + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + // When explicit allowlist is used, content-type is not auto-preserved + expect(parsed['content-type']).toBe('[REDACTED]') + expect(parsed['x-request-id']).toBe('abc-123') + expect(parsed['x-trace-id']).toBe('trace-789') + }) + + it('should keep content-type when headerKeysToRecord includes it', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + 'x-secret': 'secret-value', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + headerKeysToRecord: ['content-type', 'x-request-id'], + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['content-type']).toBe('application/json') + expect(parsed['x-request-id']).toBe('abc-123') + expect(parsed['x-secret']).toBe('[REDACTED]') + }) + }) + + describe('XHR Response Headers', () => { + it('should capture and sanitize XHR response headers', () => { + const headerString = + 'content-type: application/json\r\nauthorization: Bearer secret\r\nx-request-id: abc-123' + + const responseHeaders = parseXhrResponseHeaders(headerString) + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['content-type']).toBe('application/json') + expect(parsed['authorization']).toBe('[REDACTED]') + expect(parsed['x-request-id']).toBe('abc-123') + }) + + it('should work with parseXhrResponseHeaders and recordHeadersAndBody', () => { + const headerString = + 'content-type: application/json\r\nx-request-id: abc-123' + + const responseHeaders = parseXhrResponseHeaders(headerString) + const result = captureResponseAttributes( + responseHeaders, + '{"response": "data"}', + { recordHeadersAndBody: true }, + ) + + expect(result).not.toBeNull() + expect(result?.body).toBe('{"response": "data"}') + const parsedHeaders = JSON.parse(result?.headers ?? '{}') + expect(parsedHeaders['content-type']).toBe('application/json') + }) + }) + + describe('Edge Cases', () => { + it('should handle response with no headers', () => { + const result = captureResponseAttributes({}, '', { + recordHeadersAndBody: true, + }) + expect(result?.headers).toBe('{}') + }) + + it('should handle response headers with empty values', () => { + const responseHeaders = { + 'content-type': '', + 'x-empty': '', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['content-type']).toBe('') + expect(parsed['x-empty']).toBe('') + }) + + it('should handle response headers with special characters', () => { + const responseHeaders = { + 'content-type': 'application/json; charset=utf-8', + 'content-disposition': + 'attachment; filename="file with spaces.pdf"', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['content-type']).toBe( + 'application/json; charset=utf-8', + ) + expect(parsed['content-disposition']).toBe( + 'attachment; filename="file with spaces.pdf"', + ) + }) + + it('should not fail when content-type is missing', () => { + const responseHeaders = { + 'x-request-id': 'abc-123', + 'cache-control': 'no-cache', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed).toEqual({ + 'x-request-id': 'abc-123', + 'cache-control': 'no-cache', + }) + expect(parsed['content-type']).toBeUndefined() + }) + + it('should handle very long header values', () => { + const longValue = 'x'.repeat(10000) + const responseHeaders = { + 'content-type': 'application/json', + 'x-long-header': longValue, + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['x-long-header']).toBe(longValue) + }) + + it('should handle headers with unicode characters', () => { + const responseHeaders = { + 'content-type': 'text/plain; charset=utf-8', + 'x-custom': 'value with émojis 🎉', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['x-custom']).toBe('value with émojis 🎉') + }) + }) + }) + + describe('sanitizeUrl', () => { + describe('credential redaction', () => { + it('should redact username and password in URLs', () => { + const url = 'https://username:password@www.example.com/' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://REDACTED:REDACTED@www.example.com/', + ) + }) + + it('should redact only username when no password is present', () => { + const url = 'https://username@www.example.com/path' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://REDACTED:REDACTED@www.example.com/path', + ) + }) + + it('should handle URLs with credentials and query parameters', () => { + const url = + 'https://user:pass@example.com/path?foo=bar&sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://REDACTED:REDACTED@example.com/path?foo=bar&sig=REDACTED', + ) + }) + + it('should not modify URLs without credentials', () => { + const url = 'https://www.example.com/path' + const result = sanitizeUrl(url) + expect(result).toBe('https://www.example.com/path') + }) + }) + + describe('sensitive query parameter redaction', () => { + it('should redact AWSAccessKeyId query parameter', () => { + const url = + 'https://example.com/path?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&other=value' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://example.com/path?AWSAccessKeyId=REDACTED&other=value', + ) + }) + + it('should redact Signature query parameter', () => { + const url = + 'https://example.com/path?Signature=somesignature&foo=bar' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://example.com/path?Signature=REDACTED&foo=bar', + ) + }) + + it('should redact sig query parameter', () => { + const url = + 'https://www.example.com/path?color=blue&sig=secret123' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://www.example.com/path?color=blue&sig=REDACTED', + ) + }) + + it('should redact X-Goog-Signature query parameter', () => { + const url = + 'https://storage.googleapis.com/bucket/file?X-Goog-Signature=abc123&other=value' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://storage.googleapis.com/bucket/file?X-Goog-Signature=REDACTED&other=value', + ) + }) + + it('should be case-insensitive when matching sensitive parameters', () => { + const url = + 'https://example.com/path?signature=secret&SIG=secret2&awsaccesskeyid=key' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://example.com/path?signature=REDACTED&SIG=REDACTED&awsaccesskeyid=REDACTED', + ) + }) + + it('should preserve query parameter keys while redacting values', () => { + const url = + 'https://example.com/path?foo=bar&sig=secret&baz=qux' + const result = sanitizeUrl(url) + expect(result).toContain('sig=REDACTED') + expect(result).toContain('foo=bar') + expect(result).toContain('baz=qux') + }) + + it('should handle multiple sensitive parameters', () => { + const url = + 'https://example.com/path?AWSAccessKeyId=key&Signature=sig&sig=s&X-Goog-Signature=gs' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://example.com/path?AWSAccessKeyId=REDACTED&Signature=REDACTED&sig=REDACTED&X-Goog-Signature=REDACTED', + ) + }) + + it('should not modify URLs without sensitive query parameters', () => { + const url = 'https://example.com/path?foo=bar&baz=qux' + const result = sanitizeUrl(url) + expect(result).toBe('https://example.com/path?foo=bar&baz=qux') + }) + }) + + describe('combined scenarios', () => { + it('should handle URLs with both credentials and sensitive query params', () => { + const url = + 'https://user:pass@example.com/api?key=value&sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://REDACTED:REDACTED@example.com/api?key=value&sig=REDACTED', + ) + }) + + it('should handle URLs with fragments', () => { + const url = + 'https://user:pass@example.com/path?sig=secret#fragment' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://REDACTED:REDACTED@example.com/path?sig=REDACTED#fragment', + ) + }) + + it('should handle URLs with ports', () => { + const url = 'https://user:pass@example.com:8080/path?sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://REDACTED:REDACTED@example.com:8080/path?sig=REDACTED', + ) + }) + + it('should handle complex URLs with all components', () => { + const url = + 'https://admin:secret@api.example.com:443/v1/users?AWSAccessKeyId=KEY123&Signature=SIG456&filter=active#section' + const result = sanitizeUrl(url) + // Note: URL object automatically removes default HTTPS port (443) + expect(result).toBe( + 'https://REDACTED:REDACTED@api.example.com/v1/users?AWSAccessKeyId=REDACTED&Signature=REDACTED&filter=active#section', + ) + }) + }) + + describe('edge cases', () => { + it('should handle URLs with empty query parameter values', () => { + const url = 'https://example.com/path?sig=&foo=bar' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://example.com/path?sig=REDACTED&foo=bar', + ) + }) + + it('should handle URLs with no query parameters', () => { + const url = 'https://example.com/path' + const result = sanitizeUrl(url) + expect(result).toBe('https://example.com/path') + }) + + it('should handle URLs with only query parameters (no path)', () => { + const url = 'https://example.com?sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe('https://example.com/?sig=REDACTED') + }) + + it('should handle http (non-https) URLs', () => { + const url = 'http://user:pass@example.com/path?sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe( + 'http://REDACTED:REDACTED@example.com/path?sig=REDACTED', + ) + }) + + it('should return original URL if parsing fails', () => { + // Truly malformed URLs that fail even with a base URL fallback + const invalidUrl = 'http://[invalid-ipv6' + const result = sanitizeUrl(invalidUrl) + expect(result).toBe(invalidUrl) + }) + + it('should handle localhost URLs', () => { + const url = 'http://user:pass@localhost:3000/api?sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe( + 'http://REDACTED:REDACTED@localhost:3000/api?sig=REDACTED', + ) + }) + + it('should handle URLs with special characters in query values', () => { + const url = + 'https://example.com/path?normal=value&sig=secret%20with%20spaces' + const result = sanitizeUrl(url) + expect(result).toBe( + 'https://example.com/path?normal=value&sig=REDACTED', + ) + }) + + it('should preserve URL encoding in non-sensitive parameters', () => { + const url = + 'https://example.com/path?name=John%20Doe&sig=secret' + const result = sanitizeUrl(url) + expect(result).toContain('name=John+Doe') + expect(result).toContain('sig=REDACTED') + }) + }) + + describe('relative URLs', () => { + it('should redact sensitive query params in relative URLs', () => { + const url = '/api?sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe('/api?sig=REDACTED') + }) + + it('should redact AWSAccessKeyId in relative URLs', () => { + const url = + '/api?awsAccessKeyId=AKIAIOSFODNN7EXAMPLE&color=blue' + const result = sanitizeUrl(url) + expect(result).toBe('/api?awsAccessKeyId=REDACTED&color=blue') + }) + + it('should redact multiple sensitive params in relative URLs', () => { + const url = + '/path/to/resource?signature=abc123&x-goog-signature=xyz789' + const result = sanitizeUrl(url) + expect(result).toBe( + '/path/to/resource?signature=REDACTED&x-goog-signature=REDACTED', + ) + }) + + it('should handle relative URLs without query params', () => { + const url = '/api/data' + const result = sanitizeUrl(url) + expect(result).toBe('/api/data') + }) + + it('should handle relative URLs with fragment', () => { + const url = '/api?sig=secret#section' + const result = sanitizeUrl(url) + expect(result).toBe('/api?sig=REDACTED#section') + }) + + it('should handle relative URLs with safe query params only', () => { + const url = '/users?id=123&filter=active' + const result = sanitizeUrl(url) + expect(result).toBe('/users?id=123&filter=active') + }) + + it('should handle root-relative URLs', () => { + const url = '/?sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe('/?sig=REDACTED') + }) + }) + + describe('protocol-relative URLs', () => { + it('should redact sensitive query params while preserving host', () => { + const url = '//example.com/path?sig=secret123' + const result = sanitizeUrl(url) + expect(result).toBe('//example.com/path?sig=REDACTED') + }) + + it('should redact credentials in protocol-relative URLs', () => { + const url = '//user:pass@example.com/path' + const result = sanitizeUrl(url) + expect(result).toBe('//REDACTED:REDACTED@example.com/path') + }) + + it('should handle protocol-relative URLs with port', () => { + const url = '//example.com:8080/api?sig=secret' + const result = sanitizeUrl(url) + expect(result).toBe('//example.com:8080/api?sig=REDACTED') + }) + + it('should handle protocol-relative URLs with fragment', () => { + const url = '//example.com/path?sig=secret#section' + const result = sanitizeUrl(url) + expect(result).toBe('//example.com/path?sig=REDACTED#section') + }) + + it('should preserve safe query params in protocol-relative URLs', () => { + const url = '//example.com/api?user=123&filter=active' + const result = sanitizeUrl(url) + expect(result).toBe('//example.com/api?user=123&filter=active') + }) + + it('should handle protocol-relative URLs without query params', () => { + const url = '//example.com/path/to/resource' + const result = sanitizeUrl(url) + expect(result).toBe('//example.com/path/to/resource') + }) + + it('should redact multiple sensitive params in protocol-relative URLs', () => { + const url = + '//cdn.example.com/file?signature=abc&x-goog-signature=xyz' + const result = sanitizeUrl(url) + expect(result).toBe( + '//cdn.example.com/file?signature=REDACTED&x-goog-signature=REDACTED', + ) + }) + + it('should handle complex protocol-relative URLs with credentials, port, and sensitive params', () => { + const url = + '//admin:secret@api.example.com:443/v1/data?AWSAccessKeyId=KEY&sig=SIG#hash' + const result = sanitizeUrl(url) + expect(result).toBe( + '//REDACTED:REDACTED@api.example.com:443/v1/data?AWSAccessKeyId=REDACTED&sig=REDACTED#hash', + ) + }) + }) + }) + + describe('safeParseUrl', () => { + describe('absolute URLs', () => { + it('should parse absolute URL with path and query', () => { + const result = safeParseUrl( + 'https://example.com/api/data?foo=bar', + ) + expect(result.pathname).toBe('/api/data') + expect(result.search).toBe('?foo=bar') + expect(result.searchParams.get('foo')).toBe('bar') + expect(result.origin).toBe('https://example.com') + }) + + it('should parse absolute URL with only path', () => { + const result = safeParseUrl('https://example.com/api/data') + expect(result.pathname).toBe('/api/data') + expect(result.search).toBe('') + }) + + it('should parse absolute URL with port', () => { + const result = safeParseUrl('http://localhost:3000/api') + expect(result.pathname).toBe('/api') + expect(result.port).toBe('3000') + expect(result.origin).toBe('http://localhost:3000') + }) + }) + + describe('relative URLs', () => { + it('should parse relative URL with leading slash', () => { + const result = safeParseUrl('/api/data') + expect(result.pathname).toBe('/api/data') + expect(result.search).toBe('') + }) + + it('should parse relative URL with path and query', () => { + const result = safeParseUrl('/api/data?foo=bar&baz=qux') + expect(result.pathname).toBe('/api/data') + expect(result.search).toBe('?foo=bar&baz=qux') + expect(result.searchParams.get('foo')).toBe('bar') + expect(result.searchParams.get('baz')).toBe('qux') + }) + + it('should parse relative URL with nested path', () => { + const result = safeParseUrl('/api/v1/users/123?include=profile') + expect(result.pathname).toBe('/api/v1/users/123') + expect(result.searchParams.get('include')).toBe('profile') + }) + + it('should handle query-only relative URL', () => { + const result = safeParseUrl('?foo=bar') + expect(result.search).toBe('?foo=bar') + expect(result.searchParams.get('foo')).toBe('bar') + }) + }) + + describe('edge cases', () => { + it('should handle URL with encoded characters in query', () => { + const result = safeParseUrl( + '/search?q=hello%20world&filter=a%26b', + ) + expect(result.pathname).toBe('/search') + expect(result.searchParams.get('q')).toBe('hello world') + expect(result.searchParams.get('filter')).toBe('a&b') + }) + + it('should handle URL with multiple query params of same key', () => { + const result = safeParseUrl('/path?tag=a&tag=b&tag=c') + expect(result.pathname).toBe('/path') + expect(result.searchParams.getAll('tag')).toEqual([ + 'a', + 'b', + 'c', + ]) + }) + + it('should handle URL with fragment', () => { + const result = safeParseUrl( + 'https://example.com/page?q=test#section', + ) + expect(result.pathname).toBe('/page') + expect(result.search).toBe('?q=test') + expect(result.hash).toBe('#section') + }) + }) + }) +}) diff --git a/sdk/highlight-run/src/sdk/observe.ts b/sdk/highlight-run/src/sdk/observe.ts index 4cbe0ab55..c14222a6c 100644 --- a/sdk/highlight-run/src/sdk/observe.ts +++ b/sdk/highlight-run/src/sdk/observe.ts @@ -79,6 +79,7 @@ import { recordException } from '../client/otel/recordException' import { ObserveOptions } from '../client/types/observe' import { isMetricSafeNumber } from '../client/utils/utils' import * as SemanticAttributes from '@opentelemetry/semantic-conventions' +import { sanitizeUrl } from '../client/listeners/network-listener/utils/network-sanitizer' export class ObserveSDK implements Observe { /** Verbose project ID that is exposed to users. Legacy users may still be using ints. */ @@ -591,7 +592,7 @@ export class ObserveSDK implements Observe { attributes: { group: window.location.pathname, category: MetricCategory.WebVital, - [SemanticAttributes.ATTR_URL_FULL]: href, + [SemanticAttributes.ATTR_URL_FULL]: sanitizeUrl(href), [SemanticAttributes.ATTR_URL_PATH]: pathname, [SemanticAttributes.ATTR_SERVER_ADDRESS]: hostname, },