diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index d5b961222b..83add11213 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -38,6 +38,7 @@ Sentry.init({ // default: [/.*/] failedRequestTargets: [/.*/], }), + Sentry.Integrations.Spotlight(), ); return integrations.filter(i => i.name !== 'Dedupe'); }, diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 385692d11b..d4f57a71cf 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -69,6 +69,7 @@ Sentry.init({ // default: [/.*/] failedRequestTargets: [/.*/], }), + Sentry.Integrations.Spotlight(), ); return integrations.filter(i => i.name !== 'Dedupe'); }, diff --git a/src/js/integrations/index.ts b/src/js/integrations/index.ts index 061bc5e0a9..3a8ad303ae 100644 --- a/src/js/integrations/index.ts +++ b/src/js/integrations/index.ts @@ -7,3 +7,4 @@ export { SdkInfo } from './sdkinfo'; export { ReactNativeInfo } from './reactnativeinfo'; export { ModulesLoader } from './modulesloader'; export { HermesProfiling } from '../profiling/integration'; +export { Spotlight } from './spotlight'; diff --git a/src/js/integrations/spotlight.ts b/src/js/integrations/spotlight.ts new file mode 100644 index 0000000000..e533c3cabf --- /dev/null +++ b/src/js/integrations/spotlight.ts @@ -0,0 +1,96 @@ +import type { Client, Envelope, EventProcessor, Integration } from '@sentry/types'; +import { logger, serializeEnvelope } from '@sentry/utils'; + +import { makeUtf8TextEncoder } from '../transports/TextEncoder'; +import { ReactNativeLibraries } from '../utils/rnlibraries'; + +type SpotlightReactNativeIntegrationOptions = { + /** + * The URL of the Sidecar instance to connect and forward events to. + * If not set, Spotlight will try to connect to the Sidecar running on localhost:8969. + * + * @default "http://localhost:8969/stream" + */ + sidecarUrl?: string; +}; + +/** + * + */ +export function Spotlight({ + sidecarUrl = getDefaultSidecarUrl(), +}: SpotlightReactNativeIntegrationOptions = {}): Integration { + logger.info('[Spotlight] Using Sidecar URL', sidecarUrl); + + return { + name: 'Spotlight', + + setupOnce(_: (callback: EventProcessor) => void, getCurrentHub) { + const client = getCurrentHub().getClient(); + if (client) { + setup(client, sidecarUrl); + } else { + logger.warn('[Spotlight] Could not initialize Sidecar integration due to missing Client'); + } + }, + }; +} + +function setup(client: Client, sidecarUrl: string): void { + sendEnvelopesToSidecar(client, sidecarUrl); +} + +function sendEnvelopesToSidecar(client: Client, sidecarUrl: string): void { + if (!client.on) { + return; + } + + client.on('beforeEnvelope', (originalEnvelope: Envelope) => { + // TODO: This is a workaround for spotlight/sidecar not supporting images + const spotlightEnvelope: Envelope = [...originalEnvelope]; + const envelopeItems = [...originalEnvelope[1]].filter( + item => typeof item[0].content_type !== 'string' || !item[0].content_type.startsWith('image'), + ); + + spotlightEnvelope[1] = envelopeItems as Envelope[1]; + + fetch(sidecarUrl, { + method: 'POST', + body: serializeEnvelope(spotlightEnvelope, makeUtf8TextEncoder()), + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + mode: 'cors', + }).catch(err => { + logger.error( + "[Spotlight] Sentry SDK can't connect to Spotlight is it running? See https://spotlightjs.com to download it.", + err, + ); + }); + }); +} + +function getDefaultSidecarUrl(): string { + try { + const { url } = ReactNativeLibraries.Devtools?.getDevServer(); + return `http://${getHostnameFromString(url)}:8969/stream`; + } catch (_oO) { + // We can't load devserver URL + } + return 'http://localhost:8969/stream'; +} + +/** + * React Native implementation of the URL class is missing the `hostname` property. + */ +function getHostnameFromString(urlString: string): string | null { + const regex = /^(?:\w+:)?\/\/([^/:]+)(:\d+)?(.*)$/; + const matches = urlString.match(regex); + + if (matches && matches[1]) { + return matches[1]; + } else { + // Invalid URL format + return null; + } +}