diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5dbb6ecae..3cd9c30126 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,14 +8,25 @@
## Unreleased
+### Features
+
+- Connect React Native traces with embedded WebViews via `sentryWebViewProps` ([#6794](https://github.com/getsentry/sentry-react-native/pull/6794))
+
+ ```jsx
+ import { sentryWebViewProps } from '@sentry/react-native';
+
+
+ ```
+
### Fixes
- Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911))
## 8.31.0
-### Features
-
- Add the `applicationKey` and `moduleMetadata` options to `withSentryConfig` and `getSentryExpoConfig`, and export `thirdPartyErrorFilterIntegration` ([#6863](https://github.com/getsentry/sentry-react-native/pull/6863))
- Set the same key in `applicationKey` and in the `filterKeys` option of `thirdPartyErrorFilterIntegration` to filter errors from third-party code, for example browser extensions on Expo web.
diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md
index f666d3121e..87ace5d5e5 100644
--- a/packages/core/etc/sentry-react-native.api.md
+++ b/packages/core/etc/sentry-react-native.api.md
@@ -1081,6 +1081,17 @@ export type SentryReplayQuality = 'low' | 'medium' | 'high';
export function sentryTraceGesture(
label: string, gesture: GestureT): GestureT;
+// @public (undocumented)
+export interface SentryWebViewOptions {
+ allowedHosts: string[];
+}
+
+// @public
+export function sentryWebViewProps(options: SentryWebViewOptions): {
+ injectedJavaScriptBeforeContentLoaded: string;
+ onMessage: () => void;
+};
+
export { setAttribute }
export { setAttributes }
diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts
index 6581a17422..5621c60528 100644
--- a/packages/core/src/js/index.ts
+++ b/packages/core/src/js/index.ts
@@ -149,9 +149,17 @@ export {
wrapExpoRouterErrorBoundary,
wrapExpoImage,
wrapExpoAsset,
+ sentryWebViewProps,
} from './tracing';
-export type { TimeToDisplayProps, ExpoRouter, ExpoRouterErrorBoundaryProps, ExpoImage, ExpoAsset } from './tracing';
+export type {
+ TimeToDisplayProps,
+ ExpoRouter,
+ ExpoRouterErrorBoundaryProps,
+ ExpoImage,
+ ExpoAsset,
+ SentryWebViewOptions,
+} from './tracing';
export type { ReactNativeTracingOptions } from './tracing/reactnativetracing';
export type { ReactNativeNavigationOptions } from './tracing/reactnativenavigation';
diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts
index a5780bb648..e8411609fb 100644
--- a/packages/core/src/js/tracing/index.ts
+++ b/packages/core/src/js/tracing/index.ts
@@ -31,6 +31,9 @@ export { ReactNativeProfiler } from './reactnativeprofiler';
export { sentryTraceGesture } from './gesturetracing';
+export { sentryWebViewProps } from './webview';
+export type { SentryWebViewOptions } from './webview';
+
export * from './ops';
export * from './timetodisplay';
diff --git a/packages/core/src/js/tracing/webview.ts b/packages/core/src/js/tracing/webview.ts
new file mode 100644
index 0000000000..12b3fb38a4
--- /dev/null
+++ b/packages/core/src/js/tracing/webview.ts
@@ -0,0 +1,213 @@
+import type { SerializedTraceData } from '@sentry/core';
+
+import { debug, getTraceData, objectToBaggageHeader, parseBaggageHeader } from '@sentry/core';
+
+const WEBVIEW_LABEL = 'Sentry WebView';
+
+/**
+ * `baggage` key carrying the root span's name. It is free-form text and can
+ * contain user data (for example a parameterized URL), so it is removed from the
+ * trace context before it leaves the app and reaches the WebView page.
+ */
+const SENTRY_TRANSACTION_BAGGAGE_KEY = 'sentry-transaction';
+
+export interface SentryWebViewOptions {
+ /**
+ * Hosts whose pages receive the injected Sentry trace context.
+ *
+ * Provide bare hostnames. A page is matched when its hostname equals an entry or
+ * is a subdomain of it (e.g. `'example.com'` matches both `example.com` and
+ * `app.example.com`). Entries are normalized before comparison — surrounding
+ * whitespace, a scheme, a path, a port, and a trailing dot are stripped, and
+ * matching is case-insensitive. Only listed hosts are instrumented, so the trace
+ * context — which can carry release and environment data in `baggage` — never
+ * reaches third-party pages loaded in the WebView.
+ */
+ allowedHosts: string[];
+}
+
+/**
+ * Props to spread onto a `react-native-webview` `` to connect its traces
+ * with the React Native app's trace.
+ *
+ * The returned `injectedJavaScriptBeforeContentLoaded` writes the current trace as
+ * `sentry-trace` / `baggage` `` tags into the WebView document during its
+ * initial load, so a `@sentry/browser` running in the page continues the same trace
+ * on pageload (its pageload transaction and errors share the trace). The in-WebView
+ * page must run `@sentry/browser` for the connection to take effect.
+ *
+ * Timing note: on iOS the tags are written before any page script runs. On Android
+ * `react-native-webview` injects the script asynchronously as the page starts, so
+ * the tags can land slightly after the page's first synchronous `` scripts.
+ * `@sentry/browser` loaded the usual way — from a `'],
+ ['escaped backslash then quote', '\\";window.__pwned=1;//'],
+ ['statement + comment', '"}]);window.__pwned=1;//'],
+ ['newline injection', '\nwindow.__pwned=1;\n'],
+ ['carriage return injection', '\rwindow.__pwned=1;\r'],
+ ['U+2028 line separator', '\u2028window.__pwned=1;'],
+ ['U+2029 paragraph separator', '\u2029window.__pwned=1;'],
+ ['null byte', 'abc\u0000;window.__pwned=1;'],
+ ];
+
+ describe('via the sentry-trace value', () => {
+ it.each(BREAKOUT_PAYLOADS)('neutralizes %s', (_name, payload) => {
+ // Arrange
+ mockGetTraceData.mockReturnValue({ 'sentry-trace': payload });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const { metas, pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert — the payload is preserved verbatim as inert content, never run.
+ expect(pwned).toBe(false);
+ expect(metas).toEqual([{ name: 'sentry-trace', content: payload }]);
+ });
+ });
+
+ describe('via a baggage entry value', () => {
+ it.each(BREAKOUT_PAYLOADS)('neutralizes %s', (_name, payload) => {
+ // Arrange — carry the payload as a URL-encoded baggage entry value.
+ mockGetTraceData.mockReturnValue({
+ 'sentry-trace': SENTRY_TRACE,
+ baggage: `sentry-release=${encodeURIComponent(payload)}`,
+ });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const { pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert
+ expect(pwned).toBe(false);
+ });
+ });
+
+ describe('via an allowedHosts entry', () => {
+ it.each(BREAKOUT_PAYLOADS)('neutralizes %s', (_name, payload) => {
+ // Arrange — a hostile host alongside the real one the page is served from.
+ mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({
+ allowedHosts: [payload, 'example.com'],
+ });
+
+ // Act
+ const { pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert
+ expect(pwned).toBe(false);
+ });
+ });
+
+ it('preserves arbitrary random sentry-trace values as inert content', () => {
+ // Arrange — property-style fuzzing over a dangerous character set.
+ const CHARS = '"\'`\\${}();/<>\n\r\t\u2028\u2029abc0= ,';
+ // Seeded PRNG (Park–Miller MINSTD) keeps the run deterministic, so a failing
+ // case is reproducible instead of depending on `Math.random()`. Uses only
+ // arithmetic (stays within safe-integer range) to satisfy the no-bitwise rule.
+ let seed = 123456789;
+ const rand = (): number => {
+ seed = (seed * 48271) % 2147483647;
+ return seed / 2147483647;
+ };
+ for (let i = 0; i < 200; i++) {
+ const length = 1 + Math.floor(rand() * 40);
+ let payload = '';
+ for (let c = 0; c < length; c++) {
+ payload += CHARS[Math.floor(rand() * CHARS.length)];
+ }
+ mockGetTraceData.mockReturnValue({ 'sentry-trace': payload });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const { metas, pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert — never executes, always round-trips as content.
+ expect(pwned).toBe(false);
+ expect(metas).toEqual([{ name: 'sentry-trace', content: payload }]);
+ mockGetTraceData.mockReset();
+ }
+ });
+ });
+});
diff --git a/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.ts b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.ts
new file mode 100644
index 0000000000..b740ee864c
--- /dev/null
+++ b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from '@jest/globals';
+
+import { maestro } from '../../utils/maestro';
+
+describe('WebView trace connection', () => {
+ it('injects the React Native trace into the WebView, which reads it back', async () => {
+ // The check runs on-device in the Maestro flow: it opens the WebView demo
+ // screen and asserts the "WebView trace connected" status becomes visible,
+ // which only happens when the page reads back the injected `sentry-trace`
+ // meta tag and it matches the trace active in React Native. A failed
+ // injection makes the flow exit non-zero, rejecting this promise.
+ await expect(
+ maestro('tests/webViewTraceConnection/webViewTraceConnection.test.yml'),
+ ).resolves.toBeUndefined();
+ }, 240000);
+});
diff --git a/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.yml b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.yml
new file mode 100644
index 0000000000..90dc2babb9
--- /dev/null
+++ b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.yml
@@ -0,0 +1,29 @@
+appId: io.sentry.reactnative.sample
+---
+- launchApp:
+ clearState: true
+ stopApp: true
+ arguments:
+ isE2ETest: true
+
+- tapOn:
+ id: 'playground-tab-icon'
+
+- extendedWaitUntil:
+ visible:
+ id: 'webview-example-button'
+ timeout: 30000
+
+- tapOn:
+ id: 'webview-example-button'
+
+# The WebView loads the demo page, which reads the injected `sentry-trace` meta
+# tag and posts it back to React Native. React Native compares it against the
+# trace that was active when the screen mounted and flips the status to connected.
+# If the trace context is not injected or not readable in the page, the status
+# stays "not connected" and this assertion times out (failing the flow).
+- extendedWaitUntil:
+ visible:
+ id: 'webview-trace-status'
+ text: 'WebView trace connected.*'
+ timeout: 30000
diff --git a/samples/react-native/src/Screens/WebviewScreen.tsx b/samples/react-native/src/Screens/WebviewScreen.tsx
index a891b87bd6..cec07ee088 100644
--- a/samples/react-native/src/Screens/WebviewScreen.tsx
+++ b/samples/react-native/src/Screens/WebviewScreen.tsx
@@ -1,19 +1,163 @@
+import { getTraceData, SDK_VERSION } from '@sentry/core';
+import * as Sentry from '@sentry/react-native';
import * as React from 'react';
-import { StyleSheet } from 'react-native';
+import { StyleSheet, Text, View } from 'react-native';
import { WebView } from 'react-native-webview';
+import { getCurrentDsn, SENTRY_INTERNAL_DSN } from '../dsn';
+
+// The trace context is only injected into pages served from an allow-listed host.
+// `source.html` is loaded under `baseUrl`, so the page's `location.hostname`
+// matches this entry and the injection runs.
+const ALLOWED_HOST = 'sentry-webview-demo.local';
+const BASE_URL = `https://${ALLOWED_HOST}/`;
+
+// The in-WebView page runs `@sentry/browser`, so its pageload transaction and
+// errors continue the trace injected by `sentryWebViewProps` — this is what a real
+// app does. `@sentry/browser` is loaded from the CDN pinned to `SDK_VERSION` (the
+// `@sentry/core` version this SDK bundles), so the page and the app always share the
+// same JS SDK version. The page also reads the injected `sentry-trace` meta tag and
+// posts it back to React Native, so the connection stays observable on-device even
+// offline, when the CDN script can't load.
+function buildDemoPage(dsn: string): string {
+ return `
+
+
+
+
+
+
+
+
WebView page
+
Injected sentry-trace:
+
(reading…)
+
+
+`;
+}
+
const WebviewScreen = () => {
+ // Capture the trace and the injection props once at mount. `getTraceData()` is
+ // read on every render, and the active `sentry-trace` changes (its `span_id`)
+ // once the navigation span ends — so re-reading it later would compare the
+ // WebView's readback against a different value and flip the status to "not
+ // connected" even though the page received the injected trace correctly.
+ const [rnTrace] = React.useState(() => getTraceData()['sentry-trace']);
+ const webViewProps = React.useMemo(() => Sentry.sentryWebViewProps({ allowedHosts: [ALLOWED_HOST] }), []);
+ const [received, setReceived] = React.useState(null);
+
+ const matches = received != null && received === rnTrace;
+
+ // The WebView page's `@sentry/browser` reports to the same DSN as the app, so the
+ // connected pageload shows up in the same project. Built once per mount.
+ const demoPage = React.useMemo(() => buildDemoPage(getCurrentDsn() ?? SENTRY_INTERNAL_DSN), []);
+
return (
-
+
+
+ React Native trace
+
+ {rnTrace ?? '(no active trace)'}
+
+ WebView received
+
+ {received ?? '(waiting…)'}
+
+
+ {matches ? 'WebView trace connected ✓' : 'WebView trace not connected'}
+
+
+ {
+ try {
+ const data = JSON.parse(event.nativeEvent.data);
+ setReceived(data.sentryTrace ?? '(none)');
+ } catch {
+ // ignore non-JSON messages from the page
+ }
+ }}
+ style={styles.webview}
+ />
+
);
};
export default WebviewScreen;
const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ panel: {
+ padding: 16,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: '#ccc',
+ },
+ label: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#888',
+ marginTop: 8,
+ },
+ value: {
+ fontSize: 13,
+ fontFamily: 'Courier',
+ color: '#1e1b2e',
+ },
+ status: {
+ marginTop: 12,
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ match: {
+ color: '#2e7d32',
+ },
+ pending: {
+ color: '#b26a00',
+ },
webview: {
flex: 1,
},
diff --git a/samples/react-native/src/tabs/PlaygroundTab.tsx b/samples/react-native/src/tabs/PlaygroundTab.tsx
index 2d3b3ce713..6bd49544a6 100644
--- a/samples/react-native/src/tabs/PlaygroundTab.tsx
+++ b/samples/react-native/src/tabs/PlaygroundTab.tsx
@@ -5,9 +5,11 @@ import {
ImageBackground,
Keyboard,
KeyboardAvoidingView,
+ Platform,
Pressable,
SafeAreaView,
ScrollView,
+ StatusBar,
StyleSheet,
Text,
TextInput,
@@ -41,6 +43,11 @@ const styles = StyleSheet.create({
padding: 5,
flex: 1,
},
+ // RN's SafeAreaView is a no-op on Android, so clear the status bar explicitly
+ // to keep the first button tappable (and out from under the status bar).
+ scrollContent: {
+ paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 0,
+ },
separator: {
marginVertical: 8,
},
@@ -68,10 +75,11 @@ export default function getPlaygroundTab() {
-
+