diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/headers.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/headers.test.ts new file mode 100644 index 000000000..dbb18b89b --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/headers.test.ts @@ -0,0 +1,14 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ +import { isDatadogCustomHeader } from '../headers'; + +describe('headers', () => { + describe('isDatadogCustomHeader', () => { + it('returns false for non-custom headers', () => { + expect(isDatadogCustomHeader('non-custom-header')).toBeFalsy(); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts index f4decf2ac..8bfaec669 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/distributedTracingHeaders.ts @@ -48,9 +48,12 @@ export const getTracingHeadersFromAttributes = ( if (tracingAttributes.tracingStrategy === 'DISCARD') { return headers; } + + let hasDatadogOrW3CPropagator = false; tracingAttributes.propagatorTypes.forEach(propagator => { switch (propagator) { case PropagatorType.DATADOG: { + hasDatadogOrW3CPropagator = true; headers.push( { header: ORIGIN_HEADER_KEY, @@ -82,6 +85,7 @@ export const getTracingHeadersFromAttributes = ( break; } case PropagatorType.TRACECONTEXT: { + hasDatadogOrW3CPropagator = true; const isSampled = tracingAttributes.samplingPriorityHeader === '1'; headers.push( @@ -137,14 +141,15 @@ export const getTracingHeadersFromAttributes = ( ); } } - if (tracingAttributes.rumSessionId) { - headers.push({ - header: BAGGAGE_HEADER_KEY, - value: `${DD_RUM_SESSION_ID_TAG}=${tracingAttributes.rumSessionId}` - }); - } }); + if (hasDatadogOrW3CPropagator && tracingAttributes.rumSessionId) { + headers.push({ + header: BAGGAGE_HEADER_KEY, + value: `${DD_RUM_SESSION_ID_TAG}=${tracingAttributes.rumSessionId}` + }); + } + return headers; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts index b7d7dfa10..78c0f3b88 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/graphql/__tests__/graphqlHeaders.test.ts @@ -4,11 +4,11 @@ * Copyright 2016-Present Datadog, Inc. */ +import { isDatadogCustomHeader } from '../../headers'; import { DATADOG_GRAPH_QL_OPERATION_NAME_HEADER, DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, - DATADOG_GRAPH_QL_VARIABLES_HEADER, - isDatadogCustomHeader + DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../graphqlHeaders'; describe('GraphQL custom headers', () => { @@ -19,10 +19,4 @@ describe('GraphQL custom headers', () => { ])('%s matches the custom header pattern', header => { expect(isDatadogCustomHeader(header)).toBeTruthy(); }); - - describe('isDatadogCustomHeader', () => { - it('returns false for non-custom headers', () => { - expect(isDatadogCustomHeader('non-custom-header')).toBeFalsy(); - }); - }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts index 87c79e65e..730b1c468 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/graphql/graphqlHeaders.ts @@ -1,15 +1,10 @@ +import { DATADOG_CUSTOM_HEADER_PREFIX } from '../headers'; + /* * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. * This product includes software developed at Datadog (https://www.datadoghq.com/). * Copyright 2016-Present Datadog, Inc. */ - -const DATADOG_CUSTOM_HEADER_PREFIX = '_dd-custom-header'; - export const DATADOG_GRAPH_QL_OPERATION_NAME_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-graph-ql-operation-name`; export const DATADOG_GRAPH_QL_VARIABLES_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-graph-ql-variables`; export const DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-graph-ql-operation-type`; - -export const isDatadogCustomHeader = (header: string) => { - return header.match(new RegExp(`^${DATADOG_CUSTOM_HEADER_PREFIX}`)); -}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headers.ts b/packages/core/src/rum/instrumentation/resourceTracking/headers.ts new file mode 100644 index 000000000..6ecdd37ae --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headers.ts @@ -0,0 +1,12 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +export const DATADOG_CUSTOM_HEADER_PREFIX = '_dd-custom-header'; +export const DATADOG_BAGGAGE_HEADER = `${DATADOG_CUSTOM_HEADER_PREFIX}-baggage`; + +export const isDatadogCustomHeader = (header: string) => { + return header.match(new RegExp(`^${DATADOG_CUSTOM_HEADER_PREFIX}`)); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index e81c8014f..d48c4f01a 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -6,20 +6,24 @@ import { Timer } from '../../../../../utils/Timer'; import { getCachedSessionId } from '../../../../sessionId/sessionIdHelper'; -import { getTracingHeadersFromAttributes } from '../../distributedTracing/distributedTracingHeaders'; +import { + BAGGAGE_HEADER_KEY, + getTracingHeadersFromAttributes +} from '../../distributedTracing/distributedTracingHeaders'; import type { DdRumResourceTracingAttributes } from '../../distributedTracing/distributedTracing'; import { getTracingAttributes } from '../../distributedTracing/distributedTracing'; import { DATADOG_GRAPH_QL_OPERATION_NAME_HEADER, DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, - DATADOG_GRAPH_QL_VARIABLES_HEADER, - isDatadogCustomHeader + DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../../graphql/graphqlHeaders'; +import { DATADOG_BAGGAGE_HEADER, isDatadogCustomHeader } from '../../headers'; import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import { RequestProxy } from '../interfaces/RequestProxy'; import type { ResourceReporter } from './DatadogRumResource/ResourceReporter'; import { URLHostParser } from './URLHostParser'; +import { formatBaggageHeader } from './baggageHeaderUtils'; import { calculateResponseSize } from './responseSize'; const RESPONSE_START_LABEL = 'response_start'; @@ -39,6 +43,7 @@ interface DdRumXhrContext { reported: boolean; timer: Timer; tracingAttributes: DdRumResourceTracingAttributes; + baggageHeaderEntries: Set; } interface XHRProxyProviders { @@ -111,7 +116,8 @@ const proxyOpen = ( firstPartyHostsRegexMap, tracingSamplingRate, rumSessionId: getCachedSessionId() - }) + }), + baggageHeaderEntries: new Set() }; // eslint-disable-next-line prefer-rest-params return originalXhrOpen.apply(this, arguments as any); @@ -127,12 +133,22 @@ const proxySend = (providers: XHRProxyProviders): void => { // keep track of start time this._datadog_xhr.timer.start(); + // Tracing Headers const tracingHeaders = getTracingHeadersFromAttributes( this._datadog_xhr.tracingAttributes ); + tracingHeaders.forEach(({ header, value }) => { this.setRequestHeader(header, value); }); + + // Join all baggage header entries + const baggageHeader = formatBaggageHeader( + this._datadog_xhr.baggageHeaderEntries + ); + if (baggageHeader) { + this.setRequestHeader(DATADOG_BAGGAGE_HEADER, baggageHeader); + } } proxyOnReadyStateChange(this, providers); @@ -211,22 +227,37 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { header: string, value: string ) { - if (isDatadogCustomHeader(header)) { - if (header === DATADOG_GRAPH_QL_OPERATION_NAME_HEADER) { - this._datadog_xhr.graphql.operationName = value; - return; - } - if (header === DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER) { - this._datadog_xhr.graphql.operationType = value; - return; - } - if (header === DATADOG_GRAPH_QL_VARIABLES_HEADER) { - this._datadog_xhr.graphql.variables = value; - return; + const key = header.toLowerCase(); + if (isDatadogCustomHeader(key)) { + switch (key) { + case DATADOG_GRAPH_QL_OPERATION_NAME_HEADER: + this._datadog_xhr.graphql.operationName = value; + break; + case DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER: + this._datadog_xhr.graphql.operationType = value; + break; + case DATADOG_GRAPH_QL_VARIABLES_HEADER: + this._datadog_xhr.graphql.variables = value; + break; + case DATADOG_BAGGAGE_HEADER: + // Apply Baggage Header only if pre-processed by Datadog + return originalXhrSetRequestHeader.apply(this, [ + BAGGAGE_HEADER_KEY, + value + ]); + default: + return originalXhrSetRequestHeader.apply( + this, + // eslint-disable-next-line prefer-rest-params + arguments as any + ); } + } else if (key === BAGGAGE_HEADER_KEY) { + // Intercept User Baggage Header entries to apply them later + this._datadog_xhr.baggageHeaderEntries?.add(value); + } else { + // eslint-disable-next-line prefer-rest-params + return originalXhrSetRequestHeader.apply(this, arguments as any); } - - // eslint-disable-next-line prefer-rest-params - return originalXhrSetRequestHeader.apply(this, arguments as any); }; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 907bfe57a..cfc5e0178 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -839,6 +839,93 @@ describe('XHRProxy', () => { // THEN expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBeUndefined(); }); + + it('does not add rum session id to baggage headers when propagator type is not datadog or w3c', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://example.com'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([ + { + match: 'api.example.com', + propagatorTypes: [ + PropagatorType.DATADOG, + PropagatorType.TRACECONTEXT + ] + }, + { + match: 'example.com', // <-- no datadog or tracecontext here + propagatorTypes: [ + PropagatorType.B3, + PropagatorType.B3MULTI + ] + } + ]) + }); + + setCachedSessionId('TEST-SESSION-ID'); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toBeUndefined(); + }); + + it('rum session id does not overwrite existing baggage headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com:443/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([ + { + match: 'api.example.com', + propagatorTypes: [ + PropagatorType.DATADOG, + PropagatorType.TRACECONTEXT + ] + }, + { + match: 'example.com', + propagatorTypes: [ + PropagatorType.B3, + PropagatorType.B3MULTI + ] + } + ]) + }); + + setCachedSessionId('TEST-SESSION-ID'); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('baggage', 'existing.key=existing-value'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).not.toBeUndefined(); + expect(xhr.requestHeaders[BAGGAGE_HEADER_KEY]).toContain( + 'existing.key=existing-value' + ); + + const values = xhr.requestHeaders[BAGGAGE_HEADER_KEY].split( + ',' + ).sort(); + + expect(values[0]).toBe('existing.key=existing-value'); + expect(values[1]).toBe('session.id=TEST-SESSION-ID'); + }); }); describe('DdRum.startResource calls', () => { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/baggageHeaderUtils.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/baggageHeaderUtils.test.ts new file mode 100644 index 000000000..eee59838f --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/baggageHeaderUtils.test.ts @@ -0,0 +1,117 @@ +import { InternalLog } from '../../../../../../InternalLog'; +import { SdkVerbosity } from '../../../../../../SdkVerbosity'; +import { formatBaggageHeader } from '../baggageHeaderUtils'; + +describe('formatBaggageHeader', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(InternalLog, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('should format simple key=value entries correctly', () => { + const entries = new Set(['userId=alice', 'isProduction=false']); + const result = formatBaggageHeader(entries); + expect(result).toBe('userId=alice,isProduction=false'); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('should percent-encode spaces and non-ASCII characters in values', () => { + const entries = new Set(['user=Amélie', 'region=us east']); + const result = formatBaggageHeader(entries); + expect(result).toBe('user=Am%C3%A9lie,region=us%20east'); + }); + + it('should support properties with and without values', () => { + const entries = new Set(['traceId=abc123;sampled=true;debug']); + const result = formatBaggageHeader(entries); + expect(result).toBe('traceId=abc123;sampled=true;debug'); + }); + + it('should trim whitespace around keys, values, and properties', () => { + const entries = new Set([' foo = bar ; p1 = one ; p2 ']); + const result = formatBaggageHeader(entries); + expect(result).toBe('foo=bar;p1=one;p2'); + }); + + it('should skip invalid entries without crashing', () => { + const entries = new Set(['valid=ok', 'invalidEntry']); + const result = formatBaggageHeader(entries); + expect(result).toBe('valid=ok'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Dropped invalid baggage header entry'), + SdkVerbosity.WARN + ); + }); + + it('should skip entries with invalid key (non-token)', () => { + const entries = new Set(['in valid=value', 'user=ok']); + const result = formatBaggageHeader(entries); + expect(result).toBe('user=ok'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('key not compliant'), + SdkVerbosity.WARN + ); + }); + + it('should skip invalid properties (bad property key)', () => { + const entries = new Set(['user=ok;invalid key=value;good=yes']); + const result = formatBaggageHeader(entries); + expect(result).toBe('user=ok;good=yes'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('property key not compliant'), + SdkVerbosity.WARN + ); + }); + + it('should log warning when too many members (>64)', () => { + const entries = new Set(); + for (let i = 0; i < 70; i++) { + entries.add(`k${i}=v${i}`); + } + const result = formatBaggageHeader(entries); + expect(result?.startsWith('k0=v0')).toBe(true); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Too many baggage members'), + SdkVerbosity.WARN + ); + }); + + it('should log warning when header exceeds byte limit', () => { + const bigValue = 'x'.repeat(9000); + const entries = new Set([`large=${bigValue}`]); + const result = formatBaggageHeader(entries); + expect(result).toContain('large='); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Baggage header too large'), + SdkVerbosity.WARN + ); + }); + + it('should return null if all entries are invalid', () => { + const entries = new Set(['badEntry', 'stillBad']); + const result = formatBaggageHeader(entries); + expect(result).toBeNull(); + }); + + it('should preserve insertion order', () => { + const entries = new Set(['first=1', 'second=2', 'third=3']); + const result = formatBaggageHeader(entries); + expect(result).toBe('first=1,second=2,third=3'); + }); + + it('should trim keys and values', () => { + const entries = new Set([ + 'traceId=abc123;sampled=true;debug', + 'test1 = this is a test' + ]); + const result = formatBaggageHeader(entries); + expect(result).toBe( + 'traceId=abc123;sampled=true;debug,test1=this%20is%20a%20test' + ); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts new file mode 100644 index 000000000..7094ec4e8 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts @@ -0,0 +1,172 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../../../../InternalLog'; +import { SdkVerbosity } from '../../../../../SdkVerbosity'; + +// The resulting baggage-string should contain 64 list-members or less (https://www.w3.org/TR/baggage/#limits) +const MAX_MEMBERS = 64; + +// The resulting baggage-string should be of size 8192 bytes or less (https://www.w3.org/TR/baggage/#limits) +const MAX_BYTES = 8192; + +// The keys must follow RFC 7230 token grammar (https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6) +const TOKEN_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** + * Lazy property for {@link getBaggageHeaderSafeChars}. + */ +let baggageHeaderSafeChars: Set | undefined; + +/** + * Transform a Set of baggage entries (strings like "key=value;prop1=foo;prop2") + * into a compliant baggage header value per W3C Baggage spec. + */ +export function formatBaggageHeader(entries: Set): string | null { + const formattedParts: string[] = []; + + for (const rawEntry of entries) { + if (!rawEntry.includes('=')) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - expected format "key=value".', + SdkVerbosity.WARN + ); + continue; + } + + // Split first key=value from properties (properties are after first ';') + const [mainPart, ...rawProperties] = rawEntry.split(';'); + const idx = mainPart.indexOf('='); + if (idx <= 0) { + InternalLog.log( + "XHRProxy: Dropped invalid baggage header entry - no '=' or empty key", + SdkVerbosity.WARN + ); + continue; + } + + const rawKey = mainPart.slice(0, idx).trim(); + const rawValue = mainPart.slice(idx + 1).trim(); + + if (!TOKEN_REGEX.test(rawKey)) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - key not compliant to RFC 7230 token grammar', + SdkVerbosity.WARN + ); + continue; + } + + const encodedValue = encodeValue(rawValue); + + // Handle properties + const properties: string[] = []; + for (const rawProperty of rawProperties) { + const trimmed = rawProperty.trim(); + if (!trimmed) { + continue; + } + + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) { + // Property with no value (key1=value1;prop1; ... ) + const propKey = trimmed.trim(); + if (!TOKEN_REGEX.test(propKey)) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - property key not compliant to RFC 7230 token grammar', + SdkVerbosity.WARN + ); + continue; + } + properties.push(propKey); + } else { + // Property in key-value format (key1=value1;prop1=propValue1; ... ) + const propKey = trimmed.slice(0, eqIdx).trim(); + const propVal = trimmed.slice(eqIdx + 1).trim(); + if (!TOKEN_REGEX.test(propKey)) { + InternalLog.log( + 'XHRProxy: Dropped invalid baggage header entry - key-value property key not compliant to RFC 7230 token grammar', + SdkVerbosity.WARN + ); + continue; + } + properties.push(`${propKey}=${encodeValue(propVal)}`); + } + } + + const joinedProps = properties.length ? `;${properties.join(';')}` : ''; + formattedParts.push(`${rawKey}=${encodedValue}${joinedProps}`); + } + + if (formattedParts.length > MAX_MEMBERS) { + InternalLog.log( + `XHRProxy: Too many baggage members: ${formattedParts.length} > ${MAX_MEMBERS} - entries may be dropped (https://www.w3.org/TR/baggage/#limits)`, + SdkVerbosity.WARN + ); + } else if (formattedParts.length === 0) { + return null; + } + + const headerValue = formattedParts.join(','); + const byteLength = Buffer.byteLength(headerValue, 'utf8'); + + if (byteLength > MAX_BYTES) { + InternalLog.log( + `Baggage header too large: ${byteLength} bytes > ${MAX_BYTES} - entries may be dropped (https://www.w3.org/TR/baggage/#limits)`, + SdkVerbosity.WARN + ); + } + + return headerValue; +} + +/** + * Returns a set of valid baggage header characters. + */ +function getBaggageHeaderSafeChars(): Set { + if (baggageHeaderSafeChars) { + return baggageHeaderSafeChars; + } + + const safeChars = new Set(); + for (let c = 0x21; c <= 0x7e; c++) { + if ( + c === 0x22 || + c === 0x2c || + c === 0x3b || + c === 0x5c || + c === 0x20 + ) { + continue; + } + safeChars.add(String.fromCharCode(c)); + } + + baggageHeaderSafeChars = safeChars; + + return safeChars; +} + +/* + * Percent-encode all characters outside baggage-octet range. + */ +function encodeValue(raw: string): string { + const safeChars = getBaggageHeaderSafeChars(); + let result = ''; + for (const ch of Array.from(raw)) { + if (safeChars.has(ch)) { + result += ch; + } else { + const utf8Bytes = Buffer.from(ch, 'utf8'); + for (const value of utf8Bytes) { + result += `%${value + .toString(16) + .toUpperCase() + .padStart(2, '0')}`; + } + } + } + return result; +}