diff --git a/.size-limit.js b/.size-limit.js index 4ff4c7d05f7f..7a455389c1be 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -480,7 +480,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '480 KiB', + limit: '490 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index aa5ae4f50a08..84d0cbf52522 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -294,6 +294,18 @@ interface BaseCloudflareOptions { * @default false */ instrumentPrototypeMethods?: boolean | string[]; + + /** + * If you use Spotlight by Sentry during development, use + * this option to forward captured Sentry events to Spotlight. + * + * Either set it to true, or provide a specific Spotlight Sidecar URL. + * + * More details: https://spotlightjs.com/ + * + * IMPORTANT: Only set this option to `true` while developing, not in production! + */ + spotlight?: boolean | string; } /** diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 4a718f10f5ce..1e1896d6bc95 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -130,6 +130,7 @@ export { getDefaultIntegrations } from './sdk'; export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; +export { spotlightIntegration } from './integrations/spotlight'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; // eslint-disable-next-line typescript/no-deprecated export { honoIntegration } from './integrations/hono'; diff --git a/packages/cloudflare/src/integrations/spotlight.ts b/packages/cloudflare/src/integrations/spotlight.ts new file mode 100644 index 000000000000..ead0e8f552c3 --- /dev/null +++ b/packages/cloudflare/src/integrations/spotlight.ts @@ -0,0 +1,89 @@ +import type { Client, Envelope, IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, serializeEnvelope, suppressTracing } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +type SpotlightConnectionOptions = { + /** + * Set this if the Spotlight Sidecar is not running on localhost:8969. + * By default, the URL is set to http://localhost:8969/stream + */ + sidecarUrl?: string; +}; + +export const INTEGRATION_NAME = 'Spotlight' as const; + +const _spotlightIntegration = ((options: Partial = {}) => { + const sidecarUrl = options.sidecarUrl || 'http://localhost:8969/stream'; + + return { + name: INTEGRATION_NAME, + setup(client) { + DEBUG_BUILD && debug.log('[Spotlight] Using Sidecar URL', sidecarUrl); + setupSidecarForwarding(client, sidecarUrl); + }, + }; +}) satisfies IntegrationFn; + +/** + * Use this integration to send errors and transactions to Spotlight. + * + * Learn more about spotlight at https://spotlightjs.com + * + * Important: This integration is intended for local development only. + * Each forwarded envelope counts as a Worker subrequest (50 free / 1000 paid + * per invocation), so it should not be enabled in production. + */ +export const spotlightIntegration = defineIntegration(_spotlightIntegration); + +function setupSidecarForwarding(client: Client, sidecarUrl: string): void { + const parsedUrl = parseSidecarUrl(sidecarUrl); + if (!parsedUrl) { + return; + } + + let failCount = 0; + + client.on('beforeEnvelope', (envelope: Envelope) => { + if (failCount > 3) { + DEBUG_BUILD && debug.warn('[Spotlight] Disabled Sentry -> Spotlight forwarding due to too many failed requests'); + return; + } + + const body = serializeEnvelope(envelope); + + suppressTracing(() => { + fetch(parsedUrl.href, { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + }).then( + res => { + // Consume the response body to satisfy Cloudflare Workers' requirement + // that all fetch response bodies are read or cancelled. + res.text().catch(() => { + // no-op + }); + + if (res.status >= 200 && res.status < 400) { + failCount = 0; + } + }, + () => { + failCount++; + DEBUG_BUILD && debug.warn('[Spotlight] Failed to send envelope to Spotlight Sidecar'); + }, + ); + }); + }); +} + +function parseSidecarUrl(url: string): URL | undefined { + try { + return new URL(url); + } catch { + DEBUG_BUILD && debug.warn(`[Spotlight] Invalid sidecar URL: ${url}`); + return undefined; + } +} diff --git a/packages/cloudflare/src/options.ts b/packages/cloudflare/src/options.ts index 7506dd34468e..28e5f911d8b0 100644 --- a/packages/cloudflare/src/options.ts +++ b/packages/cloudflare/src/options.ts @@ -54,6 +54,12 @@ export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknow const tracesSampleRate = userOptions.tracesSampleRate ?? parseFloat(getEnvVar(env, 'SENTRY_TRACES_SAMPLE_RATE') ?? ''); + // Spotlight precedence (mirrors node-core's getSpotlightConfig): + // - false or explicit string from options: use as-is + // - true: enable, but prefer a custom URL from the env var if set + // - undefined: defer entirely to the env var (bool or URL) + const spotlight = getSpotlightFromEnv(userOptions.spotlight, getEnvVar(env, 'SENTRY_SPOTLIGHT')); + return { release, ...userOptions, @@ -62,5 +68,33 @@ export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknow tracesSampleRate: isFinite(tracesSampleRate) ? tracesSampleRate : undefined, debug: userOptions.debug ?? envToBool(getEnvVar(env, 'SENTRY_DEBUG')), tunnel: userOptions.tunnel ?? getEnvVar(env, 'SENTRY_TUNNEL'), + spotlight, }; } + +/** + * Resolve the spotlight option from a user-supplied value and an env binding string. + * Mirrors node-core's `getSpotlightConfig` precedence: + * - `false` or explicit string from options → use as-is + * - `true` → enable, but prefer a custom URL from the env var if set + * - `undefined` → defer entirely to the env var (bool or URL) + */ +function getSpotlightFromEnv( + optionsSpotlight: boolean | string | undefined, + envVar: string | undefined, +): boolean | string | undefined { + if (optionsSpotlight === false) { + return false; + } + if (typeof optionsSpotlight === 'string') { + return optionsSpotlight; + } + + // optionsSpotlight is true or undefined + const envBool = envToBool(envVar, { strict: true }); + const envUrl = envBool === null && envVar ? envVar : undefined; + + return optionsSpotlight === true + ? (envUrl ?? true) // true: use env URL if present, otherwise true + : (envBool ?? envUrl); // undefined: use env var (bool or URL) +} diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 2bbd704e6004..7cb21103987c 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -18,6 +18,7 @@ import { makeFlushLock } from './flush'; import { httpServerIntegration } from './integrations/httpServer'; import { fetchIntegration } from './integrations/fetch'; import { honoIntegration } from './integrations/hono'; +import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from './integrations/spotlight'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; @@ -91,6 +92,14 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { flushLock, }; + if (options.spotlight && !clientOptions.integrations.some(({ name }) => name === SPOTLIGHT_INTEGRATION_NAME)) { + clientOptions.integrations.push( + spotlightIntegration({ + sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined, + }), + ); + } + /** * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility * via a custom trace provider. diff --git a/packages/cloudflare/test/integrations/spotlight.test.ts b/packages/cloudflare/test/integrations/spotlight.test.ts new file mode 100644 index 000000000000..e485715b6e6a --- /dev/null +++ b/packages/cloudflare/test/integrations/spotlight.test.ts @@ -0,0 +1,234 @@ +import type { Envelope, EventEnvelope } from '@sentry/core'; +import { createEnvelope, debug } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CloudflareClient } from '../../src/client'; +import { INTEGRATION_NAME, spotlightIntegration } from '../../src/integrations/spotlight'; +import { createStackParser } from '@sentry/core'; + +function createTestClient(): CloudflareClient { + return new CloudflareClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [], + transport: () => ({ + send: () => Promise.resolve({}), + flush: () => Promise.resolve(true), + }), + stackParser: createStackParser(), + }); +} + +function createTestEnvelope(): EventEnvelope { + return createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + ]); +} + +describe('Spotlight (Cloudflare)', () => { + const debugWarnSpy = vi.spyOn(debug, 'warn'); + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + fetchSpy = vi.fn().mockResolvedValue({ + status: 200, + text: () => Promise.resolve(''), + }); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('has integration name "Spotlight"', () => { + const integration = spotlightIntegration(); + expect(integration.name).toEqual(INTEGRATION_NAME); + expect(integration.name).toEqual('Spotlight'); + }); + + it('registers a callback on the beforeEnvelope hook', () => { + const client = createTestClient(); + const onSpy = vi.spyOn(client, 'on'); + + const integration = spotlightIntegration(); + integration.setup!(client); + + expect(onSpy).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function)); + }); + + it('sends an envelope POST request to the default sidecar URL', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + callback(createTestEnvelope()); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:8969/stream', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }), + ); + }); + + it('sends an envelope POST request to a custom sidecar URL', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration({ sidecarUrl: 'http://mylocalhost:8888/abcd' }); + integration.setup!(client); + + callback(createTestEnvelope()); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://mylocalhost:8888/abcd', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }), + ); + }); + + it('serializes the envelope body', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + callback(createTestEnvelope()); + + const body = fetchSpy.mock.calls[0]![1].body as string; + expect(body).toContain('aa3ff046696b4bc6b609ce6d28fde9e2'); + expect(typeof body).toBe('string'); + }); + + it('stops forwarding after more than 3 failed requests', async () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + fetchSpy.mockRejectedValue(new Error('connection refused')); + + const integration = spotlightIntegration(); + integration.setup!(client); + + const envelope = createTestEnvelope(); + + // 4 failed requests should trigger the disable + for (let i = 0; i < 4; i++) { + callback(envelope); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(i + 1)); + } + + fetchSpy.mockClear(); + callback(envelope); + + // Wait a tick to ensure any async handling is done + await new Promise(resolve => setTimeout(resolve, 10)); + + // The 5th call should not reach fetch + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('resets fail count on successful request', async () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + // Fail 3 times, then succeed + fetchSpy + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ status: 200, text: () => Promise.resolve('') }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + const envelope = createTestEnvelope(); + + for (let i = 0; i < 4; i++) { + callback(envelope); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(i + 1)); + } + + // After the success, fail count should be reset, so the next call should go through + fetchSpy.mockResolvedValueOnce({ status: 200, text: () => Promise.resolve('') }); + callback(envelope); + + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(5)); + }); + + it('warns on invalid sidecar URL', () => { + const client = createTestClient(); + + const integration = spotlightIntegration({ sidecarUrl: 'not-a-valid-url' }); + integration.setup!(client); + + expect(debugWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid sidecar URL: not-a-valid-url')); + }); + + it('does not call fetch for invalid sidecar URL', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration({ sidecarUrl: 'not-a-valid-url' }); + integration.setup!(client); + + // If the URL is invalid, the beforeEnvelope hook is never registered + // so callback is never replaced — it's still the no-op default + callback(createTestEnvelope()); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not increment fail count on 4xx/5xx responses', async () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + fetchSpy.mockResolvedValue({ status: 500, text: () => Promise.resolve('') }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + const envelope = createTestEnvelope(); + + // 5 calls with 500 status — fail count is NOT incremented for HTTP errors, + // only for network rejections, so fetch should still be called each time + for (let i = 0; i < 5; i++) { + callback(envelope); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(i + 1)); + } + }); +}); diff --git a/packages/cloudflare/test/options.test.ts b/packages/cloudflare/test/options.test.ts index 9dc21606b445..e999df4b7970 100644 --- a/packages/cloudflare/test/options.test.ts +++ b/packages/cloudflare/test/options.test.ts @@ -189,4 +189,54 @@ describe('getFinalOptions', () => { expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: undefined })); }); }); + + describe('SENTRY_SPOTLIGHT', () => { + it('reads SENTRY_SPOTLIGHT boolean "true" from env', () => { + const result = getFinalOptions({}, { SENTRY_SPOTLIGHT: 'true' }); + expect(result.spotlight).toBe(true); + }); + + it('reads SENTRY_SPOTLIGHT boolean "false" from env', () => { + const result = getFinalOptions({}, { SENTRY_SPOTLIGHT: 'false' }); + expect(result.spotlight).toBe(false); + }); + + it('reads SENTRY_SPOTLIGHT URL string from env', () => { + const result = getFinalOptions({}, { SENTRY_SPOTLIGHT: 'http://localhost:9999/stream' }); + expect(result.spotlight).toBe('http://localhost:9999/stream'); + }); + + it('user option takes precedence over env', () => { + const result = getFinalOptions({ spotlight: false }, { SENTRY_SPOTLIGHT: 'true' }); + expect(result.spotlight).toBe(false); + }); + + it('user option string takes precedence over env', () => { + const result = getFinalOptions( + { spotlight: 'http://custom:1234/stream' }, + { SENTRY_SPOTLIGHT: 'http://other:5678/stream' }, + ); + expect(result.spotlight).toBe('http://custom:1234/stream'); + }); + + it('returns undefined when SENTRY_SPOTLIGHT is not set', () => { + const result = getFinalOptions({}, { SENTRY_DSN: 'test-dsn' }); + expect(result.spotlight).toBeUndefined(); + }); + + it('spotlight: true prefers env URL over boolean true', () => { + const result = getFinalOptions({ spotlight: true }, { SENTRY_SPOTLIGHT: 'http://custom:1234/stream' }); + expect(result.spotlight).toBe('http://custom:1234/stream'); + }); + + it('spotlight: true stays true when env is boolean "true"', () => { + const result = getFinalOptions({ spotlight: true }, { SENTRY_SPOTLIGHT: 'true' }); + expect(result.spotlight).toBe(true); + }); + + it('spotlight: true stays true when env is not set', () => { + const result = getFinalOptions({ spotlight: true }, {}); + expect(result.spotlight).toBe(true); + }); + }); });