Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## Unreleased

- fix(node): Fix Spotlight configuration precedence to match specification (#18195)
- feat(browser): Add environment variable support for Spotlight configuration ([#18198](https://github.com/getsentry/sentry-javascript/pull/18198))
- `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`
- fix(node): Fix Spotlight configuration precedence to match specification ([#18195](https://github.com/getsentry/sentry-javascript/pull/18195))

## 10.25.0

Expand Down
3 changes: 1 addition & 2 deletions packages/aws-serverless/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { Integration, Options } from '@sentry/core';
import { applySdkMetadata, debug, getSDKSource } from '@sentry/core';
import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node';
import { envToBool } from '@sentry/node-core';
import { DEBUG_BUILD } from './debug-build';
import { awsIntegration } from './integration/aws';
import { awsLambdaIntegration } from './integration/awslambda';
Expand Down
20 changes: 20 additions & 0 deletions packages/browser/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,29 @@ type BrowserSpecificOptions = BrowserClientReplayOptions &
*
* Either set it to true, or provide a specific Spotlight Sidecar URL.
*
* Alternatively, you can configure Spotlight using environment variables (checked in this order):
* - 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)
* - SENTRY_SPOTLIGHT (fallback for non-framework setups)
*
* Framework-specific vars have higher priority to support Docker Compose setups where
* backend uses SENTRY_SPOTLIGHT with Docker hostnames while frontend needs localhost.
*
* Precedence rules:
* - If this option is `false`, Spotlight is disabled (env vars ignored)
* - If this option is a string URL, that URL is used (env vars ignored)
* - If this option is `true` and env var is a URL, the env var URL is used
* - If this option is `undefined`, the env var value is used (if set)
*
* More details: https://spotlightjs.com/
*
* IMPORTANT: Only set this option to `true` while developing, not in production!
* Spotlight is automatically excluded from production bundles.
*/
spotlight?: boolean | string;
};
Expand Down
11 changes: 9 additions & 2 deletions packages/browser/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getIntegrationsToSetup,
inboundFiltersIntegration,
initAndBind,
resolveSpotlightOptions,
stackParserFromStackParserOptions,
} from '@sentry/core';
import type { BrowserClientOptions, BrowserOptions } from './client';
Expand All @@ -19,6 +20,7 @@ import { spotlightBrowserIntegration } from './integrations/spotlight';
import { defaultStackParser } from './stack-parsers';
import { makeFetchTransport } from './transports/fetch';
import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension';
import { getSpotlightConfig } from './utils/spotlightConfig';

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

/* rollup-include-development-only */
if (options.spotlight) {
// 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) {
if (!defaultIntegrations) {
defaultIntegrations = [];
}
const args = typeof options.spotlight === 'string' ? { sidecarUrl: options.spotlight } : undefined;
const args = typeof spotlightValue === 'string' ? { sidecarUrl: spotlightValue } : undefined;
defaultIntegrations.push(spotlightBrowserIntegration(args));
}
/* rollup-include-development-only-end */
Expand Down
39 changes: 39 additions & 0 deletions packages/browser/src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Safely gets an environment variable value with defensive guards for browser environments.
* Checks both process.env and import.meta.env to support different bundlers:
* - process.env: Webpack, Next.js, Create React App, Parcel
* - import.meta.env: Vite, Astro, SvelteKit
*
* @param key - The environment variable key to look up
* @returns The value of the environment variable or undefined if not found
*/
export function getEnvValue(key: string): string | undefined {
// Check process.env first (Webpack, Next.js, CRA, etc.)
try {
if (typeof process !== 'undefined' && process.env) {
const value = process.env[key];
if (value !== undefined) {
return value;
}
}
} catch (e) {
// Silently ignore - process might not be accessible or might throw in some environments
}

// Check import.meta.env (Vite, Astro, SvelteKit, etc.)
try {
// @ts-expect-error import.meta.env might not exist in all environments
if (typeof import.meta !== 'undefined' && import.meta.env) {
// @ts-expect-error import.meta.env is typed differently in different environments
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const value = import.meta.env[key];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Dynamic Env Access Breaks Bundler Builds

Dynamic property access import.meta.env[key] won't work with build-time environment variable replacement in bundlers like Vite, Astro, and SvelteKit. These bundlers require static property access like import.meta.env.VARIABLE_NAME for compile-time replacement. The dynamic bracket notation will always return undefined in production builds because the object doesn't exist at runtime.

Fix in Cursor Fix in Web

if (value !== undefined) {
return value;
}
}
} catch (e) {
// Silently ignore - import.meta.env might not be accessible or might throw
}

return undefined;
}
66 changes: 66 additions & 0 deletions packages/browser/src/utils/spotlightConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { debug, envToBool } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { getEnvValue } from './env';

/**
* Environment variable keys to check for Spotlight configuration, in priority order.
* The first one found with a value will be used.
*
* IMPORTANT: Framework-specific variables (PUBLIC_*, NEXT_PUBLIC_*, etc.) are prioritized
* over the generic SENTRY_SPOTLIGHT to support Docker Compose setups where:
* - Backend services need SENTRY_SPOTLIGHT=http://host.internal.docker:8969/stream
* - Frontend code needs localhost (via framework-specific vars like NEXT_PUBLIC_SENTRY_SPOTLIGHT=http://localhost:8969/stream)
*
* SENTRY_SPOTLIGHT is kept as a fallback for:
* - Simple non-Docker setups
* - Remote Spotlight instances when no framework-specific var is set
*/
const SPOTLIGHT_ENV_KEYS = [
'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
'SENTRY_SPOTLIGHT', // Fallback/base name - works in Parcel, Webpack, Rspack, Rollup, Rolldown, Node.js
] as const;

/**
* Gets the Spotlight configuration from environment variables.
* Checks multiple environment variable prefixes in priority order to support
* different bundlers and frameworks.
*
* @returns The resolved Spotlight configuration (boolean | string | undefined)
*/
export function getSpotlightConfig(): boolean | string | undefined {
for (const key of SPOTLIGHT_ENV_KEYS) {
const value = getEnvValue(key);

if (value !== undefined) {
// Try to parse as boolean first (strict mode)
const boolValue = envToBool(value, { strict: true });

if (boolValue !== null) {
// It's a valid boolean value
if (DEBUG_BUILD) {
debug.log(`[Spotlight] Found ${key}=${String(boolValue)} in environment variables`);
}
return boolValue;
}

// Not a boolean, treat as custom URL string
// 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`);
}
return value;
}
}

// No Spotlight configuration found in environment
// Note: Implicit return of undefined saves bytes and is tree-shaken in production builds
return undefined;
}
148 changes: 148 additions & 0 deletions packages/browser/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,154 @@ describe('init', () => {
});
});

describe('Spotlight environment variable support', () => {
let originalProcess: typeof globalThis.process | undefined;

afterEach(() => {
if (originalProcess !== undefined) {
globalThis.process = originalProcess;
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).process;
}
});

it('uses environment variable when options.spotlight is undefined', () => {
originalProcess = globalThis.process;
globalThis.process = {
env: {
SENTRY_SPOTLIGHT: 'true',
} as Record<string, string>,
} as NodeJS.Process;

// @ts-expect-error this is fine for testing
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
init(options);

const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
// Spotlight integration should be added
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
expect(spotlightIntegration).toBeDefined();
});

it('does not add Spotlight when environment variable is false', () => {
originalProcess = globalThis.process;
globalThis.process = {
env: {
SENTRY_SPOTLIGHT: 'false',
} as Record<string, string>,
} as NodeJS.Process;

// @ts-expect-error this is fine for testing
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
init(options);

const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
// Spotlight integration should NOT be added
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
expect(spotlightIntegration).toBeUndefined();
});

it('options.spotlight=false takes precedence over environment variable', () => {
originalProcess = globalThis.process;
globalThis.process = {
env: {
SENTRY_SPOTLIGHT: 'true',
} as Record<string, string>,
} as NodeJS.Process;

// @ts-expect-error this is fine for testing
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: false });
init(options);

const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
// Spotlight integration should NOT be added even though env var is true
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
expect(spotlightIntegration).toBeUndefined();
});

it('options.spotlight=url takes precedence over environment variable', () => {
originalProcess = globalThis.process;
const customUrl = 'http://custom:1234/stream';
globalThis.process = {
env: {
SENTRY_SPOTLIGHT: 'http://env:5678/stream',
} as Record<string, string>,
} as NodeJS.Process;

// @ts-expect-error this is fine for testing
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: customUrl });
init(options);

const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
// Spotlight integration should be added (we can't easily check the URL here without deeper inspection)
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
expect(spotlightIntegration).toBeDefined();
});

it('uses environment variable URL when options.spotlight=true', () => {
originalProcess = globalThis.process;
globalThis.process = {
env: {
SENTRY_SPOTLIGHT: 'http://env:5678/stream',
} as Record<string, string>,
} as NodeJS.Process;

// @ts-expect-error this is fine for testing
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: true });
init(options);

const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
// Spotlight integration should be added
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
expect(spotlightIntegration).toBeDefined();
});

it('respects priority order: PUBLIC_SENTRY_SPOTLIGHT over SENTRY_SPOTLIGHT', () => {
originalProcess = globalThis.process;
globalThis.process = {
env: {
PUBLIC_SENTRY_SPOTLIGHT: 'true',
SENTRY_SPOTLIGHT: 'false',
} as Record<string, string>,
} as NodeJS.Process;

// @ts-expect-error this is fine for testing
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
init(options);

const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
// Spotlight integration should be added (PUBLIC_SENTRY_SPOTLIGHT=true wins)
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
expect(spotlightIntegration).toBeDefined();
});

it('uses framework-specific prefix when base is not set', () => {
originalProcess = globalThis.process;
globalThis.process = {
env: {
NEXT_PUBLIC_SENTRY_SPOTLIGHT: 'true',
} as Record<string, string>,
} as NodeJS.Process;

// @ts-expect-error this is fine for testing
const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, spotlight: undefined });
init(options);

const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
// Spotlight integration should be added
const spotlightIntegration = optionsPassed?.integrations.find((i: Integration) => i.name === 'SpotlightBrowser');
expect(spotlightIntegration).toBeDefined();
});
});

it('returns a client from init', () => {
const client = init();
expect(client).not.toBeUndefined();
Expand Down
Loading
Loading