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