diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7498c3507..d8fb30c9e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,23 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
-## 8.31.0
+## 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';
+
+
+ ```
+
+## 8.31.0
+
- 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/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts
index 0d6b223cc5..c70700abde 100644
--- a/packages/core/src/js/integrations/exports.ts
+++ b/packages/core/src/js/integrations/exports.ts
@@ -34,6 +34,7 @@ export { logEnricherIntegration } from './logEnricherIntegration';
export { graphqlIntegration } from './graphql';
export { supabaseIntegration } from './supabase';
export { deeplinkIntegration } from './deeplink';
+export { sentryWebViewProps } from './webview';
// User-facing integration option types. Re-exported from the entry point so their
// full shape lands in the API report and field-level changes are surfaced in its diff.
@@ -43,6 +44,7 @@ export type { ReactNativeErrorHandlersOptions } from './reactnativeerrorhandlers
export type { SpotlightReactNativeIntegrationOptions } from './spotlight';
export type { GraphQLReactNativeIntegrationOptions } from './graphql';
export type { SupabaseReactNativeIntegrationOptions } from './supabase';
+export type { SentryWebViewOptions } from './webview';
export type { HermesProfilingOptions } from '../profiling/integration';
export type { MobileReplayOptions, ScreenshotStrategy } from '../replay/mobilereplay';
export type { ReplayConfiguration } from '../replay/browserReplay';
diff --git a/packages/core/src/js/integrations/webview.ts b/packages/core/src/js/integrations/webview.ts
new file mode 100644
index 0000000000..99ea4ae07f
--- /dev/null
+++ b/packages/core/src/js/integrations/webview.ts
@@ -0,0 +1,154 @@
+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.
+ *
+ * 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`).
+ * 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 before its
+ * `@sentry/browser` initializes, so the page's pageload transaction and errors
+ * continue the same trace. The in-WebView page must run `@sentry/browser` for the
+ * connection to take effect.
+ *
+ * The root span name (`sentry-transaction`) is stripped from `baggage` before it
+ * is injected, as it is free-form text that may carry user data.
+ *
+ * A no-op `onMessage` is also returned: `react-native-webview` only executes
+ * injected JavaScript on Android when an `onMessage` handler is set, so this is
+ * required for the injection to run there. If you need your own `onMessage`, spread
+ * these props first and set `onMessage` after them — any handler keeps the injection
+ * running on Android.
+ *
+ * `injectedJavaScriptBeforeContentLoaded` is a single string, not a list. Spreading
+ * these props and then setting your own `injectedJavaScriptBeforeContentLoaded`
+ * after replaces Sentry's injection and silently disables trace linking (and setting
+ * it before the spread drops yours). If you have your own before-content script,
+ * concatenate it with the returned one instead of overriding it.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * @example
+ * Composing with your own `injectedJavaScriptBeforeContentLoaded`:
+ * ```tsx
+ * const { injectedJavaScriptBeforeContentLoaded, onMessage } =
+ * sentryWebViewProps({ allowedHosts: ['example.com'] });
+ *
+ *
+ * ```
+ */
+export function sentryWebViewProps(options: SentryWebViewOptions): {
+ injectedJavaScriptBeforeContentLoaded: string;
+ onMessage: () => void;
+} {
+ return {
+ injectedJavaScriptBeforeContentLoaded: createSentryWebViewInjection(options),
+ // Required on Android: `react-native-webview` only runs injected JavaScript
+ // when an `onMessage` handler is present.
+ onMessage: () => {
+ // noop
+ },
+ };
+}
+
+/**
+ * Builds the script run inside the WebView before content loads. Returns an empty
+ * string when there is no active trace to propagate.
+ */
+function createSentryWebViewInjection({ allowedHosts }: SentryWebViewOptions): string {
+ const traceData: SerializedTraceData = getTraceData();
+ const sentryTrace = traceData['sentry-trace'];
+
+ if (!sentryTrace) {
+ debug.log(`[${WEBVIEW_LABEL}] No active trace found; skipping WebView trace injection.`);
+ return '';
+ }
+
+ // Values are embedded via JSON.stringify so any characters in `baggage` are
+ // safely escaped. `allowedHosts` is checked against `location.hostname` inside
+ // the page: the script re-runs on every document load, so each navigation is
+ // gated on its own host rather than the one the WebView was opened with. The
+ // script is idempotent — if a `sentry-trace` meta already exists (e.g. injected
+ // by another path), it does nothing, so the trace context is never duplicated.
+ const allowedHostsLiteral = JSON.stringify((allowedHosts ?? []).map(host => host.toLowerCase()));
+ const sentryTraceLiteral = JSON.stringify(sentryTrace);
+ const baggageLiteral = JSON.stringify(stripTransactionFromBaggage(traceData.baggage) ?? null);
+
+ return `(function(){try{
+var allowed=${allowedHostsLiteral};
+var host=(window.location&&window.location.hostname||'').toLowerCase();
+if(!host)return;
+if(!allowed.some(function(h){return host===h||host.slice(-(h.length+1))==='.'+h;}))return;
+if(document.querySelector('meta[name="sentry-trace"]'))return;
+var head=document.head||document.documentElement;
+if(!head)return;
+var set=function(n,c){if(!c)return;var m=document.createElement('meta');m.setAttribute('name',n);m.setAttribute('content',c);head.appendChild(m);};
+set('sentry-trace',${sentryTraceLiteral});
+set('baggage',${baggageLiteral});
+}catch(e){}})();
+true;`;
+}
+
+/**
+ * Removes the `sentry-transaction` entry from a `baggage` header before it is
+ * injected into the WebView. The root span name is free-form text that may carry
+ * user data, while every other Dynamic Sampling Context entry (release,
+ * environment, sample rate, etc.) is kept so sampling stays consistent across the
+ * connected traces. Any non-Sentry (third-party) baggage entries are preserved.
+ *
+ * Returns `undefined` when there is no baggage to propagate after trimming.
+ */
+function stripTransactionFromBaggage(baggage: string | undefined): string | undefined {
+ if (!baggage) {
+ return undefined;
+ }
+
+ // A non-empty header always parses to an object; `?? {}` only satisfies the type.
+ const parsed = parseBaggageHeader(baggage) ?? {};
+
+ // Rebuild without the transaction entry rather than deleting it in place.
+ const safe: Record = {};
+ for (const key of Object.keys(parsed)) {
+ const value = parsed[key];
+ if (key !== SENTRY_TRANSACTION_BAGGAGE_KEY && value !== undefined) {
+ safe[key] = value;
+ }
+ }
+
+ // `objectToBaggageHeader` returns `undefined` when nothing is left to propagate.
+ return objectToBaggageHeader(safe);
+}
diff --git a/packages/core/test/integrations/webview.test.ts b/packages/core/test/integrations/webview.test.ts
new file mode 100644
index 0000000000..353517b2a0
--- /dev/null
+++ b/packages/core/test/integrations/webview.test.ts
@@ -0,0 +1,344 @@
+import { getTraceData } from '@sentry/core';
+
+import { sentryWebViewProps } from '../../src/js/integrations/webview';
+
+jest.mock('@sentry/core', () => {
+ const actual = jest.requireActual('@sentry/core');
+ return {
+ ...actual,
+ getTraceData: jest.fn(),
+ };
+});
+
+const mockGetTraceData = getTraceData as jest.Mock;
+
+const SENTRY_TRACE = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1';
+const BAGGAGE = 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod';
+
+/**
+ * Runs the generated injection script against a minimal DOM and returns the meta
+ * tags present afterwards. `existing` pre-seeds meta tags already in the document.
+ */
+function runInjection(
+ script: string,
+ hostname: string,
+ existing: Array<{ name: string; content: string }> = [],
+): Array<{ name: string; content: string }> {
+ const metas: Array<{ name: string; content: string }> = [...existing];
+ const head = {
+ appendChild: (el: { name: string; content: string }) => metas.push({ name: el.name, content: el.content }),
+ };
+ const fakeDocument = {
+ head,
+ documentElement: head,
+ createElement: () => {
+ const el: { name: string; content: string; setAttribute: (k: string, v: string) => void } = {
+ name: '',
+ content: '',
+ setAttribute(k: string, v: string) {
+ (this as unknown as Record)[k] = v;
+ },
+ };
+ return el;
+ },
+ querySelector: (selector: string) => {
+ const match = /meta\[name="([^"]+)"\]/.exec(selector);
+ if (!match) {
+ return null;
+ }
+ const found = metas.find(m => m.name === match[1]);
+ return found ? { ...found } : null;
+ },
+ };
+ const fakeWindow = { location: { hostname } };
+
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
+ new Function('window', 'document', script)(fakeWindow, fakeDocument);
+ return metas;
+}
+
+describe('sentryWebViewProps', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when there is an active trace', () => {
+ beforeEach(() => {
+ mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE, baggage: BAGGAGE });
+ });
+
+ it('injects sentry-trace and baggage meta tags on an allow-listed host', () => {
+ // Arrange
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert
+ expect(metas).toEqual([
+ { name: 'sentry-trace', content: SENTRY_TRACE },
+ { name: 'baggage', content: BAGGAGE },
+ ]);
+ });
+
+ it('injects on a subdomain of an allow-listed host', () => {
+ // Arrange
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'app.example.com');
+
+ // Assert
+ expect(metas).toHaveLength(2);
+ });
+
+ it('matches allow-listed hosts case-insensitively', () => {
+ // Arrange
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['Example.COM'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'app.example.com');
+
+ // Assert
+ expect(metas).toHaveLength(2);
+ });
+
+ it('does not inject on a host that is not allow-listed', () => {
+ // Arrange
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'evil.com');
+
+ // Assert
+ expect(metas).toHaveLength(0);
+ });
+
+ it('does not inject on a host that merely ends with an allow-listed host', () => {
+ // Arrange
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'notexample.com');
+
+ // Assert
+ expect(metas).toHaveLength(0);
+ });
+ });
+
+ describe('when the page already has a sentry-trace meta', () => {
+ it('does not inject again (idempotent)', () => {
+ // Arrange
+ mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE, baggage: BAGGAGE });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'example.com', [
+ { name: 'sentry-trace', content: 'already-there' },
+ ]);
+
+ // Assert
+ expect(metas).toEqual([{ name: 'sentry-trace', content: 'already-there' }]);
+ });
+ });
+
+ describe('when baggage is absent', () => {
+ it('injects only the sentry-trace meta tag', () => {
+ // Arrange
+ mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert
+ expect(metas).toEqual([{ name: 'sentry-trace', content: SENTRY_TRACE }]);
+ });
+ });
+
+ describe('when baggage carries a transaction entry', () => {
+ it('strips sentry-transaction but keeps every other entry', () => {
+ // Arrange
+ mockGetTraceData.mockReturnValue({
+ 'sentry-trace': SENTRY_TRACE,
+ baggage:
+ 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod,' +
+ 'sentry-transaction=GET%20%2Fusers%2F123,sentry-sample_rate=1.0,othervendor=keepme',
+ });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert
+ const baggageMeta = metas.find(m => m.name === 'baggage');
+ expect(baggageMeta?.content).toBe(
+ 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod,sentry-sample_rate=1.0,othervendor=keepme',
+ );
+ expect(baggageMeta?.content).not.toContain('sentry-transaction');
+ });
+
+ it('drops the baggage meta entirely when transaction is the only entry', () => {
+ // Arrange
+ mockGetTraceData.mockReturnValue({
+ 'sentry-trace': SENTRY_TRACE,
+ baggage: 'sentry-transaction=GET%20%2Fusers%2F123',
+ });
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Act
+ const metas = runInjection(injectedJavaScriptBeforeContentLoaded, 'example.com');
+
+ // Assert
+ expect(metas).toEqual([{ name: 'sentry-trace', content: SENTRY_TRACE }]);
+ });
+ });
+
+ describe('when there is no active trace', () => {
+ it('returns an empty injection script', () => {
+ // Arrange
+ mockGetTraceData.mockReturnValue({});
+
+ // Act
+ const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Assert
+ expect(injectedJavaScriptBeforeContentLoaded).toBe('');
+ });
+ });
+
+ describe('returned props', () => {
+ it('include a no-op onMessage (required for injected JS to run on Android)', () => {
+ // Arrange
+ mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE });
+
+ // Act
+ const props = sentryWebViewProps({ allowedHosts: ['example.com'] });
+
+ // Assert
+ expect(typeof props.onMessage).toBe('function');
+ expect(props.onMessage()).toBeUndefined();
+ });
+ });
+
+ // The injected script embeds trace values via JSON.stringify. These tests feed
+ // hostile values through every embedding point and assert they can only ever be
+ // read back as inert meta content — never executed as code. A breakout would set
+ // the `__pwned` canary on the fuzzed window.
+ describe('fuzzing: embedded values cannot break out of the injected script', () => {
+ /** Runs the script against a fresh DOM and reports whether a breakout executed. */
+ function runFuzz(
+ script: string,
+ hostname: string,
+ ): { metas: Array<{ name: string; content: string }>; pwned: boolean } {
+ const metas: Array<{ name: string; content: string }> = [];
+ const head = {
+ appendChild: (el: { name: string; content: string }) => metas.push({ name: el.name, content: el.content }),
+ };
+ const fakeDocument = {
+ head,
+ documentElement: head,
+ createElement: () => ({
+ name: '',
+ content: '',
+ setAttribute(k: string, v: string) {
+ (this as unknown as Record)[k] = v;
+ },
+ }),
+ querySelector: () => null,
+ };
+ const fakeWindow: { location: { hostname: string }; __pwned?: boolean } = { location: { hostname } };
+
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
+ new Function('window', 'document', script)(fakeWindow, fakeDocument);
+ return { metas, pwned: fakeWindow.__pwned === true };
+ }
+
+ // Each payload attempts a different escape from the JSON string literal it is
+ // embedded in, then tries to execute `window.__pwned=1`.
+ const BREAKOUT_PAYLOADS: Array<[name: string, payload: string]> = [
+ ['double-quote close', '");window.__pwned=1;("'],
+ ['single-quote close', "');window.__pwned=1;('"],
+ ['backtick / template', '`;window.__pwned=1;`'],
+ ['template substitution', '${window.__pwned=1}'],
+ ['script tag', ''],
+ ['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= ,';
+ for (let i = 0; i < 200; i++) {
+ const length = 1 + Math.floor(Math.random() * 40);
+ let payload = '';
+ for (let c = 0; c < length; c++) {
+ payload += CHARS[Math.floor(Math.random() * 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..9853630a09 100644
--- a/samples/react-native/src/Screens/WebviewScreen.tsx
+++ b/samples/react-native/src/Screens/WebviewScreen.tsx
@@ -1,19 +1,158 @@
+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 = () => {
+ // The trace active in React Native when the WebView mounts — this is what gets injected.
+ const rnTrace = getTraceData()['sentry-trace'];
+ 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() {
-
+