{description}
++ {isRecording + ? '✓ Observability recording active' + : '⏳ Starting recording...'} +
+ +{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