diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e8c0704a..ee3c070384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +### Features + +- Add [`@spotlightjs/spotlight`](https://spotlightjs.com/) support ([#3550](https://github.com/getsentry/sentry-react-native/pull/3550)) + + Download the `Spotlight` desktop application and add the integration to your `Sentry.init`. + + ```javascript + import * as Sentry from '@sentry/react-native'; + + Sentry.init({ + dsn: '___DSN___', + enableSpotlight: __DEV__, + }); + ``` + ### Fixes - Prevent pod install crash when visionos is not present ([#3548](https://github.com/getsentry/sentry-react-native/pull/3548)) diff --git a/jest.config.js b/jest.config.js index 902b0a443b..1e344ffb99 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { collectCoverage: true, preset: 'react-native', - setupFilesAfterEnv: ['jest-extended/all', '/test/mockConsole.ts'], + setupFilesAfterEnv: ['jest-extended/all', '/test/mockConsole.ts', '/test/mockFetch.ts'], globals: { __DEV__: true, 'ts-jest': { diff --git a/package.json b/package.json index 4bc77a5ed5..957f5d48a3 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "expo-module-scripts": "^3.1.0", "jest": "^29.6.2", "jest-environment-jsdom": "^29.6.2", + "jest-fetch-mock": "^3.0.3", "jest-extended": "^4.0.2", "madge": "^6.1.0", "metro": "0.76", diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index d5b961222b..9a5b51c0b4 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -62,6 +62,7 @@ Sentry.init({ _experiments: { profilesSampleRate: 0, }, + enableSpotlight: true, }); export default function TabOneScreen() { diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 385692d11b..bfc0573ef7 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -93,6 +93,7 @@ Sentry.init({ _experiments: { profilesSampleRate: 0, }, + enableSpotlight: true, }); const Stack = createStackNavigator(); diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index 7b50b564da..4dc16bfae1 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -18,6 +18,7 @@ import { Release } from './release'; import { createReactNativeRewriteFrames } from './rewriteframes'; import { Screenshot } from './screenshot'; import { SdkInfo } from './sdkinfo'; +import { Spotlight } from './spotlight'; import { ViewHierarchy } from './viewhierarchy'; /** @@ -94,5 +95,13 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(new ExpoContext()); } + if (options.enableSpotlight) { + integrations.push( + Spotlight({ + sidecarUrl: options.spotlightSidecarUrl, + }), + ); + } + return integrations; } 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..149cdb56ea --- /dev/null +++ b/src/js/integrations/spotlight.ts @@ -0,0 +1,98 @@ +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; +}; + +/** + * Use this integration to send errors and transactions to Spotlight. + * + * Learn more about spotlight at https://spotlightjs.com + */ +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; + } +} diff --git a/src/js/options.ts b/src/js/options.ts index f2644953e3..d12db7991f 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -159,6 +159,27 @@ export interface BaseReactNativeOptions { * @default false */ enableCaptureFailedRequests?: boolean; + + /** + * This option will enable forwarding captured Sentry events to Spotlight. + * + * More details: https://spotlightjs.com/ + * + * IMPORTANT: Only set this option to `true` while developing, not in production! + */ + enableSpotlight?: boolean; + + /** + * This option changes the default Spotlight Sidecar URL. + * + * By default, the SDK expects the Sidecar to be running + * on the same host as React Native Metro Dev Server. + * + * More details: https://spotlightjs.com/ + * + * @default "http://localhost:8969/stream" + */ + spotlightSidecarUrl?: string; } export interface ReactNativeTransportOptions extends BrowserTransportOptions { diff --git a/test/integrations/spotlight.test.ts b/test/integrations/spotlight.test.ts new file mode 100644 index 0000000000..e58918fc06 --- /dev/null +++ b/test/integrations/spotlight.test.ts @@ -0,0 +1,97 @@ +import type { Envelope, Hub } from '@sentry/types'; +import fetchMock from 'jest-fetch-mock'; + +import { Spotlight } from '../../src/js/integrations/spotlight'; + +describe('spotlight', () => { + it('should not change the original envelope', () => { + const mockHub = createMockHub(); + + const spotlight = Spotlight(); + spotlight.setupOnce( + () => {}, + () => mockHub as unknown as Hub, + ); + + const spotlightBeforeEnvelope = mockHub.getClient().on.mock.calls[0]?.[1] as + | ((envelope: Envelope) => void) + | undefined; + + const originalEnvelopeReference = createMockEnvelope(); + spotlightBeforeEnvelope?.(originalEnvelopeReference); + + expect(spotlightBeforeEnvelope).toBeDefined(); + expect(originalEnvelopeReference).toEqual(createMockEnvelope()); + }); + + it('should remove image attachments from spotlight envelope', () => { + fetchMock.mockOnce(); + const mockHub = createMockHub(); + + const spotlight = Spotlight(); + spotlight.setupOnce( + () => {}, + () => mockHub as unknown as Hub, + ); + + const spotlightBeforeEnvelope = mockHub.getClient().on.mock.calls[0]?.[1] as + | ((envelope: Envelope) => void) + | undefined; + + spotlightBeforeEnvelope?.(createMockEnvelope()); + + expect(spotlightBeforeEnvelope).toBeDefined(); + expect(fetchMock.mock.lastCall?.[1]?.body?.toString().includes('image/png')).toBe(false); + }); +}); + +function createMockHub() { + const client = { + on: jest.fn(), + }; + + return { + getClient: jest.fn().mockReturnValue(client), + }; +} + +function createMockEnvelope(): Envelope { + return [ + { + event_id: 'event_id', + sent_at: 'sent_at', + sdk: { + name: 'sdk_name', + version: 'sdk_version', + }, + }, + [ + [ + { + type: 'event', + length: 0, + }, + { + event_id: 'event_id', + }, + ], + [ + { + type: 'attachment', + length: 10, + filename: 'filename', + }, + 'attachment', + ], + [ + { + type: 'attachment', + length: 8, + filename: 'filename2', + content_type: 'image/png', + }, + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), // PNG header + ], + ], + ]; +} diff --git a/test/mockFetch.ts b/test/mockFetch.ts new file mode 100644 index 0000000000..1f06e1ce7f --- /dev/null +++ b/test/mockFetch.ts @@ -0,0 +1,2 @@ +import { enableFetchMocks } from 'jest-fetch-mock'; +enableFetchMocks(); diff --git a/test/sdk.test.ts b/test/sdk.test.ts index c65bdc7650..dd906d54d8 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -553,6 +553,24 @@ describe('Tests the SDK functionality', () => { ); }); + it('no spotlight integration by default', () => { + init({}); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'Spotlight' })])); + }); + + it('adds spotlight integration', () => { + init({ + enableSpotlight: true, + }); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + expect(actualIntegrations).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'Spotlight' })])); + }); + it('no default integrations', () => { init({ defaultIntegrations: false, diff --git a/yarn.lock b/yarn.lock index be98b2ac93..89a725b3f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5908,7 +5908,7 @@ cosmiconfig@^5.0.5, cosmiconfig@^5.1.0: js-yaml "^3.13.1" parse-json "^4.0.0" -cross-fetch@^3.1.5: +cross-fetch@^3.0.4, cross-fetch@^3.1.5: version "3.1.8" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== @@ -9020,6 +9020,14 @@ jest-extended@^4.0.2: jest-diff "^29.0.0" jest-get-type "^29.0.0" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^29.0.0, jest-get-type@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" @@ -11562,6 +11570,11 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g== +promise-polyfill@^8.1.3: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63" + integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== + promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"