diff --git a/packages/utils/src/baggage.ts b/packages/utils/src/baggage.ts new file mode 100644 index 000000000000..db402608a22b --- /dev/null +++ b/packages/utils/src/baggage.ts @@ -0,0 +1,86 @@ +import { IS_DEBUG_BUILD } from './flags'; +import { logger } from './logger'; + +export type AllowedBaggageKeys = 'environment' | 'release'; // TODO: Add remaining allowed baggage keys | 'transaction' | 'userid' | 'usersegment'; +export type BaggageObj = Partial & Record>; + +/** + * The baggage data structure represents key,value pairs based on the baggage + * spec: https://www.w3.org/TR/baggage + * + * It is expected that users interact with baggage using the helpers methods: + * `createBaggage`, `getBaggageValue`, and `setBaggageValue`. + * + * Internally, the baggage data structure is a tuple of length 2, separating baggage values + * based on if they are related to Sentry or not. If the baggage values are + * set/used by sentry, they will be stored in an object to be easily accessed. + * If they are not, they are kept as a string to be only accessed when serialized + * at baggage propagation time. + */ +export type Baggage = [BaggageObj, string]; + +export const BAGGAGE_HEADER_NAME = 'baggage'; + +export const SENTRY_BAGGAGE_KEY_PREFIX = 'sentry-'; + +export const SENTRY_BAGGAGE_KEY_PREFIX_REGEX = /^sentry-/; + +/** + * Max length of a serialized baggage string + * + * https://www.w3.org/TR/baggage/#limits + */ +export const MAX_BAGGAGE_STRING_LENGTH = 8192; + +/** Create an instance of Baggage */ +export function createBaggage(initItems: BaggageObj, baggageString: string = ''): Baggage { + return [{ ...initItems }, baggageString]; +} + +/** Get a value from baggage */ +export function getBaggageValue(baggage: Baggage, key: keyof BaggageObj): BaggageObj[keyof BaggageObj] { + return baggage[0][key]; +} + +/** Add a value to baggage */ +export function setBaggageValue(baggage: Baggage, key: keyof BaggageObj, value: BaggageObj[keyof BaggageObj]): void { + baggage[0][key] = value; +} + +/** Serialize a baggage object */ +export function serializeBaggage(baggage: Baggage): string { + return Object.keys(baggage[0]).reduce((prev, key: keyof BaggageObj) => { + const val = baggage[0][key] as string; + const baggageEntry = `${SENTRY_BAGGAGE_KEY_PREFIX}${encodeURIComponent(key)}=${encodeURIComponent(val)}`; + const newVal = prev === '' ? baggageEntry : `${prev},${baggageEntry}`; + if (newVal.length > MAX_BAGGAGE_STRING_LENGTH) { + IS_DEBUG_BUILD && + logger.warn(`Not adding key: ${key} with val: ${val} to baggage due to exceeding baggage size limits.`); + return prev; + } else { + return newVal; + } + }, baggage[1]); +} + +/** Parse a baggage header to a string */ +export function parseBaggageString(inputBaggageString: string): Baggage { + return inputBaggageString.split(',').reduce( + ([baggageObj, baggageString], curr) => { + const [key, val] = curr.split('='); + if (SENTRY_BAGGAGE_KEY_PREFIX_REGEX.test(key)) { + const baggageKey = decodeURIComponent(key.split('-')[1]); + return [ + { + ...baggageObj, + [baggageKey]: decodeURIComponent(val), + }, + baggageString, + ]; + } else { + return [baggageObj, baggageString === '' ? curr : `${baggageString},${curr}`]; + } + }, + [{}, ''], + ); +} diff --git a/packages/utils/test/baggage.test.ts b/packages/utils/test/baggage.test.ts new file mode 100644 index 000000000000..08d4213f2a99 --- /dev/null +++ b/packages/utils/test/baggage.test.ts @@ -0,0 +1,92 @@ +import { createBaggage, getBaggageValue, parseBaggageString, serializeBaggage, setBaggageValue } from '../src/baggage'; + +describe('Baggage', () => { + describe('createBaggage', () => { + it.each([ + ['creates an empty baggage instance', {}, [{}, '']], + [ + 'creates a baggage instance with initial values', + { environment: 'production', anyKey: 'anyValue' }, + [{ environment: 'production', anyKey: 'anyValue' }, ''], + ], + ])('%s', (_: string, input, output) => { + expect(createBaggage(input)).toEqual(output); + }); + }); + + describe('getBaggageValue', () => { + it.each([ + [ + 'gets a baggage item', + createBaggage({ environment: 'production', anyKey: 'anyValue' }), + 'environment', + 'production', + ], + ['finds undefined items', createBaggage({}), 'environment', undefined], + ])('%s', (_: string, baggage, key, value) => { + expect(getBaggageValue(baggage, key)).toEqual(value); + }); + }); + + describe('setBaggageValue', () => { + it.each([ + ['sets a baggage item', createBaggage({}), 'environment', 'production'], + ['overwrites a baggage item', createBaggage({ environment: 'development' }), 'environment', 'production'], + ])('%s', (_: string, baggage, key, value) => { + setBaggageValue(baggage, key, value); + expect(getBaggageValue(baggage, key)).toEqual(value); + }); + }); + + describe('serializeBaggage', () => { + it.each([ + ['serializes empty baggage', createBaggage({}), ''], + [ + 'serializes baggage with a single value', + createBaggage({ environment: 'production' }), + 'sentry-environment=production', + ], + [ + 'serializes baggage with multiple values', + createBaggage({ environment: 'production', release: '10.0.2' }), + 'sentry-environment=production,sentry-release=10.0.2', + ], + [ + 'keeps non-sentry prefixed baggage items', + createBaggage( + { environment: 'production', release: '10.0.2' }, + 'userId=alice,serverNode=DF%2028,isProduction=false', + ), + 'userId=alice,serverNode=DF%2028,isProduction=false,sentry-environment=production,sentry-release=10.0.2', + ], + [ + 'can only use non-sentry prefixed baggage items', + createBaggage({}, 'userId=alice,serverNode=DF%2028,isProduction=false'), + 'userId=alice,serverNode=DF%2028,isProduction=false', + ], + ])('%s', (_: string, baggage, serializedBaggage) => { + expect(serializeBaggage(baggage)).toEqual(serializedBaggage); + }); + }); + + describe('parseBaggageString', () => { + it.each([ + ['parses an empty string', '', createBaggage({})], + [ + 'parses sentry values into baggage', + 'sentry-environment=production,sentry-release=10.0.2', + createBaggage({ environment: 'production', release: '10.0.2' }), + ], + [ + 'parses arbitrary baggage headers', + 'userId=alice,serverNode=DF%2028,isProduction=false,sentry-environment=production,sentry-release=10.0.2', + createBaggage( + { environment: 'production', release: '10.0.2' }, + 'userId=alice,serverNode=DF%2028,isProduction=false', + ), + ], + ])('%s', (_: string, baggageString, baggage) => { + expect(parseBaggageString(baggageString)).toEqual(baggage); + }); + }); +});