diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 06e224e64006..11a8ccf19548 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -99,6 +99,7 @@ export function init(options: BrowserOptions = {}): Client | undefined { /* rollup-include-development-only */ // Resolve Spotlight configuration with proper precedence const envSpotlight = getSpotlightConfig(); + // resolveSpotlightOptions is the single source of truth that ensures empty strings are never used const spotlightValue = resolveSpotlightOptions(options.spotlight, envSpotlight); if (spotlightValue) { diff --git a/packages/browser/src/utils/spotlightConfig.ts b/packages/browser/src/utils/spotlightConfig.ts index 07393e28627c..d1efedc4e4d6 100644 --- a/packages/browser/src/utils/spotlightConfig.ts +++ b/packages/browser/src/utils/spotlightConfig.ts @@ -50,7 +50,9 @@ export function getSpotlightConfig(): boolean | string | undefined { } // Not a boolean, treat as custom URL string - // Note: empty/whitespace strings are filtered by resolveSpotlightOptions + // Note: Empty/whitespace strings are intentionally returned as-is. The resolveSpotlightOptions + // function is the single source of truth for filtering these and ensuring empty strings are + // NEVER used (returns undefined instead). if (DEBUG_BUILD) { debug.log(`[Spotlight] Found ${key}=${value} (custom URL) in environment variables`); } diff --git a/packages/browser/test/utils/spotlightConfig.test.ts b/packages/browser/test/utils/spotlightConfig.test.ts index eac34ea7de1f..a540c6be8ce6 100644 --- a/packages/browser/test/utils/spotlightConfig.test.ts +++ b/packages/browser/test/utils/spotlightConfig.test.ts @@ -59,24 +59,24 @@ describe('getSpotlightConfig', () => { expect(getSpotlightConfig()).toBe(customUrl); }); - it('returns undefined when SENTRY_SPOTLIGHT is an empty string', () => { + it('returns empty string when SENTRY_SPOTLIGHT is an empty string (filtered by resolveSpotlightOptions)', () => { globalThis.process = { env: { SENTRY_SPOTLIGHT: '', } as Record, } as NodeJS.Process; - expect(getSpotlightConfig()).toBeUndefined(); + expect(getSpotlightConfig()).toBe(''); }); - it('returns undefined when SENTRY_SPOTLIGHT is whitespace only', () => { + it('returns whitespace string when SENTRY_SPOTLIGHT is whitespace only (filtered by resolveSpotlightOptions)', () => { globalThis.process = { env: { SENTRY_SPOTLIGHT: ' ', } as Record, } as NodeJS.Process; - expect(getSpotlightConfig()).toBeUndefined(); + expect(getSpotlightConfig()).toBe(' '); }); it('parses various truthy values correctly', () => { diff --git a/packages/core/src/utils/resolveSpotlightOptions.ts b/packages/core/src/utils/resolveSpotlightOptions.ts index 01322956c2c8..520b1f4d6647 100644 --- a/packages/core/src/utils/resolveSpotlightOptions.ts +++ b/packages/core/src/utils/resolveSpotlightOptions.ts @@ -2,9 +2,13 @@ * Resolves the final spotlight configuration based on options and environment variables. * Implements the precedence rules from the Spotlight spec. * + * This is the single source of truth for filtering empty/whitespace strings - it ensures that + * empty strings are NEVER returned (returns undefined instead). All callers can rely on this + * guarantee when handling spotlight configuration. + * * @param optionsSpotlight - The spotlight option from user config (false | true | string | undefined) * @param envSpotlight - The spotlight value from environment variables (false | true | string | undefined) - * @returns The resolved spotlight configuration + * @returns The resolved spotlight configuration (false | true | string | undefined) - NEVER an empty string */ export function resolveSpotlightOptions( optionsSpotlight: boolean | string | undefined, diff --git a/packages/core/test/utils/resolveSpotlightOptions.test.ts b/packages/core/test/utils/resolveSpotlightOptions.test.ts index 4232ffe2e989..27b1a61dddd5 100644 --- a/packages/core/test/utils/resolveSpotlightOptions.test.ts +++ b/packages/core/test/utils/resolveSpotlightOptions.test.ts @@ -55,36 +55,66 @@ describe('resolveSpotlightOptions', () => { expect(resolveSpotlightOptions(undefined, envUrl)).toBe(envUrl); }); - describe('empty string handling', () => { - it('returns undefined when options.spotlight is an empty string', () => { + describe('empty string handling - NEVER returns empty strings', () => { + it('returns undefined (never empty string) when options.spotlight is an empty string', () => { expect(resolveSpotlightOptions('', undefined)).toBeUndefined(); expect(resolveSpotlightOptions('', true)).toBeUndefined(); expect(resolveSpotlightOptions('', 'http://env:8969')).toBeUndefined(); }); - it('returns undefined when options.spotlight is whitespace only', () => { + it('returns undefined (never empty string) when options.spotlight is whitespace only', () => { expect(resolveSpotlightOptions(' ', undefined)).toBeUndefined(); expect(resolveSpotlightOptions('\t\n', true)).toBeUndefined(); }); - it('returns undefined when env is an empty string and options.spotlight is undefined', () => { + it('returns undefined (never empty string) when env is an empty string and options.spotlight is undefined', () => { expect(resolveSpotlightOptions(undefined, '')).toBeUndefined(); }); - it('returns undefined when env is whitespace only and options.spotlight is undefined', () => { + it('returns undefined (never empty string) when env is whitespace only and options.spotlight is undefined', () => { expect(resolveSpotlightOptions(undefined, ' ')).toBeUndefined(); expect(resolveSpotlightOptions(undefined, '\t\n')).toBeUndefined(); }); - it('returns true when options.spotlight is true and env is empty string', () => { + it('returns true when options.spotlight is true and env is empty string (filters out empty env)', () => { expect(resolveSpotlightOptions(true, '')).toBe(true); expect(resolveSpotlightOptions(true, ' ')).toBe(true); }); - it('returns valid URL when options.spotlight is valid URL even if env is empty', () => { + it('returns valid URL when options.spotlight is valid URL even if env is empty (filters out empty env)', () => { const validUrl = 'http://localhost:8969/stream'; expect(resolveSpotlightOptions(validUrl, '')).toBe(validUrl); expect(resolveSpotlightOptions(validUrl, ' ')).toBe(validUrl); }); + + it('NEVER returns empty string - comprehensive check of all combinations', () => { + // Test all possible combinations to ensure empty strings are never returned + const emptyValues = ['', ' ', '\t\n', ' \t \n ']; + const nonEmptyValues = [false, true, undefined, 'http://localhost:8969']; + + // Empty options.spotlight with any env + for (const emptyOption of emptyValues) { + for (const env of [...emptyValues, ...nonEmptyValues]) { + const result = resolveSpotlightOptions(emptyOption, env); + expect(result).not.toBe(''); + // Only test regex on strings + if (typeof result === 'string') { + expect(result).not.toMatch(/^\s+$/); + } + } + } + + // Any options.spotlight with empty env + for (const option of [...emptyValues, ...nonEmptyValues]) { + for (const emptyEnv of emptyValues) { + const result = resolveSpotlightOptions(option, emptyEnv); + expect(result).not.toBe(''); + // Only test regex on strings + if (typeof result === 'string') { + expect(result).not.toMatch(/^\s+$/); + } + } + } + }); }); }); diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 18df85c58e34..f8010e09b5c6 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -187,7 +187,8 @@ function getClientOptions( // Parse spotlight configuration with proper precedence per spec const envBool = envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true }); const envSpotlight = envBool !== null ? envBool : process.env.SENTRY_SPOTLIGHT; - // Note: resolveSpotlightOptions handles empty/whitespace string filtering + // Note: resolveSpotlightOptions is the single source of truth for filtering empty/whitespace strings + // and ensures that empty strings are NEVER returned (returns undefined instead) const spotlight = resolveSpotlightOptions(options.spotlight, envSpotlight); const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); diff --git a/packages/replay-canvas/core b/packages/replay-canvas/core new file mode 100644 index 000000000000..c0595c9682e7 Binary files /dev/null and b/packages/replay-canvas/core differ