Skip to content

Commit eb53e59

Browse files
feat(integrations): Add Spotlight integration (#3550)
1 parent 58b3261 commit eb53e59

File tree

13 files changed

+279
-2
lines changed

13 files changed

+279
-2
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Add [`@spotlightjs/spotlight`](https://spotlightjs.com/) support ([#3550](https://github.com/getsentry/sentry-react-native/pull/3550))
8+
9+
Download the `Spotlight` desktop application and add the integration to your `Sentry.init`.
10+
11+
```javascript
12+
import * as Sentry from '@sentry/react-native';
13+
14+
Sentry.init({
15+
dsn: '___DSN___',
16+
enableSpotlight: __DEV__,
17+
});
18+
```
19+
520
### Fixes
621

722
- Prevent pod install crash when visionos is not present ([#3548](https://github.com/getsentry/sentry-react-native/pull/3548))

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
collectCoverage: true,
33
preset: 'react-native',
4-
setupFilesAfterEnv: ['jest-extended/all', '<rootDir>/test/mockConsole.ts'],
4+
setupFilesAfterEnv: ['jest-extended/all', '<rootDir>/test/mockConsole.ts', '<rootDir>/test/mockFetch.ts'],
55
globals: {
66
__DEV__: true,
77
'ts-jest': {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
"expo-module-scripts": "^3.1.0",
9797
"jest": "^29.6.2",
9898
"jest-environment-jsdom": "^29.6.2",
99+
"jest-fetch-mock": "^3.0.3",
99100
"jest-extended": "^4.0.2",
100101
"madge": "^6.1.0",
101102
"metro": "0.76",

samples/expo/app/(tabs)/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Sentry.init({
6262
_experiments: {
6363
profilesSampleRate: 0,
6464
},
65+
enableSpotlight: true,
6566
});
6667

6768
export default function TabOneScreen() {

samples/react-native/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Sentry.init({
9393
_experiments: {
9494
profilesSampleRate: 0,
9595
},
96+
enableSpotlight: true,
9697
});
9798

9899
const Stack = createStackNavigator();

src/js/integrations/default.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Release } from './release';
1818
import { createReactNativeRewriteFrames } from './rewriteframes';
1919
import { Screenshot } from './screenshot';
2020
import { SdkInfo } from './sdkinfo';
21+
import { Spotlight } from './spotlight';
2122
import { ViewHierarchy } from './viewhierarchy';
2223

2324
/**
@@ -94,5 +95,13 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
9495
integrations.push(new ExpoContext());
9596
}
9697

98+
if (options.enableSpotlight) {
99+
integrations.push(
100+
Spotlight({
101+
sidecarUrl: options.spotlightSidecarUrl,
102+
}),
103+
);
104+
}
105+
97106
return integrations;
98107
}

src/js/integrations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export { SdkInfo } from './sdkinfo';
77
export { ReactNativeInfo } from './reactnativeinfo';
88
export { ModulesLoader } from './modulesloader';
99
export { HermesProfiling } from '../profiling/integration';
10+
export { Spotlight } from './spotlight';

src/js/integrations/spotlight.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { Client, Envelope, EventProcessor, Integration } from '@sentry/types';
2+
import { logger, serializeEnvelope } from '@sentry/utils';
3+
4+
import { makeUtf8TextEncoder } from '../transports/TextEncoder';
5+
import { ReactNativeLibraries } from '../utils/rnlibraries';
6+
7+
type SpotlightReactNativeIntegrationOptions = {
8+
/**
9+
* The URL of the Sidecar instance to connect and forward events to.
10+
* If not set, Spotlight will try to connect to the Sidecar running on localhost:8969.
11+
*
12+
* @default "http://localhost:8969/stream"
13+
*/
14+
sidecarUrl?: string;
15+
};
16+
17+
/**
18+
* Use this integration to send errors and transactions to Spotlight.
19+
*
20+
* Learn more about spotlight at https://spotlightjs.com
21+
*/
22+
export function Spotlight({
23+
sidecarUrl = getDefaultSidecarUrl(),
24+
}: SpotlightReactNativeIntegrationOptions = {}): Integration {
25+
logger.info('[Spotlight] Using Sidecar URL', sidecarUrl);
26+
27+
return {
28+
name: 'Spotlight',
29+
30+
setupOnce(_: (callback: EventProcessor) => void, getCurrentHub) {
31+
const client = getCurrentHub().getClient();
32+
if (client) {
33+
setup(client, sidecarUrl);
34+
} else {
35+
logger.warn('[Spotlight] Could not initialize Sidecar integration due to missing Client');
36+
}
37+
},
38+
};
39+
}
40+
41+
function setup(client: Client, sidecarUrl: string): void {
42+
sendEnvelopesToSidecar(client, sidecarUrl);
43+
}
44+
45+
function sendEnvelopesToSidecar(client: Client, sidecarUrl: string): void {
46+
if (!client.on) {
47+
return;
48+
}
49+
50+
client.on('beforeEnvelope', (originalEnvelope: Envelope) => {
51+
// TODO: This is a workaround for spotlight/sidecar not supporting images
52+
const spotlightEnvelope: Envelope = [...originalEnvelope];
53+
const envelopeItems = [...originalEnvelope[1]].filter(
54+
item => typeof item[0].content_type !== 'string' || !item[0].content_type.startsWith('image'),
55+
);
56+
57+
spotlightEnvelope[1] = envelopeItems as Envelope[1];
58+
59+
fetch(sidecarUrl, {
60+
method: 'POST',
61+
body: serializeEnvelope(spotlightEnvelope, makeUtf8TextEncoder()),
62+
headers: {
63+
'Content-Type': 'application/x-sentry-envelope',
64+
},
65+
mode: 'cors',
66+
}).catch(err => {
67+
logger.error(
68+
"[Spotlight] Sentry SDK can't connect to Spotlight is it running? See https://spotlightjs.com to download it.",
69+
err,
70+
);
71+
});
72+
});
73+
}
74+
75+
function getDefaultSidecarUrl(): string {
76+
try {
77+
const { url } = ReactNativeLibraries.Devtools?.getDevServer();
78+
return `http://${getHostnameFromString(url)}:8969/stream`;
79+
} catch (_oO) {
80+
// We can't load devserver URL
81+
}
82+
return 'http://localhost:8969/stream';
83+
}
84+
85+
/**
86+
* React Native implementation of the URL class is missing the `hostname` property.
87+
*/
88+
function getHostnameFromString(urlString: string): string | null {
89+
const regex = /^(?:\w+:)?\/\/([^/:]+)(:\d+)?(.*)$/;
90+
const matches = urlString.match(regex);
91+
92+
if (matches && matches[1]) {
93+
return matches[1];
94+
} else {
95+
// Invalid URL format
96+
return null;
97+
}
98+
}

src/js/options.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,27 @@ export interface BaseReactNativeOptions {
159159
* @default false
160160
*/
161161
enableCaptureFailedRequests?: boolean;
162+
163+
/**
164+
* This option will enable forwarding captured Sentry events to Spotlight.
165+
*
166+
* More details: https://spotlightjs.com/
167+
*
168+
* IMPORTANT: Only set this option to `true` while developing, not in production!
169+
*/
170+
enableSpotlight?: boolean;
171+
172+
/**
173+
* This option changes the default Spotlight Sidecar URL.
174+
*
175+
* By default, the SDK expects the Sidecar to be running
176+
* on the same host as React Native Metro Dev Server.
177+
*
178+
* More details: https://spotlightjs.com/
179+
*
180+
* @default "http://localhost:8969/stream"
181+
*/
182+
spotlightSidecarUrl?: string;
162183
}
163184

164185
export interface ReactNativeTransportOptions extends BrowserTransportOptions {

test/integrations/spotlight.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Envelope, Hub } from '@sentry/types';
2+
import fetchMock from 'jest-fetch-mock';
3+
4+
import { Spotlight } from '../../src/js/integrations/spotlight';
5+
6+
describe('spotlight', () => {
7+
it('should not change the original envelope', () => {
8+
const mockHub = createMockHub();
9+
10+
const spotlight = Spotlight();
11+
spotlight.setupOnce(
12+
() => {},
13+
() => mockHub as unknown as Hub,
14+
);
15+
16+
const spotlightBeforeEnvelope = mockHub.getClient().on.mock.calls[0]?.[1] as
17+
| ((envelope: Envelope) => void)
18+
| undefined;
19+
20+
const originalEnvelopeReference = createMockEnvelope();
21+
spotlightBeforeEnvelope?.(originalEnvelopeReference);
22+
23+
expect(spotlightBeforeEnvelope).toBeDefined();
24+
expect(originalEnvelopeReference).toEqual(createMockEnvelope());
25+
});
26+
27+
it('should remove image attachments from spotlight envelope', () => {
28+
fetchMock.mockOnce();
29+
const mockHub = createMockHub();
30+
31+
const spotlight = Spotlight();
32+
spotlight.setupOnce(
33+
() => {},
34+
() => mockHub as unknown as Hub,
35+
);
36+
37+
const spotlightBeforeEnvelope = mockHub.getClient().on.mock.calls[0]?.[1] as
38+
| ((envelope: Envelope) => void)
39+
| undefined;
40+
41+
spotlightBeforeEnvelope?.(createMockEnvelope());
42+
43+
expect(spotlightBeforeEnvelope).toBeDefined();
44+
expect(fetchMock.mock.lastCall?.[1]?.body?.toString().includes('image/png')).toBe(false);
45+
});
46+
});
47+
48+
function createMockHub() {
49+
const client = {
50+
on: jest.fn(),
51+
};
52+
53+
return {
54+
getClient: jest.fn().mockReturnValue(client),
55+
};
56+
}
57+
58+
function createMockEnvelope(): Envelope {
59+
return [
60+
{
61+
event_id: 'event_id',
62+
sent_at: 'sent_at',
63+
sdk: {
64+
name: 'sdk_name',
65+
version: 'sdk_version',
66+
},
67+
},
68+
[
69+
[
70+
{
71+
type: 'event',
72+
length: 0,
73+
},
74+
{
75+
event_id: 'event_id',
76+
},
77+
],
78+
[
79+
{
80+
type: 'attachment',
81+
length: 10,
82+
filename: 'filename',
83+
},
84+
'attachment',
85+
],
86+
[
87+
{
88+
type: 'attachment',
89+
length: 8,
90+
filename: 'filename2',
91+
content_type: 'image/png',
92+
},
93+
Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), // PNG header
94+
],
95+
],
96+
];
97+
}

0 commit comments

Comments
 (0)