Skip to content

Commit 1edca18

Browse files
committed
test: Add E2E tests for SENTRY_SPOTLIGHT env var support
- Add startSpotlightProxyServer to test-utils for capturing Spotlight events - Add waitForSpotlightError and waitForSpotlightTransaction helpers - Create browser-spotlight test app that validates env var parsing - Test verifies events are sent to both tunnel AND Spotlight sidecar
1 parent c71233e commit 1edca18

File tree

11 files changed

+308
-0
lines changed

11 files changed

+308
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as path from 'path';
2+
import * as url from 'url';
3+
import HtmlWebpackPlugin from 'html-webpack-plugin';
4+
import TerserPlugin from 'terser-webpack-plugin';
5+
import webpack from 'webpack';
6+
7+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
8+
9+
webpack(
10+
{
11+
entry: path.join(__dirname, 'src/index.js'),
12+
output: {
13+
path: path.join(__dirname, 'build'),
14+
filename: 'app.js',
15+
},
16+
optimization: {
17+
minimize: true,
18+
minimizer: [new TerserPlugin()],
19+
},
20+
plugins: [
21+
new webpack.DefinePlugin({
22+
// E2E_TEST_DSN can be passed from environment or defaults to empty
23+
'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN || ''),
24+
// SENTRY_SPOTLIGHT is hardcoded to point to the Spotlight proxy server on port 3032
25+
// This tests that the SDK correctly parses the env var and sends events to Spotlight
26+
'process.env.SENTRY_SPOTLIGHT': JSON.stringify('http://localhost:3032/stream'),
27+
}),
28+
new HtmlWebpackPlugin({
29+
template: path.join(__dirname, 'public/index.html'),
30+
}),
31+
],
32+
mode: 'production',
33+
},
34+
(err, stats) => {
35+
if (err) {
36+
console.error(err.stack || err);
37+
if (err.details) {
38+
console.error(err.details);
39+
}
40+
return;
41+
}
42+
43+
const info = stats.toJson();
44+
45+
if (stats.hasErrors()) {
46+
console.error(info.errors);
47+
process.exit(1);
48+
}
49+
50+
if (stats.hasWarnings()) {
51+
console.warn(info.warnings);
52+
process.exit(1);
53+
}
54+
},
55+
);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "browser-spotlight-test-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"dependencies": {
6+
"@sentry/react": "latest || *",
7+
"@types/node": "^18.19.1",
8+
"react": "^18.2.0",
9+
"react-dom": "^18.2.0",
10+
"typescript": "~5.0.0"
11+
},
12+
"scripts": {
13+
"start": "serve -s build",
14+
"build": "node build.mjs",
15+
"test": "playwright test",
16+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
17+
"test:build": "SENTRY_SPOTLIGHT=http://localhost:3032/stream pnpm install && SENTRY_SPOTLIGHT=http://localhost:3032/stream pnpm build",
18+
"test:assert": "pnpm test"
19+
},
20+
"devDependencies": {
21+
"@playwright/test": "~1.56.0",
22+
"@sentry-internal/test-utils": "link:../../../test-utils",
23+
"webpack": "^5.91.0",
24+
"serve": "14.0.1",
25+
"terser-webpack-plugin": "^5.3.10",
26+
"html-webpack-plugin": "^5.6.0"
27+
},
28+
"volta": {
29+
"extends": "../../package.json"
30+
}
31+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
});
6+
7+
// Add the Spotlight proxy server as an additional webServer
8+
// This runs alongside the main event proxy and app server
9+
config.webServer.push({
10+
command: 'node start-spotlight-proxy.mjs',
11+
port: 3032,
12+
stdout: 'pipe',
13+
stderr: 'pipe',
14+
});
15+
16+
export default config;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Browser Spotlight Test App</title>
7+
</head>
8+
<body>
9+
<div id="app">
10+
<h1>Sentry Spotlight E2E Test</h1>
11+
<p>This app tests that SENTRY_SPOTLIGHT env var enables Spotlight integration.</p>
12+
</div>
13+
14+
<input type="button" value="Capture Exception" id="exception-button" />
15+
16+
<!-- The script tags for the bundled JavaScript files will be injected here by HtmlWebpackPlugin in build.mjs-->
17+
</body>
18+
</html>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Sentry from '@sentry/react';
2+
3+
// Initialize Sentry with DSN and tunnel for regular event capture
4+
// SENTRY_SPOTLIGHT is injected via webpack's EnvironmentPlugin at build time
5+
// The @sentry/react SDK automatically parses SENTRY_SPOTLIGHT and enables Spotlight
6+
Sentry.init({
7+
dsn: process.env.E2E_TEST_DSN,
8+
integrations: [Sentry.browserTracingIntegration()],
9+
tracesSampleRate: 1.0,
10+
release: 'e2e-test',
11+
environment: 'qa',
12+
// Use tunnel to capture events at our proxy server
13+
tunnel: 'http://localhost:3031',
14+
// NOTE: We intentionally do NOT set `spotlight` here!
15+
// The SDK should automatically parse SENTRY_SPOTLIGHT env var
16+
// and enable Spotlight with the URL from the env var
17+
});
18+
19+
document.getElementById('exception-button').addEventListener('click', () => {
20+
throw new Error('Spotlight test error!');
21+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
// Start the main event proxy server that captures events via tunnel
4+
startEventProxyServer({
5+
port: 3031,
6+
proxyServerName: 'browser-spotlight',
7+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { startSpotlightProxyServer } from '@sentry-internal/test-utils';
2+
3+
// Start a Spotlight proxy server that captures events sent to /stream
4+
// This simulates the Spotlight sidecar and allows us to verify events arrive
5+
startSpotlightProxyServer({
6+
port: 3032,
7+
proxyServerName: 'browser-spotlight-sidecar',
8+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForSpotlightError, waitForSpotlightTransaction } from '@sentry-internal/test-utils';
3+
4+
/**
5+
* Test that SENTRY_SPOTLIGHT environment variable enables Spotlight integration.
6+
*
7+
* This test verifies that:
8+
* 1. The SDK correctly parses SENTRY_SPOTLIGHT env var at build time (via webpack DefinePlugin)
9+
* 2. The SDK resolves the spotlight option from the env var
10+
* 3. Events are sent to both the tunnel AND the Spotlight sidecar URL
11+
* 4. The Spotlight sidecar receives the events at the /stream endpoint
12+
*
13+
* Test setup:
14+
* - SENTRY_SPOTLIGHT is set to 'http://localhost:3032/stream' at build time
15+
* - tunnel is set to 'http://localhost:3031' for regular event capture
16+
* - A Spotlight proxy server runs on port 3032 to capture Spotlight events
17+
* - A regular event proxy server runs on port 3031 to capture tunnel events
18+
*/
19+
test('SENTRY_SPOTLIGHT env var enables Spotlight and events are sent to sidecar', async ({ page }) => {
20+
// Wait for the error event to arrive at the Spotlight sidecar (port 3032)
21+
const spotlightErrorPromise = waitForSpotlightError('browser-spotlight-sidecar', event => {
22+
return !event.type && event.exception?.values?.[0]?.value === 'Spotlight test error!';
23+
});
24+
25+
// Also wait for the error to arrive at the regular tunnel (port 3031)
26+
// This verifies that Spotlight doesn't interfere with normal event sending
27+
const tunnelErrorPromise = waitForError('browser-spotlight', event => {
28+
return !event.type && event.exception?.values?.[0]?.value === 'Spotlight test error!';
29+
});
30+
31+
await page.goto('/');
32+
33+
const exceptionButton = page.locator('id=exception-button');
34+
await exceptionButton.click();
35+
36+
// Both promises should resolve - the error should be sent to BOTH destinations
37+
const [spotlightError, tunnelError] = await Promise.all([spotlightErrorPromise, tunnelErrorPromise]);
38+
39+
// Verify the Spotlight sidecar received the error
40+
expect(spotlightError.exception?.values).toHaveLength(1);
41+
expect(spotlightError.exception?.values?.[0]?.value).toBe('Spotlight test error!');
42+
expect(spotlightError.exception?.values?.[0]?.type).toBe('Error');
43+
44+
// Verify the tunnel also received the error (normal Sentry flow still works)
45+
expect(tunnelError.exception?.values).toHaveLength(1);
46+
expect(tunnelError.exception?.values?.[0]?.value).toBe('Spotlight test error!');
47+
48+
// Both events should have the same trace context
49+
expect(spotlightError.contexts?.trace?.trace_id).toBe(tunnelError.contexts?.trace?.trace_id);
50+
});
51+
52+
/**
53+
* Test that Spotlight receives transaction events as well.
54+
*/
55+
test('SENTRY_SPOTLIGHT sends transactions to sidecar', async ({ page }) => {
56+
// Wait for a pageload transaction to arrive at the Spotlight sidecar
57+
const spotlightTransactionPromise = waitForSpotlightTransaction('browser-spotlight-sidecar', event => {
58+
return event.type === 'transaction' && event.contexts?.trace?.op === 'pageload';
59+
});
60+
61+
await page.goto('/');
62+
63+
const spotlightTransaction = await spotlightTransactionPromise;
64+
65+
// Verify the Spotlight sidecar received the transaction
66+
expect(spotlightTransaction.type).toBe('transaction');
67+
expect(spotlightTransaction.contexts?.trace?.op).toBe('pageload');
68+
expect(spotlightTransaction.transaction).toBe('/');
69+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"types": ["node"],
4+
"esModuleInterop": true,
5+
"lib": ["DOM", "ES2020"],
6+
"strict": true,
7+
"outDir": "dist"
8+
},
9+
"include": ["*.ts", "src/**/*.ts", "tests/**/*.ts"]
10+
}

dev-packages/test-utils/src/event-proxy-server.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface SentryRequestCallbackData {
2525
envelope: Envelope;
2626
rawProxyRequestBody: string;
2727
rawProxyRequestHeaders: Record<string, string | string[] | undefined>;
28+
rawProxyRequestUrl?: string;
2829
rawSentryResponseBody: string;
2930
sentryResponseStatusCode?: number;
3031
}
@@ -184,6 +185,7 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P
184185
envelope: parseEnvelope(proxyRequestBody),
185186
rawProxyRequestBody: proxyRequestBody,
186187
rawProxyRequestHeaders: proxyRequest.headers,
188+
rawProxyRequestUrl: proxyRequest.url,
187189
rawSentryResponseBody: '',
188190
sentryResponseStatusCode: 200,
189191
};
@@ -391,6 +393,74 @@ export function waitForTransaction(
391393
});
392394
}
393395

396+
interface SpotlightProxyServerOptions {
397+
/** Port to start the spotlight proxy server at. */
398+
port: number;
399+
/** The name for the proxy server used for referencing it with listener functions */
400+
proxyServerName: string;
401+
}
402+
403+
/**
404+
* Starts a proxy server that acts like a Spotlight sidecar.
405+
* It accepts envelopes at /stream and allows tests to wait for them.
406+
* Point the SDK's `spotlight` option or `SENTRY_SPOTLIGHT` env var to this server.
407+
*/
408+
export async function startSpotlightProxyServer(options: SpotlightProxyServerOptions): Promise<void> {
409+
await startProxyServer(options, async (eventCallbackListeners, proxyRequest, proxyRequestBody, eventBuffer) => {
410+
// Spotlight sends to /stream endpoint
411+
const url = proxyRequest.url || '';
412+
if (!url.includes('/stream')) {
413+
// Return 404 for non-spotlight requests
414+
return [404, 'Not Found', {}];
415+
}
416+
417+
const data: SentryRequestCallbackData = {
418+
envelope: parseEnvelope(proxyRequestBody),
419+
rawProxyRequestBody: proxyRequestBody,
420+
rawProxyRequestHeaders: proxyRequest.headers,
421+
rawProxyRequestUrl: proxyRequest.url,
422+
rawSentryResponseBody: '',
423+
sentryResponseStatusCode: 200,
424+
};
425+
426+
const dataString = Buffer.from(JSON.stringify(data)).toString('base64');
427+
428+
eventBuffer.push({ data: dataString, timestamp: getNanosecondTimestamp() });
429+
430+
eventCallbackListeners.forEach(listener => {
431+
listener(dataString);
432+
});
433+
434+
return [
435+
200,
436+
'{}',
437+
{
438+
'Access-Control-Allow-Origin': '*',
439+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
440+
'Access-Control-Allow-Headers': 'Content-Type',
441+
},
442+
];
443+
});
444+
}
445+
446+
/** Wait for an error to be sent to Spotlight. */
447+
export function waitForSpotlightError(
448+
proxyServerName: string,
449+
callback: (errorEvent: Event) => Promise<boolean> | boolean,
450+
): Promise<Event> {
451+
// Reuse the same logic as waitForError - just uses a different proxy server name
452+
return waitForError(proxyServerName, callback);
453+
}
454+
455+
/** Wait for a transaction to be sent to Spotlight. */
456+
export function waitForSpotlightTransaction(
457+
proxyServerName: string,
458+
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
459+
): Promise<Event> {
460+
// Reuse the same logic as waitForTransaction - just uses a different proxy server name
461+
return waitForTransaction(proxyServerName, callback);
462+
}
463+
394464
const TEMP_FILE_PREFIX = 'event-proxy-server-';
395465

396466
async function registerCallbackServerPort(serverName: string, port: string): Promise<void> {

0 commit comments

Comments
 (0)