Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3847399
feat(tracing): Connect React Native traces with embedded WebViews
antonis Sep 28, 2026
02a8c01
docs(changelog): Link WebView trace connection entry to #6794
antonis Sep 28, 2026
f6fe535
docs(changelog): Add sentryWebViewProps usage example
antonis Sep 28, 2026
80e7457
fix(tracing): Return no-op onMessage so WebView injection runs on Andโ€ฆ
antonis Sep 28, 2026
910298b
test(e2e): Make WebView sample and flow robust on Android
antonis Sep 28, 2026
e273db0
fix(tracing): Make WebView trace injection idempotent
antonis Sep 28, 2026
7be1cfe
Merge branch 'main' into feat/webview-trace-connection
antonis Oct 1, 2026
d9e21c5
Move changelog to the unreleased section
antonis Oct 1, 2026
531d0cb
Merge branch 'main' into feat/webview-trace-connection
antonis Oct 14, 2026
714acc2
fix(core): strip sentry-transaction from injected WebView baggage
antonis Oct 14, 2026
87043de
test(core): fuzz WebView trace injection against script breakout
antonis Oct 14, 2026
9f89d96
test(sample): Inject webview configuration for e2e test
antonis Oct 14, 2026
1627eee
docs(core): document composing injectedJavaScriptBeforeContentLoaded
antonis Oct 14, 2026
8927fb9
Merge branch 'main' into feat/webview-trace-connection
antonis Oct 15, 2026
02d47b4
Merge remote-tracking branch 'origin/main' into feat/webview-trace-coโ€ฆ
antonis Oct 16, 2026
13de007
fix(sample): capture WebView trace once at mount to avoid flaky status
antonis Oct 16, 2026
4dbffe6
Merge remote-tracking branch 'origin/main' into feat/webview-trace-coโ€ฆ
antonis Oct 19, 2026
746da1a
refactor(tracing): address review feedback on sentryWebViewProps
antonis Oct 19, 2026
06b5985
fix(tracing): avoid polynomial regex in WebView host normalization
antonis Oct 19, 2026
b2eb4fa
Merge branch 'main' into feat/webview-trace-connection
antonis Oct 19, 2026
727d6cb
docs(tracing): clarify WebView injection timing for Android
antonis Oct 19, 2026
1266495
Merge remote-tracking branch 'origin/main' into feat/webview-trace-coโ€ฆ
antonis Oct 19, 2026
d9557a8
fix(tracing): handle IPv6 literals in WebView allowedHosts normalization
antonis Oct 19, 2026
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
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment thread
antonis marked this conversation as resolved.

```jsx
import { sentryWebViewProps } from '@sentry/react-native';

<WebView
source={{ uri: 'https://example.com' }}
{...sentryWebViewProps({ allowedHosts: ['example.com'] })}
/>
```

### 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.

Expand Down
11 changes: 11 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,17 @@ export type SentryReplayQuality = 'low' | 'medium' | 'high';
export function sentryTraceGesture<GestureT>(
label: string, gesture: GestureT): GestureT;

// @public (undocumented)
export interface SentryWebViewOptions {
allowedHosts: string[];
}

// @public
export function sentryWebViewProps(options: SentryWebViewOptions): {
injectedJavaScriptBeforeContentLoaded: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: Thinking forward this could potentially grow into multiple events being supported. Maybe it makes sense to prepare and use a structured approach, e.g.:

Suggested change
injectedJavaScriptBeforeContentLoaded: string;
injectedCustomScripts: {
beforeContentLoaded: string
}

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.

Thank you @philprime ๐Ÿ™‡ I surfaced the underlying component api for this one but I'll consider future proofing it.

onMessage: () => void;
};

export { setAttribute }

export { setAttributes }
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/js/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
213 changes: 213 additions & 0 deletions packages/core/src/js/tracing/webview.ts
Original file line number Diff line number Diff line change
@@ -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` `<WebView>` to connect its traces
* with the React Native app's trace.
*
* The returned `injectedJavaScriptBeforeContentLoaded` writes the current trace as
* `sentry-trace` / `baggage` `<meta>` 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 `<head>` scripts.
* `@sentry/browser` loaded the usual way โ€” from a `<script>` tag, a bundle, or an
* `async`/deferred init โ€” reads the tags after they're present and continues the
* trace; only a `@sentry/browser` initialized synchronously inline at the very top
* of `<head>` may run before the tags exist on Android and start a new trace. In
* that case the WebView simply gets its own trace; nothing else is affected.
*
* 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
* <WebView
* source={{ uri: 'https://example.com' }}
* {...sentryWebViewProps({ allowedHosts: ['example.com'] })}
* />
* ```
*
* @example
* Composing with your own `injectedJavaScriptBeforeContentLoaded`:
* ```tsx
* const { injectedJavaScriptBeforeContentLoaded, onMessage } =
* sentryWebViewProps({ allowedHosts: ['example.com'] });
*
* <WebView
* source={{ uri: 'https://example.com' }}
* onMessage={onMessage}
* injectedJavaScriptBeforeContentLoaded={myScript + '\n' + injectedJavaScriptBeforeContentLoaded}
* />
* ```
*/
export function sentryWebViewProps(options: SentryWebViewOptions): {
injectedJavaScriptBeforeContentLoaded: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: See my comment above w.r.t. future-proofing

onMessage: () => void;
} {
return {
injectedJavaScriptBeforeContentLoaded: createSentryWebViewInjection(options),
// Required on Android: `react-native-webview` only runs injected JavaScript
// when an `onMessage` handler is present.
onMessage: () => {
// noop
},
};
}
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
antonis marked this conversation as resolved.

/**
* 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 {
// `allowedHosts` is required, but validate at runtime so a misuse (or an empty
// list) degrades to a no-op with a warning instead of throwing during render.
// Entries are normalized to bare lowercase hostnames so they match the page's
// `location.hostname`.
const normalizedHosts = (Array.isArray(allowedHosts) ? allowedHosts : [])
.map(normalizeHost)
.filter(host => host.length > 0);
if (normalizedHosts.length === 0) {
debug.warn(`[${WEBVIEW_LABEL}] No valid allowedHosts provided; skipping WebView trace injection.`);
return '';
}

const traceData: SerializedTraceData = getTraceData();
const sentryTrace = traceData['sentry-trace'];

if (!sentryTrace) {
debug.log(
`[${WEBVIEW_LABEL}] No trace data available (the SDK is disabled or not initialized); skipping WebView trace injection.`,
);
return '';
Comment thread
lucas-zimerman marked this conversation as resolved.
}

// 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(normalizedHosts);
const sentryTraceLiteral = JSON.stringify(sentryTrace);
const baggageLiteral = JSON.stringify(stripTransactionFromBaggage(traceData.baggage) ?? null);

return `(function(){try{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: Is this safe for code injection attacks? Just thinking if any of the values are from external systems/cookies etc. and could be abused by an attacker.

cc @NinjaLikesCheez as you are a security expert

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should absolutely engage the security team as early as possible in the design of this feature - here be dragons.

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 point ๐Ÿ‘ Marking as blocked to get more feedback on the security side of the changes.

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.

Check RN-796 for a link to the security review

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;

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.

Q: allowedHosts never get normalized, right? Because I see the comparison is pretty strict (host===h) and I guess the normalization of allowedHosts needs to be done for this to work properly.

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 idea to enhance this. Updated with 746da1a

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;`;
}

/**
* Normalizes an `allowedHosts` entry to a bare lowercase hostname so it can be
* compared against the page's `location.hostname`. Tolerates entries that
* accidentally include surrounding whitespace, a scheme, a path, a port, or a
* trailing dot (e.g. `' https://App.Example.com:8080/x '` โ†’ `'app.example.com'`).
* IPv6 literals are bracketed to match `location.hostname` (`'::1'` โ†’ `'[::1]'`,
* `'[::1]:8080'` โ†’ `'[::1]'`).
*/
function normalizeHost(host: string): string {
const withoutScheme = host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // leading scheme://
// Drop everything from the first path/query/fragment separator. Uses
// `search` + `slice` rather than a `.*`-anchored regex to avoid polynomial
// backtracking (CodeQL flags `[/?#].*$`).
const pathStart = withoutScheme.search(/[/?#]/);
let hostAndPort = pathStart === -1 ? withoutScheme : withoutScheme.slice(0, pathStart);

// Strip the port without mistaking an IPv6 hextet for one. Browsers bracket IPv6
// in `location.hostname` (e.g. `[::1]`), so: a bracketed host keeps everything up
// to the closing bracket (dropping any `:port` after it); an unbracketed IPv6
// literal (more than one colon) is wrapped in brackets so it still matches; a
// regular hostname or IPv4 keeps its port-stripping.
const closingBracket = hostAndPort.indexOf(']');
if (closingBracket !== -1) {
hostAndPort = hostAndPort.slice(0, closingBracket + 1);
} else if ((hostAndPort.match(/:/g) ?? []).length > 1) {
hostAndPort = `[${hostAndPort}]`;
} else {
hostAndPort = hostAndPort.replace(/:\d+$/, '');
}

return hostAndPort
.replace(/\.$/, '') // trailing dot
.toLowerCase();
}

/**
* 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<string, string> = {};
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);
}
Loading
Loading