From a6ff35aa0d478890c1227bc08d071e2d70497e7c Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Thu, 11 Dec 2025 08:58:22 -0600 Subject: [PATCH 01/11] feat: enhance Web Vitals telemetry with semantic attributes --- sdk/highlight-run/src/sdk/observe.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/highlight-run/src/sdk/observe.ts b/sdk/highlight-run/src/sdk/observe.ts index 11ba9d29e..db601ed64 100644 --- a/sdk/highlight-run/src/sdk/observe.ts +++ b/sdk/highlight-run/src/sdk/observe.ts @@ -80,6 +80,7 @@ import { ObserveOptions } from '../client/types/observe' import { WebTracerProvider } from '@opentelemetry/sdk-trace-web' import { MeterProvider } from '@opentelemetry/sdk-metrics' import { isMetricSafeNumber } from '../client/utils/utils' +import * as SemanticAttributes from '@opentelemetry/semantic-conventions' export class ObserveSDK implements Observe { /** Verbose project ID that is exposed to users. Legacy users may still be using ints. */ @@ -576,12 +577,16 @@ export class ObserveSDK implements Observe { ) WebVitalsListener((data) => { const { name, value } = data + const { hostname, pathname, href } = window.location this.recordGauge({ name, value, attributes: { - group: window.location.href, + group: window.location.pathname, category: MetricCategory.WebVital, + [SemanticAttributes.ATTR_URL_FULL]: href, + [SemanticAttributes.ATTR_URL_PATH]: pathname, + [SemanticAttributes.ATTR_SERVER_ADDRESS]: hostname, }, }) }) From f0cd3666d10536b6ada0eb2f5049eefbf3d08cb4 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Thu, 11 Dec 2025 10:05:17 -0600 Subject: [PATCH 02/11] Sanitize URLs and headers for OpenTelemetry spans Adds URL sanitization to redact credentials and sensitive query parameters per OpenTelemetry semantic conventions. Refactors request/response attribute handling to conditionally record and sanitize headers and bodies, and introduces helper functions for parsing and formatting headers. Includes comprehensive tests for sanitization and attribute formatting. --- .../utils/network-sanitizer.ts | 57 + sdk/highlight-run/src/client/otel/index.ts | 212 +++- .../src/client/otel/instrumentation.test.ts | 973 ++++++++++++++++++ sdk/highlight-run/src/sdk/observe.ts | 3 +- 4 files changed, 1203 insertions(+), 42 deletions(-) create mode 100644 sdk/highlight-run/src/client/otel/instrumentation.test.ts 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..2d9e612b8 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,60 @@ 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', +] + +/** + * Sanitizes a URL according to OpenTelemetry semantic conventions. + * - Redacts credentials (username:password) in the URL + * - Redacts sensitive query parameter values while preserving keys + * + * @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' + */ +export const sanitizeUrl = (url: string): string => { + try { + const urlObject = new URL(url) + + // Redact credentials if present + if (urlObject.username || urlObject.password) { + urlObject.username = 'REDACTED' + urlObject.password = 'REDACTED' + } + + // Redact sensitive query parameters + const searchParams = urlObject.searchParams + SENSITIVE_QUERY_PARAMS.forEach((sensitiveParam) => { + // Check all query params case-insensitively + for (const key of Array.from(searchParams.keys())) { + if (key.toLowerCase() === sensitiveParam) { + searchParams.set(key, 'REDACTED') + } + } + }) + + return urlObject.toString() + } catch { + // If URL parsing fails, return original URL + // This handles relative URLs or malformed URLs + return url + } +} diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index de1197788..db0b0323c 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -28,6 +28,7 @@ import { getResponseBody } from '../listeners/network-listener/utils/fetch-liste import { DEFAULT_URL_BLOCKLIST, sanitizeHeaders, + sanitizeUrl, } from '../listeners/network-listener/utils/network-sanitizer' import { shouldNetworkRequestBeRecorded, @@ -232,7 +233,8 @@ export const setupBrowserTracing = ( if (!(response instanceof Response)) { span.setAttributes({ 'http.response.error': response.message, - 'http.response.status': response.status, + [SemanticAttributes.ATTR_HTTP_RESPONSE_STATUS_CODE]: + response.status, }) return } @@ -244,13 +246,28 @@ export const setupBrowserTracing = ( config.networkRecordingOptions, ) - const body = await getResponseBody( - response, - config.networkRecordingOptions?.bodyKeysToRecord, - config.networkRecordingOptions - ?.networkBodyKeysToRedact, - ) - span.setAttribute('http.response.body', body) + // Only process response body/headers if recording is enabled + 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, + ) + } }, }), ) @@ -285,14 +302,36 @@ 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) + // Only process response body/headers if recording is enabled + 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, + ) + } }, }), ) @@ -440,51 +479,142 @@ 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 sanitizedUrl = sanitizeUrl(url) + const sanitizedUrlObject = new URL(sanitizedUrl) - let parsedBody + // Extract GraphQL operation name if present (useful metadata even without body recording) + 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, - ) - + // Set basic URL attributes (always recorded) 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.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 + if (sanitizedUrlObject.searchParams.size > 0) { + span.setAttribute( + 'url.query_params', + JSON.stringify(Object.fromEntries(sanitizedUrlObject.searchParams)), + ) } + + // Only record body and headers if explicitly enabled + 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] + * - http.response.header.: [value] + * + * @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 + */ +const convertHeadersToOtelAttributes = ( + headers: { [key: string]: string }, + prefix: 'http.request.header' | 'http.response.header', +): { [key: string]: string[] } => { + const attributes: { [key: string]: string[] } = {} + + Object.entries(headers).forEach(([key, value]) => { + // Normalize header name: lowercase and replace underscores with dashes + const normalizedKey = key.toLowerCase().replace(/_/g, '-') + const attributeName = `${prefix}.${normalizedKey}` + + // OTel spec requires header values to be arrays + attributes[attributeName] = [value] + }) + + return attributes +} + +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, + ) + + // Always preserve content-type unless explicitly excluded via headerKeysToRecord + const contentType = + responseHeaders['content-type'] ?? responseHeaders['Content-Type'] + if (contentType && !networkRecordingOptions.headerKeysToRecord) { + sanitizedResponseHeaders['content-type'] = contentType + } + + // Set response headers following OTel semantic conventions + const headerAttributes = convertHeadersToOtelAttributes( + sanitizedResponseHeaders, + 'http.response.header', + ) + span.setAttributes(headerAttributes) } const shouldRecordRequest = ( 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..b0d495bc5 --- /dev/null +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -0,0 +1,973 @@ +import { describe, it, expect } from 'vitest' +import { + sanitizeHeaders, + sanitizeUrl, +} from '../listeners/network-listener/utils/network-sanitizer' +import { parseXhrResponseHeaders } from './index' + +/** + * Helper to convert headers to OTel semantic convention format for testing. + * This mirrors the logic in convertHeadersToOtelAttributes. + */ +const convertHeadersToOtelFormat = ( + headers: { [key: string]: string }, + prefix: 'http.request.header' | 'http.response.header', +): { [key: string]: string[] } => { + const attributes: { [key: string]: string[] } = {} + Object.entries(headers).forEach(([key, value]) => { + const normalizedKey = key.toLowerCase().replace(/_/g, '-') + attributes[`${prefix}.${normalizedKey}`] = [value] + }) + return attributes +} + +describe('Network Instrumentation Custom Attributes', () => { + describe('convertHeadersToOtelFormat', () => { + 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 = convertHeadersToOtelFormat( + 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 = convertHeadersToOtelFormat( + 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 = convertHeadersToOtelFormat( + headers, + 'http.request.header', + ) + + expect(result).toEqual({ + 'http.request.header.x-custom-header': ['value'], + 'http.request.header.another-header': ['test'], + }) + }) + + it('should wrap values in arrays per OTel spec', () => { + const headers = { + 'content-type': 'application/json', + } + + const result = convertHeadersToOtelFormat( + headers, + 'http.request.header', + ) + + expect(result['http.request.header.content-type']).toBeInstanceOf( + Array, + ) + expect(result['http.request.header.content-type']).toEqual([ + 'application/json', + ]) + }) + + it('should handle empty headers object', () => { + const result = convertHeadersToOtelFormat({}, '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, + ) + + // Always preserve content-type unless explicitly excluded via headerKeysToRecord + const contentType = + responseHeaders['content-type'] ?? + responseHeaders['Content-Type'] + if (contentType && !networkRecordingOptions?.headerKeysToRecord) { + sanitizedResponseHeaders['content-type'] = contentType + } + + 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 preserve content-type even when trying to redact it', () => { + const responseHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'abc-123', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + networkHeadersToRedact: ['content-type'], + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + // content-type should be preserved despite being in redact list + expect(parsed['content-type']).toBe('application/json') + }) + + it('should handle Content-Type with different casing', () => { + const responseHeaders = { + 'Content-Type': 'text/html', + 'X-Request-ID': 'abc-123', + } + + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['content-type']).toBe('text/html') + }) + + it('should NOT preserve 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 preserve 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 preserve content-type in XHR responses', () => { + const headerString = + 'content-type: text/html\r\nx-custom: value' + + const responseHeaders = parseXhrResponseHeaders(headerString) + const result = captureResponseAttributes(responseHeaders, '', { + recordHeadersAndBody: true, + networkHeadersToRedact: ['content-type'], + }) + const parsed = JSON.parse(result?.headers ?? '{}') + + expect(parsed['content-type']).toBe('text/html') + }) + + 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', () => { + const invalidUrl = 'not-a-valid-url' + 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') + }) + }) + }) +}) diff --git a/sdk/highlight-run/src/sdk/observe.ts b/sdk/highlight-run/src/sdk/observe.ts index db601ed64..fa2343080 100644 --- a/sdk/highlight-run/src/sdk/observe.ts +++ b/sdk/highlight-run/src/sdk/observe.ts @@ -81,6 +81,7 @@ import { WebTracerProvider } from '@opentelemetry/sdk-trace-web' import { MeterProvider } from '@opentelemetry/sdk-metrics' 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. */ @@ -584,7 +585,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, }, From eba38c976ba1491e5cc30b8e04f30340eec0236e Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Thu, 11 Dec 2025 10:12:26 -0600 Subject: [PATCH 03/11] Update network-sanitizer.ts --- .../listeners/network-listener/utils/network-sanitizer.ts | 2 -- 1 file changed, 2 deletions(-) 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 2d9e612b8..0a31bd160 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 @@ -102,10 +102,8 @@ export const sanitizeUrl = (url: string): string => { urlObject.password = 'REDACTED' } - // Redact sensitive query parameters const searchParams = urlObject.searchParams SENSITIVE_QUERY_PARAMS.forEach((sensitiveParam) => { - // Check all query params case-insensitively for (const key of Array.from(searchParams.keys())) { if (key.toLowerCase() === sensitiveParam) { searchParams.set(key, 'REDACTED') From f040869b64771ddbb535539e0ed7474953109d00 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Thu, 11 Dec 2025 12:57:45 -0600 Subject: [PATCH 04/11] Don't use arrays if single value --- e2e/react-router/src/main.tsx | 2 + e2e/react-router/src/routes/http-test.tsx | 523 ++++++++++++++++++ e2e/react-router/src/routes/root.tsx | 1 + sdk/highlight-run/src/client/otel/index.ts | 29 +- .../src/client/otel/instrumentation.test.ts | 72 ++- 5 files changed, 599 insertions(+), 28 deletions(-) create mode 100644 e2e/react-router/src/routes/http-test.tsx 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..9b74aca84 --- /dev/null +++ b/e2e/react-router/src/routes/http-test.tsx @@ -0,0 +1,523 @@ +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) + } + }} + /> + + + + { + 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/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index 67e52b509..cd613903e 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -249,7 +249,6 @@ export const setupBrowserTracing = ( config.networkRecordingOptions, ) - // Only process response body/headers if recording is enabled if ( config.networkRecordingOptions?.recordHeadersAndBody ) { @@ -305,7 +304,6 @@ export const setupBrowserTracing = ( config.networkRecordingOptions, ) - // Only process response body/headers if recording is enabled if ( config.networkRecordingOptions?.recordHeadersAndBody ) { @@ -500,7 +498,6 @@ const enhanceSpanWithHttpRequestAttributes = ( const sanitizedUrl = sanitizeUrl(url) const sanitizedUrlObject = new URL(sanitizedUrl) - // Extract GraphQL operation name if present (useful metadata even without body recording) const stringBody = typeof body === 'string' ? body : String(body) try { const parsedBody = body ? JSON.parse(stringBody) : undefined @@ -514,7 +511,6 @@ const enhanceSpanWithHttpRequestAttributes = ( // Ignore parsing errors } - // Set basic URL attributes (always recorded) span.setAttributes({ 'highlight.type': 'http.request', [SemanticAttributes.ATTR_URL_FULL]: sanitizedUrl, @@ -530,7 +526,6 @@ const enhanceSpanWithHttpRequestAttributes = ( ) } - // Only record body and headers if explicitly enabled if (networkRecordingOptions?.recordHeadersAndBody) { const requestBody = getBodyThatShouldBeRecorded( body, @@ -576,26 +571,34 @@ export const parseXhrResponseHeaders = ( /** * Converts headers object to OpenTelemetry semantic convention format. * Headers are set as individual attributes with the pattern: - * - http.request.header.: [value] - * - http.response.header.: [value] + * - http.request.header.: value (or [value1, value2] if multiple) + * - http.response.header.: value (or [value1, value2] if multiple) * * @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 + * @returns Object with OTel semantic convention attribute names (arrays only for duplicate headers) */ const convertHeadersToOtelAttributes = ( headers: { [key: string]: string }, prefix: 'http.request.header' | 'http.response.header', -): { [key: string]: string[] } => { - const attributes: { [key: string]: string[] } = {} +): { [key: string]: string | string[] } => { + const attributes: { [key: string]: string | string[] } = {} Object.entries(headers).forEach(([key, value]) => { - // Normalize header name: lowercase and replace underscores with dashes const normalizedKey = key.toLowerCase().replace(/_/g, '-') const attributeName = `${prefix}.${normalizedKey}` - // OTel spec requires header values to be arrays - attributes[attributeName] = [value] + // OTel spec says header values should be arrays. However, this clutters the + // UI and makes queryiing more complex, so we are using strings when possible. + // Only use arrays if there are multiple values for the same header + if (attributes[attributeName]) { + const existing = attributes[attributeName] + attributes[attributeName] = Array.isArray(existing) + ? [...existing, value] + : [existing, value] + } else { + attributes[attributeName] = value + } }) return attributes diff --git a/sdk/highlight-run/src/client/otel/instrumentation.test.ts b/sdk/highlight-run/src/client/otel/instrumentation.test.ts index b0d495bc5..8552537f6 100644 --- a/sdk/highlight-run/src/client/otel/instrumentation.test.ts +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -12,11 +12,23 @@ import { parseXhrResponseHeaders } from './index' const convertHeadersToOtelFormat = ( headers: { [key: string]: string }, prefix: 'http.request.header' | 'http.response.header', -): { [key: string]: string[] } => { - const attributes: { [key: string]: string[] } = {} +): { [key: string]: string | string[] } => { + const attributes: { [key: string]: string | string[] } = {} Object.entries(headers).forEach(([key, value]) => { const normalizedKey = key.toLowerCase().replace(/_/g, '-') - attributes[`${prefix}.${normalizedKey}`] = [value] + const attributeName = `${prefix}.${normalizedKey}` + + // Only use arrays if there are multiple values for the same header + if (attributes[attributeName]) { + // Convert to array if not already, then add new value + const existing = attributes[attributeName] + attributes[attributeName] = Array.isArray(existing) + ? [...existing, value] + : [existing, value] + } else { + // Single value - store as string + attributes[attributeName] = value + } }) return attributes } @@ -36,9 +48,9 @@ describe('Network Instrumentation Custom Attributes', () => { ) 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'], + 'http.request.header.content-type': 'application/json', + 'http.request.header.x-request-id': 'abc-123', + 'http.request.header.cache-control': 'no-cache', }) }) @@ -54,8 +66,8 @@ describe('Network Instrumentation Custom Attributes', () => { ) expect(result).toEqual({ - 'http.response.header.content-type': ['text/html'], - 'http.response.header.x-custom-header': ['value'], + 'http.response.header.content-type': 'text/html', + 'http.response.header.x-custom-header': 'value', }) }) @@ -71,12 +83,12 @@ describe('Network Instrumentation Custom Attributes', () => { ) expect(result).toEqual({ - 'http.request.header.x-custom-header': ['value'], - 'http.request.header.another-header': ['test'], + 'http.request.header.x-custom-header': 'value', + 'http.request.header.another-header': 'test', }) }) - it('should wrap values in arrays per OTel spec', () => { + it('should store single values as strings, not arrays', () => { const headers = { 'content-type': 'application/json', } @@ -86,11 +98,41 @@ describe('Network Instrumentation Custom Attributes', () => { 'http.request.header', ) - expect(result['http.request.header.content-type']).toBeInstanceOf( - Array, - ) - expect(result['http.request.header.content-type']).toEqual([ + expect(result['http.request.header.content-type']).toBe( 'application/json', + ) + expect( + result['http.request.header.content-type'], + ).not.toBeInstanceOf(Array) + }) + + 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 = convertHeadersToOtelFormat( + 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', ]) }) From dfddf2c98c707bf5f00456ba68060b9393fa2923 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Thu, 11 Dec 2025 13:51:47 -0600 Subject: [PATCH 05/11] Handle comma separated values --- sdk/highlight-run/src/client/otel/index.ts | 66 ++++++--- .../src/client/otel/instrumentation.test.ts | 128 +++++++++++++++++- 2 files changed, 168 insertions(+), 26 deletions(-) diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index cd613903e..b9e94fc04 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -233,6 +233,13 @@ export const setupBrowserTracing = ( return } + enhanceSpanWithHttpRequestAttributes( + span, + request.body, + request.headers, + config.networkRecordingOptions, + ) + if (!(response instanceof Response)) { span.setAttributes({ 'http.response.error': response.message, @@ -242,13 +249,6 @@ export const setupBrowserTracing = ( return } - enhanceSpanWithHttpRequestAttributes( - span, - request.body, - request.headers, - config.networkRecordingOptions, - ) - if ( config.networkRecordingOptions?.recordHeadersAndBody ) { @@ -412,7 +412,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, @@ -494,7 +494,7 @@ const enhanceSpanWithHttpRequestAttributes = ( return } const readableSpan = span as unknown as ReadableSpan - const url = readableSpan.attributes['http.url'] as string + const url = getUrlFromSpan(readableSpan) const sanitizedUrl = sanitizeUrl(url) const sanitizedUrlObject = new URL(sanitizedUrl) @@ -513,6 +513,7 @@ const enhanceSpanWithHttpRequestAttributes = ( span.setAttributes({ 'highlight.type': 'http.request', + [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, @@ -574,9 +575,12 @@ export const parseXhrResponseHeaders = ( * - 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 (arrays only for duplicate headers) + * @returns Object with OTel semantic convention attribute names */ const convertHeadersToOtelAttributes = ( headers: { [key: string]: string }, @@ -587,23 +591,40 @@ const convertHeadersToOtelAttributes = ( Object.entries(headers).forEach(([key, value]) => { const normalizedKey = key.toLowerCase().replace(/_/g, '-') const attributeName = `${prefix}.${normalizedKey}` + const values = splitHeaderValue(value) - // OTel spec says header values should be arrays. However, this clutters the - // UI and makes queryiing more complex, so we are using strings when possible. - // Only use arrays if there are multiple values for the same header + // Handle duplicate header keys (same header appearing multiple times) if (attributes[attributeName]) { const existing = attributes[attributeName] - attributes[attributeName] = Array.isArray(existing) - ? [...existing, value] - : [existing, value] + + if (Array.isArray(existing)) { + attributes[attributeName] = [...existing, ...values] + } else { + attributes[attributeName] = [existing, ...values] + } } else { - attributes[attributeName] = value + attributes[attributeName] = values.length === 1 ? values[0] : values } }) return attributes } +/** + * Splits a header value by commas, trimming whitespace from each value. + * Handles edge cases like quality values (e.g., "en-US, en;q=0.9") properly. + * + * @param value - The header value string + * @returns Array of trimmed values + */ +const splitHeaderValue = (value: string): string[] => { + // 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 }, @@ -618,14 +639,13 @@ const enhanceSpanWithHttpResponseAttributes = ( networkRecordingOptions.headerKeysToRecord, ) - // Always preserve content-type unless explicitly excluded via headerKeysToRecord + // Always preserve content-type unless explicitly excluded const contentType = responseHeaders['content-type'] ?? responseHeaders['Content-Type'] if (contentType && !networkRecordingOptions.headerKeysToRecord) { sanitizedResponseHeaders['content-type'] = contentType } - // Set response headers following OTel semantic conventions const headerAttributes = convertHeadersToOtelAttributes( sanitizedResponseHeaders, 'http.response.header', @@ -793,3 +813,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 index 8552537f6..02f5df7da 100644 --- a/sdk/highlight-run/src/client/otel/instrumentation.test.ts +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -5,6 +5,17 @@ import { } from '../listeners/network-listener/utils/network-sanitizer' import { parseXhrResponseHeaders } from './index' +/** + * Helper to split a header value by commas for testing. + * This mirrors the logic in splitHeaderValue. + */ +const splitHeaderValue = (value: string): string[] => { + return value + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0) +} + /** * Helper to convert headers to OTel semantic convention format for testing. * This mirrors the logic in convertHeadersToOtelAttributes. @@ -18,16 +29,20 @@ const convertHeadersToOtelFormat = ( const normalizedKey = key.toLowerCase().replace(/_/g, '-') const attributeName = `${prefix}.${normalizedKey}` - // Only use arrays if there are multiple values for the same header + // Handle duplicate header keys (same header appearing multiple times) if (attributes[attributeName]) { - // Convert to array if not already, then add new value const existing = attributes[attributeName] - attributes[attributeName] = Array.isArray(existing) - ? [...existing, value] - : [existing, value] + const newValues = splitHeaderValue(value) + + if (Array.isArray(existing)) { + attributes[attributeName] = [...existing, ...newValues] + } else { + attributes[attributeName] = [existing, ...newValues] + } } else { - // Single value - store as string - attributes[attributeName] = value + // Split comma-separated values into arrays per OTel spec + const values = splitHeaderValue(value) + attributes[attributeName] = values.length === 1 ? values[0] : values } }) return attributes @@ -106,6 +121,105 @@ describe('Network Instrumentation Custom Attributes', () => { ).not.toBeInstanceOf(Array) }) + it('should split comma-separated header values into arrays', () => { + const headers = { + accept: 'application/json, text/plain, */*', + } + + const result = convertHeadersToOtelFormat( + 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 = convertHeadersToOtelFormat( + 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 = convertHeadersToOtelFormat( + 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', + 'x-custom-multi': 'value1, value2, value3', + } + + const result = convertHeadersToOtelFormat( + 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', + ) + expect(result['http.request.header.x-custom-multi']).toEqual([ + 'value1', + 'value2', + 'value3', + ]) + }) + + it('should trim whitespace from split values', () => { + const headers = { + accept: 'application/json, text/plain , */*', + } + + const result = convertHeadersToOtelFormat( + 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 = { From 45ab0bb0ee106dcce6c88665da4074c8ea601c89 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Thu, 11 Dec 2025 13:51:55 -0600 Subject: [PATCH 06/11] More tests --- e2e/react-router/src/routes/http-test.tsx | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/e2e/react-router/src/routes/http-test.tsx b/e2e/react-router/src/routes/http-test.tsx index 9b74aca84..8bd16f885 100644 --- a/e2e/react-router/src/routes/http-test.tsx +++ b/e2e/react-router/src/routes/http-test.tsx @@ -389,6 +389,134 @@ export default function HttpTest() { /> + + { + 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) + }} + /> + + Date: Fri, 12 Dec 2025 11:54:32 -0600 Subject: [PATCH 07/11] Fix handling of headers that aren't comma separated --- sdk/highlight-run/src/client/otel/index.ts | 62 +++- .../src/client/otel/instrumentation.test.ts | 347 +++++++++++++++--- 2 files changed, 342 insertions(+), 67 deletions(-) diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index b9e94fc04..a2c5dff27 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -582,7 +582,7 @@ export const parseXhrResponseHeaders = ( * @param prefix - Either 'http.request.header' or 'http.response.header' * @returns Object with OTel semantic convention attribute names */ -const convertHeadersToOtelAttributes = ( +export const convertHeadersToOtelAttributes = ( headers: { [key: string]: string }, prefix: 'http.request.header' | 'http.response.header', ): { [key: string]: string | string[] } => { @@ -591,7 +591,7 @@ const convertHeadersToOtelAttributes = ( Object.entries(headers).forEach(([key, value]) => { const normalizedKey = key.toLowerCase().replace(/_/g, '-') const attributeName = `${prefix}.${normalizedKey}` - const values = splitHeaderValue(value) + const values = splitHeaderValue(normalizedKey, value) // Handle duplicate header keys (same header appearing multiple times) if (attributes[attributeName]) { @@ -611,13 +611,63 @@ const convertHeadersToOtelAttributes = ( } /** - * Splits a header value by commas, trimming whitespace from each value. - * Handles edge cases like quality values (e.g., "en-US, en;q=0.9") properly. + * 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 trimmed values + * @returns Array of values (single element for non-comma-separated headers) */ -const splitHeaderValue = (value: string): string[] => { +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(',') diff --git a/sdk/highlight-run/src/client/otel/instrumentation.test.ts b/sdk/highlight-run/src/client/otel/instrumentation.test.ts index 02f5df7da..68d8827a7 100644 --- a/sdk/highlight-run/src/client/otel/instrumentation.test.ts +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -3,53 +3,96 @@ import { sanitizeHeaders, sanitizeUrl, } from '../listeners/network-listener/utils/network-sanitizer' -import { parseXhrResponseHeaders } from './index' - -/** - * Helper to split a header value by commas for testing. - * This mirrors the logic in splitHeaderValue. - */ -const splitHeaderValue = (value: string): string[] => { - return value - .split(',') - .map((v) => v.trim()) - .filter((v) => v.length > 0) -} - -/** - * Helper to convert headers to OTel semantic convention format for testing. - * This mirrors the logic in convertHeadersToOtelAttributes. - */ -const convertHeadersToOtelFormat = ( - 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}` - - // Handle duplicate header keys (same header appearing multiple times) - if (attributes[attributeName]) { - const existing = attributes[attributeName] - const newValues = splitHeaderValue(value) - - if (Array.isArray(existing)) { - attributes[attributeName] = [...existing, ...newValues] - } else { - attributes[attributeName] = [existing, ...newValues] - } - } else { - // Split comma-separated values into arrays per OTel spec - const values = splitHeaderValue(value) - attributes[attributeName] = values.length === 1 ? values[0] : values - } - }) - return attributes -} +import { + parseXhrResponseHeaders, + splitHeaderValue, + convertHeadersToOtelAttributes, +} from './index' describe('Network Instrumentation Custom Attributes', () => { - describe('convertHeadersToOtelFormat', () => { + 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', @@ -57,7 +100,7 @@ describe('Network Instrumentation Custom Attributes', () => { 'Cache-Control': 'no-cache', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.request.header', ) @@ -75,7 +118,7 @@ describe('Network Instrumentation Custom Attributes', () => { 'X-Custom-Header': 'value', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.response.header', ) @@ -92,7 +135,7 @@ describe('Network Instrumentation Custom Attributes', () => { another_header: 'test', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.request.header', ) @@ -108,7 +151,7 @@ describe('Network Instrumentation Custom Attributes', () => { 'content-type': 'application/json', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.request.header', ) @@ -126,7 +169,7 @@ describe('Network Instrumentation Custom Attributes', () => { accept: 'application/json, text/plain, */*', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.request.header', ) @@ -143,7 +186,7 @@ describe('Network Instrumentation Custom Attributes', () => { 'accept-language': 'en-US, en;q=0.9, es;q=0.8', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.request.header', ) @@ -160,7 +203,7 @@ describe('Network Instrumentation Custom Attributes', () => { 'cache-control': 'no-cache, no-store, must-revalidate', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.response.header', ) @@ -177,10 +220,10 @@ describe('Network Instrumentation Custom Attributes', () => { 'content-type': 'application/json', accept: 'application/json, application/xml, text/html', 'x-custom-single': 'single-value', - 'x-custom-multi': 'value1, value2, value3', + 'cache-control': 'no-cache, no-store, max-age=0', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.request.header', ) @@ -196,11 +239,93 @@ describe('Network Instrumentation Custom Attributes', () => { expect(result['http.request.header.x-custom-single']).toBe( 'single-value', ) - expect(result['http.request.header.x-custom-multi']).toEqual([ - 'value1', - 'value2', - 'value3', + // 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', () => { @@ -208,7 +333,7 @@ describe('Network Instrumentation Custom Attributes', () => { accept: 'application/json, text/plain , */*', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.request.header', ) @@ -226,7 +351,7 @@ describe('Network Instrumentation Custom Attributes', () => { 'set-cookie': 'session=abc123', } - const result = convertHeadersToOtelFormat( + const result = convertHeadersToOtelAttributes( headers, 'http.response.header', ) @@ -251,7 +376,10 @@ describe('Network Instrumentation Custom Attributes', () => { }) it('should handle empty headers object', () => { - const result = convertHeadersToOtelFormat({}, 'http.request.header') + const result = convertHeadersToOtelAttributes( + {}, + 'http.request.header', + ) expect(result).toEqual({}) }) }) @@ -1126,4 +1254,101 @@ describe('Network Instrumentation Custom Attributes', () => { }) }) }) + + describe('parseUrlComponents', () => { + describe('absolute URLs', () => { + it('should parse absolute URL with path and query', () => { + const result = parseUrlComponents( + 'https://example.com/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 absolute URL with only path', () => { + const result = parseUrlComponents( + 'https://example.com/api/data', + ) + expect(result.pathname).toBe('/api/data') + expect(result.search).toBe('') + expect(Array.from(result.searchParams.keys()).length).toBe(0) + }) + + it('should parse absolute URL with root path', () => { + const result = parseUrlComponents('https://example.com/') + expect(result.pathname).toBe('/') + expect(result.search).toBe('') + }) + }) + + describe('relative URLs', () => { + it('should parse relative URL with path only', () => { + const result = parseUrlComponents('/api/data') + expect(result.pathname).toBe('/api/data') + expect(result.search).toBe('') + expect(Array.from(result.searchParams.keys()).length).toBe(0) + }) + + it('should parse relative URL with path and query', () => { + const result = parseUrlComponents('/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 only query params', () => { + const result = parseUrlComponents('?foo=bar') + expect(result.pathname).toBe('') + expect(result.search).toBe('?foo=bar') + expect(result.searchParams.get('foo')).toBe('bar') + }) + + it('should parse relative URL with nested path', () => { + const result = parseUrlComponents( + '/api/v1/users/123?include=profile', + ) + expect(result.pathname).toBe('/api/v1/users/123') + expect(result.search).toBe('?include=profile') + expect(result.searchParams.get('include')).toBe('profile') + }) + + it('should handle relative URL without leading slash', () => { + const result = parseUrlComponents('api/data?key=value') + expect(result.pathname).toBe('api/data') + expect(result.search).toBe('?key=value') + expect(result.searchParams.get('key')).toBe('value') + }) + }) + + describe('edge cases', () => { + it('should handle empty string', () => { + const result = parseUrlComponents('') + expect(result.pathname).toBe('') + expect(result.search).toBe('') + expect(Array.from(result.searchParams.keys()).length).toBe(0) + }) + + it('should handle URL with multiple query params of same key', () => { + const result = parseUrlComponents('/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 encoded characters in query', () => { + const result = parseUrlComponents( + '/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') + }) + }) + }) }) From 01a1f3d1151daf8ad4a0a23e3b0410a89f811069 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Fri, 12 Dec 2025 11:58:42 -0600 Subject: [PATCH 08/11] Add safeParseUrl utility and update URL parsing logic --- sdk/highlight-run/src/client/otel/index.ts | 26 +++++- .../src/client/otel/instrumentation.test.ts | 79 ++++++++----------- 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index a2c5dff27..f6195c557 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -481,6 +481,23 @@ export const shutdown = async () => { ]) } +/** + * Safely parses a URL, handling both absolute and relative URLs. + * For relative URLs, resolves against window.location.origin (browser) + * or a placeholder base (non-browser environments). + */ +export const safeParseUrl = (url: string): URL => { + try { + return new URL(url) + } catch { + if (typeof window !== 'undefined') { + return new URL(url, window.location.origin) + } + + return new URL(url, 'http://localhost') + } +} + const enhanceSpanWithHttpRequestAttributes = ( span: api.Span, body: Request['body'] | RequestInit['body'] | BrowserXHR['_body'], @@ -496,7 +513,7 @@ const enhanceSpanWithHttpRequestAttributes = ( const readableSpan = span as unknown as ReadableSpan const url = getUrlFromSpan(readableSpan) const sanitizedUrl = sanitizeUrl(url) - const sanitizedUrlObject = new URL(sanitizedUrl) + const sanitizedUrlObject = safeParseUrl(sanitizedUrl) const stringBody = typeof body === 'string' ? body : String(body) try { @@ -520,10 +537,13 @@ const enhanceSpanWithHttpRequestAttributes = ( }) // Set sanitized query params as JSON object for easier querying - if (sanitizedUrlObject.searchParams.size > 0) { + const searchParamsEntries = Array.from( + sanitizedUrlObject.searchParams.entries(), + ) + if (searchParamsEntries.length > 0) { span.setAttribute( 'url.query_params', - JSON.stringify(Object.fromEntries(sanitizedUrlObject.searchParams)), + JSON.stringify(Object.fromEntries(searchParamsEntries)), ) } diff --git a/sdk/highlight-run/src/client/otel/instrumentation.test.ts b/sdk/highlight-run/src/client/otel/instrumentation.test.ts index 68d8827a7..0f276d6b7 100644 --- a/sdk/highlight-run/src/client/otel/instrumentation.test.ts +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -7,6 +7,7 @@ import { parseXhrResponseHeaders, splitHeaderValue, convertHeadersToOtelAttributes, + safeParseUrl, } from './index' describe('Network Instrumentation Custom Attributes', () => { @@ -1255,84 +1256,72 @@ describe('Network Instrumentation Custom Attributes', () => { }) }) - describe('parseUrlComponents', () => { + describe('safeParseUrl', () => { describe('absolute URLs', () => { it('should parse absolute URL with path and query', () => { - const result = parseUrlComponents( - 'https://example.com/api/data?foo=bar&baz=qux', + const result = safeParseUrl( + 'https://example.com/api/data?foo=bar', ) expect(result.pathname).toBe('/api/data') - expect(result.search).toBe('?foo=bar&baz=qux') + expect(result.search).toBe('?foo=bar') expect(result.searchParams.get('foo')).toBe('bar') - expect(result.searchParams.get('baz')).toBe('qux') + expect(result.origin).toBe('https://example.com') }) it('should parse absolute URL with only path', () => { - const result = parseUrlComponents( - 'https://example.com/api/data', - ) + const result = safeParseUrl('https://example.com/api/data') expect(result.pathname).toBe('/api/data') expect(result.search).toBe('') - expect(Array.from(result.searchParams.keys()).length).toBe(0) }) - it('should parse absolute URL with root path', () => { - const result = parseUrlComponents('https://example.com/') - expect(result.pathname).toBe('/') - 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 path only', () => { - const result = parseUrlComponents('/api/data') + it('should parse relative URL with leading slash', () => { + const result = safeParseUrl('/api/data') expect(result.pathname).toBe('/api/data') expect(result.search).toBe('') - expect(Array.from(result.searchParams.keys()).length).toBe(0) }) it('should parse relative URL with path and query', () => { - const result = parseUrlComponents('/api/data?foo=bar&baz=qux') + 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 only query params', () => { - const result = parseUrlComponents('?foo=bar') - expect(result.pathname).toBe('') - expect(result.search).toBe('?foo=bar') - expect(result.searchParams.get('foo')).toBe('bar') - }) - it('should parse relative URL with nested path', () => { - const result = parseUrlComponents( - '/api/v1/users/123?include=profile', - ) + const result = safeParseUrl('/api/v1/users/123?include=profile') expect(result.pathname).toBe('/api/v1/users/123') - expect(result.search).toBe('?include=profile') expect(result.searchParams.get('include')).toBe('profile') }) - it('should handle relative URL without leading slash', () => { - const result = parseUrlComponents('api/data?key=value') - expect(result.pathname).toBe('api/data') - expect(result.search).toBe('?key=value') - expect(result.searchParams.get('key')).toBe('value') + 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 empty string', () => { - const result = parseUrlComponents('') - expect(result.pathname).toBe('') - expect(result.search).toBe('') - expect(Array.from(result.searchParams.keys()).length).toBe(0) + 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 = parseUrlComponents('/path?tag=a&tag=b&tag=c') + const result = safeParseUrl('/path?tag=a&tag=b&tag=c') expect(result.pathname).toBe('/path') expect(result.searchParams.getAll('tag')).toEqual([ 'a', @@ -1341,13 +1330,13 @@ describe('Network Instrumentation Custom Attributes', () => { ]) }) - it('should handle URL with encoded characters in query', () => { - const result = parseUrlComponents( - '/search?q=hello%20world&filter=a%26b', + it('should handle URL with fragment', () => { + const result = safeParseUrl( + 'https://example.com/page?q=test#section', ) - expect(result.pathname).toBe('/search') - expect(result.searchParams.get('q')).toBe('hello world') - expect(result.searchParams.get('filter')).toBe('a&b') + expect(result.pathname).toBe('/page') + expect(result.search).toBe('?q=test') + expect(result.hash).toBe('#section') }) }) }) From 3e9f251e3a8934fb1c27750ed885e860f18af5e1 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Fri, 12 Dec 2025 13:41:05 -0600 Subject: [PATCH 09/11] Improve URL sanitization to support relative URLs --- .../utils/network-sanitizer.ts | 31 +++++++++-- sdk/highlight-run/src/client/otel/index.ts | 18 +------ .../src/client/otel/instrumentation.test.ts | 53 ++++++++++++++++++- 3 files changed, 79 insertions(+), 23 deletions(-) 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 0a31bd160..a8ebbd9e1 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 @@ -76,10 +76,27 @@ const SENSITIVE_QUERY_PARAMS = [ '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 both absolute and relative URLs * * @param url - The URL string to sanitize * @returns Sanitized URL string @@ -91,12 +108,15 @@ const SENSITIVE_QUERY_PARAMS = [ * @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' */ export const sanitizeUrl = (url: string): string => { try { - const urlObject = new URL(url) + const urlObject = safeParseUrl(url) - // Redact credentials if present if (urlObject.username || urlObject.password) { urlObject.username = 'REDACTED' urlObject.password = 'REDACTED' @@ -111,10 +131,13 @@ export const sanitizeUrl = (url: string): string => { } }) + // If the URL is relative, return only the pathname + search + hash + if (!url.includes('://')) { + return urlObject.pathname + urlObject.search + urlObject.hash + } + return urlObject.toString() } catch { - // If URL parsing fails, return original URL - // This handles relative URLs or malformed URLs return url } } diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index f6195c557..32756f7b0 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -27,6 +27,7 @@ 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' @@ -481,23 +482,6 @@ export const shutdown = async () => { ]) } -/** - * Safely parses a URL, handling both absolute and relative URLs. - * For relative URLs, resolves against window.location.origin (browser) - * or a placeholder base (non-browser environments). - */ -export const safeParseUrl = (url: string): URL => { - try { - return new URL(url) - } catch { - if (typeof window !== 'undefined') { - return new URL(url, window.location.origin) - } - - return new URL(url, 'http://localhost') - } -} - const enhanceSpanWithHttpRequestAttributes = ( span: api.Span, body: Request['body'] | RequestInit['body'] | BrowserXHR['_body'], diff --git a/sdk/highlight-run/src/client/otel/instrumentation.test.ts b/sdk/highlight-run/src/client/otel/instrumentation.test.ts index 0f276d6b7..498c35712 100644 --- a/sdk/highlight-run/src/client/otel/instrumentation.test.ts +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { + safeParseUrl, sanitizeHeaders, sanitizeUrl, } from '../listeners/network-listener/utils/network-sanitizer' @@ -7,7 +8,6 @@ import { parseXhrResponseHeaders, splitHeaderValue, convertHeadersToOtelAttributes, - safeParseUrl, } from './index' describe('Network Instrumentation Custom Attributes', () => { @@ -1224,7 +1224,8 @@ describe('Network Instrumentation Custom Attributes', () => { }) it('should return original URL if parsing fails', () => { - const invalidUrl = 'not-a-valid-url' + // Truly malformed URLs that fail even with a base URL fallback + const invalidUrl = 'http://[invalid-ipv6' const result = sanitizeUrl(invalidUrl) expect(result).toBe(invalidUrl) }) @@ -1254,6 +1255,54 @@ describe('Network Instrumentation Custom Attributes', () => { 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('safeParseUrl', () => { From a53eccaac2fa1a281d64c1c26748682a58175f0b Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Mon, 15 Dec 2025 09:19:18 -0600 Subject: [PATCH 10/11] Clean up handling of content-type --- sdk/highlight-run/src/client/otel/index.ts | 7 --- .../src/client/otel/instrumentation.test.ts | 56 +------------------ 2 files changed, 2 insertions(+), 61 deletions(-) diff --git a/sdk/highlight-run/src/client/otel/index.ts b/sdk/highlight-run/src/client/otel/index.ts index 32756f7b0..abd21b43d 100644 --- a/sdk/highlight-run/src/client/otel/index.ts +++ b/sdk/highlight-run/src/client/otel/index.ts @@ -693,13 +693,6 @@ const enhanceSpanWithHttpResponseAttributes = ( networkRecordingOptions.headerKeysToRecord, ) - // Always preserve content-type unless explicitly excluded - const contentType = - responseHeaders['content-type'] ?? responseHeaders['Content-Type'] - if (contentType && !networkRecordingOptions.headerKeysToRecord) { - sanitizedResponseHeaders['content-type'] = contentType - } - const headerAttributes = convertHeadersToOtelAttributes( sanitizedResponseHeaders, 'http.response.header', diff --git a/sdk/highlight-run/src/client/otel/instrumentation.test.ts b/sdk/highlight-run/src/client/otel/instrumentation.test.ts index 498c35712..250283272 100644 --- a/sdk/highlight-run/src/client/otel/instrumentation.test.ts +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -658,14 +658,6 @@ describe('Network Instrumentation Custom Attributes', () => { networkRecordingOptions?.headerKeysToRecord, ) - // Always preserve content-type unless explicitly excluded via headerKeysToRecord - const contentType = - responseHeaders['content-type'] ?? - responseHeaders['Content-Type'] - if (contentType && !networkRecordingOptions?.headerKeysToRecord) { - sanitizedResponseHeaders['content-type'] = contentType - } - return { headers: JSON.stringify(sanitizedResponseHeaders), body: responseBody, @@ -843,37 +835,7 @@ describe('Network Instrumentation Custom Attributes', () => { }) }) - it('should preserve content-type even when trying to redact it', () => { - const responseHeaders = { - 'content-type': 'application/json', - 'x-request-id': 'abc-123', - } - - const result = captureResponseAttributes(responseHeaders, '', { - recordHeadersAndBody: true, - networkHeadersToRedact: ['content-type'], - }) - const parsed = JSON.parse(result?.headers ?? '{}') - - // content-type should be preserved despite being in redact list - expect(parsed['content-type']).toBe('application/json') - }) - - it('should handle Content-Type with different casing', () => { - const responseHeaders = { - 'Content-Type': 'text/html', - 'X-Request-ID': 'abc-123', - } - - const result = captureResponseAttributes(responseHeaders, '', { - recordHeadersAndBody: true, - }) - const parsed = JSON.parse(result?.headers ?? '{}') - - expect(parsed['content-type']).toBe('text/html') - }) - - it('should NOT preserve content-type when headerKeysToRecord is set and excludes it', () => { + it('should redact content-type when headerKeysToRecord is set and excludes it', () => { const responseHeaders = { 'content-type': 'application/json', 'x-request-id': 'abc-123', @@ -892,7 +854,7 @@ describe('Network Instrumentation Custom Attributes', () => { expect(parsed['x-trace-id']).toBe('trace-789') }) - it('should preserve content-type when headerKeysToRecord includes it', () => { + it('should keep content-type when headerKeysToRecord includes it', () => { const responseHeaders = { 'content-type': 'application/json', 'x-request-id': 'abc-123', @@ -927,20 +889,6 @@ describe('Network Instrumentation Custom Attributes', () => { expect(parsed['x-request-id']).toBe('abc-123') }) - it('should preserve content-type in XHR responses', () => { - const headerString = - 'content-type: text/html\r\nx-custom: value' - - const responseHeaders = parseXhrResponseHeaders(headerString) - const result = captureResponseAttributes(responseHeaders, '', { - recordHeadersAndBody: true, - networkHeadersToRedact: ['content-type'], - }) - const parsed = JSON.parse(result?.headers ?? '{}') - - expect(parsed['content-type']).toBe('text/html') - }) - it('should work with parseXhrResponseHeaders and recordHeadersAndBody', () => { const headerString = 'content-type: application/json\r\nx-request-id: abc-123' From 71efe38d25ec0819b779f720822353d789137e15 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Mon, 15 Dec 2025 12:28:04 -0600 Subject: [PATCH 11/11] Support protocol-relative URLs in sanitizeUrl --- .../utils/network-sanitizer.ts | 25 ++++++++- .../src/client/otel/instrumentation.test.ts | 56 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) 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 a8ebbd9e1..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 @@ -96,7 +96,7 @@ export const safeParseUrl = (url: string): URL => { * Sanitizes a URL according to OpenTelemetry semantic conventions. * - Redacts credentials (username:password) in the URL * - Redacts sensitive query parameter values while preserving keys - * - Handles both absolute and relative URLs + * - Handles absolute, relative, and protocol-relative URLs * * @param url - The URL string to sanitize * @returns Sanitized URL string @@ -112,6 +112,10 @@ export const safeParseUrl = (url: string): URL => { * @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 { @@ -131,11 +135,26 @@ export const sanitizeUrl = (url: string): string => { } }) - // If the URL is relative, return only the pathname + search + hash - if (!url.includes('://')) { + // 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/instrumentation.test.ts b/sdk/highlight-run/src/client/otel/instrumentation.test.ts index 250283272..31072ff8b 100644 --- a/sdk/highlight-run/src/client/otel/instrumentation.test.ts +++ b/sdk/highlight-run/src/client/otel/instrumentation.test.ts @@ -1251,6 +1251,62 @@ describe('Network Instrumentation Custom Attributes', () => { 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', () => {