diff --git a/.size-limit.js b/.size-limit.js
index 24772d8380f5..7b82a3988362 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -8,7 +8,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init'),
gzip: true,
- limit: '25 KB',
+ limit: '25.5 KB',
},
{
name: '@sentry/browser - with treeshaking flags',
@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
- limit: '42 KB',
+ limit: '42.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
@@ -82,7 +82,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
- limit: '85 KB',
+ limit: '86 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
@@ -140,7 +140,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary'),
ignore: ['react/jsx-runtime'],
gzip: true,
- limit: '27 KB',
+ limit: '27.5 KB',
},
{
name: '@sentry/react (incl. Tracing)',
@@ -148,7 +148,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
- limit: '44 KB',
+ limit: '45 KB',
},
// Vue SDK (ESM)
{
@@ -163,7 +163,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
- limit: '44 KB',
+ limit: '44.5 KB',
},
// Svelte SDK (ESM)
{
@@ -171,20 +171,20 @@ module.exports = [
path: 'packages/svelte/build/esm/index.js',
import: createImport('init'),
gzip: true,
- limit: '25 KB',
+ limit: '25.6 KB',
},
// Browser CDN bundles
{
name: 'CDN Bundle',
path: createCDNPath('bundle.min.js'),
gzip: true,
- limit: '27.5 KB',
+ limit: '28 KB',
},
{
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
- limit: '42.5 KB',
+ limit: '43 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
@@ -234,7 +234,7 @@ module.exports = [
import: createImport('init'),
ignore: ['next/router', 'next/constants'],
gzip: true,
- limit: '46.5 KB',
+ limit: '47 KB',
},
// SvelteKit SDK (ESM)
{
@@ -243,7 +243,7 @@ module.exports = [
import: createImport('init'),
ignore: ['$app/stores'],
gzip: true,
- limit: '42 KB',
+ limit: '43 KB',
},
// Node-Core SDK (ESM)
{
diff --git a/dev-packages/bundler-tests/tests/bundling.test.ts b/dev-packages/bundler-tests/tests/bundling.test.ts
index 2cc8113ca83b..ee13fc8baa54 100644
--- a/dev-packages/bundler-tests/tests/bundling.test.ts
+++ b/dev-packages/bundler-tests/tests/bundling.test.ts
@@ -136,9 +136,52 @@ describe('spotlight', () => {
expect(code).toContain(SPOTLIGHT_URL);
});
- test(`${name} production bundle does not contain spotlight`, async () => {
+ // Spotlight is now included in production builds too (not dev-only)
+ // The integration is gated by the spotlight option, so there's no need
+ // to strip it from production builds
+ test(`${name} production bundle contains spotlight`, async () => {
const code = await bundler('production');
- expect(code).not.toContain(SPOTLIGHT_URL);
+ expect(code).toContain(SPOTLIGHT_URL);
});
}
});
+
+describe('__VITE_SPOTLIGHT_ENV__ rollup replacement', () => {
+ // Test that our rollup build correctly replaces __VITE_SPOTLIGHT_ENV__
+ // ESM bundles should have import.meta.env access, CJS should have undefined
+
+ function readSdkFile(packageName: string, format: 'esm' | 'cjs'): string {
+ const sdkPath = path.join(rootDir(), 'packages', packageName, 'build', format, 'sdk.js');
+ if (!fs.existsSync(sdkPath)) {
+ throw new Error(`SDK file not found: ${sdkPath}. Make sure to run yarn build:dev first.`);
+ }
+ return fs.readFileSync(sdkPath, 'utf8');
+ }
+
+ // Remove comments from code to test only actual code
+ function stripComments(code: string): string {
+ // Remove single-line comments
+ return code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
+ }
+
+ test.each(['react', 'vue', 'svelte', 'solid'] as const)(
+ '%s ESM bundle contains import.meta.env.VITE_SENTRY_SPOTLIGHT access',
+ packageName => {
+ const code = stripComments(readSdkFile(packageName, 'esm'));
+ // ESM bundles should have import.meta.env access for Vite support
+ // The replacement is: import.meta.env.VITE_SENTRY_SPOTLIGHT (without optional chaining)
+ // so that Vite can properly do static replacement at build time
+ expect(code).toMatch(/import\.meta\.env\.[A-Z_]+SPOTLIGHT/);
+ },
+ );
+
+ test.each(['react', 'vue', 'svelte', 'solid'] as const)(
+ '%s CJS bundle does not contain import.meta.env (CJS incompatible)',
+ packageName => {
+ const code = stripComments(readSdkFile(packageName, 'cjs'));
+ // CJS bundles should NOT have import.meta.env as it's ESM-only syntax
+ // The __VITE_SPOTLIGHT_ENV__ placeholder should be replaced with 'undefined'
+ expect(code).not.toMatch(/import\.meta\.env/);
+ },
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/.npmrc b/dev-packages/e2e-tests/test-applications/browser-spotlight/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/index.html b/dev-packages/e2e-tests/test-applications/browser-spotlight/index.html
new file mode 100644
index 000000000000..67e9a7a134c6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Spotlight E2E Test
+
+
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/package.json b/dev-packages/e2e-tests/test-applications/browser-spotlight/package.json
new file mode 100644
index 000000000000..3fd81e2ec852
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "browser-spotlight-test-app",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "VITE_SENTRY_SPOTLIGHT=http://localhost:3032/stream VITE_E2E_TEST_DSN=${E2E_TEST_DSN:-https://public@dsn.ingest.sentry.io/1234567} vite build",
+ "preview": "vite preview --port 3030",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test"
+ },
+ "dependencies": {
+ "@sentry/react": "latest || *",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@vitejs/plugin-react": "^4.2.1",
+ "vite": "~5.4.0"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/browser-spotlight/playwright.config.mjs
new file mode 100644
index 000000000000..485fe6b76a23
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/playwright.config.mjs
@@ -0,0 +1,16 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm preview`,
+});
+
+// Add the Spotlight proxy server as an additional webServer
+// This runs alongside the main event proxy and app server
+config.webServer.push({
+ command: 'node start-spotlight-proxy.mjs',
+ port: 3032,
+ stdout: 'pipe',
+ stderr: 'pipe',
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/src/main.jsx b/dev-packages/e2e-tests/test-applications/browser-spotlight/src/main.jsx
new file mode 100644
index 000000000000..b157746714e1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/src/main.jsx
@@ -0,0 +1,65 @@
+import * as Sentry from '@sentry/react';
+
+// Debug: Log env vars
+console.log('[E2E Debug] VITE_SENTRY_SPOTLIGHT:', import.meta.env.VITE_SENTRY_SPOTLIGHT);
+console.log('[E2E Debug] VITE_E2E_TEST_DSN:', import.meta.env.VITE_E2E_TEST_DSN);
+console.log('[E2E Debug] MODE:', import.meta.env.MODE);
+
+// Debug: Check if import.meta.env is available at runtime
+console.log('[E2E Debug] typeof import.meta:', typeof import.meta);
+console.log('[E2E Debug] typeof import.meta.env:', typeof import.meta.env);
+console.log('[E2E Debug] import.meta.env object:', JSON.stringify(import.meta.env));
+
+// Initialize Sentry - the @sentry/react SDK automatically parses
+// VITE_SENTRY_SPOTLIGHT from import.meta.env (zero-config for Vite!)
+const initOptions = {
+ dsn: import.meta.env.VITE_E2E_TEST_DSN,
+ integrations: [Sentry.browserTracingIntegration()],
+ tracesSampleRate: 1.0,
+ release: 'e2e-test',
+ environment: 'qa',
+ // Use tunnel to capture events at our proxy server
+ tunnel: 'http://localhost:3031',
+ debug: true,
+};
+
+console.log(
+ '[E2E Debug] Init options BEFORE Sentry.init:',
+ JSON.stringify({
+ dsn: initOptions.dsn,
+ spotlight: initOptions.spotlight,
+ debug: initOptions.debug,
+ }),
+);
+
+const client = Sentry.init(initOptions);
+
+// Debug: Check what the client received
+const clientOptions = client?.getOptions();
+console.log(
+ '[E2E Debug] Client options AFTER Sentry.init:',
+ JSON.stringify({
+ dsn: clientOptions?.dsn,
+ spotlight: clientOptions?.spotlight,
+ debug: clientOptions?.debug,
+ }),
+);
+
+// Debug: Check if Spotlight integration was added
+const integrations = clientOptions?.integrations || [];
+const integrationNames = integrations.map(i => i.name);
+console.log('[E2E Debug] Integrations:', integrationNames.join(', '));
+console.log('[E2E Debug] Has SpotlightBrowser:', integrationNames.includes('SpotlightBrowser'));
+
+// Simple render
+document.getElementById('root').innerHTML = `
+
+
Spotlight E2E Test
+
This page tests that VITE_SENTRY_SPOTLIGHT env var enables Spotlight integration.
+
+
+`;
+
+document.getElementById('exception-button').addEventListener('click', () => {
+ throw new Error('Spotlight test error!');
+});
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/browser-spotlight/start-event-proxy.mjs
new file mode 100644
index 000000000000..d2543f009fe8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/start-event-proxy.mjs
@@ -0,0 +1,7 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+// Start the main event proxy server that captures events via tunnel
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'browser-spotlight',
+});
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/start-spotlight-proxy.mjs b/dev-packages/e2e-tests/test-applications/browser-spotlight/start-spotlight-proxy.mjs
new file mode 100644
index 000000000000..58ffdb73b5f7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/start-spotlight-proxy.mjs
@@ -0,0 +1,16 @@
+import { startSpotlightProxyServer } from '@sentry-internal/test-utils';
+
+console.log('[Spotlight Proxy] Starting spotlight proxy server on port 3032...');
+
+// Start a Spotlight proxy server that captures events sent to /stream
+// This simulates the Spotlight sidecar and allows us to verify events arrive
+startSpotlightProxyServer({
+ port: 3032,
+ proxyServerName: 'browser-spotlight-sidecar',
+})
+ .then(() => {
+ console.log('[Spotlight Proxy] Server started successfully on port 3032');
+ })
+ .catch(err => {
+ console.error('[Spotlight Proxy] Failed to start server:', err);
+ });
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/tests/spotlight.test.ts b/dev-packages/e2e-tests/test-applications/browser-spotlight/tests/spotlight.test.ts
new file mode 100644
index 000000000000..fdeffb10da01
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/tests/spotlight.test.ts
@@ -0,0 +1,80 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForSpotlightError, waitForSpotlightTransaction } from '@sentry-internal/test-utils';
+
+// Forward browser console messages to the test output for debugging
+test.beforeEach(async ({ page }) => {
+ page.on('console', msg => {
+ console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
+ });
+ page.on('pageerror', error => {
+ console.log(`[Browser Error] ${error.message}`);
+ });
+});
+
+/**
+ * Test that VITE_SENTRY_SPOTLIGHT environment variable automatically enables Spotlight.
+ *
+ * This test verifies that:
+ * 1. The SDK automatically parses VITE_SENTRY_SPOTLIGHT env var via import.meta.env
+ * 2. The SDK enables Spotlight WITHOUT explicit configuration (zero-config for Vite!)
+ * 3. Events are sent to both the tunnel AND the Spotlight sidecar URL
+ *
+ * IMPORTANT: The test app does NOT explicitly add spotlightBrowserIntegration.
+ * The SDK should automatically enable Spotlight when VITE_SENTRY_SPOTLIGHT is set.
+ *
+ * Test setup:
+ * - VITE_SENTRY_SPOTLIGHT is set to 'http://localhost:3032/stream' at build time
+ * - tunnel is set to 'http://localhost:3031' for regular event capture
+ * - A Spotlight proxy server runs on port 3032 to capture Spotlight events
+ * - A regular event proxy server runs on port 3031 to capture tunnel events
+ */
+test('VITE_SENTRY_SPOTLIGHT env var automatically enables Spotlight', async ({ page }) => {
+ // Wait for the error to arrive at the regular tunnel (port 3031)
+ const tunnelErrorPromise = waitForError('browser-spotlight', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'Spotlight test error!';
+ });
+
+ // Wait for the error event to arrive at the Spotlight sidecar (port 3032)
+ const spotlightErrorPromise = waitForSpotlightError('browser-spotlight-sidecar', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'Spotlight test error!';
+ });
+
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ // Both promises should resolve - the error should be sent to BOTH destinations
+ const [tunnelError, spotlightError] = await Promise.all([tunnelErrorPromise, spotlightErrorPromise]);
+
+ // Verify the Spotlight sidecar received the error
+ expect(spotlightError.exception?.values).toHaveLength(1);
+ expect(spotlightError.exception?.values?.[0]?.value).toBe('Spotlight test error!');
+ expect(spotlightError.exception?.values?.[0]?.type).toBe('Error');
+
+ // Verify the tunnel also received the error (normal Sentry flow still works)
+ expect(tunnelError.exception?.values).toHaveLength(1);
+ expect(tunnelError.exception?.values?.[0]?.value).toBe('Spotlight test error!');
+
+ // Both events should have the same trace context
+ expect(spotlightError.contexts?.trace?.trace_id).toBe(tunnelError.contexts?.trace?.trace_id);
+});
+
+/**
+ * Test that Spotlight automatically receives transaction events.
+ */
+test('VITE_SENTRY_SPOTLIGHT automatically sends transactions to sidecar', async ({ page }) => {
+ // Wait for a pageload transaction to arrive at the Spotlight sidecar
+ const spotlightTransactionPromise = waitForSpotlightTransaction('browser-spotlight-sidecar', event => {
+ return event.type === 'transaction' && event.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto('/');
+
+ const spotlightTransaction = await spotlightTransactionPromise;
+
+ // Verify the Spotlight sidecar received the transaction
+ expect(spotlightTransaction.type).toBe('transaction');
+ expect(spotlightTransaction.contexts?.trace?.op).toBe('pageload');
+ expect(spotlightTransaction.transaction).toBe('/');
+});
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/tsconfig.json b/dev-packages/e2e-tests/test-applications/browser-spotlight/tsconfig.json
new file mode 100644
index 000000000000..2ebd0dd6a0c8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "target": "ESNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["tests/**/*.ts"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/browser-spotlight/vite.config.js b/dev-packages/e2e-tests/test-applications/browser-spotlight/vite.config.js
new file mode 100644
index 000000000000..31d0974d5395
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/browser-spotlight/vite.config.js
@@ -0,0 +1,15 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+// https://vitejs.dev/config/
+// VITE_SENTRY_SPOTLIGHT and VITE_E2E_TEST_DSN are set as env vars in package.json build script
+// Vite automatically replaces import.meta.env.VITE_* with values from actual env vars
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: 'build',
+ },
+ preview: {
+ port: 3030,
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/global-error.tsx
new file mode 100644
index 000000000000..4356dc5ebb25
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/global-error.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import * as Sentry from '@sentry/nextjs';
+import { useEffect } from 'react';
+
+export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ }, [error]);
+
+ return (
+
+
+ Something went wrong!
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/layout.tsx
new file mode 100644
index 000000000000..c73806a33629
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/layout.tsx
@@ -0,0 +1,12 @@
+export const metadata = {
+ title: 'Next.js Spotlight E2E Test',
+ description: 'Tests NEXT_PUBLIC_SENTRY_SPOTLIGHT env var support',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/page.tsx
new file mode 100644
index 000000000000..4214849e642d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/app/page.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+export default function Home() {
+ const handleClick = () => {
+ throw new Error('Spotlight test error!');
+ };
+
+ return (
+
+ Next.js Spotlight E2E Test
+ This page tests that NEXT_PUBLIC_SENTRY_SPOTLIGHT env var enables Spotlight integration.
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/instrumentation-client.ts
new file mode 100644
index 000000000000..8082eef7ac82
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/instrumentation-client.ts
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/nextjs';
+
+// Initialize Sentry - the @sentry/nextjs SDK automatically parses
+// NEXT_PUBLIC_SENTRY_SPOTLIGHT from process.env (zero-config for Next.js!)
+//
+// NOTE: We do NOT explicitly set `spotlight` option!
+// The SDK should automatically:
+// 1. Read NEXT_PUBLIC_SENTRY_SPOTLIGHT from process.env
+// 2. Enable Spotlight with the URL from the env var
+// 3. Add the spotlightBrowserIntegration to send events to the sidecar
+Sentry.init({
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/',
+ tracesSampleRate: 1.0,
+ environment: 'qa',
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/instrumentation.ts
new file mode 100644
index 000000000000..93b5b741184e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/instrumentation.ts
@@ -0,0 +1,23 @@
+import * as Sentry from '@sentry/nextjs';
+
+export async function register() {
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ // Server-side Sentry init
+ Sentry.init({
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tracesSampleRate: 1.0,
+ debug: true,
+ environment: 'qa',
+ });
+ }
+
+ if (process.env.NEXT_RUNTIME === 'edge') {
+ // Edge runtime Sentry init
+ Sentry.init({
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tracesSampleRate: 1.0,
+ debug: true,
+ environment: 'qa',
+ });
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/next.config.js
new file mode 100644
index 000000000000..1098c2ce5a4f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/next.config.js
@@ -0,0 +1,8 @@
+const { withSentryConfig } = require('@sentry/nextjs');
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+module.exports = withSentryConfig(nextConfig, {
+ silent: true,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/package.json b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/package.json
new file mode 100644
index 000000000000..f792a096ec45
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "nextjs-spotlight-test-app",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "build": "NEXT_PUBLIC_SENTRY_SPOTLIGHT=http://localhost:3032/stream next build",
+ "start": "next start -p 3030",
+ "dev": "NEXT_PUBLIC_SENTRY_SPOTLIGHT=http://localhost:3032/stream next dev -p 3030",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml .next",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test"
+ },
+ "dependencies": {
+ "@sentry/nextjs": "latest || *",
+ "@types/node": "^18.19.1",
+ "@types/react": "18.0.26",
+ "@types/react-dom": "18.0.9",
+ "next": "14.2.32",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "typescript": "~5.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/playwright.config.mjs
new file mode 100644
index 000000000000..2d5d3233668c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/playwright.config.mjs
@@ -0,0 +1,16 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: 'pnpm start',
+ port: 3030,
+});
+
+// Add the Spotlight proxy server as an additional webServer
+config.webServer.push({
+ command: 'node start-spotlight-proxy.mjs',
+ port: 3032,
+ stdout: 'pipe',
+ stderr: 'pipe',
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/start-event-proxy.mjs
new file mode 100644
index 000000000000..4f637d20a0c9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'nextjs-spotlight',
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/start-spotlight-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/start-spotlight-proxy.mjs
new file mode 100644
index 000000000000..18c04d195002
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/start-spotlight-proxy.mjs
@@ -0,0 +1,10 @@
+import { startSpotlightProxyServer } from '@sentry-internal/test-utils';
+
+console.log('[Spotlight Proxy] Starting spotlight proxy server on port 3032...');
+
+startSpotlightProxyServer({
+ port: 3032,
+ proxyServerName: 'nextjs-spotlight-sidecar',
+});
+
+console.log('[Spotlight Proxy] Server started successfully on port 3032');
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/tests/spotlight.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/tests/spotlight.test.ts
new file mode 100644
index 000000000000..a89a3921cd1b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/tests/spotlight.test.ts
@@ -0,0 +1,66 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForSpotlightError, waitForSpotlightTransaction } from '@sentry-internal/test-utils';
+
+/**
+ * Test that NEXT_PUBLIC_SENTRY_SPOTLIGHT environment variable automatically enables Spotlight integration.
+ *
+ * This test verifies that:
+ * 1. The SDK automatically parses NEXT_PUBLIC_SENTRY_SPOTLIGHT from process.env
+ * 2. The SDK enables Spotlight without explicit configuration
+ * 3. Events are sent to both the tunnel AND the Spotlight sidecar URL
+ *
+ * Test setup:
+ * - NEXT_PUBLIC_SENTRY_SPOTLIGHT is set to 'http://localhost:3032/stream' at build time
+ * - tunnel is set to 'http://localhost:3031' for regular event capture
+ * - A Spotlight proxy server runs on port 3032 to capture Spotlight events
+ * - A regular event proxy server runs on port 3031 to capture tunnel events
+ */
+test('NEXT_PUBLIC_SENTRY_SPOTLIGHT env var automatically enables Spotlight', async ({ page }) => {
+ // Wait for the error to arrive at the regular tunnel (port 3031)
+ const tunnelErrorPromise = waitForError('nextjs-spotlight', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'Spotlight test error!';
+ });
+
+ // Wait for the error event to arrive at the Spotlight sidecar (port 3032)
+ const spotlightErrorPromise = waitForSpotlightError('nextjs-spotlight-sidecar', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'Spotlight test error!';
+ });
+
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ // Both promises should resolve - the error should be sent to BOTH destinations
+ const [tunnelError, spotlightError] = await Promise.all([tunnelErrorPromise, spotlightErrorPromise]);
+
+ // Verify the Spotlight sidecar received the error
+ expect(spotlightError.exception?.values).toHaveLength(1);
+ expect(spotlightError.exception?.values?.[0]?.value).toBe('Spotlight test error!');
+ expect(spotlightError.exception?.values?.[0]?.type).toBe('Error');
+
+ // Verify the tunnel also received the error (normal Sentry flow still works)
+ expect(tunnelError.exception?.values).toHaveLength(1);
+ expect(tunnelError.exception?.values?.[0]?.value).toBe('Spotlight test error!');
+
+ // Both events should have the same trace context
+ expect(spotlightError.contexts?.trace?.trace_id).toBe(tunnelError.contexts?.trace?.trace_id);
+});
+
+/**
+ * Test that Spotlight receives transaction events as well.
+ */
+test('NEXT_PUBLIC_SENTRY_SPOTLIGHT automatically sends transactions to sidecar', async ({ page }) => {
+ // Wait for a pageload transaction to arrive at the Spotlight sidecar
+ const spotlightTransactionPromise = waitForSpotlightTransaction('nextjs-spotlight-sidecar', event => {
+ return event.type === 'transaction' && event.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto('/');
+
+ const spotlightTransaction = await spotlightTransactionPromise;
+
+ // Verify the Spotlight sidecar received the transaction
+ expect(spotlightTransaction.type).toBe('transaction');
+ expect(spotlightTransaction.contexts?.trace?.op).toBe('pageload');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-spotlight/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/tsconfig.json
new file mode 100644
index 000000000000..23ba4fd54943
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-spotlight/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs
index d5f7428b992d..ca48bbc9ac2e 100644
--- a/dev-packages/rollup-utils/npmHelpers.mjs
+++ b/dev-packages/rollup-utils/npmHelpers.mjs
@@ -19,6 +19,7 @@ import {
makeProductionReplacePlugin,
makeRrwebBuildPlugin,
makeSucrasePlugin,
+ makeViteSpotlightEnvReplacePlugin,
} from './plugins/index.mjs';
import { makePackageNodeEsm } from './plugins/make-esm-plugin.mjs';
import { mergePlugins } from './utils.mjs';
@@ -121,13 +122,19 @@ export function makeNPMConfigVariants(baseConfig, options = {}) {
if (emitCjs) {
if (splitDevProd) {
- variantSpecificConfigs.push({ output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs/dev') } });
+ variantSpecificConfigs.push({
+ output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs/dev') },
+ plugins: [makeViteSpotlightEnvReplacePlugin('cjs')],
+ });
variantSpecificConfigs.push({
output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs/prod') },
- plugins: [makeProductionReplacePlugin()],
+ plugins: [makeProductionReplacePlugin(), makeViteSpotlightEnvReplacePlugin('cjs')],
});
} else {
- variantSpecificConfigs.push({ output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs') } });
+ variantSpecificConfigs.push({
+ output: { format: 'cjs', dir: path.join(baseConfig.output.dir, 'cjs') },
+ plugins: [makeViteSpotlightEnvReplacePlugin('cjs')],
+ });
}
}
@@ -139,6 +146,7 @@ export function makeNPMConfigVariants(baseConfig, options = {}) {
dir: path.join(baseConfig.output.dir, 'esm/dev'),
plugins: [makePackageNodeEsm()],
},
+ plugins: [makeViteSpotlightEnvReplacePlugin('esm')],
});
variantSpecificConfigs.push({
output: {
@@ -146,6 +154,7 @@ export function makeNPMConfigVariants(baseConfig, options = {}) {
dir: path.join(baseConfig.output.dir, 'esm/prod'),
plugins: [makeProductionReplacePlugin(), makePackageNodeEsm()],
},
+ plugins: [makeViteSpotlightEnvReplacePlugin('esm')],
});
} else {
variantSpecificConfigs.push({
@@ -154,6 +163,7 @@ export function makeNPMConfigVariants(baseConfig, options = {}) {
dir: path.join(baseConfig.output.dir, 'esm'),
plugins: [makePackageNodeEsm()],
},
+ plugins: [makeViteSpotlightEnvReplacePlugin('esm')],
});
}
}
diff --git a/dev-packages/rollup-utils/plugins/npmPlugins.mjs b/dev-packages/rollup-utils/plugins/npmPlugins.mjs
index 7f08873f1c80..989206f0795e 100644
--- a/dev-packages/rollup-utils/plugins/npmPlugins.mjs
+++ b/dev-packages/rollup-utils/plugins/npmPlugins.mjs
@@ -168,6 +168,29 @@ export function makeRrwebBuildPlugin({ excludeShadowDom, excludeIframe } = {}) {
});
}
+/**
+ * Creates a plugin to replace __VITE_SPOTLIGHT_ENV__ with the appropriate value based on output format.
+ * - ESM: import.meta.env.VITE_SENTRY_SPOTLIGHT (allows Vite to provide zero-config Spotlight support)
+ * - CJS: undefined (import.meta is not available in CJS)
+ *
+ * Note: We don't use optional chaining (?.) here because Vite's static replacement only works on
+ * exact matches of `import.meta.env.VITE_*`. The SDK code uses typeof to guard against undefined.
+ *
+ * @param format The output format ('esm' or 'cjs')
+ * @returns A `@rollup/plugin-replace` instance.
+ */
+export function makeViteSpotlightEnvReplacePlugin(format) {
+ const isEsm = format === 'esm';
+ return replace({
+ preventAssignment: true,
+ values: {
+ // ESM: Replace with import.meta.env.VITE_SENTRY_SPOTLIGHT for Vite zero-config support
+ // CJS: Replace with undefined since import.meta is not available
+ __VITE_SPOTLIGHT_ENV__: isEsm ? 'import.meta.env.VITE_SENTRY_SPOTLIGHT' : 'undefined',
+ },
+ });
+}
+
/**
* Plugin that uploads bundle analysis to codecov.
*
diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts
index 9c411c3fc015..41c974e3b7f7 100644
--- a/dev-packages/test-utils/src/event-proxy-server.ts
+++ b/dev-packages/test-utils/src/event-proxy-server.ts
@@ -32,6 +32,7 @@ interface SentryRequestCallbackData {
envelope: Envelope;
rawProxyRequestBody: string;
rawProxyRequestHeaders: Record;
+ rawProxyRequestUrl?: string;
rawSentryResponseBody: string;
sentryResponseStatusCode?: number;
}
@@ -90,6 +91,17 @@ export async function startProxyServer(
});
proxyRequest.addListener('end', () => {
+ // Handle CORS preflight requests before processing body
+ if (proxyRequest.method === 'OPTIONS') {
+ proxyResponse.writeHead(200, {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Sentry-Auth',
+ });
+ proxyResponse.end();
+ return;
+ }
+
const proxyRequestBody =
proxyRequest.headers['content-encoding'] === 'gzip'
? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString()
@@ -191,6 +203,7 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P
envelope: parseEnvelope(proxyRequestBody),
rawProxyRequestBody: proxyRequestBody,
rawProxyRequestHeaders: proxyRequest.headers,
+ rawProxyRequestUrl: proxyRequest.url,
rawSentryResponseBody: '',
sentryResponseStatusCode: 200,
};
@@ -398,6 +411,74 @@ export function waitForTransaction(
});
}
+interface SpotlightProxyServerOptions {
+ /** Port to start the spotlight proxy server at. */
+ port: number;
+ /** The name for the proxy server used for referencing it with listener functions */
+ proxyServerName: string;
+}
+
+/**
+ * Starts a proxy server that acts like a Spotlight sidecar.
+ * It accepts envelopes at /stream and allows tests to wait for them.
+ * Point the SDK's `spotlight` option or `SENTRY_SPOTLIGHT` env var to this server.
+ */
+export async function startSpotlightProxyServer(options: SpotlightProxyServerOptions): Promise {
+ await startProxyServer(options, async (eventCallbackListeners, proxyRequest, proxyRequestBody, eventBuffer) => {
+ // Spotlight sends to /stream endpoint
+ const url = proxyRequest.url || '';
+ if (!url.includes('/stream')) {
+ // Return 404 for non-spotlight requests
+ return [404, 'Not Found', {}];
+ }
+
+ const data: SentryRequestCallbackData = {
+ envelope: parseEnvelope(proxyRequestBody),
+ rawProxyRequestBody: proxyRequestBody,
+ rawProxyRequestHeaders: proxyRequest.headers,
+ rawProxyRequestUrl: proxyRequest.url,
+ rawSentryResponseBody: '',
+ sentryResponseStatusCode: 200,
+ };
+
+ const dataString = Buffer.from(JSON.stringify(data)).toString('base64');
+
+ eventBuffer.push({ data: dataString, timestamp: getNanosecondTimestamp() });
+
+ eventCallbackListeners.forEach(listener => {
+ listener(dataString);
+ });
+
+ return [
+ 200,
+ '{}',
+ {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ },
+ ];
+ });
+}
+
+/** Wait for an error to be sent to Spotlight. */
+export function waitForSpotlightError(
+ proxyServerName: string,
+ callback: (errorEvent: Event) => Promise | boolean,
+): Promise {
+ // Reuse the same logic as waitForError - just uses a different proxy server name
+ return waitForError(proxyServerName, callback);
+}
+
+/** Wait for a transaction to be sent to Spotlight. */
+export function waitForSpotlightTransaction(
+ proxyServerName: string,
+ callback: (transactionEvent: Event) => Promise | boolean,
+): Promise {
+ // Reuse the same logic as waitForTransaction - just uses a different proxy server name
+ return waitForTransaction(proxyServerName, callback);
+}
+
/**
* Wait for metric items to be sent.
*/
diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts
index b14248aabd95..9aba265d5499 100644
--- a/dev-packages/test-utils/src/index.ts
+++ b/dev-packages/test-utils/src/index.ts
@@ -1,12 +1,15 @@
export {
startProxyServer,
startEventProxyServer,
+ startSpotlightProxyServer,
waitForEnvelopeItem,
waitForError,
waitForRequest,
waitForTransaction,
waitForSession,
waitForPlainRequest,
+ waitForSpotlightError,
+ waitForSpotlightTransaction,
waitForMetric,
} from './event-proxy-server';
diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts
index 21c5770f255f..ef61ab47d595 100644
--- a/packages/astro/src/client/sdk.ts
+++ b/packages/astro/src/client/sdk.ts
@@ -1,9 +1,26 @@
import type { BrowserOptions } from '@sentry/browser';
import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser';
import type { Client, Integration } from '@sentry/core';
-import { applySdkMetadata } from '@sentry/core';
+import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
import { browserTracingIntegration } from './browserTracingIntegration';
+// Type for spotlight-related env vars injected by Vite
+interface SpotlightEnv {
+ PUBLIC_SENTRY_SPOTLIGHT?: string;
+ SENTRY_SPOTLIGHT?: string;
+}
+
+// Access import.meta.env in a way that works with TypeScript
+// Vite replaces this at build time
+function getSpotlightEnv(): SpotlightEnv {
+ try {
+ // @ts-expect-error - import.meta.env is injected by Vite
+ return typeof import.meta !== 'undefined' && import.meta.env ? (import.meta.env as SpotlightEnv) : {};
+ } catch {
+ return {};
+ }
+}
+
// Tree-shakable guard to remove all code related to tracing
declare const __SENTRY_TRACING__: boolean;
@@ -13,9 +30,16 @@ declare const __SENTRY_TRACING__: boolean;
* @param options Configuration options for the SDK.
*/
export function init(options: BrowserOptions): Client | undefined {
+ // Read PUBLIC_SENTRY_SPOTLIGHT (set by spotlight run, Astro uses PUBLIC_ prefix)
+ // OR fallback to SENTRY_SPOTLIGHT (injected by our integration)
+ const spotlightEnv = getSpotlightEnv();
+ const spotlightEnvRaw = spotlightEnv.PUBLIC_SENTRY_SPOTLIGHT || spotlightEnv.SENTRY_SPOTLIGHT;
+ const spotlightEnvValue = parseSpotlightEnvValue(spotlightEnvRaw);
+
const opts = {
defaultIntegrations: getDefaultIntegrations(options),
...options,
+ spotlight: resolveSpotlightValue(options.spotlight, spotlightEnvValue),
};
applySdkMetadata(opts, 'astro', ['astro', 'browser']);
diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts
index 86f2f3f03bde..ba3b6caba32a 100644
--- a/packages/astro/src/integration/index.ts
+++ b/packages/astro/src/integration/index.ts
@@ -179,6 +179,18 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
}
}
+ // Inject SENTRY_SPOTLIGHT env var for client bundles (fallback for manual setup without PUBLIC_ prefix)
+ // Only add if we have a value to inject and the SDK is enabled
+ if (process.env.SENTRY_SPOTLIGHT && sdkEnabled.client) {
+ updateConfig({
+ vite: {
+ define: {
+ 'import.meta.env.SENTRY_SPOTLIGHT': JSON.stringify(process.env.SENTRY_SPOTLIGHT),
+ },
+ },
+ });
+ }
+
const isSSR = config && (config.output === 'server' || config.output === 'hybrid');
const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false;
diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts
index 800c1b701352..11859c17ebc7 100644
--- a/packages/browser/src/sdk.ts
+++ b/packages/browser/src/sdk.ts
@@ -94,7 +94,9 @@ export function init(options: BrowserOptions = {}): Client | undefined {
let defaultIntegrations =
options.defaultIntegrations == null ? getDefaultIntegrations(options) : options.defaultIntegrations;
- /* rollup-include-development-only */
+ // Add Spotlight integration if configured
+ // This is included in all builds (not just development) so users can use Spotlight
+ // in any environment where they set the spotlight option
if (options.spotlight) {
if (!defaultIntegrations) {
defaultIntegrations = [];
@@ -102,7 +104,6 @@ export function init(options: BrowserOptions = {}): Client | undefined {
const args = typeof options.spotlight === 'string' ? { sidecarUrl: options.spotlight } : undefined;
defaultIntegrations.push(spotlightBrowserIntegration(args));
}
- /* rollup-include-development-only-end */
const clientOptions: BrowserClientOptions = {
...options,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c4884edf939b..6b5b1bd070e6 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -69,6 +69,10 @@ export { hasSpansEnabled } from './utils/hasSpansEnabled';
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
export { handleCallbackErrors } from './utils/handleCallbackErrors';
export { parameterize, fmt } from './utils/parameterize';
+export { envToBool, FALSY_ENV_VALUES, TRUTHY_ENV_VALUES } from './utils/envToBool';
+export type { BoolCastOptions, StrictBoolCast, LooseBoolCast } from './utils/envToBool';
+export { parseSpotlightEnvValue, resolveSpotlightValue } from './utils/spotlight';
+export type { SpotlightConnectionOptions } from './utils/spotlight';
export { addAutoIpAddressToSession } from './utils/ipAddress';
// eslint-disable-next-line deprecation/deprecation
diff --git a/packages/core/src/utils/envToBool.ts b/packages/core/src/utils/envToBool.ts
new file mode 100644
index 000000000000..f78d05bb380c
--- /dev/null
+++ b/packages/core/src/utils/envToBool.ts
@@ -0,0 +1,38 @@
+export const FALSY_ENV_VALUES = new Set(['false', 'f', 'n', 'no', 'off', '0']);
+export const TRUTHY_ENV_VALUES = new Set(['true', 't', 'y', 'yes', 'on', '1']);
+
+export type StrictBoolCast = {
+ strict: true;
+};
+
+export type LooseBoolCast = {
+ strict?: false;
+};
+
+export type BoolCastOptions = StrictBoolCast | LooseBoolCast;
+
+export function envToBool(value: unknown, options?: LooseBoolCast): boolean;
+export function envToBool(value: unknown, options: StrictBoolCast): boolean | null;
+export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null;
+/**
+ * A helper function which casts an ENV variable value to `true` or `false` using the constants defined above.
+ * In strict mode, it may return `null` if the value doesn't match any of the predefined values.
+ *
+ * @param value The value of the env variable
+ * @param options -- Only has `strict` key for now, which requires a strict match for `true` in TRUTHY_ENV_VALUES
+ * @returns true/false if the lowercase value matches the predefined values above. If not, null in strict mode,
+ * and Boolean(value) in loose mode.
+ */
+export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null {
+ const normalized = String(value).toLowerCase();
+
+ if (FALSY_ENV_VALUES.has(normalized)) {
+ return false;
+ }
+
+ if (TRUTHY_ENV_VALUES.has(normalized)) {
+ return true;
+ }
+
+ return options?.strict ? null : Boolean(value);
+}
diff --git a/packages/core/src/utils/spotlight.ts b/packages/core/src/utils/spotlight.ts
new file mode 100644
index 000000000000..b0a76676364d
--- /dev/null
+++ b/packages/core/src/utils/spotlight.ts
@@ -0,0 +1,82 @@
+import { debug } from './debug-logger';
+import { envToBool } from './envToBool';
+
+/**
+ * Spotlight configuration option type.
+ * - `undefined` - not configured
+ * - `false` - explicitly disabled
+ * - `true` - enabled with default URL (http://localhost:8969/stream)
+ * - `string` - enabled with custom URL
+ */
+export type SpotlightConnectionOptions = boolean | string | undefined;
+
+/**
+ * Parses a SENTRY_SPOTLIGHT environment variable value.
+ *
+ * Per the Spotlight spec:
+ * - Truthy values ("true", "t", "y", "yes", "on", "1") -> true
+ * - Falsy values ("false", "f", "n", "no", "off", "0") -> false
+ * - Any other non-empty string -> treated as URL
+ * - Empty string or undefined -> undefined
+ *
+ * @see https://develop.sentry.dev/sdk/expected-features/spotlight.md
+ */
+export function parseSpotlightEnvValue(envValue: string | undefined): SpotlightConnectionOptions {
+ if (envValue === undefined || envValue === '') {
+ return undefined;
+ }
+
+ // Try strict boolean parsing first
+ const boolValue = envToBool(envValue, { strict: true });
+ if (boolValue !== null) {
+ return boolValue;
+ }
+
+ // Not a boolean - treat as URL
+ return envValue;
+}
+
+/**
+ * Resolves the final Spotlight configuration value based on the config option and environment variable.
+ *
+ * Precedence rules (per spec):
+ * 1. Config `false` -> DISABLED (ignore env var, log warning)
+ * 2. Config URL string -> USE CONFIG URL (log warning if env var also set to URL)
+ * 3. Config `true` + Env URL -> USE ENV VAR URL (this is the key case!)
+ * 4. Config `true` + Env bool/undefined -> USE DEFAULT URL (true)
+ * 5. Config `undefined` -> USE ENV VAR VALUE
+ *
+ * @see https://develop.sentry.dev/sdk/expected-features/spotlight.md
+ */
+export function resolveSpotlightValue(
+ optionValue: SpotlightConnectionOptions,
+ envValue: SpotlightConnectionOptions,
+): SpotlightConnectionOptions {
+ // Case 1: Config explicitly disables Spotlight
+ if (optionValue === false) {
+ if (envValue !== undefined) {
+ // Per spec: MUST warn when config false ignores env var
+ debug.warn('Spotlight disabled via config, ignoring SENTRY_SPOTLIGHT environment variable');
+ }
+ return false;
+ }
+
+ // Case 2: Config provides explicit URL
+ if (typeof optionValue === 'string') {
+ if (typeof envValue === 'string') {
+ // Per spec: MUST warn when config URL overrides env var URL
+ debug.warn('Spotlight config URL takes precedence over SENTRY_SPOTLIGHT environment variable');
+ }
+ return optionValue;
+ }
+
+ // Case 3 & 4: Config is true - enable Spotlight
+ if (optionValue === true) {
+ // Per spec: If config true AND env var is URL, MUST use env var URL
+ // This enables `spotlight: true` in code while `spotlight run` provides the URL
+ return typeof envValue === 'string' ? envValue : true;
+ }
+
+ // Case 5: Config undefined - fully defer to env var
+ return envValue;
+}
diff --git a/packages/core/test/lib/utils/envToBool.test.ts b/packages/core/test/lib/utils/envToBool.test.ts
new file mode 100644
index 000000000000..fefde285762e
--- /dev/null
+++ b/packages/core/test/lib/utils/envToBool.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { envToBool, FALSY_ENV_VALUES, TRUTHY_ENV_VALUES } from '../../../src/utils/envToBool';
+
+describe('envToBool', () => {
+ describe('TRUTHY_ENV_VALUES', () => {
+ it.each([...TRUTHY_ENV_VALUES])('returns true for "%s"', value => {
+ expect(envToBool(value)).toBe(true);
+ expect(envToBool(value, { strict: true })).toBe(true);
+ });
+
+ it('handles case insensitivity', () => {
+ expect(envToBool('TRUE')).toBe(true);
+ expect(envToBool('True')).toBe(true);
+ expect(envToBool('YES')).toBe(true);
+ expect(envToBool('Yes')).toBe(true);
+ });
+ });
+
+ describe('FALSY_ENV_VALUES', () => {
+ it.each([...FALSY_ENV_VALUES])('returns false for "%s"', value => {
+ expect(envToBool(value)).toBe(false);
+ expect(envToBool(value, { strict: true })).toBe(false);
+ });
+
+ it('handles case insensitivity', () => {
+ expect(envToBool('FALSE')).toBe(false);
+ expect(envToBool('False')).toBe(false);
+ expect(envToBool('NO')).toBe(false);
+ expect(envToBool('No')).toBe(false);
+ });
+ });
+
+ describe('non-matching values', () => {
+ it('returns null in strict mode for non-matching values', () => {
+ expect(envToBool('http://localhost:8969', { strict: true })).toBe(null);
+ expect(envToBool('random', { strict: true })).toBe(null);
+ expect(envToBool('', { strict: true })).toBe(null);
+ });
+
+ it('returns Boolean(value) in loose mode for non-matching values', () => {
+ expect(envToBool('http://localhost:8969')).toBe(true); // truthy string
+ expect(envToBool('random')).toBe(true); // truthy string
+ expect(envToBool('')).toBe(false); // falsy empty string
+ });
+
+ it('defaults to loose mode when options not provided', () => {
+ expect(envToBool('http://localhost:8969')).toBe(true);
+ });
+ });
+});
diff --git a/packages/core/test/lib/utils/spotlight.test.ts b/packages/core/test/lib/utils/spotlight.test.ts
new file mode 100644
index 000000000000..70bdf110ab6a
--- /dev/null
+++ b/packages/core/test/lib/utils/spotlight.test.ts
@@ -0,0 +1,138 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as debugLogger from '../../../src/utils/debug-logger';
+import { parseSpotlightEnvValue, resolveSpotlightValue } from '../../../src/utils/spotlight';
+
+describe('parseSpotlightEnvValue', () => {
+ it('returns undefined for undefined input', () => {
+ expect(parseSpotlightEnvValue(undefined)).toBe(undefined);
+ });
+
+ it('returns undefined for empty string', () => {
+ expect(parseSpotlightEnvValue('')).toBe(undefined);
+ });
+
+ describe('truthy values', () => {
+ it.each(['true', 'True', 'TRUE', 't', 'T', 'y', 'Y', 'yes', 'Yes', 'YES', 'on', 'On', 'ON', '1'])(
+ 'returns true for "%s"',
+ value => {
+ expect(parseSpotlightEnvValue(value)).toBe(true);
+ },
+ );
+ });
+
+ describe('falsy values', () => {
+ it.each(['false', 'False', 'FALSE', 'f', 'F', 'n', 'N', 'no', 'No', 'NO', 'off', 'Off', 'OFF', '0'])(
+ 'returns false for "%s"',
+ value => {
+ expect(parseSpotlightEnvValue(value)).toBe(false);
+ },
+ );
+ });
+
+ describe('URL values', () => {
+ it('treats non-boolean strings as URLs', () => {
+ expect(parseSpotlightEnvValue('http://localhost:8969/stream')).toBe('http://localhost:8969/stream');
+ });
+
+ it('treats arbitrary strings as URLs', () => {
+ expect(parseSpotlightEnvValue('some-custom-url')).toBe('some-custom-url');
+ });
+
+ it('treats port-only values as URLs', () => {
+ // '8080' is not in the truthy/falsy lists, so it's treated as a URL
+ expect(parseSpotlightEnvValue('8080')).toBe('8080');
+ });
+ });
+});
+
+describe('resolveSpotlightValue', () => {
+ let warnSpy: ReturnType;
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(debugLogger.debug, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ describe('Case 1: Config false - DISABLED', () => {
+ it('returns false and ignores env var', () => {
+ expect(resolveSpotlightValue(false, 'http://localhost:8969/stream')).toBe(false);
+ });
+
+ it('logs warning when env var is set', () => {
+ resolveSpotlightValue(false, 'http://localhost:8969/stream');
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Spotlight disabled via config, ignoring SENTRY_SPOTLIGHT environment variable',
+ );
+ });
+
+ it('does not log warning when env var is undefined', () => {
+ resolveSpotlightValue(false, undefined);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Case 2: Config URL string - USE CONFIG URL', () => {
+ it('returns config URL', () => {
+ expect(resolveSpotlightValue('http://config-url:8080', 'http://env-url:9090')).toBe('http://config-url:8080');
+ });
+
+ it('logs warning when env var is also a URL', () => {
+ resolveSpotlightValue('http://config-url:8080', 'http://env-url:9090');
+ expect(warnSpy).toHaveBeenCalledWith(
+ 'Spotlight config URL takes precedence over SENTRY_SPOTLIGHT environment variable',
+ );
+ });
+
+ it('does not log warning when env var is not a URL', () => {
+ resolveSpotlightValue('http://config-url:8080', true);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not log warning when env var is undefined', () => {
+ resolveSpotlightValue('http://config-url:8080', undefined);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Case 3: Config true + Env URL - USE ENV VAR URL (key case!)', () => {
+ it('returns env var URL when config is true', () => {
+ expect(resolveSpotlightValue(true, 'http://localhost:8969/stream')).toBe('http://localhost:8969/stream');
+ });
+ });
+
+ describe('Case 4: Config true + Env bool/undefined - USE DEFAULT URL', () => {
+ it('returns true when env var is true', () => {
+ expect(resolveSpotlightValue(true, true)).toBe(true);
+ });
+
+ it('returns true when env var is false', () => {
+ // Config true wins over env false - this is the expected behavior per precedence
+ expect(resolveSpotlightValue(true, false)).toBe(true);
+ });
+
+ it('returns true when env var is undefined', () => {
+ expect(resolveSpotlightValue(true, undefined)).toBe(true);
+ });
+ });
+
+ describe('Case 5: Config undefined - USE ENV VAR VALUE', () => {
+ it('returns env var URL', () => {
+ expect(resolveSpotlightValue(undefined, 'http://localhost:8969/stream')).toBe('http://localhost:8969/stream');
+ });
+
+ it('returns env var true', () => {
+ expect(resolveSpotlightValue(undefined, true)).toBe(true);
+ });
+
+ it('returns env var false', () => {
+ expect(resolveSpotlightValue(undefined, false)).toBe(false);
+ });
+
+ it('returns undefined when env var is undefined', () => {
+ expect(resolveSpotlightValue(undefined, undefined)).toBe(undefined);
+ });
+ });
+});
diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts
index d7a2478987ae..ad635002aad5 100644
--- a/packages/nextjs/src/client/index.ts
+++ b/packages/nextjs/src/client/index.ts
@@ -2,7 +2,15 @@
// can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703
/* eslint-disable import/export */
import type { Client, EventProcessor, Integration } from '@sentry/core';
-import { addEventProcessor, applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core';
+import {
+ addEventProcessor,
+ applySdkMetadata,
+ consoleSandbox,
+ getGlobalScope,
+ GLOBAL_OBJ,
+ parseSpotlightEnvValue,
+ resolveSpotlightValue,
+} from '@sentry/core';
import type { BrowserOptions } from '@sentry/react';
import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react';
import { DEBUG_BUILD } from '../common/debug-build';
@@ -32,6 +40,7 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryBasePath?: string;
_sentryRelease?: string;
_experimentalThirdPartyOriginStackFrames?: string;
+ _sentrySpotlight?: string; // For turbopack fallback
};
// Treeshakable guard to remove all code related to tracing
@@ -64,11 +73,20 @@ export function init(options: BrowserOptions): Client | undefined {
removeIsrSsgTraceMetaTags();
}
+ // Read NEXT_PUBLIC_SENTRY_SPOTLIGHT (set by spotlight run, works with both bundlers)
+ // OR fallback to SENTRY_SPOTLIGHT (webpack: process.env, turbopack: globalThis)
+ const spotlightEnvRaw =
+ process.env.NEXT_PUBLIC_SENTRY_SPOTLIGHT ||
+ process.env.SENTRY_SPOTLIGHT ||
+ globalWithInjectedValues._sentrySpotlight;
+ const spotlightEnvValue = parseSpotlightEnvValue(spotlightEnvRaw);
+
const opts = {
environment: getVercelEnv(true) || process.env.NODE_ENV,
defaultIntegrations: getDefaultIntegrations(options),
release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease,
...options,
+ spotlight: resolveSpotlightValue(options.spotlight, spotlightEnvValue),
} satisfies BrowserOptions;
applyTunnelRouteOption(opts);
diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts
index 2cf96b5f5ad7..7be21cc5ac9c 100644
--- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts
+++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts
@@ -28,6 +28,11 @@ export function generateValueInjectionRules({
clientValues._sentryRouteManifest = JSON.stringify(routeManifest);
}
+ // Inject SENTRY_SPOTLIGHT for client (fallback for manual setup without NEXT_PUBLIC_ prefix)
+ if (process.env.SENTRY_SPOTLIGHT) {
+ clientValues._sentrySpotlight = process.env.SENTRY_SPOTLIGHT;
+ }
+
// Inject tunnel route path for both client and server
if (tunnelPath) {
isomorphicValues._sentryRewritesTunnelPath = tunnelPath;
diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts
index 7ae5b6859330..04483d9dc911 100644
--- a/packages/nextjs/src/config/webpack.ts
+++ b/packages/nextjs/src/config/webpack.ts
@@ -440,6 +440,15 @@ export function constructWebpackConfigFunction({
}),
);
+ // Inject SENTRY_SPOTLIGHT env var for client bundles (fallback for manual setup without NEXT_PUBLIC_ prefix)
+ if (!buildContext.isServer) {
+ newConfig.plugins.push(
+ new buildContext.webpack.DefinePlugin({
+ 'process.env.SENTRY_SPOTLIGHT': JSON.stringify(process.env.SENTRY_SPOTLIGHT || ''),
+ }),
+ );
+ }
+
return newConfig;
};
}
diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts
index 1f0fd8835340..1ee436302f18 100644
--- a/packages/node-core/src/sdk/index.ts
+++ b/packages/node-core/src/sdk/index.ts
@@ -4,6 +4,7 @@ import {
consoleIntegration,
consoleSandbox,
debug,
+ envToBool,
functionToStringIntegration,
getCurrentScope,
getIntegrationsToSetup,
@@ -11,8 +12,10 @@ import {
hasSpansEnabled,
inboundFiltersIntegration,
linkedErrorsIntegration,
+ parseSpotlightEnvValue,
propagationContextFromHeaders,
requestDataIntegration,
+ resolveSpotlightValue,
stackParserFromStackParserOptions,
} from '@sentry/core';
import {
@@ -37,7 +40,6 @@ import { systemErrorIntegration } from '../integrations/systemError';
import { makeNodeTransport } from '../transports';
import type { NodeClientOptions, NodeOptions } from '../types';
import { isCjs } from '../utils/detection';
-import { envToBool } from '../utils/envToBool';
import { defaultStackParser, getSentryRelease } from './api';
import { NodeClient } from './client';
import { initializeEsmLoader } from './esmLoader';
@@ -193,21 +195,8 @@ function getClientOptions(
const release = getRelease(options.release);
// Parse spotlight configuration with proper precedence per spec
- let spotlight: boolean | string | undefined;
- if (options.spotlight === false) {
- spotlight = false;
- } else if (typeof options.spotlight === 'string') {
- spotlight = options.spotlight;
- } else {
- // options.spotlight is true or undefined
- const envBool = envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true });
- const envUrl = envBool === null && process.env.SENTRY_SPOTLIGHT ? process.env.SENTRY_SPOTLIGHT : undefined;
-
- spotlight =
- options.spotlight === true
- ? (envUrl ?? true) // true: use env URL if present, otherwise true
- : (envBool ?? envUrl); // undefined: use env var (bool or URL)
- }
+ const spotlightEnvValue = parseSpotlightEnvValue(process.env.SENTRY_SPOTLIGHT);
+ const spotlight = resolveSpotlightValue(options.spotlight, spotlightEnvValue);
const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate);
diff --git a/packages/node-core/src/utils/envToBool.ts b/packages/node-core/src/utils/envToBool.ts
index f78d05bb380c..b52da2d5ab3d 100644
--- a/packages/node-core/src/utils/envToBool.ts
+++ b/packages/node-core/src/utils/envToBool.ts
@@ -1,38 +1,3 @@
-export const FALSY_ENV_VALUES = new Set(['false', 'f', 'n', 'no', 'off', '0']);
-export const TRUTHY_ENV_VALUES = new Set(['true', 't', 'y', 'yes', 'on', '1']);
-
-export type StrictBoolCast = {
- strict: true;
-};
-
-export type LooseBoolCast = {
- strict?: false;
-};
-
-export type BoolCastOptions = StrictBoolCast | LooseBoolCast;
-
-export function envToBool(value: unknown, options?: LooseBoolCast): boolean;
-export function envToBool(value: unknown, options: StrictBoolCast): boolean | null;
-export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null;
-/**
- * A helper function which casts an ENV variable value to `true` or `false` using the constants defined above.
- * In strict mode, it may return `null` if the value doesn't match any of the predefined values.
- *
- * @param value The value of the env variable
- * @param options -- Only has `strict` key for now, which requires a strict match for `true` in TRUTHY_ENV_VALUES
- * @returns true/false if the lowercase value matches the predefined values above. If not, null in strict mode,
- * and Boolean(value) in loose mode.
- */
-export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null {
- const normalized = String(value).toLowerCase();
-
- if (FALSY_ENV_VALUES.has(normalized)) {
- return false;
- }
-
- if (TRUTHY_ENV_VALUES.has(normalized)) {
- return true;
- }
-
- return options?.strict ? null : Boolean(value);
-}
+// Re-export from core for backwards compatibility
+export { envToBool, FALSY_ENV_VALUES, TRUTHY_ENV_VALUES } from '@sentry/core';
+export type { BoolCastOptions, StrictBoolCast, LooseBoolCast } from '@sentry/core';
diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts
index 5db856dae689..64837c827cb6 100644
--- a/packages/nuxt/src/client/sdk.ts
+++ b/packages/nuxt/src/client/sdk.ts
@@ -1,18 +1,42 @@
import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowser } from '@sentry/browser';
import type { Client } from '@sentry/core';
-import { applySdkMetadata } from '@sentry/core';
+import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
import type { SentryNuxtClientOptions } from '../common/types';
+// Type for spotlight-related env vars injected by Vite
+interface SpotlightEnv {
+ VITE_SENTRY_SPOTLIGHT?: string;
+ SENTRY_SPOTLIGHT?: string;
+}
+
+// Access import.meta.env in a way that works with TypeScript
+// Vite replaces this at build time
+function getSpotlightEnv(): SpotlightEnv {
+ try {
+ // @ts-expect-error - import.meta.env is injected by Vite
+ return typeof import.meta !== 'undefined' && import.meta.env ? (import.meta.env as SpotlightEnv) : {};
+ } catch {
+ return {};
+ }
+}
+
/**
* Initializes the client-side of the Nuxt SDK
*
* @param options Configuration options for the SDK.
*/
export function init(options: SentryNuxtClientOptions): Client | undefined {
+ // Read VITE_SENTRY_SPOTLIGHT (set by spotlight run, auto-exposed by Vite)
+ // OR fallback to SENTRY_SPOTLIGHT (injected by our module)
+ const spotlightEnv = getSpotlightEnv();
+ const spotlightEnvRaw = spotlightEnv.VITE_SENTRY_SPOTLIGHT || spotlightEnv.SENTRY_SPOTLIGHT;
+ const spotlightEnvValue = parseSpotlightEnvValue(spotlightEnvRaw);
+
const sentryOptions = {
/* BrowserTracing is added later with the Nuxt client plugin */
defaultIntegrations: [...getBrowserDefaultIntegrations(options)],
...options,
+ spotlight: resolveSpotlightValue(options.spotlight, spotlightEnvValue),
};
applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'vue']);
diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts
index 3656eac56e63..7b4e473f3d93 100644
--- a/packages/nuxt/src/module.ts
+++ b/packages/nuxt/src/module.ts
@@ -107,6 +107,17 @@ export default defineNuxtModule({
addOTelCommonJSImportAlias(nuxt);
+ // Inject SENTRY_SPOTLIGHT env var for client bundles (fallback for manual setup without VITE_ prefix)
+ // Only add the hook if we have a value to inject
+ if (process.env.SENTRY_SPOTLIGHT) {
+ nuxt.hook('vite:extendConfig', (viteConfig, env) => {
+ if (env.isClient) {
+ viteConfig.define = viteConfig.define || {};
+ viteConfig.define['import.meta.env.SENTRY_SPOTLIGHT'] = JSON.stringify(process.env.SENTRY_SPOTLIGHT);
+ }
+ });
+ }
+
const pagesDataTemplate = addTemplate({
filename: 'sentry--nuxt-pages-data.mjs',
// Initial empty array (later filled in pages:extend hook)
diff --git a/packages/react/src/sdk.ts b/packages/react/src/sdk.ts
index 844bc30f1785..72f7d296529f 100644
--- a/packages/react/src/sdk.ts
+++ b/packages/react/src/sdk.ts
@@ -1,9 +1,16 @@
import type { BrowserOptions } from '@sentry/browser';
import { init as browserInit, setContext } from '@sentry/browser';
import type { Client } from '@sentry/core';
-import { applySdkMetadata } from '@sentry/core';
+import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
import { version } from 'react';
+// Build-time placeholder - Rollup replaces per output format
+// ESM: import.meta.env.VITE_SENTRY_SPOTLIGHT (zero-config for Vite)
+// CJS: undefined
+// Note: We don't use optional chaining (?.) because Vite only does static replacement
+// on exact matches of import.meta.env.VITE_*
+declare const __VITE_SPOTLIGHT_ENV__: string | undefined;
+
/**
* Inits the React SDK
*/
@@ -12,6 +19,31 @@ export function init(options: BrowserOptions): Client | undefined {
...options,
};
+ // Check for spotlight env vars:
+ // 1. process.env.SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 2. process.env.VITE_SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 3. import.meta.env.VITE_SENTRY_SPOTLIGHT (ESM only, zero-config for Vite!)
+ //
+ // For option 3, Rollup replaces __VITE_SPOTLIGHT_ENV__ with import.meta.env.VITE_SENTRY_SPOTLIGHT
+ // Then Vite replaces that with the actual value or undefined at build time.
+ // We wrap in try-catch because in non-Vite ESM environments (like Next.js), import.meta.env may not exist.
+ let viteSpotlightEnv: string | undefined;
+ try {
+ viteSpotlightEnv = typeof __VITE_SPOTLIGHT_ENV__ !== 'undefined' ? __VITE_SPOTLIGHT_ENV__ : undefined;
+ } catch {
+ // import.meta.env doesn't exist in this environment (e.g., Next.js with webpack)
+ }
+
+ const spotlightEnvRaw =
+ (typeof process !== 'undefined' && (process.env?.SENTRY_SPOTLIGHT || process.env?.VITE_SENTRY_SPOTLIGHT)) ||
+ viteSpotlightEnv ||
+ undefined;
+
+ if (spotlightEnvRaw) {
+ const envValue = parseSpotlightEnvValue(spotlightEnvRaw);
+ opts.spotlight = resolveSpotlightValue(options.spotlight, envValue);
+ }
+
applySdkMetadata(opts, 'react');
setContext('react', { version });
return browserInit(opts);
diff --git a/packages/solid/src/sdk.ts b/packages/solid/src/sdk.ts
index 9968b0ace8f0..9f478a02c4e9 100644
--- a/packages/solid/src/sdk.ts
+++ b/packages/solid/src/sdk.ts
@@ -1,7 +1,14 @@
import type { BrowserOptions } from '@sentry/browser';
import { init as browserInit } from '@sentry/browser';
import type { Client } from '@sentry/core';
-import { applySdkMetadata } from '@sentry/core';
+import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
+
+// Build-time placeholder - Rollup replaces per output format
+// ESM: import.meta.env.VITE_SENTRY_SPOTLIGHT (zero-config for Vite)
+// CJS: undefined
+// Note: We don't use optional chaining (?.) because Vite only does static replacement
+// on exact matches of import.meta.env.VITE_*
+declare const __VITE_SPOTLIGHT_ENV__: string | undefined;
/**
* Initializes the Solid SDK
@@ -11,6 +18,31 @@ export function init(options: BrowserOptions): Client | undefined {
...options,
};
+ // Check for spotlight env vars:
+ // 1. process.env.SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 2. process.env.VITE_SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 3. import.meta.env.VITE_SENTRY_SPOTLIGHT (ESM only, zero-config for Vite!)
+ //
+ // For option 3, Rollup replaces __VITE_SPOTLIGHT_ENV__ with import.meta.env.VITE_SENTRY_SPOTLIGHT
+ // Then Vite replaces that with the actual value or undefined at build time.
+ // We wrap in try-catch because in non-Vite ESM environments (like SolidStart with other bundlers), import.meta.env may not exist.
+ let viteSpotlightEnv: string | undefined;
+ try {
+ viteSpotlightEnv = typeof __VITE_SPOTLIGHT_ENV__ !== 'undefined' ? __VITE_SPOTLIGHT_ENV__ : undefined;
+ } catch {
+ // import.meta.env doesn't exist in this environment
+ }
+
+ const spotlightEnvRaw =
+ (typeof process !== 'undefined' && (process.env?.SENTRY_SPOTLIGHT || process.env?.VITE_SENTRY_SPOTLIGHT)) ||
+ viteSpotlightEnv ||
+ undefined;
+
+ if (spotlightEnvRaw) {
+ const envValue = parseSpotlightEnvValue(spotlightEnvRaw);
+ opts.spotlight = resolveSpotlightValue(options.spotlight, envValue);
+ }
+
applySdkMetadata(opts, 'solid');
return browserInit(opts);
diff --git a/packages/svelte/src/sdk.ts b/packages/svelte/src/sdk.ts
index b46a09bfdfa9..c9ff5662d6dc 100644
--- a/packages/svelte/src/sdk.ts
+++ b/packages/svelte/src/sdk.ts
@@ -1,7 +1,15 @@
import type { BrowserOptions } from '@sentry/browser';
import { init as browserInit } from '@sentry/browser';
import type { Client } from '@sentry/core';
-import { applySdkMetadata } from '@sentry/core';
+import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
+
+// Build-time placeholder - Rollup replaces per output format
+// ESM: import.meta.env.VITE_SENTRY_SPOTLIGHT (zero-config for Vite)
+// CJS: undefined
+// Note: We don't use optional chaining (?.) because Vite only does static replacement
+// on exact matches of import.meta.env.VITE_*
+declare const __VITE_SPOTLIGHT_ENV__: string | undefined;
+
/**
* Inits the Svelte SDK
*/
@@ -10,6 +18,31 @@ export function init(options: BrowserOptions): Client | undefined {
...options,
};
+ // Check for spotlight env vars:
+ // 1. process.env.SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 2. process.env.VITE_SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 3. import.meta.env.VITE_SENTRY_SPOTLIGHT (ESM only, zero-config for Vite!)
+ //
+ // For option 3, Rollup replaces __VITE_SPOTLIGHT_ENV__ with import.meta.env.VITE_SENTRY_SPOTLIGHT
+ // Then Vite replaces that with the actual value or undefined at build time.
+ // We wrap in try-catch because in non-Vite ESM environments (like SvelteKit with other bundlers), import.meta.env may not exist.
+ let viteSpotlightEnv: string | undefined;
+ try {
+ viteSpotlightEnv = typeof __VITE_SPOTLIGHT_ENV__ !== 'undefined' ? __VITE_SPOTLIGHT_ENV__ : undefined;
+ } catch {
+ // import.meta.env doesn't exist in this environment
+ }
+
+ const spotlightEnvRaw =
+ (typeof process !== 'undefined' && (process.env?.SENTRY_SPOTLIGHT || process.env?.VITE_SENTRY_SPOTLIGHT)) ||
+ viteSpotlightEnv ||
+ undefined;
+
+ if (spotlightEnvRaw) {
+ const envValue = parseSpotlightEnvValue(spotlightEnvRaw);
+ opts.spotlight = resolveSpotlightValue(options.spotlight, envValue);
+ }
+
applySdkMetadata(opts, 'svelte');
return browserInit(opts);
diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts
index a6294ad25977..d964b83d3539 100644
--- a/packages/sveltekit/src/client/sdk.ts
+++ b/packages/sveltekit/src/client/sdk.ts
@@ -1,9 +1,26 @@
import type { Client, Integration } from '@sentry/core';
-import { applySdkMetadata } from '@sentry/core';
+import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
import type { BrowserOptions } from '@sentry/svelte';
import { getDefaultIntegrations as getDefaultSvelteIntegrations, init as initSvelteSdk, WINDOW } from '@sentry/svelte';
import { browserTracingIntegration as svelteKitBrowserTracingIntegration } from './browserTracingIntegration';
+// Type for spotlight-related env vars injected by Vite
+interface SpotlightEnv {
+ PUBLIC_SENTRY_SPOTLIGHT?: string;
+ SENTRY_SPOTLIGHT?: string;
+}
+
+// Access import.meta.env in a way that works with TypeScript
+// Vite replaces this at build time
+function getSpotlightEnv(): SpotlightEnv {
+ try {
+ // @ts-expect-error - import.meta.env is injected by Vite
+ return typeof import.meta !== 'undefined' && import.meta.env ? (import.meta.env as SpotlightEnv) : {};
+ } catch {
+ return {};
+ }
+}
+
type WindowWithSentryFetchProxy = typeof WINDOW & {
_sentryFetchProxy?: typeof fetch;
};
@@ -17,9 +34,16 @@ declare const __SENTRY_TRACING__: boolean;
* @param options Configuration options for the SDK.
*/
export function init(options: BrowserOptions): Client | undefined {
+ // Read PUBLIC_SENTRY_SPOTLIGHT (set by spotlight run, SvelteKit uses PUBLIC_ prefix)
+ // OR fallback to SENTRY_SPOTLIGHT (injected by our vite plugin)
+ const spotlightEnv = getSpotlightEnv();
+ const spotlightEnvRaw = spotlightEnv.PUBLIC_SENTRY_SPOTLIGHT || spotlightEnv.SENTRY_SPOTLIGHT;
+ const spotlightEnvValue = parseSpotlightEnvValue(spotlightEnvRaw);
+
const opts = {
defaultIntegrations: getDefaultIntegrations(options),
...options,
+ spotlight: resolveSpotlightValue(options.spotlight, spotlightEnvValue),
};
applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'svelte']);
diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts
index 56493e071a55..5cbcad3d8453 100644
--- a/packages/sveltekit/src/vite/sentryVitePlugins.ts
+++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts
@@ -13,6 +13,26 @@ const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = {
debug: false,
};
+/**
+ * Creates a Vite plugin that injects SENTRY_SPOTLIGHT env var for client bundles.
+ * This enables zero-config Spotlight support when using `spotlight run`.
+ */
+function makeSpotlightDefinePlugin(): Plugin {
+ return {
+ name: 'sentry-sveltekit-spotlight-define',
+ config() {
+ // Read PUBLIC_SENTRY_SPOTLIGHT (set by spotlight run, SvelteKit uses PUBLIC_ prefix)
+ // OR fallback to SENTRY_SPOTLIGHT (manual setup)
+ const spotlightValue = process.env.PUBLIC_SENTRY_SPOTLIGHT || process.env.SENTRY_SPOTLIGHT || '';
+ return {
+ define: {
+ 'import.meta.env.SENTRY_SPOTLIGHT': JSON.stringify(spotlightValue),
+ },
+ };
+ },
+ };
+}
+
/**
* Vite Plugins for the Sentry SvelteKit SDK, taking care of creating
* Sentry releases and uploading source maps to Sentry.
@@ -31,6 +51,11 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
const sentryPlugins: Plugin[] = [];
+ // Add spotlight plugin for zero-config Spotlight support (only if env var is set)
+ if (process.env.PUBLIC_SENTRY_SPOTLIGHT || process.env.SENTRY_SPOTLIGHT) {
+ sentryPlugins.push(makeSpotlightDefinePlugin());
+ }
+
if (mergedOptions.autoInstrument) {
// TODO: Once tracing is promoted stable, we need to adjust this check!
const kitTracingEnabled = !!svelteConfig.kit?.experimental?.tracing?.server;
diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts
index 689a17dacbc4..c2a770cfe479 100644
--- a/packages/vue/src/sdk.ts
+++ b/packages/vue/src/sdk.ts
@@ -1,9 +1,16 @@
import { getDefaultIntegrations, init as browserInit } from '@sentry/browser';
import type { Client } from '@sentry/core';
-import { applySdkMetadata } from '@sentry/core';
+import { applySdkMetadata, parseSpotlightEnvValue, resolveSpotlightValue } from '@sentry/core';
import { vueIntegration } from './integration';
import type { Options } from './types';
+// Build-time placeholder - Rollup replaces per output format
+// ESM: import.meta.env.VITE_SENTRY_SPOTLIGHT (zero-config for Vite)
+// CJS: undefined
+// Note: We don't use optional chaining (?.) because Vite only does static replacement
+// on exact matches of import.meta.env.VITE_*
+declare const __VITE_SPOTLIGHT_ENV__: string | undefined;
+
/**
* Inits the Vue SDK
*/
@@ -13,6 +20,31 @@ export function init(options: Partial> = {}): Cl
...options,
};
+ // Check for spotlight env vars:
+ // 1. process.env.SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 2. process.env.VITE_SENTRY_SPOTLIGHT (all bundlers, requires config)
+ // 3. import.meta.env.VITE_SENTRY_SPOTLIGHT (ESM only, zero-config for Vite!)
+ //
+ // For option 3, Rollup replaces __VITE_SPOTLIGHT_ENV__ with import.meta.env.VITE_SENTRY_SPOTLIGHT
+ // Then Vite replaces that with the actual value or undefined at build time.
+ // We wrap in try-catch because in non-Vite ESM environments (like Nuxt), import.meta.env may not exist.
+ let viteSpotlightEnv: string | undefined;
+ try {
+ viteSpotlightEnv = typeof __VITE_SPOTLIGHT_ENV__ !== 'undefined' ? __VITE_SPOTLIGHT_ENV__ : undefined;
+ } catch {
+ // import.meta.env doesn't exist in this environment
+ }
+
+ const spotlightEnvRaw =
+ (typeof process !== 'undefined' && (process.env?.SENTRY_SPOTLIGHT || process.env?.VITE_SENTRY_SPOTLIGHT)) ||
+ viteSpotlightEnv ||
+ undefined;
+
+ if (spotlightEnvRaw) {
+ const envValue = parseSpotlightEnvValue(spotlightEnvRaw);
+ opts.spotlight = resolveSpotlightValue(options.spotlight, envValue);
+ }
+
applySdkMetadata(opts, 'vue');
return browserInit(opts);