Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Features

- Enable "Open Sentry" button in Playground for Expo apps ([#5947](https://github.com/getsentry/sentry-react-native/pull/5947))

## 8.7.0

### Features
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/js/metro/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const SENTRY_MIDDLEWARE_PATH = '__sentry';
export const SENTRY_CONTEXT_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/context`;
export const SENTRY_OPEN_URL_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/open-url`;
17 changes: 17 additions & 0 deletions packages/core/src/js/metro/getRawBody.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { IncomingMessage } from 'http';

/**
* Get the raw body of a request.
*/
export function getRawBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
request.on('data', chunk => {
data += chunk;
});
request.on('end', () => {
resolve(data);
});
request.on('error', reject);
});
}
17 changes: 17 additions & 0 deletions packages/core/src/js/metro/openUrlInBrowser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { debug } from '@sentry/core';

import { getDevServer } from '../integrations/debugsymbolicatorutils';
import { SENTRY_OPEN_URL_REQUEST_PATH } from './constants';

/**
* Send request to the Metro Development Server Middleware to open a URL in the system browser.
*/
export function openURLInBrowser(url: string): void {
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
fetch(`${getDevServer()?.url || '/'}${SENTRY_OPEN_URL_REQUEST_PATH}`, {
method: 'POST',
body: JSON.stringify({ url }),
}).catch(e => {
debug.error('Error opening URL:', e);
});
}
109 changes: 109 additions & 0 deletions packages/core/src/js/metro/openUrlMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { IncomingMessage, ServerResponse } from 'http';

import { getRawBody } from './getRawBody';

/*
* Prefix for Sentry Metro logs to make them stand out to the user.
*/
const S = '\u001b[45;1m SENTRY \u001b[0m';

let open: ((url: string) => Promise<void>) | undefined = undefined;

/**
* Open a URL in the system browser.
*
* Inspired by https://github.com/react-native-community/cli/blob/a856ce027a6b25f9363a8689311cdd4416c0fc89/packages/cli-server-api/src/openURLMiddleware.ts#L17
*/
export async function openURLMiddleware(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method !== 'POST') {
res.writeHead(405);
res.end('Method not allowed. Use POST.');
return;
}

if (!open) {
try {
// oxlint-disable-next-line import/no-extraneous-dependencies
const imported = require('open');
// Handle both CJS (`module.exports = fn`) and ESM default export (`{ default: fn }`)
// oxlint-disable-next-line typescript-eslint(no-unsafe-member-access)
open = typeof imported === 'function' ? imported : imported?.default;
} catch (e) {
// noop
}
}

const body = await getRawBody(req);
let url: string | undefined = undefined;

try {
const parsedBody = JSON.parse(body) as { url?: string };
url = parsedBody.url;
} catch (e) {
res.writeHead(400);
res.end('Invalid request body. Expected a JSON object with a url key.');
return;
}

if (!url) {
res.writeHead(400);
res.end('Invalid request body. Expected a JSON object with a url key.');
return;
}

if (!url.startsWith('https://') && !url.startsWith('http://')) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd only allow requests that has the host sentry.io to avoid any malicious code invoking any kind of url with this method.
This impact self-hosted users, but it's worth the risk,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what's the best way to approach that. Technically we shouldn't exclude those using self-hosted Sentry.

Copy link
Copy Markdown
Contributor

@alwx alwx Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An idea: if the host is not sentry.io then instead of automatically opening the link gets printed to the console with the note that you should only open it if you trust the host (so we basically jump to what's on lines 58-61)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @lucas-zimerman and thank you for the suggestion @alwx ๐Ÿ™‡
Applied it with ec8663b

res.writeHead(400);
res.end('Invalid URL scheme. Only http:// and https:// URLs are allowed.');
return;
}

if (!isTrustedSentryHost(url)) {
// oxlint-disable-next-line no-console
console.log(
`${S} Untrusted host, not opening automatically. Open manually if you trust this URL: ${sanitizeForLog(url)}`,
);
res.writeHead(200);
res.end();
return;
}

if (!open) {
// oxlint-disable-next-line no-console
console.log(`${S} Could not open URL automatically. Open manually: ${sanitizeForLog(url)}`);
res.writeHead(500);
res.end('Failed to open URL. The "open" package is not available. Install it or open the URL manually.');
return;
}

try {
await open(url);
} catch (e) {
// oxlint-disable-next-line no-console
console.log(`${S} Failed to open URL automatically. Open manually: ${sanitizeForLog(url)}`);
res.writeHead(500);
res.end('Failed to open URL.');
return;
}

// oxlint-disable-next-line no-console
console.log(`${S} Opened URL: ${sanitizeForLog(url)}`);
res.writeHead(200);
res.end();
}

/**
* Strip control characters to prevent terminal escape sequence injection when logging URLs.
*/
function sanitizeForLog(value: string): string {
// oxlint-disable-next-line no-control-regex
return value.replace(/[\x00-\x1f\x7f]/g, '');
}

function isTrustedSentryHost(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'sentry.io' || hostname.endsWith('.sentry.io');
} catch (e) {
return false;
}
}
38 changes: 9 additions & 29 deletions packages/core/src/js/playground/modal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { debug } from '@sentry/core';
import * as React from 'react';
import { Animated, Image, Modal, Platform, Pressable, Text, useColorScheme, View } from 'react-native';

import { getDevServer } from '../integrations/debugsymbolicatorutils';
import { isExpo, isExpoGo, isWeb } from '../utils/environment';
import { openURLInBrowser } from '../metro/openUrlInBrowser';
import { isExpoGo, isWeb } from '../utils/environment';
import { bug as bugAnimation, hi as hiAnimation, thumbsup as thumbsupAnimation } from './animations';
import { nativeCrashExample, tryCatchExample, uncaughtErrorExample } from './examples';
import { bug as bugImage, hi as hiImage, thumbsup as thumbsupImage } from './images';
Expand Down Expand Up @@ -71,7 +70,6 @@ export const SentryPlayground = ({
}
};

const showOpenSentryButton = !isExpo();
const isNativeCrashDisabled = isWeb() || isExpoGo() || __DEV__;

const animationContainerYPosition = React.useRef(new Animated.Value(0)).current;
Expand Down Expand Up @@ -158,15 +156,13 @@ export const SentryPlayground = ({
justifyContent: 'space-evenly', // Space between buttons
}}
>
{showOpenSentryButton && (
<Button
secondary
title={'Open Sentry'}
onPress={() => {
openURLInBrowser(issuesStreamUrl);
}}
/>
)}
<Button
secondary
title={'Open Sentry'}
onPress={() => {
openURLInBrowser(issuesStreamUrl);
}}
/>
<Button
title={'Go to my App'}
onPress={() => {
Expand Down Expand Up @@ -269,19 +265,3 @@ const Button = ({
</View>
);
};

function openURLInBrowser(url: string): void {
const devServer = getDevServer();
if (devServer?.url) {
// This doesn't work for Expo project with Web enabled
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
fetch(`${devServer.url}open-url`, {
method: 'POST',
body: JSON.stringify({ url }),
}).catch(e => {
debug.error('Error opening URL:', e);
});
} else {
debug.error('Dev server URL not available');
}
}
24 changes: 7 additions & 17 deletions packages/core/src/js/tools/metroMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { addContextToFrame, debug } from '@sentry/core';
import { readFile } from 'fs';
import { promisify } from 'util';

import { SENTRY_CONTEXT_REQUEST_PATH, SENTRY_OPEN_URL_REQUEST_PATH } from '../metro/constants';
import { getRawBody } from '../metro/getRawBody';
import { openURLMiddleware } from '../metro/openUrlMiddleware';

const readFileAsync = promisify(readFile);

/**
Expand Down Expand Up @@ -71,29 +75,15 @@ function badRequest(response: ServerResponse, message: string): void {
response.end(message);
}

function getRawBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
request.on('data', chunk => {
data += chunk;
});
request.on('end', () => {
resolve(data);
});
request.on('error', reject);
});
}

const SENTRY_MIDDLEWARE_PATH = '/__sentry';
const SENTRY_CONTEXT_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/context`;

/**
* Creates a middleware that adds source context to the Sentry formatted stack frames.
*/
export const createSentryMetroMiddleware = (middleware: Middleware): Middleware => {
return (request: IncomingMessage, response: ServerResponse, next: () => void) => {
if (request.url?.startsWith(SENTRY_CONTEXT_REQUEST_PATH)) {
if (request.url?.startsWith(`/${SENTRY_CONTEXT_REQUEST_PATH}`)) {
return stackFramesContextMiddleware(request, response, next);
} else if (request.url?.startsWith(`/${SENTRY_OPEN_URL_REQUEST_PATH}`)) {
return openURLMiddleware(request, response);
}
return (middleware as (req: IncomingMessage, res: ServerResponse, next: () => void) => void)(
request,
Expand Down
19 changes: 19 additions & 0 deletions packages/core/test/tools/metroMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StackFrame } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import * as fs from 'fs';

import * as openUrlMiddlewareModule from '../../src/js/metro/openUrlMiddleware';
import * as metroMiddleware from '../../src/js/tools/metroMiddleware';

const { withSentryMiddleware, createSentryMetroMiddleware, stackFramesContextMiddleware } = metroMiddleware;
Expand Down Expand Up @@ -83,6 +84,24 @@ describe('metroMiddleware', () => {
expect(spiedStackFramesContextMiddleware).toHaveBeenCalledWith(sentryRequest, response, next);
});

it('should call openURLMiddleware for sentry open-url requests', () => {
const spiedOpenURLMiddleware = jest
.spyOn(openUrlMiddlewareModule, 'openURLMiddleware')
.mockReturnValue(undefined as any);

const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware);

const openUrlRequest = {
url: '/__sentry/open-url',
} as any;
testedMiddleware(openUrlRequest, response, next);
expect(defaultMiddleware).not.toHaveBeenCalled();
expect(spiedStackFramesContextMiddleware).not.toHaveBeenCalled();
expect(spiedOpenURLMiddleware).toHaveBeenCalledWith(openUrlRequest, response);

spiedOpenURLMiddleware.mockRestore();
});

it('should call default middleware for non-sentry requests', () => {
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware);

Expand Down
Loading
Loading