Skip to content

Commit 9d9663d

Browse files
committed
feat(spotlight): Add SENTRY_SPOTLIGHT environment variable support for browser SDKs
Enable Spotlight via SENTRY_SPOTLIGHT environment variable across browser frameworks with zero configuration for users. ## Changes ### Core Utilities (@sentry/core) - Move envToBool from node-core to @sentry/core for reuse - Add parseSpotlightEnvValue() and resolveSpotlightValue() implementing Spotlight spec precedence rules - Add unit tests for spotlight utilities ### Framework SDKs with Build-Time Injection - **Next.js**: webpack DefinePlugin + turbopack valueInjectionLoader - **Nuxt**: vite:extendConfig hook - **Astro**: updateConfig with vite define - **SvelteKit**: New makeSpotlightDefinePlugin() in sentrySvelteKit() ### Framework-Agnostic SDKs (React, Vue, Svelte, Solid) - Add __VITE_SPOTLIGHT_ENV__ placeholder replaced by Rollup - ESM: import.meta.env?.VITE_SENTRY_SPOTLIGHT (zero-config for Vite) - CJS: undefined (requires bundler config) ### Rollup Utils - Add makeViteSpotlightEnvReplacePlugin() for format-specific replacement Zero-config experience with 'spotlight run' for Next.js, Nuxt, Astro, SvelteKit, and Vite ESM users.
1 parent 2fb0f99 commit 9d9663d

File tree

22 files changed

+588
-65
lines changed

22 files changed

+588
-65
lines changed

dev-packages/rollup-utils/npmHelpers.mjs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
makeProductionReplacePlugin,
2020
makeRrwebBuildPlugin,
2121
makeSucrasePlugin,
22+
makeViteSpotlightEnvReplacePlugin,
2223
} from './plugins/index.mjs';
2324
import { makePackageNodeEsm } from './plugins/make-esm-plugin.mjs';
2425
import { mergePlugins } from './utils.mjs';
@@ -121,13 +122,19 @@ export function makeNPMConfigVariants(baseConfig, options = {}) {
121122

122123
if (emitCjs) {
123124
if (splitDevProd) {
124-
variantSpecificConfigs.push({ output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs/dev') } });
125+
variantSpecificConfigs.push({
126+
output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs/dev') },
127+
plugins: [makeViteSpotlightEnvReplacePlugin('cjs')],
128+
});
125129
variantSpecificConfigs.push({
126130
output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs/prod') },
127-
plugins: [makeProductionReplacePlugin()],
131+
plugins: [makeProductionReplacePlugin(), makeViteSpotlightEnvReplacePlugin('cjs')],
128132
});
129133
} else {
130-
variantSpecificConfigs.push({ output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs') } });
134+
variantSpecificConfigs.push({
135+
output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs') },
136+
plugins: [makeViteSpotlightEnvReplacePlugin('cjs')],
137+
});
131138
}
132139
}
133140

@@ -139,13 +146,15 @@ export function makeNPMConfigVariants(baseConfig, options = {}) {
139146
dir: path.join(baseConfig.output.dir, 'esm/dev'),
140147
plugins: [makePackageNodeEsm()],
141148
},
149+
plugins: [makeViteSpotlightEnvReplacePlugin('esm')],
142150
});
143151
variantSpecificConfigs.push({
144152
output: {
145153
format: 'esm',
146154
dir: path.join(baseConfig.output.dir, 'esm/prod'),
147155
plugins: [makeProductionReplacePlugin(), makePackageNodeEsm()],
148156
},
157+
plugins: [makeViteSpotlightEnvReplacePlugin('esm')],
149158
});
150159
} else {
151160
variantSpecificConfigs.push({
@@ -154,6 +163,7 @@ export function makeNPMConfigVariants(baseConfig, options = {}) {
154163
dir: path.join(baseConfig.output.dir, 'esm'),
155164
plugins: [makePackageNodeEsm()],
156165
},
166+
plugins: [makeViteSpotlightEnvReplacePlugin('esm')],
157167
});
158168
}
159169
}

dev-packages/rollup-utils/plugins/npmPlugins.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,24 @@ export function makeRrwebBuildPlugin({ excludeShadowDom, excludeIframe } = {}) {
168168
});
169169
}
170170

171+
/**
172+
* Creates a plugin to replace __VITE_SPOTLIGHT_ENV__ with the appropriate value based on output format.
173+
* - ESM: import.meta.env?.VITE_SENTRY_SPOTLIGHT (allows Vite to provide zero-config Spotlight support)
174+
* - CJS: undefined (import.meta is not available in CJS)
175+
*
176+
* @param format The output format ('esm' or 'cjs')
177+
* @returns A `@rollup/plugin-replace` instance.
178+
*/
179+
export function makeViteSpotlightEnvReplacePlugin(format) {
180+
const value = format === 'esm' ? 'import.meta.env?.VITE_SENTRY_SPOTLIGHT' : 'undefined';
181+
return replace({
182+
preventAssignment: true,
183+
values: {
184+
__VITE_SPOTLIGHT_ENV__: value,
185+
},
186+
});
187+
}
188+
171189
/**
172190
* Plugin that uploads bundle analysis to codecov.
173191
*

packages/astro/src/client/sdk.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
import type { BrowserOptions } from '@sentry/browser';
22
import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser';
33
import type { Client, Integration } from '@sentry/core';
4-
import { applySdkMetadata } from '@sentry/core';
4+
import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
55
import { browserTracingIntegration } from './browserTracingIntegration';
66

7+
// Type for spotlight-related env vars injected by Vite
8+
interface SpotlightEnv {
9+
PUBLIC_SENTRY_SPOTLIGHT?: string;
10+
SENTRY_SPOTLIGHT?: string;
11+
}
12+
13+
// Access import.meta.env in a way that works with TypeScript
14+
// Vite replaces this at build time
15+
function getSpotlightEnv(): SpotlightEnv {
16+
try {
17+
// @ts-expect-error - import.meta.env is injected by Vite
18+
return typeof import.meta !== 'undefined' && import.meta.env ? (import.meta.env as SpotlightEnv) : {};
19+
} catch {
20+
return {};
21+
}
22+
}
23+
724
// Tree-shakable guard to remove all code related to tracing
825
declare const __SENTRY_TRACING__: boolean;
926

@@ -13,9 +30,16 @@ declare const __SENTRY_TRACING__: boolean;
1330
* @param options Configuration options for the SDK.
1431
*/
1532
export function init(options: BrowserOptions): Client | undefined {
33+
// Read PUBLIC_SENTRY_SPOTLIGHT (set by spotlight run, Astro uses PUBLIC_ prefix)
34+
// OR fallback to SENTRY_SPOTLIGHT (injected by our integration)
35+
const spotlightEnv = getSpotlightEnv();
36+
const spotlightEnvRaw = spotlightEnv.PUBLIC_SENTRY_SPOTLIGHT || spotlightEnv.SENTRY_SPOTLIGHT;
37+
const spotlightEnvValue = parseSpotlightEnvValue(spotlightEnvRaw);
38+
1639
const opts = {
1740
defaultIntegrations: getDefaultIntegrations(options),
1841
...options,
42+
spotlight: resolveSpotlightValue(options.spotlight, spotlightEnvValue),
1943
};
2044

2145
applySdkMetadata(opts, 'astro', ['astro', 'browser']);

packages/astro/src/integration/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
179179
}
180180
}
181181

182+
// Inject SENTRY_SPOTLIGHT env var for client bundles (fallback for manual setup without PUBLIC_ prefix)
183+
updateConfig({
184+
vite: {
185+
define: {
186+
'import.meta.env.SENTRY_SPOTLIGHT': JSON.stringify(process.env.SENTRY_SPOTLIGHT || ''),
187+
},
188+
},
189+
});
190+
182191
const isSSR = config && (config.output === 'server' || config.output === 'hybrid');
183192
const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false;
184193

packages/core/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ export { hasSpansEnabled } from './utils/hasSpansEnabled';
6969
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
7070
export { handleCallbackErrors } from './utils/handleCallbackErrors';
7171
export { parameterize, fmt } from './utils/parameterize';
72+
export {
73+
envToBool,
74+
FALSY_ENV_VALUES,
75+
TRUTHY_ENV_VALUES,
76+
} from './utils/envToBool';
77+
export type { BoolCastOptions, StrictBoolCast, LooseBoolCast } from './utils/envToBool';
78+
export { parseSpotlightEnvValue, resolveSpotlightValue } from './utils/spotlight';
79+
export type { SpotlightConnectionOptions } from './utils/spotlight';
7280

7381
export { addAutoIpAddressToSession } from './utils/ipAddress';
7482
// eslint-disable-next-line deprecation/deprecation
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const FALSY_ENV_VALUES = new Set(['false', 'f', 'n', 'no', 'off', '0']);
2+
export const TRUTHY_ENV_VALUES = new Set(['true', 't', 'y', 'yes', 'on', '1']);
3+
4+
export type StrictBoolCast = {
5+
strict: true;
6+
};
7+
8+
export type LooseBoolCast = {
9+
strict?: false;
10+
};
11+
12+
export type BoolCastOptions = StrictBoolCast | LooseBoolCast;
13+
14+
export function envToBool(value: unknown, options?: LooseBoolCast): boolean;
15+
export function envToBool(value: unknown, options: StrictBoolCast): boolean | null;
16+
export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null;
17+
/**
18+
* A helper function which casts an ENV variable value to `true` or `false` using the constants defined above.
19+
* In strict mode, it may return `null` if the value doesn't match any of the predefined values.
20+
*
21+
* @param value The value of the env variable
22+
* @param options -- Only has `strict` key for now, which requires a strict match for `true` in TRUTHY_ENV_VALUES
23+
* @returns true/false if the lowercase value matches the predefined values above. If not, null in strict mode,
24+
* and Boolean(value) in loose mode.
25+
*/
26+
export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null {
27+
const normalized = String(value).toLowerCase();
28+
29+
if (FALSY_ENV_VALUES.has(normalized)) {
30+
return false;
31+
}
32+
33+
if (TRUTHY_ENV_VALUES.has(normalized)) {
34+
return true;
35+
}
36+
37+
return options?.strict ? null : Boolean(value);
38+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { debug } from './debug-logger';
2+
import { envToBool } from './envToBool';
3+
4+
/**
5+
* Spotlight configuration option type.
6+
* - `undefined` - not configured
7+
* - `false` - explicitly disabled
8+
* - `true` - enabled with default URL (http://localhost:8969/stream)
9+
* - `string` - enabled with custom URL
10+
*/
11+
export type SpotlightConnectionOptions = boolean | string | undefined;
12+
13+
/**
14+
* Parses a SENTRY_SPOTLIGHT environment variable value.
15+
*
16+
* Per the Spotlight spec:
17+
* - Truthy values ("true", "t", "y", "yes", "on", "1") -> true
18+
* - Falsy values ("false", "f", "n", "no", "off", "0") -> false
19+
* - Any other non-empty string -> treated as URL
20+
* - Empty string or undefined -> undefined
21+
*
22+
* @see https://develop.sentry.dev/sdk/expected-features/spotlight.md
23+
*/
24+
export function parseSpotlightEnvValue(envValue: string | undefined): SpotlightConnectionOptions {
25+
if (envValue === undefined || envValue === '') {
26+
return undefined;
27+
}
28+
29+
// Try strict boolean parsing first
30+
const boolValue = envToBool(envValue, { strict: true });
31+
if (boolValue !== null) {
32+
return boolValue;
33+
}
34+
35+
// Not a boolean - treat as URL
36+
return envValue;
37+
}
38+
39+
/**
40+
* Resolves the final Spotlight configuration value based on the config option and environment variable.
41+
*
42+
* Precedence rules (per spec):
43+
* 1. Config `false` -> DISABLED (ignore env var, log warning)
44+
* 2. Config URL string -> USE CONFIG URL (log warning if env var also set to URL)
45+
* 3. Config `true` + Env URL -> USE ENV VAR URL (this is the key case!)
46+
* 4. Config `true` + Env bool/undefined -> USE DEFAULT URL (true)
47+
* 5. Config `undefined` -> USE ENV VAR VALUE
48+
*
49+
* @see https://develop.sentry.dev/sdk/expected-features/spotlight.md
50+
*/
51+
export function resolveSpotlightValue(
52+
optionValue: SpotlightConnectionOptions,
53+
envValue: SpotlightConnectionOptions,
54+
): SpotlightConnectionOptions {
55+
// Case 1: Config explicitly disables Spotlight
56+
if (optionValue === false) {
57+
if (envValue !== undefined) {
58+
// Per spec: MUST warn when config false ignores env var
59+
debug.warn('Spotlight disabled via config, ignoring SENTRY_SPOTLIGHT environment variable');
60+
}
61+
return false;
62+
}
63+
64+
// Case 2: Config provides explicit URL
65+
if (typeof optionValue === 'string') {
66+
if (typeof envValue === 'string') {
67+
// Per spec: MUST warn when config URL overrides env var URL
68+
debug.warn('Spotlight config URL takes precedence over SENTRY_SPOTLIGHT environment variable');
69+
}
70+
return optionValue;
71+
}
72+
73+
// Case 3 & 4: Config is true - enable Spotlight
74+
if (optionValue === true) {
75+
// Per spec: If config true AND env var is URL, MUST use env var URL
76+
// This enables `spotlight: true` in code while `spotlight run` provides the URL
77+
return typeof envValue === 'string' ? envValue : true;
78+
}
79+
80+
// Case 5: Config undefined - fully defer to env var
81+
return envValue;
82+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { envToBool, FALSY_ENV_VALUES, TRUTHY_ENV_VALUES } from '../../../src/utils/envToBool';
3+
4+
describe('envToBool', () => {
5+
describe('TRUTHY_ENV_VALUES', () => {
6+
it.each([...TRUTHY_ENV_VALUES])('returns true for "%s"', value => {
7+
expect(envToBool(value)).toBe(true);
8+
expect(envToBool(value, { strict: true })).toBe(true);
9+
});
10+
11+
it('handles case insensitivity', () => {
12+
expect(envToBool('TRUE')).toBe(true);
13+
expect(envToBool('True')).toBe(true);
14+
expect(envToBool('YES')).toBe(true);
15+
expect(envToBool('Yes')).toBe(true);
16+
});
17+
});
18+
19+
describe('FALSY_ENV_VALUES', () => {
20+
it.each([...FALSY_ENV_VALUES])('returns false for "%s"', value => {
21+
expect(envToBool(value)).toBe(false);
22+
expect(envToBool(value, { strict: true })).toBe(false);
23+
});
24+
25+
it('handles case insensitivity', () => {
26+
expect(envToBool('FALSE')).toBe(false);
27+
expect(envToBool('False')).toBe(false);
28+
expect(envToBool('NO')).toBe(false);
29+
expect(envToBool('No')).toBe(false);
30+
});
31+
});
32+
33+
describe('non-matching values', () => {
34+
it('returns null in strict mode for non-matching values', () => {
35+
expect(envToBool('http://localhost:8969', { strict: true })).toBe(null);
36+
expect(envToBool('random', { strict: true })).toBe(null);
37+
expect(envToBool('', { strict: true })).toBe(null);
38+
});
39+
40+
it('returns Boolean(value) in loose mode for non-matching values', () => {
41+
expect(envToBool('http://localhost:8969')).toBe(true); // truthy string
42+
expect(envToBool('random')).toBe(true); // truthy string
43+
expect(envToBool('')).toBe(false); // falsy empty string
44+
});
45+
46+
it('defaults to loose mode when options not provided', () => {
47+
expect(envToBool('http://localhost:8969')).toBe(true);
48+
});
49+
});
50+
});

0 commit comments

Comments
 (0)