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);