Skip to content

Commit fe90ccd

Browse files
feat(cloudflare): Add Spotlight integration for local dev event forwarding (#22490)
## Summary Add a fetch-based `spotlightIntegration` to `@sentry/cloudflare` that mirrors Sentry envelopes to a local Spotlight sidecar during development. Cloudflare Workers was the only server SDK without Spotlight support. ### What this enables - `spotlight: true` in `Sentry.init()` (or `withSentry`) forwards all envelopes (errors, transactions, logs, AI spans) to `http://localhost:8969/stream` - `SENTRY_SPOTLIGHT` wrangler env binding (boolean or custom URL) for zero-code-change enablement - Works with `sentry local serve` from the Sentry CLI for a complete local dev experience ### Changes - **New integration** `src/integrations/spotlight.ts` — uses `fetch` with `suppressTracing` (CF instruments outbound fetch), consumes response body (Workers requirement), disables after >3 failures - **`sdk.ts`** — wire integration in `init()` behind a runtime `if (options.spotlight)` guard (matches the node-core pattern) - **`options.ts`** — read `SENTRY_SPOTLIGHT` from CF env binding with the same precedence as node-core's `getSpotlightConfig` (user option > env boolean > env URL) - **`client.ts`** — add `spotlight?: boolean | string` to `BaseCloudflareOptions` (`CloudflareOptions extends Options`/`CoreOptions`, which does not include `ServerRuntimeOptions` where `spotlight` normally lives) - **`index.ts`** — export `spotlightIntegration` - **Tests** — full coverage for integration (10 tests) + options (9 new tests), all existing tests still pass ### Why fetch, not node:http Cloudflare Workers don't have `node:http`. The integration mirrors the browser SDK's fetch-based approach but adds `suppressTracing` (like Node) since CF's `fetchIntegration` instruments all outbound fetch, and drains the response body (Workers-specific requirement, same as the CF transport). ### Production behavior Spotlight forwarding only activates when `options.spotlight` is truthy, which is falsy in production by default — identical to how `@sentry/node`, `@sentry/bun`, and all other server SDKs ship the spotlight integration. Each forwarded envelope counts as a Worker subrequest (cap 50 free / 1000 paid), documented in the integration JSDoc; keep it disabled in production. --------- Co-authored-by: JPeer264 <jan.peer@sentry.io>
1 parent cf5243b commit fe90ccd

10 files changed

Lines changed: 461 additions & 35 deletions

File tree

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ module.exports = [
436436
// Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output
437437
{
438438
name: '@sentry/cloudflare (withSentry) - minified',
439-
path: 'packages/cloudflare/build/esm/index.js',
439+
path: 'packages/cloudflare/build/esm/prod/index.js',
440440
import: createImport('withSentry', 'instrumentDurableObjectWithSentry', 'instrumentWorkflowWithSentry'),
441441
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
442442
gzip: false,
@@ -456,7 +456,7 @@ module.exports = [
456456
// Cloudflare SDK (ESM) - uncompressed, unminified to match `wrangler deploy --dry-run` output
457457
{
458458
name: '@sentry/cloudflare (withSentry)',
459-
path: 'packages/cloudflare/build/esm/index.js',
459+
path: 'packages/cloudflare/build/esm/prod/index.js',
460460
import: createImport('withSentry', 'instrumentDurableObjectWithSentry', 'instrumentWorkflowWithSentry'),
461461
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
462462
gzip: false,

packages/cloudflare/package.json

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,50 +12,40 @@
1212
"files": [
1313
"/build"
1414
],
15-
"main": "build/cjs/index.js",
16-
"module": "build/esm/index.js",
15+
"main": "build/cjs/prod/index.js",
16+
"module": "build/esm/prod/index.js",
1717
"types": "build/types/index.d.ts",
1818
"exports": {
1919
"./package.json": "./package.json",
2020
".": {
21-
"import": {
22-
"types": "./build/types/index.d.ts",
23-
"default": "./build/esm/index.js"
21+
"types": "./build/types/index.d.ts",
22+
"development": {
23+
"import": "./build/esm/dev/index.js",
24+
"require": "./build/cjs/dev/index.js"
2425
},
25-
"require": {
26-
"types": "./build/types/index.d.ts",
27-
"default": "./build/cjs/index.js"
26+
"production": {
27+
"import": "./build/esm/prod/index.js",
28+
"require": "./build/cjs/prod/index.js"
29+
},
30+
"default": {
31+
"import": "./build/esm/prod/index.js",
32+
"require": "./build/cjs/prod/index.js"
2833
}
2934
},
3035
"./request": {
31-
"import": {
32-
"types": "./build/types/request.d.ts",
33-
"default": "./build/esm/request.js"
34-
},
35-
"require": {
36-
"types": "./build/types/request.d.ts",
37-
"default": "./build/cjs/request.js"
38-
}
36+
"types": "./build/types/request.d.ts",
37+
"import": "./build/esm/prod/request.js",
38+
"require": "./build/cjs/prod/request.js"
3939
},
4040
"./nodejs_compat": {
41-
"import": {
42-
"types": "./build/types/nodejs_compat/index.d.ts",
43-
"default": "./build/esm/nodejs_compat/index.js"
44-
},
45-
"require": {
46-
"types": "./build/types/nodejs_compat/index.d.ts",
47-
"default": "./build/cjs/nodejs_compat/index.js"
48-
}
41+
"types": "./build/types/nodejs_compat/index.d.ts",
42+
"import": "./build/esm/prod/nodejs_compat/index.js",
43+
"require": "./build/cjs/prod/nodejs_compat/index.js"
4944
},
5045
"./vite": {
51-
"import": {
52-
"types": "./build/types/vite/index.d.ts",
53-
"default": "./build/esm/vite/index.js"
54-
},
55-
"require": {
56-
"types": "./build/types/vite/index.d.ts",
57-
"default": "./build/cjs/vite/index.js"
58-
}
46+
"types": "./build/types/vite/index.d.ts",
47+
"import": "./build/esm/prod/vite/index.js",
48+
"require": "./build/cjs/prod/vite/index.js"
5949
}
6050
},
6151
"publishConfig": {
@@ -97,7 +87,7 @@
9787
"clean": "rimraf build coverage sentry-cloudflare-*.tgz",
9888
"lint:fix": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --fix --type-aware",
9989
"lint": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --type-aware",
100-
"lint:es-compatibility": "es-check es2022 ./build/cjs/*.js && es-check es2022 ./build/esm/*.js --module",
90+
"lint:es-compatibility": "es-check es2022 ./build/cjs/prod/*.js && es-check es2022 ./build/esm/prod/*.js --module",
10191
"test": "yarn test:unit",
10292
"test:unit": "vitest run",
10393
"test:watch": "vitest --watch",

packages/cloudflare/rollup.npm.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export default makeNPMConfigVariants(
44
makeBaseNPMConfig({
55
entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts', 'src/vite/index.ts'],
66
}),
7+
{ splitDevProd: true },
78
);

packages/cloudflare/src/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,18 @@ interface BaseCloudflareOptions {
253253
* @default false
254254
*/
255255
instrumentPrototypeMethods?: boolean | string[];
256+
257+
/**
258+
* If you use Spotlight by Sentry during development, use
259+
* this option to forward captured Sentry events to Spotlight.
260+
*
261+
* Either set it to true, or provide a specific Spotlight Sidecar URL.
262+
*
263+
* More details: https://spotlightjs.com/
264+
*
265+
* IMPORTANT: Only set this option to `true` while developing, not in production!
266+
*/
267+
spotlight?: boolean | string;
256268
}
257269

258270
/**

packages/cloudflare/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export { getDefaultIntegrations } from './sdk';
128128

129129
export { httpServerIntegration } from './integrations/httpServer';
130130
export { fetchIntegration } from './integrations/fetch';
131+
export { spotlightIntegration } from './integrations/spotlight';
131132
export { vercelAIIntegration } from './integrations/tracing/vercelai';
132133

133134
// eslint-disable-next-line typescript/no-deprecated
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Client, Envelope, IntegrationFn } from '@sentry/core';
2+
import { debug, defineIntegration, serializeEnvelope, suppressTracing } from '@sentry/core';
3+
import { DEBUG_BUILD } from '../debug-build';
4+
5+
type SpotlightConnectionOptions = {
6+
/**
7+
* Set this if the Spotlight Sidecar is not running on localhost:8969.
8+
* By default, the URL is set to http://localhost:8969/stream
9+
*/
10+
sidecarUrl?: string;
11+
};
12+
13+
export const INTEGRATION_NAME = 'Spotlight' as const;
14+
15+
const _spotlightIntegration = ((options: Partial<SpotlightConnectionOptions> = {}) => {
16+
const sidecarUrl = options.sidecarUrl || 'http://localhost:8969/stream';
17+
18+
return {
19+
name: INTEGRATION_NAME,
20+
setup(client) {
21+
DEBUG_BUILD && debug.log('[Spotlight] Using Sidecar URL', sidecarUrl);
22+
setupSidecarForwarding(client, sidecarUrl);
23+
},
24+
};
25+
}) satisfies IntegrationFn;
26+
27+
/**
28+
* Use this integration to send errors and transactions to Spotlight.
29+
*
30+
* Learn more about spotlight at https://spotlightjs.com
31+
*
32+
* Important: This integration is intended for local development only.
33+
* Each forwarded envelope counts as a Worker subrequest (50 free / 1000 paid
34+
* per invocation), so it should not be enabled in production.
35+
*/
36+
export const spotlightIntegration = defineIntegration(_spotlightIntegration);
37+
38+
function setupSidecarForwarding(client: Client, sidecarUrl: string): void {
39+
const parsedUrl = parseSidecarUrl(sidecarUrl);
40+
if (!parsedUrl) {
41+
return;
42+
}
43+
44+
let failCount = 0;
45+
46+
client.on('beforeEnvelope', (envelope: Envelope) => {
47+
if (failCount > 3) {
48+
DEBUG_BUILD && debug.warn('[Spotlight] Disabled Sentry -> Spotlight forwarding due to too many failed requests');
49+
return;
50+
}
51+
52+
const body = serializeEnvelope(envelope);
53+
54+
suppressTracing(() => {
55+
fetch(parsedUrl.href, {
56+
method: 'POST',
57+
body,
58+
headers: {
59+
'Content-Type': 'application/x-sentry-envelope',
60+
},
61+
}).then(
62+
res => {
63+
// Consume the response body to satisfy Cloudflare Workers' requirement
64+
// that all fetch response bodies are read or cancelled.
65+
res.text().catch(() => {
66+
// no-op
67+
});
68+
69+
if (res.status >= 200 && res.status < 400) {
70+
failCount = 0;
71+
}
72+
},
73+
() => {
74+
failCount++;
75+
DEBUG_BUILD && debug.warn('[Spotlight] Failed to send envelope to Spotlight Sidecar');
76+
},
77+
);
78+
});
79+
});
80+
}
81+
82+
function parseSidecarUrl(url: string): URL | undefined {
83+
try {
84+
return new URL(url);
85+
} catch {
86+
DEBUG_BUILD && debug.warn(`[Spotlight] Invalid sidecar URL: ${url}`);
87+
return undefined;
88+
}
89+
}

packages/cloudflare/src/options.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknow
5454
const tracesSampleRate =
5555
userOptions.tracesSampleRate ?? parseFloat(getEnvVar(env, 'SENTRY_TRACES_SAMPLE_RATE') ?? '');
5656

57+
// Spotlight precedence (mirrors node-core's getSpotlightConfig):
58+
// - false or explicit string from options: use as-is
59+
// - true: enable, but prefer a custom URL from the env var if set
60+
// - undefined: defer entirely to the env var (bool or URL)
61+
/*! rollup-include-development-only */
62+
const spotlight = getSpotlightFromEnv(userOptions.spotlight, getEnvVar(env, 'SENTRY_SPOTLIGHT'));
63+
/*! rollup-include-development-only-end */
64+
5765
return {
5866
release,
5967
...userOptions,
@@ -62,5 +70,35 @@ export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknow
6270
tracesSampleRate: isFinite(tracesSampleRate) ? tracesSampleRate : undefined,
6371
debug: userOptions.debug ?? envToBool(getEnvVar(env, 'SENTRY_DEBUG')),
6472
tunnel: userOptions.tunnel ?? getEnvVar(env, 'SENTRY_TUNNEL'),
73+
/*! rollup-include-development-only */
74+
spotlight,
75+
/*! rollup-include-development-only-end */
6576
};
6677
}
78+
79+
/**
80+
* Resolve the spotlight option from a user-supplied value and an env binding string.
81+
* Mirrors node-core's `getSpotlightConfig` precedence:
82+
* - `false` or explicit string from options → use as-is
83+
* - `true` → enable, but prefer a custom URL from the env var if set
84+
* - `undefined` → defer entirely to the env var (bool or URL)
85+
*/
86+
function getSpotlightFromEnv(
87+
optionsSpotlight: boolean | string | undefined,
88+
envVar: string | undefined,
89+
): boolean | string | undefined {
90+
if (optionsSpotlight === false) {
91+
return false;
92+
}
93+
if (typeof optionsSpotlight === 'string') {
94+
return optionsSpotlight;
95+
}
96+
97+
// optionsSpotlight is true or undefined
98+
const envBool = envToBool(envVar, { strict: true });
99+
const envUrl = envBool === null && envVar ? envVar : undefined;
100+
101+
return optionsSpotlight === true
102+
? (envUrl ?? true) // true: use env URL if present, otherwise true
103+
: (envBool ?? envUrl); // undefined: use env var (bool or URL)
104+
}

packages/cloudflare/src/sdk.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { CloudflareClient } from './client';
1717
import { makeFlushLock } from './flush';
1818
import { httpServerIntegration } from './integrations/httpServer';
1919
import { fetchIntegration } from './integrations/fetch';
20+
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from './integrations/spotlight';
2021
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
2122
import { makeCloudflareTransport } from './transport';
2223
import { defaultStackParser } from './vendor/stacktrace';
@@ -88,6 +89,16 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined {
8889
flushLock,
8990
};
9091

92+
/*! rollup-include-development-only */
93+
if (options.spotlight && !clientOptions.integrations.some(({ name }) => name === SPOTLIGHT_INTEGRATION_NAME)) {
94+
clientOptions.integrations.push(
95+
spotlightIntegration({
96+
sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined,
97+
}),
98+
);
99+
}
100+
/*! rollup-include-development-only-end */
101+
91102
/**
92103
* The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
93104
* via a custom trace provider.

0 commit comments

Comments
 (0)