Skip to content

Commit 9dda75e

Browse files
antonisclaude
andauthored
feat(tracing): Connect React Native traces with embedded WebViews (#6794)
* feat(tracing): Connect React Native traces with embedded WebViews Add `sentryWebViewProps({ allowedHosts })` which injects the React Native trace context (`sentry-trace`/`baggage` meta tags, via `injectedJavaScriptBeforeContentLoaded`) into a `react-native-webview`, so the page's `@sentry/browser` continues the same trace on pageload. Host-gated by `allowedHosts` (case-insensitive, subdomain match) so the context never reaches non-allow-listed pages. Pure JS, no native/bridge changes, both architectures. Also adds a sample WebView demo screen and an iOS/Android Maestro E2E. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changelog): Link WebView trace connection entry to #6794 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changelog): Add sentryWebViewProps usage example Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tracing): Return no-op onMessage so WebView injection runs on Android `react-native-webview` only executes `injectedJavaScriptBeforeContentLoaded` on Android when an `onMessage` handler is set. `sentryWebViewProps` now returns a no-op `onMessage` so the trace-context injection also runs on Android for the documented usage; users who need their own handler spread the props first and set `onMessage` after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(e2e): Make WebView sample and flow robust on Android - Poll for the injected meta tag in the sample page: on Android the injected script runs shortly after the page's initial script, so an immediate read misses it (real apps' @sentry/browser read on pageload init, later). - Spread sentryWebViewProps before the sample's own onMessage so the sample's handler wins now that the helper returns one. - Add Android status-bar top padding to the Playground screen so the first button is not under the status bar. - Increase the button wait to 30s for slower Android cold starts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tracing): Make WebView trace injection idempotent Skip injection if a `sentry-trace` meta tag already exists in the page, so the trace context is never duplicated when more than one path could inject it (e.g. a future native injector alongside the RN helper, or the script running twice). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Move changelog to the unreleased section * fix(core): strip sentry-transaction from injected WebView baggage The root span name is propagated to the WebView via the `baggage` meta tag as `sentry-transaction`. It is free-form text that can carry user data (for example a parameterized URL), so remove it before the trace context leaves the app. Every other Dynamic Sampling Context entry (release, environment, sample rate, sample rand, sampled) and any third-party baggage entries are preserved, so dynamic sampling stays consistent across the connected traces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(core): fuzz WebView trace injection against script breakout Feed hostile values (quote/backtick/template-literal, </script>, backslash, newline/CR, U+2028/U+2029 line separators, null byte, statement + comment) and 200 random dangerous-charset strings through every value embedded in the injected script — the sentry-trace value, a baggage entry value, and an allowedHosts entry. Assert a window canary never executes and the values are only ever read back as inert meta content, proving JSON.stringify is the escaping boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(sample): Inject webview configuration for e2e test * docs(core): document composing injectedJavaScriptBeforeContentLoaded `sentryWebViewProps` returns a single `injectedJavaScriptBeforeContentLoaded` string. Spreading the props and then setting your own value after the spread silently replaces Sentry's injection and disables trace linking (and setting it before drops yours). Unlike `onMessage` — where any handler keeps the Android injection running — this prop cannot be safely overridden, so document that it must be concatenated with the returned string, with an example. Addresses Warden review finding 9C6-FDS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(sample): capture WebView trace once at mount to avoid flaky status The demo compared the WebView's injected-trace readback against a live `getTraceData()` re-read on every render. Once the navigation span ends the active `sentry-trace` keeps the same trace_id but gets a new span_id, so the comparison could flip to "not connected" even when the page received the injected trace correctly — making the status and its Maestro assertion a race against the navigation span's idle timeout. Capture the trace (`useState` initializer) and the injection props (`useMemo`) once at mount, so the injected value and the comparison baseline are the same mount-time snapshot. Addresses the Warden and Bugbot review findings on WebviewScreen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(tracing): address review feedback on sentryWebViewProps - Move the webview helper out of `integrations/` (it is not an integration) to `src/js/tracing/webview.ts` and re-export it via the tracing barrel. The public API is unchanged. - Normalize `allowedHosts` entries (trim, strip scheme/path/port/trailing dot, lowercase) so bare-hostname matching is robust. - Replace the `allowedHosts ?? []` fallback on the required field with an explicit validation guard that degrades to a no-op with a warning instead of throwing during render. - Reword the no-trace debug log to reflect the real cause (the SDK being disabled or not initialized, not "no active trace"). - Make the fuzz test deterministic with a seeded PRNG and add tests for the normalization and empty-allowedHosts cases. Addresses review feedback from @alwx. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tracing): avoid polynomial regex in WebView host normalization CodeQL flagged `/[/?#].*$/` in `normalizeHost` as a polynomial regular expression (backtracking on inputs with many `#`). Replace it with a `search` + `slice` that strips everything from the first path/query/fragment separator without a `.*` to backtrack on. Normalization output is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(tracing): clarify WebView injection timing for Android The JSDoc claimed the trace tags are written "before @sentry/browser initializes", which only holds on iOS. On Android `react-native-webview` injects asynchronously as the page starts, so the tags can land after the page's first synchronous <head> scripts. Reword to describe the real timing: a @sentry/browser loaded the usual way (script tag, bundle, or async/deferred init) reads the tags once present and continues the trace; only a synchronous inline init at the very top of <head> may miss them on Android and start a new trace (which fails safe — the WebView just gets its own trace). Verified on a Pixel 8 emulator: real @sentry/browser continued the trace 5/5 runs; a probe confirmed the meta is not present at the first synchronous head script. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tracing): handle IPv6 literals in WebView allowedHosts normalization `normalizeHost` stripped a trailing `:<digits>` unconditionally, mistaking an unbracketed IPv6 hextet for a port (`::1` -> `:`, `2001:db8::1` -> `2001:db8:`), so an IPv6-hosted page could never match and the trace was skipped. Browsers bracket IPv6 in `location.hostname` (e.g. `[::1]`), so: keep a bracketed host up to the closing bracket (dropping any `:port` after it), wrap an unbracketed IPv6 literal (more than one colon) in brackets so it still matches, and keep normal `:port` stripping for hostnames and IPv4. Still no `.*` regex (CodeQL-safe). Addresses Warden review finding on allowedHosts normalization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8547b02 commit 9dda75e

10 files changed

Lines changed: 861 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@
88
99
## Unreleased
1010

11+
### Features
12+
13+
- Connect React Native traces with embedded WebViews via `sentryWebViewProps` ([#6794](https://github.com/getsentry/sentry-react-native/pull/6794))
14+
15+
```jsx
16+
import { sentryWebViewProps } from '@sentry/react-native';
17+
18+
<WebView
19+
source={{ uri: 'https://example.com' }}
20+
{...sentryWebViewProps({ allowedHosts: ['example.com'] })}
21+
/>
22+
```
23+
1124
### Fixes
1225

1326
- 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))
@@ -27,8 +40,6 @@
2740

2841
## 8.31.0
2942

30-
### Features
31-
3243
- Add the `applicationKey` and `moduleMetadata` options to `withSentryConfig` and `getSentryExpoConfig`, and export `thirdPartyErrorFilterIntegration` ([#6863](https://github.com/getsentry/sentry-react-native/pull/6863))
3344
- 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.
3445

‎packages/core/etc/sentry-react-native.api.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,17 @@ export type SentryReplayQuality = 'low' | 'medium' | 'high';
10811081
export function sentryTraceGesture<GestureT>(
10821082
label: string, gesture: GestureT): GestureT;
10831083

1084+
// @public (undocumented)
1085+
export interface SentryWebViewOptions {
1086+
allowedHosts: string[];
1087+
}
1088+
1089+
// @public
1090+
export function sentryWebViewProps(options: SentryWebViewOptions): {
1091+
injectedJavaScriptBeforeContentLoaded: string;
1092+
onMessage: () => void;
1093+
};
1094+
10841095
export { setAttribute }
10851096

10861097
export { setAttributes }

‎packages/core/src/js/index.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,17 @@ export {
149149
wrapExpoRouterErrorBoundary,
150150
wrapExpoImage,
151151
wrapExpoAsset,
152+
sentryWebViewProps,
152153
} from './tracing';
153154

154-
export type { TimeToDisplayProps, ExpoRouter, ExpoRouterErrorBoundaryProps, ExpoImage, ExpoAsset } from './tracing';
155+
export type {
156+
TimeToDisplayProps,
157+
ExpoRouter,
158+
ExpoRouterErrorBoundaryProps,
159+
ExpoImage,
160+
ExpoAsset,
161+
SentryWebViewOptions,
162+
} from './tracing';
155163

156164
export type { ReactNativeTracingOptions } from './tracing/reactnativetracing';
157165
export type { ReactNativeNavigationOptions } from './tracing/reactnativenavigation';

‎packages/core/src/js/tracing/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export { ReactNativeProfiler } from './reactnativeprofiler';
3131

3232
export { sentryTraceGesture } from './gesturetracing';
3333

34+
export { sentryWebViewProps } from './webview';
35+
export type { SentryWebViewOptions } from './webview';
36+
3437
export * from './ops';
3538

3639
export * from './timetodisplay';
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import type { SerializedTraceData } from '@sentry/core';
2+
3+
import { debug, getTraceData, objectToBaggageHeader, parseBaggageHeader } from '@sentry/core';
4+
5+
const WEBVIEW_LABEL = 'Sentry WebView';
6+
7+
/**
8+
* `baggage` key carrying the root span's name. It is free-form text and can
9+
* contain user data (for example a parameterized URL), so it is removed from the
10+
* trace context before it leaves the app and reaches the WebView page.
11+
*/
12+
const SENTRY_TRANSACTION_BAGGAGE_KEY = 'sentry-transaction';
13+
14+
export interface SentryWebViewOptions {
15+
/**
16+
* Hosts whose pages receive the injected Sentry trace context.
17+
*
18+
* Provide bare hostnames. A page is matched when its hostname equals an entry or
19+
* is a subdomain of it (e.g. `'example.com'` matches both `example.com` and
20+
* `app.example.com`). Entries are normalized before comparison — surrounding
21+
* whitespace, a scheme, a path, a port, and a trailing dot are stripped, and
22+
* matching is case-insensitive. Only listed hosts are instrumented, so the trace
23+
* context — which can carry release and environment data in `baggage` — never
24+
* reaches third-party pages loaded in the WebView.
25+
*/
26+
allowedHosts: string[];
27+
}
28+
29+
/**
30+
* Props to spread onto a `react-native-webview` `<WebView>` to connect its traces
31+
* with the React Native app's trace.
32+
*
33+
* The returned `injectedJavaScriptBeforeContentLoaded` writes the current trace as
34+
* `sentry-trace` / `baggage` `<meta>` tags into the WebView document during its
35+
* initial load, so a `@sentry/browser` running in the page continues the same trace
36+
* on pageload (its pageload transaction and errors share the trace). The in-WebView
37+
* page must run `@sentry/browser` for the connection to take effect.
38+
*
39+
* Timing note: on iOS the tags are written before any page script runs. On Android
40+
* `react-native-webview` injects the script asynchronously as the page starts, so
41+
* the tags can land slightly after the page's first synchronous `<head>` scripts.
42+
* `@sentry/browser` loaded the usual way — from a `<script>` tag, a bundle, or an
43+
* `async`/deferred init — reads the tags after they're present and continues the
44+
* trace; only a `@sentry/browser` initialized synchronously inline at the very top
45+
* of `<head>` may run before the tags exist on Android and start a new trace. In
46+
* that case the WebView simply gets its own trace; nothing else is affected.
47+
*
48+
* The root span name (`sentry-transaction`) is stripped from `baggage` before it
49+
* is injected, as it is free-form text that may carry user data.
50+
*
51+
* A no-op `onMessage` is also returned: `react-native-webview` only executes
52+
* injected JavaScript on Android when an `onMessage` handler is set, so this is
53+
* required for the injection to run there. If you need your own `onMessage`, spread
54+
* these props first and set `onMessage` after them — any handler keeps the injection
55+
* running on Android.
56+
*
57+
* `injectedJavaScriptBeforeContentLoaded` is a single string, not a list. Spreading
58+
* these props and then setting your own `injectedJavaScriptBeforeContentLoaded`
59+
* after replaces Sentry's injection and silently disables trace linking (and setting
60+
* it before the spread drops yours). If you have your own before-content script,
61+
* concatenate it with the returned one instead of overriding it.
62+
*
63+
* @example
64+
* ```tsx
65+
* <WebView
66+
* source={{ uri: 'https://example.com' }}
67+
* {...sentryWebViewProps({ allowedHosts: ['example.com'] })}
68+
* />
69+
* ```
70+
*
71+
* @example
72+
* Composing with your own `injectedJavaScriptBeforeContentLoaded`:
73+
* ```tsx
74+
* const { injectedJavaScriptBeforeContentLoaded, onMessage } =
75+
* sentryWebViewProps({ allowedHosts: ['example.com'] });
76+
*
77+
* <WebView
78+
* source={{ uri: 'https://example.com' }}
79+
* onMessage={onMessage}
80+
* injectedJavaScriptBeforeContentLoaded={myScript + '\n' + injectedJavaScriptBeforeContentLoaded}
81+
* />
82+
* ```
83+
*/
84+
export function sentryWebViewProps(options: SentryWebViewOptions): {
85+
injectedJavaScriptBeforeContentLoaded: string;
86+
onMessage: () => void;
87+
} {
88+
return {
89+
injectedJavaScriptBeforeContentLoaded: createSentryWebViewInjection(options),
90+
// Required on Android: `react-native-webview` only runs injected JavaScript
91+
// when an `onMessage` handler is present.
92+
onMessage: () => {
93+
// noop
94+
},
95+
};
96+
}
97+
98+
/**
99+
* Builds the script run inside the WebView before content loads. Returns an empty
100+
* string when there is no active trace to propagate.
101+
*/
102+
function createSentryWebViewInjection({ allowedHosts }: SentryWebViewOptions): string {
103+
// `allowedHosts` is required, but validate at runtime so a misuse (or an empty
104+
// list) degrades to a no-op with a warning instead of throwing during render.
105+
// Entries are normalized to bare lowercase hostnames so they match the page's
106+
// `location.hostname`.
107+
const normalizedHosts = (Array.isArray(allowedHosts) ? allowedHosts : [])
108+
.map(normalizeHost)
109+
.filter(host => host.length > 0);
110+
if (normalizedHosts.length === 0) {
111+
debug.warn(`[${WEBVIEW_LABEL}] No valid allowedHosts provided; skipping WebView trace injection.`);
112+
return '';
113+
}
114+
115+
const traceData: SerializedTraceData = getTraceData();
116+
const sentryTrace = traceData['sentry-trace'];
117+
118+
if (!sentryTrace) {
119+
debug.log(
120+
`[${WEBVIEW_LABEL}] No trace data available (the SDK is disabled or not initialized); skipping WebView trace injection.`,
121+
);
122+
return '';
123+
}
124+
125+
// Values are embedded via JSON.stringify so any characters in `baggage` are
126+
// safely escaped. `allowedHosts` is checked against `location.hostname` inside
127+
// the page: the script re-runs on every document load, so each navigation is
128+
// gated on its own host rather than the one the WebView was opened with. The
129+
// script is idempotent — if a `sentry-trace` meta already exists (e.g. injected
130+
// by another path), it does nothing, so the trace context is never duplicated.
131+
const allowedHostsLiteral = JSON.stringify(normalizedHosts);
132+
const sentryTraceLiteral = JSON.stringify(sentryTrace);
133+
const baggageLiteral = JSON.stringify(stripTransactionFromBaggage(traceData.baggage) ?? null);
134+
135+
return `(function(){try{
136+
var allowed=${allowedHostsLiteral};
137+
var host=(window.location&&window.location.hostname||'').toLowerCase();
138+
if(!host)return;
139+
if(!allowed.some(function(h){return host===h||host.slice(-(h.length+1))==='.'+h;}))return;
140+
if(document.querySelector('meta[name="sentry-trace"]'))return;
141+
var head=document.head||document.documentElement;
142+
if(!head)return;
143+
var set=function(n,c){if(!c)return;var m=document.createElement('meta');m.setAttribute('name',n);m.setAttribute('content',c);head.appendChild(m);};
144+
set('sentry-trace',${sentryTraceLiteral});
145+
set('baggage',${baggageLiteral});
146+
}catch(e){}})();
147+
true;`;
148+
}
149+
150+
/**
151+
* Normalizes an `allowedHosts` entry to a bare lowercase hostname so it can be
152+
* compared against the page's `location.hostname`. Tolerates entries that
153+
* accidentally include surrounding whitespace, a scheme, a path, a port, or a
154+
* trailing dot (e.g. `' https://App.Example.com:8080/x '` → `'app.example.com'`).
155+
* IPv6 literals are bracketed to match `location.hostname` (`'::1'` → `'[::1]'`,
156+
* `'[::1]:8080'` → `'[::1]'`).
157+
*/
158+
function normalizeHost(host: string): string {
159+
const withoutScheme = host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // leading scheme://
160+
// Drop everything from the first path/query/fragment separator. Uses
161+
// `search` + `slice` rather than a `.*`-anchored regex to avoid polynomial
162+
// backtracking (CodeQL flags `[/?#].*$`).
163+
const pathStart = withoutScheme.search(/[/?#]/);
164+
let hostAndPort = pathStart === -1 ? withoutScheme : withoutScheme.slice(0, pathStart);
165+
166+
// Strip the port without mistaking an IPv6 hextet for one. Browsers bracket IPv6
167+
// in `location.hostname` (e.g. `[::1]`), so: a bracketed host keeps everything up
168+
// to the closing bracket (dropping any `:port` after it); an unbracketed IPv6
169+
// literal (more than one colon) is wrapped in brackets so it still matches; a
170+
// regular hostname or IPv4 keeps its port-stripping.
171+
const closingBracket = hostAndPort.indexOf(']');
172+
if (closingBracket !== -1) {
173+
hostAndPort = hostAndPort.slice(0, closingBracket + 1);
174+
} else if ((hostAndPort.match(/:/g) ?? []).length > 1) {
175+
hostAndPort = `[${hostAndPort}]`;
176+
} else {
177+
hostAndPort = hostAndPort.replace(/:\d+$/, '');
178+
}
179+
180+
return hostAndPort
181+
.replace(/\.$/, '') // trailing dot
182+
.toLowerCase();
183+
}
184+
185+
/**
186+
* Removes the `sentry-transaction` entry from a `baggage` header before it is
187+
* injected into the WebView. The root span name is free-form text that may carry
188+
* user data, while every other Dynamic Sampling Context entry (release,
189+
* environment, sample rate, etc.) is kept so sampling stays consistent across the
190+
* connected traces. Any non-Sentry (third-party) baggage entries are preserved.
191+
*
192+
* Returns `undefined` when there is no baggage to propagate after trimming.
193+
*/
194+
function stripTransactionFromBaggage(baggage: string | undefined): string | undefined {
195+
if (!baggage) {
196+
return undefined;
197+
}
198+
199+
// A non-empty header always parses to an object; `?? {}` only satisfies the type.
200+
const parsed = parseBaggageHeader(baggage) ?? {};
201+
202+
// Rebuild without the transaction entry rather than deleting it in place.
203+
const safe: Record<string, string> = {};
204+
for (const key of Object.keys(parsed)) {
205+
const value = parsed[key];
206+
if (key !== SENTRY_TRANSACTION_BAGGAGE_KEY && value !== undefined) {
207+
safe[key] = value;
208+
}
209+
}
210+
211+
// `objectToBaggageHeader` returns `undefined` when nothing is left to propagate.
212+
return objectToBaggageHeader(safe);
213+
}

0 commit comments

Comments
 (0)