Skip to content

Commit d565739

Browse files
committed
feat(browser): Add environment variable support for Spotlight configuration
Implements full Spotlight spec with support for multiple framework-specific environment variable prefixes. Adds defensive environment variable access for both process.env and import.meta.env to support various bundlers. Supported environment variables (in priority order): - SENTRY_SPOTLIGHT (base/official) - PUBLIC_SENTRY_SPOTLIGHT (SvelteKit, Astro, Qwik) - NEXT_PUBLIC_SENTRY_SPOTLIGHT (Next.js) - VITE_SENTRY_SPOTLIGHT (Vite) - NUXT_PUBLIC_SENTRY_SPOTLIGHT (Nuxt) - REACT_APP_SENTRY_SPOTLIGHT (Create React App) - VUE_APP_SENTRY_SPOTLIGHT (Vue CLI) - GATSBY_SENTRY_SPOTLIGHT (Gatsby) Refactors envToBool utility from node-core to core package for shared usage. Adds resolveSpotlightOptions utility to ensure consistent precedence rules across Browser and Node SDKs. Includes comprehensive test coverage for all new utilities and integration tests for environment variable precedence behavior.
1 parent c8ca286 commit d565739

File tree

17 files changed

+723
-21
lines changed

17 files changed

+723
-21
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@
22

33
## Unreleased
44

5+
- feat(browser): Add environment variable support for Spotlight configuration
6+
- Supports `SENTRY_SPOTLIGHT`, `PUBLIC_SENTRY_SPOTLIGHT`, `NEXT_PUBLIC_SENTRY_SPOTLIGHT`, `VITE_SENTRY_SPOTLIGHT`, `NUXT_PUBLIC_SENTRY_SPOTLIGHT`, `REACT_APP_SENTRY_SPOTLIGHT`, `VUE_APP_SENTRY_SPOTLIGHT`, and `GATSBY_SENTRY_SPOTLIGHT` with proper precedence
7+
- Adds defensive environment variable access for both `process.env` and `import.meta.env`
8+
- feat(core): Add shared utilities for Spotlight configuration
9+
- Moves `envToBool` from `node-core` to `core` for shared usage across SDKs
10+
- Adds `resolveSpotlightOptions` utility for consistent precedence rules
11+
- feat(node): Update Spotlight configuration to use shared utilities
512
- fix(node): Fix Spotlight configuration precedence to match specification (#18195)
613

14+
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
15+
716
## 10.25.0
817

918
- feat(browser): Include Spotlight in development bundles ([#18078](https://github.com/getsentry/sentry-javascript/pull/18078))

packages/aws-serverless/src/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Integration, Options } from '@sentry/core';
22
import { applySdkMetadata, debug, getSDKSource } from '@sentry/core';
33
import type { NodeClient, NodeOptions } from '@sentry/node';
44
import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node';
5-
import { envToBool } from '@sentry/node-core';
5+
import { envToBool } from '@sentry/core';
66
import { DEBUG_BUILD } from './debug-build';
77
import { awsIntegration } from './integration/aws';
88
import { awsLambdaIntegration } from './integration/awslambda';

packages/browser/src/client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,26 @@ type BrowserSpecificOptions = BrowserClientReplayOptions &
7070
*
7171
* Either set it to true, or provide a specific Spotlight Sidecar URL.
7272
*
73+
* Alternatively, you can configure Spotlight using environment variables:
74+
* - SENTRY_SPOTLIGHT (base/official name)
75+
* - PUBLIC_SENTRY_SPOTLIGHT (SvelteKit, Astro, Qwik)
76+
* - NEXT_PUBLIC_SENTRY_SPOTLIGHT (Next.js)
77+
* - VITE_SENTRY_SPOTLIGHT (Vite)
78+
* - NUXT_PUBLIC_SENTRY_SPOTLIGHT (Nuxt)
79+
* - REACT_APP_SENTRY_SPOTLIGHT (Create React App)
80+
* - VUE_APP_SENTRY_SPOTLIGHT (Vue CLI)
81+
* - GATSBY_SENTRY_SPOTLIGHT (Gatsby)
82+
*
83+
* Precedence rules:
84+
* - If this option is `false`, Spotlight is disabled (env vars ignored)
85+
* - If this option is a string URL, that URL is used (env vars ignored)
86+
* - If this option is `true` and env var is a URL, the env var URL is used
87+
* - If this option is `undefined`, the env var value is used (if set)
88+
*
7389
* More details: https://spotlightjs.com/
7490
*
7591
* IMPORTANT: Only set this option to `true` while developing, not in production!
92+
* Spotlight is automatically excluded from production bundles.
7693
*/
7794
spotlight?: boolean | string;
7895
};

packages/browser/src/sdk.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getIntegrationsToSetup,
66
inboundFiltersIntegration,
77
initAndBind,
8+
resolveSpotlightOptions,
89
stackParserFromStackParserOptions,
910
} from '@sentry/core';
1011
import type { BrowserClientOptions, BrowserOptions } from './client';
@@ -19,6 +20,7 @@ import { spotlightBrowserIntegration } from './integrations/spotlight';
1920
import { defaultStackParser } from './stack-parsers';
2021
import { makeFetchTransport } from './transports/fetch';
2122
import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension';
23+
import { getSpotlightConfig } from './utils/spotlightConfig';
2224

2325
/** Get the default integrations for the browser SDK. */
2426
export function getDefaultIntegrations(_options: Options): Integration[] {
@@ -95,11 +97,15 @@ export function init(options: BrowserOptions = {}): Client | undefined {
9597
options.defaultIntegrations == null ? getDefaultIntegrations(options) : options.defaultIntegrations;
9698

9799
/* rollup-include-development-only */
98-
if (options.spotlight) {
100+
// Resolve Spotlight configuration with proper precedence
101+
const envSpotlight = getSpotlightConfig();
102+
const spotlightValue = resolveSpotlightOptions(options.spotlight, envSpotlight);
103+
104+
if (spotlightValue) {
99105
if (!defaultIntegrations) {
100106
defaultIntegrations = [];
101107
}
102-
const args = typeof options.spotlight === 'string' ? { sidecarUrl: options.spotlight } : undefined;
108+
const args = typeof spotlightValue === 'string' ? { sidecarUrl: spotlightValue } : undefined;
103109
defaultIntegrations.push(spotlightBrowserIntegration(args));
104110
}
105111
/* rollup-include-development-only-end */

packages/browser/src/utils/env.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Safely gets an environment variable value with defensive guards for browser environments.
3+
* Checks both process.env and import.meta.env with proper error handling.
4+
*
5+
* @param key - The environment variable key to look up
6+
* @returns The value of the environment variable or undefined if not found
7+
*/
8+
export function getEnvValue(key: string): string | undefined {
9+
// Try process.env first (available in some bundlers like Webpack, Rspack)
10+
try {
11+
if (typeof process !== 'undefined' && process.env) {
12+
const value = process.env[key];
13+
if (value !== undefined) {
14+
return value;
15+
}
16+
}
17+
} catch (e) {
18+
// Silently ignore - process might not be accessible or might throw in some environments
19+
}
20+
21+
// Try import.meta.env (available in Vite, Rollup, and other modern bundlers)
22+
try {
23+
// @ts-expect-error import.meta.env is not always available in all environments
24+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
25+
if (typeof import.meta !== 'undefined' && import.meta.env) {
26+
// @ts-expect-error import.meta.env is not always available in all environments
27+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
28+
const value = import.meta.env[key];
29+
if (value !== undefined) {
30+
return String(value);
31+
}
32+
}
33+
} catch (e) {
34+
// Silently ignore - import.meta might not be accessible or might throw in some environments
35+
}
36+
37+
return undefined;
38+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { debug, envToBool } from '@sentry/core';
2+
import { DEBUG_BUILD } from '../debug-build';
3+
import { getEnvValue } from './env';
4+
5+
/**
6+
* Environment variable keys to check for Spotlight configuration, in priority order.
7+
* The first one found with a value will be used.
8+
*/
9+
const SPOTLIGHT_ENV_KEYS = [
10+
'SENTRY_SPOTLIGHT', // Base/official name - works in Parcel, Webpack, Rspack, Rollup, Rolldown, Node.js
11+
'PUBLIC_SENTRY_SPOTLIGHT', // SvelteKit, Astro, Qwik
12+
'NEXT_PUBLIC_SENTRY_SPOTLIGHT', // Next.js
13+
'VITE_SENTRY_SPOTLIGHT', // Vite
14+
'NUXT_PUBLIC_SENTRY_SPOTLIGHT', // Nuxt
15+
'REACT_APP_SENTRY_SPOTLIGHT', // Create React App
16+
'VUE_APP_SENTRY_SPOTLIGHT', // Vue CLI
17+
'GATSBY_SENTRY_SPOTLIGHT', // Gatsby
18+
] as const;
19+
20+
/**
21+
* Gets the Spotlight configuration from environment variables.
22+
* Checks multiple environment variable prefixes in priority order to support
23+
* different bundlers and frameworks.
24+
*
25+
* @returns The resolved Spotlight configuration (boolean | string | undefined)
26+
*/
27+
export function getSpotlightConfig(): boolean | string | undefined {
28+
for (const key of SPOTLIGHT_ENV_KEYS) {
29+
const value = getEnvValue(key);
30+
31+
if (value !== undefined) {
32+
// Try to parse as boolean first (strict mode)
33+
const boolValue = envToBool(value, { strict: true });
34+
35+
if (boolValue !== null) {
36+
// It's a valid boolean value
37+
if (DEBUG_BUILD) {
38+
debug.log(`[Spotlight] Found ${key}=${String(boolValue)} in environment variables`);
39+
}
40+
return boolValue;
41+
}
42+
43+
// Not a boolean, treat as custom URL string
44+
if (DEBUG_BUILD) {
45+
debug.log(`[Spotlight] Found ${key}=${value} (custom URL) in environment variables`);
46+
}
47+
return value;
48+
}
49+
}
50+
51+
// No Spotlight configuration found in environment
52+
return undefined;
53+
}

packages/browser/test/sdk.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,161 @@ describe('init', () => {
234234
});
235235
});
236236

237+
describe('Spotlight environment variable support', () => {
238+
let originalProcess: typeof globalThis.process | undefined;
239+
240+
afterEach(() => {
241+
if (originalProcess !== undefined) {
242+
globalThis.process = originalProcess;
243+
} else {
244+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
245+
delete (globalThis as any).process;
246+
}
247+
});
248+
249+
it('uses environment variable when options.spotlight is undefined', () => {
250+
originalProcess = globalThis.process;
251+
globalThis.process = {
252+
env: {
253+
SENTRY_SPOTLIGHT: 'true',
254+
},
255+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
256+
} as any;
257+
258+
// @ts-expect-error this is fine for testing
259+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
260+
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
261+
init(options);
262+
263+
const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
264+
// Spotlight integration should be added
265+
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
266+
expect(spotlightIntegration).toBeDefined();
267+
});
268+
269+
it('does not add Spotlight when environment variable is false', () => {
270+
originalProcess = globalThis.process;
271+
globalThis.process = {
272+
env: {
273+
SENTRY_SPOTLIGHT: 'false',
274+
},
275+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
276+
} as any;
277+
278+
// @ts-expect-error this is fine for testing
279+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
280+
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
281+
init(options);
282+
283+
const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
284+
// Spotlight integration should NOT be added
285+
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
286+
expect(spotlightIntegration).toBeUndefined();
287+
});
288+
289+
it('options.spotlight=false takes precedence over environment variable', () => {
290+
originalProcess = globalThis.process;
291+
globalThis.process = {
292+
env: {
293+
SENTRY_SPOTLIGHT: 'true',
294+
},
295+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
296+
} as any;
297+
298+
// @ts-expect-error this is fine for testing
299+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
300+
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: false });
301+
init(options);
302+
303+
const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
304+
// Spotlight integration should NOT be added even though env var is true
305+
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
306+
expect(spotlightIntegration).toBeUndefined();
307+
});
308+
309+
it('options.spotlight=url takes precedence over environment variable', () => {
310+
originalProcess = globalThis.process;
311+
const customUrl = 'http://custom:1234/stream';
312+
globalThis.process = {
313+
env: {
314+
SENTRY_SPOTLIGHT: 'http://env:5678/stream',
315+
},
316+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
317+
} as any;
318+
319+
// @ts-expect-error this is fine for testing
320+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
321+
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: customUrl });
322+
init(options);
323+
324+
const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
325+
// Spotlight integration should be added (we can't easily check the URL here without deeper inspection)
326+
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
327+
expect(spotlightIntegration).toBeDefined();
328+
});
329+
330+
it('uses environment variable URL when options.spotlight=true', () => {
331+
originalProcess = globalThis.process;
332+
globalThis.process = {
333+
env: {
334+
SENTRY_SPOTLIGHT: 'http://env:5678/stream',
335+
},
336+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
337+
} as any;
338+
339+
// @ts-expect-error this is fine for testing
340+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
341+
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: true });
342+
init(options);
343+
344+
const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
345+
// Spotlight integration should be added
346+
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
347+
expect(spotlightIntegration).toBeDefined();
348+
});
349+
350+
it('respects priority order: SENTRY_SPOTLIGHT over PUBLIC_SENTRY_SPOTLIGHT', () => {
351+
originalProcess = globalThis.process;
352+
globalThis.process = {
353+
env: {
354+
SENTRY_SPOTLIGHT: 'true',
355+
PUBLIC_SENTRY_SPOTLIGHT: 'false',
356+
},
357+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
358+
} as any;
359+
360+
// @ts-expect-error this is fine for testing
361+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
362+
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
363+
init(options);
364+
365+
const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
366+
// Spotlight integration should be added (SENTRY_SPOTLIGHT=true wins)
367+
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
368+
expect(spotlightIntegration).toBeDefined();
369+
});
370+
371+
it('uses framework-specific prefix when base is not set', () => {
372+
originalProcess = globalThis.process;
373+
globalThis.process = {
374+
env: {
375+
NEXT_PUBLIC_SENTRY_SPOTLIGHT: 'true',
376+
},
377+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
378+
} as any;
379+
380+
// @ts-expect-error this is fine for testing
381+
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
382+
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
383+
init(options);
384+
385+
const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
386+
// Spotlight integration should be added
387+
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
388+
expect(spotlightIntegration).toBeDefined();
389+
});
390+
});
391+
237392
it('returns a client from init', () => {
238393
const client = init();
239394
expect(client).not.toBeUndefined();

0 commit comments

Comments
 (0)