From 669c5aa1424d65504d499c35b265c106269c0245 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 27 Oct 2026 17:01:09 +0100 Subject: [PATCH 1/6] feat(android): Connect traces with web content in a WebView Add SentryWebViewTracing.addTracePropagation, which writes the current trace into pages from allowed hosts as sentry-trace and baggage meta tags before any page script runs, so @sentry/browser in the page continues the trace on pageload. It uses androidx.webkit's document-start script, a compileOnly dependency checked at runtime, and adds a sample screen and an instrumented test. Refs JAVA-716 Co-Authored-By: Claude Opus 5.5 --- gradle/libs.versions.toml | 2 + .../api/sentry-android-core.api | 5 + sentry-android-core/build.gradle.kts | 4 + sentry-android-core/proguard-rules.pro | 6 + .../core/AndroidxWebViewScriptInstaller.java | 47 +++ .../android/core/SentryWebViewTracing.java | 176 ++++++++++ .../android/core/WebViewScriptInstaller.java | 26 ++ .../core/WebViewTracePropagationScript.java | 229 +++++++++++++ .../src/main/res/values/ids.xml | 5 + .../android/core/SentryWebViewTracingTest.kt | 275 +++++++++++++++ .../core/WebViewTracePropagationScriptTest.kt | 320 ++++++++++++++++++ .../sentry-uitest-android/build.gradle.kts | 1 + .../android/WebViewTracePropagationTest.kt | 45 +++ .../src/main/AndroidManifest.xml | 1 + .../src/main/assets/webview/iframe.html | 15 + .../src/main/assets/webview/index.html | 16 + .../uitest/android/WebViewTraceActivity.kt | 66 ++++ .../sentry-samples-android/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 5 + .../src/main/assets/webview/iframe.html | 20 ++ .../src/main/assets/webview/index.html | 46 +++ .../io/sentry/samples/android/MainActivity.kt | 10 + .../sentry/samples/android/WebViewActivity.kt | 143 ++++++++ 23 files changed, 1464 insertions(+) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java create mode 100644 sentry-android-core/src/main/res/values/ids.xml create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/WebViewTracePropagationTest.kt create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/iframe.html create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/index.html create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/WebViewTraceActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html create mode 100644 sentry-samples/sentry-samples-android/src/main/assets/webview/index.html create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/WebViewActivity.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cbe8e626c8..b763402ae5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -124,6 +124,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-webkit = { module = "androidx.webkit:webkit", version = "1.14.0" } androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } @@ -295,6 +296,7 @@ okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version. # apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath okhttp-mockwebserver5 = { module = "com.squareup.okhttp3:mockwebserver", version = "5.3.0" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } +rhino = { module = "org.mozilla:rhino", version = "1.8.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } [bundles] diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index e8b95794ea0..47d41726d39 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -623,6 +623,11 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackForm$Op public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V } +public final class io/sentry/android/core/SentryWebViewTracing { + public static fun addTracePropagation (Landroid/webkit/WebView;Ljava/util/List;)Z + public static fun addTracePropagation (Landroid/webkit/WebView;[Ljava/lang/String;)Z +} + public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 4facf6f4c23..b4bd503a4d4 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -100,6 +100,8 @@ dependencies { implementation(libs.androidx.core) // photo picker for user feedback screenshot attachments compileOnly(libs.androidx.activity) + // document-start script injection for WebView trace propagation + compileOnly(libs.androidx.webkit) implementation(libs.epitaph) @@ -128,6 +130,8 @@ dependencies { testImplementation(projects.sentryAndroidNdk) testImplementation(libs.androidx.activity.compose) + testImplementation(libs.androidx.webkit) + testImplementation(libs.rhino) testImplementation(libs.androidx.compose.ui) testImplementation(libs.androidx.compose.foundation) testImplementation(libs.androidx.compose.foundation.layout) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index eadc6917af7..34fc51f709a 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -30,6 +30,12 @@ -dontwarn androidx.activity.ComponentActivity -dontwarn androidx.activity.result.** +# androidx.webkit is a compileOnly dependency, used by WebView trace propagation +# its presence is checked at runtime before use +-dontwarn androidx.webkit.** +# keep the name of the class the presence check looks up, so it still finds it in minified apps +-keepnames class androidx.webkit.ScriptHandler + # To ensure that stack traces is unambiguous # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java new file mode 100644 index 00000000000..0bdc2501814 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java @@ -0,0 +1,47 @@ +package io.sentry.android.core; + +import android.webkit.WebView; +import androidx.webkit.ScriptHandler; +import androidx.webkit.WebViewCompat; +import androidx.webkit.WebViewFeature; +import java.util.Set; +import org.jetbrains.annotations.NotNull; + +/** + * Installs Sentry's document-start script through androidx.webkit. All androidx.webkit references + * are isolated in this class, so it must only be loaded once {@link SentryWebViewTracing} has + * checked that androidx.webkit is available. That check deliberately lives outside of this class, + * so that it can run without linking any androidx.webkit type. + */ +final class AndroidxWebViewScriptInstaller implements WebViewScriptInstaller { + + @Override + public boolean isDocumentStartScriptSupported() { + return WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT); + } + + @Override + public void removeInstalledScript(final @NotNull WebView webView) { + final Object handler = webView.getTag(R.id.sentry_webview_trace_propagation); + // A handler only exists if the feature was supported when it was installed. + if (handler instanceof ScriptHandler + && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + ((ScriptHandler) handler).remove(); + } + webView.setTag(R.id.sentry_webview_trace_propagation, null); + } + + @Override + public boolean installScript( + final @NotNull WebView webView, + final @NotNull String script, + final @NotNull Set allowedOriginRules) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + return false; + } + final @NotNull ScriptHandler handler = + WebViewCompat.addDocumentStartJavaScript(webView, script, allowedOriginRules); + webView.setTag(R.id.sentry_webview_trace_propagation, handler); + return true; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java new file mode 100644 index 00000000000..e641ce74ca3 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java @@ -0,0 +1,176 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.os.Build; +import android.os.Looper; +import android.webkit.WebView; +import androidx.annotation.MainThread; +import io.sentry.BaggageHeader; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.util.LoadClass; +import io.sentry.util.TracingUtils; +import java.util.Arrays; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Connects the app's trace with the trace of web content shown in a {@link WebView}. + * + *

{@link #addTracePropagation(WebView, List)} writes the current trace into every document the + * WebView loads from an allowed host as {@code sentry-trace} and {@code baggage} meta tags, before + * any page script runs. A {@code @sentry/browser} running in the page with {@code + * browserTracingIntegration} then continues the trace on pageload. + * + *

Requires the {@code androidx.webkit:webkit} dependency and an Android System WebView that + * supports {@code WebViewFeature.DOCUMENT_START_SCRIPT}. + */ +public final class SentryWebViewTracing { + + static final @NotNull String SCRIPT_HANDLER_CLASS_NAME = "androidx.webkit.ScriptHandler"; + + private static final @NotNull String INTEGRATION_NAME = "WebViewTracePropagation"; + + private SentryWebViewTracing() {} + + /** + * Same as {@link #addTracePropagation(WebView, List)}. + * + * @param webView the WebView to connect + * @param allowedHosts hosts whose pages receive the trace + * @return true if the trace propagation script was installed + */ + @MainThread + public static boolean addTracePropagation( + final @NotNull WebView webView, final @NotNull String... allowedHosts) { + return addTracePropagation(webView, Arrays.asList(allowedHosts)); + } + + /** + * Installs a script that writes the current trace into documents the WebView loads from an + * allowed host. Call it on the main thread, before loading a URL: it applies to documents that + * start loading after the call. + * + *

{@code allowedHosts} is a trust boundary: only list hosts whose content you control. A page + * matches when its hostname equals an entry or is a subdomain of it, over https. Entries are + * normalized, so a scheme, a path, a port, and a trailing dot are ignored. + * + *

The trace is a snapshot taken when this is called. To propagate a newer trace to a later + * page load, call this again: it replaces the script Sentry installed earlier and leaves other + * scripts alone. If the call fails, no Sentry script is left on the WebView. + * + *

The root span name ({@code sentry-transaction}) is removed from {@code baggage}, as it is + * free-form text that may carry user data. + * + * @param webView the WebView to connect + * @param allowedHosts hosts whose pages receive the trace + * @return true if the script was installed; false if androidx.webkit is missing, the WebView does + * not support document-start scripts, {@code allowedHosts} has no valid entry, or there is no + * trace (for example the SDK is not initialized), or it is called off the WebView's thread + */ + @MainThread + public static boolean addTracePropagation( + final @NotNull WebView webView, final @NotNull List allowedHosts) { + return addTracePropagation(webView, allowedHosts, Sentry.getCurrentScopes(), new LoadClass()); + } + + static boolean addTracePropagation( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull LoadClass loadClass) { + // Registered on use rather than on success, like the other SDKs, so adoption counts apps that + // wired the feature up even when this call cannot install. + addIntegrationToSdkVersion(INTEGRATION_NAME); + + if (!loadClass.isClassAvailable(SCRIPT_HANDLER_CLASS_NAME, scopes.getOptions())) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "WebView trace propagation requires the androidx.webkit dependency, skipping."); + return false; + } + return install(webView, allowedHosts, scopes, new AndroidxWebViewScriptInstaller()); + } + + static boolean install( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull WebViewScriptInstaller installer) { + final @NotNull ILogger logger = scopes.getOptions().getLogger(); + + // WebView methods throw when called off the WebView's thread; fail like any other misuse. + if (!isOnWebViewThread(webView)) { + logger.log( + SentryLevel.WARNING, + "addTracePropagation must be called on the WebView's thread, skipping WebView trace propagation."); + return false; + } + + // Remove Sentry's previous script first, so a failed re-add never leaves a stale trace behind. + installer.removeInstalledScript(webView); + + final @NotNull List hosts = WebViewTracePropagationScript.normalizeHosts(allowedHosts); + if (hosts.isEmpty()) { + logger.log( + SentryLevel.WARNING, + "No valid allowedHosts provided, skipping WebView trace propagation."); + return false; + } + + if (!installer.isDocumentStartScriptSupported()) { + logger.log( + SentryLevel.WARNING, + "The WebView does not support document-start scripts, skipping WebView trace propagation."); + return false; + } + + final @Nullable TracingUtils.TracingHeaders headers = + TracingUtils.trace(scopes, null, scopes.getSpan()); + if (headers == null) { + logger.log( + SentryLevel.DEBUG, + "No trace available (the SDK is disabled or not initialized), skipping WebView trace propagation."); + return false; + } + + final @Nullable BaggageHeader baggageHeader = headers.getBaggageHeader(); + final @Nullable String baggage = + WebViewTracePropagationScript.stripTransactionFromBaggage( + baggageHeader == null ? null : baggageHeader.getValue()); + final @NotNull String script = + WebViewTracePropagationScript.build( + hosts, headers.getSentryTraceHeader().getValue(), baggage); + + try { + if (!installer.installScript( + webView, script, WebViewTracePropagationScript.originRules(hosts))) { + logger.log( + SentryLevel.WARNING, + "The WebView does not support document-start scripts, skipping WebView trace propagation."); + return false; + } + } catch (IllegalArgumentException e) { + logger.log( + SentryLevel.WARNING, "Invalid allowedHosts, skipping WebView trace propagation.", e); + return false; + } + + return true; + } + + private static boolean isOnWebViewThread(final @NotNull WebView webView) { + final @NotNull Looper webViewLooper = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? webView.getWebViewLooper() + : Looper.getMainLooper(); + return Looper.myLooper() == webViewLooper; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java new file mode 100644 index 00000000000..36d92588fbc --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java @@ -0,0 +1,26 @@ +package io.sentry.android.core; + +import android.webkit.WebView; +import java.util.Set; +import org.jetbrains.annotations.NotNull; + +/** + * Installs Sentry's document-start script on a WebView. Declares no androidx.webkit types, so it + * can be referenced before the androidx.webkit availability check. + */ +interface WebViewScriptInstaller { + + boolean isDocumentStartScriptSupported(); + + /** Removes the script Sentry installed earlier on this WebView, if any. */ + void removeInstalledScript(@NotNull WebView webView); + + /** + * Installs the script for documents whose origin matches one of the rules. + * + * @return false if document-start scripts are not supported, in which case nothing is installed + * @throws IllegalArgumentException if an origin rule is malformed + */ + boolean installScript( + @NotNull WebView webView, @NotNull String script, @NotNull Set allowedOriginRules); +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java new file mode 100644 index 00000000000..b7148d84e1a --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java @@ -0,0 +1,229 @@ +package io.sentry.android.core; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Builds the script that writes the native trace into a WebView document as {@code sentry-trace} + * and {@code baggage} meta tags, plus the origin rules that gate where it runs. Has no androidx + * dependency so it can be used before the androidx.webkit availability check. + */ +final class WebViewTracePropagationScript { + + /** + * {@code 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 before it reaches the WebView page. + */ + private static final @NotNull String SENTRY_TRANSACTION_BAGGAGE_KEY = "sentry-transaction"; + + private static final @NotNull Pattern SCHEME = + Pattern.compile("^[a-z][a-z0-9+.-]*://", Pattern.CASE_INSENSITIVE); + private static final @NotNull Pattern PORT = Pattern.compile(":\\d+$"); + private static final @NotNull Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$"); + + private WebViewTracePropagationScript() {} + + /** + * Normalizes each entry with {@link #normalizeHost(String)}, dropping blank entries and + * duplicates. + */ + static @NotNull List normalizeHosts(final @NotNull Collection allowedHosts) { + final @NotNull Set normalized = new LinkedHashSet<>(); + for (final @Nullable String host : allowedHosts) { + if (host == null) { + continue; + } + final @NotNull String normalizedHost = normalizeHost(host); + if (!normalizedHost.isEmpty()) { + normalized.add(normalizedHost); + } + } + return new ArrayList<>(normalized); + } + + /** + * Normalizes an {@code allowedHosts} entry to a bare lowercase hostname so it can be compared + * against the page's {@code location.hostname}. Tolerates surrounding whitespace, a scheme, a + * path, a port, a trailing dot, and a leading subdomain wildcard (e.g. {@code " + * https://*.App.Example.com:8080/x "} becomes {@code "app.example.com"}). IPv6 literals are + * bracketed to match {@code location.hostname} ({@code "::1"} becomes {@code "[::1]"}). + */ + static @NotNull String normalizeHost(final @NotNull String host) { + String hostAndPort = SCHEME.matcher(host.trim()).replaceFirst(""); + + final int pathStart = indexOfAny(hostAndPort, "/?#"); + if (pathStart != -1) { + hostAndPort = hostAndPort.substring(0, pathStart); + } + + // Strip the port without mistaking an IPv6 hextet for one. + final int closingBracket = hostAndPort.indexOf(']'); + if (closingBracket != -1) { + hostAndPort = hostAndPort.substring(0, closingBracket + 1); + } else if (hostAndPort.indexOf(':') != hostAndPort.lastIndexOf(':')) { + hostAndPort = "[" + hostAndPort + "]"; + } else { + hostAndPort = PORT.matcher(hostAndPort).replaceFirst(""); + } + + if (hostAndPort.endsWith(".")) { + hostAndPort = hostAndPort.substring(0, hostAndPort.length() - 1); + } + // Subdomains always match, so a subdomain wildcard is redundant. Kept, it would never match + // the page's hostname and would make the origin rules invalid. + while (hostAndPort.startsWith("*.")) { + hostAndPort = hostAndPort.substring(2); + } + return hostAndPort.toLowerCase(Locale.ROOT); + } + + /** + * Origin rules for {@code WebViewCompat.addDocumentStartJavaScript}: each host and its subdomains + * over https. IP literals cannot take a subdomain wildcard. + */ + static @NotNull Set originRules(final @NotNull List normalizedHosts) { + final @NotNull Set rules = new LinkedHashSet<>(); + for (final @NotNull String host : normalizedHosts) { + rules.add("https://" + host); + if (!isIpLiteral(host)) { + rules.add("https://*." + host); + } + } + return rules; + } + + /** + * Removes the {@code sentry-transaction} entry from a {@code baggage} header, keeping every other + * entry. Returns null when nothing is left to propagate. + */ + static @Nullable String stripTransactionFromBaggage(final @Nullable String baggage) { + if (baggage == null) { + return null; + } + final @NotNull StringBuilder result = new StringBuilder(); + for (final @NotNull String entry : baggage.split(",", -1)) { + final @NotNull String trimmed = entry.trim(); + if (trimmed.isEmpty()) { + continue; + } + final int separator = trimmed.indexOf('='); + final @NotNull String key = separator == -1 ? trimmed : trimmed.substring(0, separator); + if (SENTRY_TRANSACTION_BAGGAGE_KEY.equals(key.trim())) { + continue; + } + if (result.length() > 0) { + result.append(','); + } + result.append(trimmed); + } + return result.length() == 0 ? null : result.toString(); + } + + /** + * Builds the script run at document start. Values are embedded as escaped string literals so any + * character in them stays inert. The script only writes the tags in the main frame of a page + * whose hostname is (a subdomain of) an allowed host, and does nothing if a {@code sentry-trace} + * meta tag already exists. + */ + static @NotNull String build( + final @NotNull List normalizedHosts, + final @NotNull String sentryTrace, + final @Nullable String baggage) { + final @NotNull StringBuilder allowed = new StringBuilder("["); + for (int i = 0; i < normalizedHosts.size(); i++) { + if (i > 0) { + allowed.append(','); + } + allowed.append(toJsStringLiteral(normalizedHosts.get(i))); + } + allowed.append(']'); + + return "(function(){try{\n" + + "if(window.top!==window)return;\n" + + "var allowed=" + + allowed + + ";\n" + + "var host=(window.location&&window.location.hostname||'').toLowerCase();\n" + + "if(!host)return;\n" + + "if(!allowed.some(function(h){return host===h||host.slice(-(h.length+1))==='.'+h;}))return;\n" + + "if(document.querySelector('meta[name=\"sentry-trace\"]'))return;\n" + + "var head=document.head||document.documentElement;\n" + + "if(!head)return;\n" + + "var set=function(n,c){if(!c)return;var m=document.createElement('meta');" + + "m.setAttribute('name',n);m.setAttribute('content',c);head.appendChild(m);};\n" + + "set('sentry-trace'," + + toJsStringLiteral(sentryTrace) + + ");\n" + + "set('baggage'," + + (baggage == null ? "null" : toJsStringLiteral(baggage)) + + ");\n" + + "}catch(e){}})();"; + } + + /** + * Escapes a value as a double-quoted JavaScript string literal, like {@code JSON.stringify}. + * Additionally escapes U+2028/U+2029 (line terminators in pre-ES2019 engines) and {@code <} so + * the literal stays inert in any embedding context. + */ + static @NotNull String toJsStringLiteral(final @NotNull String value) { + final @NotNull StringBuilder sb = new StringBuilder(value.length() + 2); + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '<': + case '
': + case '
': + appendUnicodeEscape(sb, c); + break; + default: + if (c < 0x20) { + appendUnicodeEscape(sb, c); + } else { + sb.append(c); + } + } + } + sb.append('"'); + return sb.toString(); + } + + private static void appendUnicodeEscape(final @NotNull StringBuilder sb, final char c) { + sb.append(String.format(Locale.ROOT, "\\u%04x", (int) c)); + } + + private static boolean isIpLiteral(final @NotNull String host) { + return host.startsWith("[") || IPV4.matcher(host).matches(); + } + + private static int indexOfAny(final @NotNull String value, final @NotNull String chars) { + for (int i = 0; i < value.length(); i++) { + if (chars.indexOf(value.charAt(i)) != -1) { + return i; + } + } + return -1; + } +} diff --git a/sentry-android-core/src/main/res/values/ids.xml b/sentry-android-core/src/main/res/values/ids.xml new file mode 100644 index 00000000000..5b6125d18e5 --- /dev/null +++ b/sentry-android-core/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt new file mode 100644 index 00000000000..ca84fd71f8f --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt @@ -0,0 +1,275 @@ +package io.sentry.android.core + +import android.webkit.WebView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.TransactionOptions +import io.sentry.util.LoadClass +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class SentryWebViewTracingTest { + + private class FakeInstaller( + var supported: Boolean = true, + var installFailure: IllegalArgumentException? = null, + ) : WebViewScriptInstaller { + var removeCount = 0 + val installed = mutableListOf>>() + + override fun isDocumentStartScriptSupported(): Boolean = supported + + override fun removeInstalledScript(webView: WebView) { + removeCount++ + } + + override fun installScript( + webView: WebView, + script: String, + allowedOriginRules: Set, + ): Boolean { + installFailure?.let { throw it } + installed.add(script to allowedOriginRules) + return true + } + } + + private lateinit var webView: WebView + private val installer = FakeInstaller() + + @BeforeTest + fun setUp() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + webView = WebView(ApplicationProvider.getApplicationContext()) + } + + @AfterTest + fun tearDown() { + Sentry.close() + } + + private fun initSentry(): IScopes { + Sentry.init { + it.dsn = "https://key@sentry.io/123" + it.release = "app@1.0" + it.environment = "prod" + it.tracesSampleRate = 1.0 + } + return Sentry.getCurrentScopes() + } + + private fun add( + allowedHosts: List = listOf("example.com"), + scopes: IScopes = Sentry.getCurrentScopes(), + ): Boolean = SentryWebViewTracing.install(webView, allowedHosts, scopes, installer) + + @Test + fun `installs the current trace for the allowed hosts`() { + val scopes = initSentry() + + assertThat(add()).isTrue() + + val (script, rules) = installer.installed.single() + assertThat(script).contains(scopes.traceparent!!.value) + assertThat(script).contains("sentry-release=app%401.0") + assertThat(rules).containsExactly("https://example.com", "https://*.example.com") + } + + @Test + fun `does not propagate the root span name`() { + initSentry() + val transaction = + Sentry.startTransaction( + "Checkout user@example.com", + "ui.load", + TransactionOptions().apply { isBindToScope = true }, + ) + + assertThat(add()).isTrue() + + val (script, _) = installer.installed.single() + assertThat(script).contains(transaction.toSentryTrace().value) + assertThat(script).contains("sentry-sampled=true") + assertThat(script).doesNotContain("sentry-transaction") + transaction.finish() + } + + @Test + fun `removes the previous script before installing again`() { + initSentry() + + assertThat(add()).isTrue() + assertThat(add()).isTrue() + + assertThat(installer.removeCount).isEqualTo(2) + assertThat(installer.installed).hasSize(2) + } + + @Test + fun `returns false and leaves no script when allowedHosts has no valid entry`() { + initSentry() + + assertThat(add(listOf(" ", ""))).isFalse() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).isEmpty() + } + + @Test + fun `returns false and leaves no script when document-start scripts are unsupported`() { + initSentry() + installer.supported = false + + assertThat(add()).isFalse() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).isEmpty() + } + + @Test + fun `returns false and leaves no script when the SDK is not initialized`() { + assertThat(add()).isFalse() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).isEmpty() + } + + @Test + fun `a re-add after Sentry close removes the previous script`() { + initSentry() + assertThat(add()).isTrue() + Sentry.close() + + assertThat(add()).isFalse() + + assertThat(installer.removeCount).isEqualTo(2) + assertThat(installer.installed).hasSize(1) + } + + @Test + fun `treats a subdomain wildcard entry like the bare host`() { + initSentry() + + assertThat(add(listOf("*.example.com"))).isTrue() + + val (_, rules) = installer.installed.single() + assertThat(rules).containsExactly("https://example.com", "https://*.example.com") + } + + @Test + fun `returns false and touches nothing when called off the WebView's thread`() { + initSentry() + var result: Boolean? = null + + val thread = Thread { result = add() } + thread.start() + thread.join() + + assertThat(result).isFalse() + assertThat(installer.removeCount).isEqualTo(0) + assertThat(installer.installed).isEmpty() + } + + @Test + fun `returns false when the origin rules are rejected`() { + initSentry() + installer.installFailure = IllegalArgumentException("bad rule") + + assertThat(add(listOf("*"))).isFalse() + } + + @Test + fun `returns false when androidx webkit is not available and still records adoption`() { + val scopes = initSentry() + val loadClass = mock() + whenever( + loadClass.isClassAvailable( + eq(SentryWebViewTracing.SCRIPT_HANDLER_CLASS_NAME), + any(), + ) + ) + .thenReturn(false) + + assertThat( + SentryWebViewTracing.addTracePropagation( + webView, + listOf("example.com"), + scopes, + loadClass, + ) + ) + .isFalse() + // The adoption marker is registered on use, even when nothing can be installed. + assertThat(SentryIntegrationPackageStorage.getInstance().integrations) + .contains("WebViewTracePropagation") + } + + @Test + fun `androidx installer stores the script handler and removes it on the next call`() { + val handler = mock() + mockStatic(WebViewCompat::class.java).use { webViewCompat -> + mockStatic(WebViewFeature::class.java).use { webViewFeature -> + webViewFeature + .`when` { + WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + } + .thenReturn(true) + webViewCompat + .`when` { + WebViewCompat.addDocumentStartJavaScript(eq(webView), any(), any()) + } + .thenReturn(handler) + val androidxInstaller = AndroidxWebViewScriptInstaller() + + assertThat(androidxInstaller.isDocumentStartScriptSupported).isTrue() + androidxInstaller.removeInstalledScript(webView) + assertThat(androidxInstaller.installScript(webView, "script", setOf("https://example.com"))) + .isTrue() + assertThat(webView.getTag(R.id.sentry_webview_trace_propagation)).isSameInstanceAs(handler) + + androidxInstaller.removeInstalledScript(webView) + + verify(handler, times(1)).remove() + assertThat(webView.getTag(R.id.sentry_webview_trace_propagation)).isNull() + } + } + } + + @Test + fun `androidx installer installs nothing when document-start scripts are unsupported`() { + mockStatic(WebViewCompat::class.java).use { webViewCompat -> + mockStatic(WebViewFeature::class.java).use { webViewFeature -> + webViewFeature + .`when` { + WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + } + .thenReturn(false) + + val installed = + AndroidxWebViewScriptInstaller() + .installScript(webView, "script", setOf("https://example.com")) + + assertThat(installed).isFalse() + webViewCompat.verifyNoInteractions() + assertThat(webView.getTag(R.id.sentry_webview_trace_propagation)).isNull() + } + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt new file mode 100644 index 00000000000..bd04c3cd78c --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt @@ -0,0 +1,320 @@ +package io.sentry.android.core + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import org.mozilla.javascript.Context +import org.mozilla.javascript.ScriptableObject + +class WebViewTracePropagationScriptTest { + + private data class Meta(val name: String, val content: String) + + private data class Result(val metas: List, val pwned: Boolean) + + /** + * Runs the injection script against a minimal fake DOM, like the React Native SDK tests do, and + * returns the meta tags present afterwards. A breakout payload would set `window.__pwned`. + */ + private fun runInjection( + script: String, + hostname: String, + existing: List = emptyList(), + mainFrame: Boolean = true, + ): Result { + val cx = Context.enter() + try { + cx.languageVersion = Context.VERSION_ES6 + val scope = cx.initStandardObjects() + ScriptableObject.putProperty(scope, "HOST", hostname) + ScriptableObject.putProperty(scope, "MAIN_FRAME", mainFrame) + cx.evaluateString( + scope, + """ + var metas = []; + var head = { appendChild: function(el) { metas.push({ name: el.name, content: el.content }); } }; + var document = { + head: head, + documentElement: head, + createElement: function() { + return { name: '', content: '', setAttribute: function(k, v) { this[k] = v; } }; + }, + querySelector: function(selector) { + var match = /meta\[name="([^"]+)"\]/.exec(selector); + if (!match) return null; + for (var i = 0; i < metas.length; i++) { + if (metas[i].name === match[1]) return metas[i]; + } + return null; + } + }; + var window = { location: { hostname: HOST } }; + window.top = MAIN_FRAME ? window : {}; + """ + .trimIndent(), + "harness", + 1, + null, + ) + for (meta in existing) { + cx.evaluateString( + scope, + "metas.push({ name: ${WebViewTracePropagationScript.toJsStringLiteral(meta.name)}, " + + "content: ${WebViewTracePropagationScript.toJsStringLiteral(meta.content)} });", + "existing", + 1, + null, + ) + } + + cx.evaluateString(scope, script, "injection", 1, null) + + val count = Context.toNumber(cx.evaluateString(scope, "metas.length", "count", 1, null)) + val metas = + (0 until count.toInt()).map { i -> + Meta( + Context.toString(cx.evaluateString(scope, "metas[$i].name", "name", 1, null)), + Context.toString(cx.evaluateString(scope, "metas[$i].content", "content", 1, null)), + ) + } + val pwned = + Context.toBoolean( + cx.evaluateString( + scope, + "window.__pwned === 1 || typeof __pwned !== 'undefined'", + "pwned", + 1, + null, + ) + ) + return Result(metas, pwned) + } finally { + Context.exit() + } + } + + private fun script( + allowedHosts: List = listOf("example.com"), + sentryTrace: String = SENTRY_TRACE, + baggage: String? = BAGGAGE, + ): String = + WebViewTracePropagationScript.build( + WebViewTracePropagationScript.normalizeHosts(allowedHosts), + sentryTrace, + baggage, + ) + + @Test + fun `injects sentry-trace and baggage meta tags on an allowed host`() { + val result = runInjection(script(), "example.com") + + assertThat(result.metas) + .containsExactly(Meta("sentry-trace", SENTRY_TRACE), Meta("baggage", BAGGAGE)) + .inOrder() + } + + @Test + fun `injects on a subdomain of an allowed host`() { + assertThat(runInjection(script(), "app.example.com").metas).hasSize(2) + } + + @Test + fun `matches allowed hosts case-insensitively`() { + assertThat(runInjection(script(listOf("Example.COM")), "app.example.com").metas).hasSize(2) + } + + @Test + fun `does not inject on a host that is not allowed`() { + assertThat(runInjection(script(), "evil.com").metas).isEmpty() + } + + @Test + fun `does not inject on a host that merely ends with an allowed host`() { + assertThat(runInjection(script(), "notexample.com").metas).isEmpty() + } + + @Test + fun `does not inject into iframes`() { + assertThat(runInjection(script(), "example.com", mainFrame = false).metas).isEmpty() + } + + @Test + fun `does not inject again when the page already has a sentry-trace meta tag`() { + val existing = listOf(Meta("sentry-trace", "already-there")) + + val result = runInjection(script(), "example.com", existing) + + assertThat(result.metas).containsExactly(Meta("sentry-trace", "already-there")) + } + + @Test + fun `injects only sentry-trace when there is no baggage`() { + val result = runInjection(script(baggage = null), "example.com") + + assertThat(result.metas).containsExactly(Meta("sentry-trace", SENTRY_TRACE)) + } + + @Test + fun `normalizes allowedHosts entries`() { + for (entry in + listOf( + " example.com ", + "https://example.com", + "example.com:8080", + "example.com/some/path", + "example.com.", + "https://Example.com:443/x?y#z", + )) { + assertThat(WebViewTracePropagationScript.normalizeHost(entry)).isEqualTo("example.com") + assertThat(runInjection(script(listOf(entry)), "example.com").metas).hasSize(2) + } + } + + @Test + fun `normalizes a subdomain wildcard entry to the bare host`() { + for (entry in listOf("*.example.com", "https://*.Example.com/x", "*.*.example.com")) { + assertThat(WebViewTracePropagationScript.normalizeHost(entry)).isEqualTo("example.com") + } + assertThat(runInjection(script(listOf("*.example.com")), "app.example.com").metas).hasSize(2) + assertThat(runInjection(script(listOf("*.example.com")), "example.com").metas).hasSize(2) + } + + @Test + fun `normalizes IPv6 allowedHosts entries to the bracketed hostname`() { + for ((entry, hostname) in + listOf( + "::1" to "[::1]", + "[::1]" to "[::1]", + "[::1]:8080" to "[::1]", + "2001:db8::1" to "[2001:db8::1]", + "[2001:DB8::1]" to "[2001:db8::1]", + )) { + assertThat(WebViewTracePropagationScript.normalizeHost(entry)).isEqualTo(hostname) + assertThat(runInjection(script(listOf(entry)), hostname).metas).hasSize(2) + } + } + + @Test + fun `normalizeHosts drops blank entries and duplicates`() { + val hosts = + WebViewTracePropagationScript.normalizeHosts( + listOf(" ", "", "example.com", "https://EXAMPLE.com/", "other.com") + ) + + assertThat(hosts).containsExactly("example.com", "other.com").inOrder() + } + + @Test + fun `originRules allow each host and its subdomains over https`() { + val rules = WebViewTracePropagationScript.originRules(listOf("example.com", "other.com")) + + assertThat(rules) + .containsExactly( + "https://example.com", + "https://*.example.com", + "https://other.com", + "https://*.other.com", + ) + } + + @Test + fun `originRules do not add a subdomain wildcard to IP literals`() { + val rules = WebViewTracePropagationScript.originRules(listOf("[::1]", "10.0.2.2")) + + assertThat(rules).containsExactly("https://[::1]", "https://10.0.2.2") + } + + @Test + fun `stripTransactionFromBaggage removes sentry-transaction and keeps every other entry`() { + val baggage = + "sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod," + + "sentry-transaction=GET%20%2Fusers%2F123,sentry-sample_rate=1.0,othervendor=keepme" + + assertThat(WebViewTracePropagationScript.stripTransactionFromBaggage(baggage)) + .isEqualTo( + "sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod," + + "sentry-sample_rate=1.0,othervendor=keepme" + ) + } + + @Test + fun `stripTransactionFromBaggage returns null when only sentry-transaction is left`() { + assertThat( + WebViewTracePropagationScript.stripTransactionFromBaggage( + "sentry-transaction=GET%20%2Fusers%2F123" + ) + ) + .isNull() + assertThat(WebViewTracePropagationScript.stripTransactionFromBaggage(null)).isNull() + } + + @Test + fun `neutralizes breakout payloads in the sentry-trace value`() { + for (payload in BREAKOUT_PAYLOADS) { + val result = runInjection(script(sentryTrace = payload), "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).contains(Meta("sentry-trace", payload)) + } + } + + @Test + fun `neutralizes breakout payloads in the baggage value`() { + for (payload in BREAKOUT_PAYLOADS) { + val result = runInjection(script(baggage = payload), "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).contains(Meta("baggage", payload)) + } + } + + @Test + fun `neutralizes breakout payloads in allowedHosts entries`() { + for (payload in BREAKOUT_PAYLOADS) { + val script = + WebViewTracePropagationScript.build(listOf(payload, "example.com"), SENTRY_TRACE, BAGGAGE) + + val result = runInjection(script, "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).hasSize(2) + } + } + + @Test + fun `preserves random sentry-trace values as inert content`() { + val chars = "\"'`\\\${}();/<>\n\r\t

abc0= ," + val random = java.util.Random(123456789) + repeat(200) { + val payload = buildString { + repeat(1 + random.nextInt(40)) { append(chars[random.nextInt(chars.length)]) } + } + + val result = runInjection(script(sentryTrace = payload), "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).contains(Meta("sentry-trace", payload)) + } + } + + companion object { + private const val SENTRY_TRACE = "d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1" + private const val BAGGAGE = + "sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod" + + private val BREAKOUT_PAYLOADS = + listOf( + "\");window.__pwned=1;(\"", + "');window.__pwned=1;('", + "`;window.__pwned=1;`", + "\${window.__pwned=1}", + "", + "\\\";window.__pwned=1;//", + "\"}]);window.__pwned=1;//", + "\nwindow.__pwned=1;\n", + "\rwindow.__pwned=1;\r", + "
window.__pwned=1;", + "
window.__pwned=1;", + "abc\u0000;window.__pwned=1;", + ) + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 8f479470cac..effa623db3b 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.webkit) implementation(libs.androidx.test.espresso.idling.resource) implementation(libs.leakcanary) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/WebViewTracePropagationTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/WebViewTracePropagationTest.kt new file mode 100644 index 00000000000..1d95819ddf4 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/WebViewTracePropagationTest.kt @@ -0,0 +1,45 @@ +package io.sentry.uitest.android + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.WebViewFeature +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.awaitility.kotlin.await +import org.junit.Assume.assumeTrue +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WebViewTracePropagationTest : BaseUiTest() { + + @Test + fun injectsTheTraceBeforePageScriptsAndOnlyIntoTheMainFrame() { + assumeTrue( + "The System WebView does not support document-start scripts", + WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT), + ) + initSentry { it.tracesSampleRate = 1.0 } + + val scenario = launchActivity() + scenario.moveToState(Lifecycle.State.RESUMED) + + var activity: WebViewTraceActivity? = null + scenario.onActivity { activity = it } + val webViewActivity = assertNotNull(activity) + await.atMost(30, TimeUnit.SECONDS).until { + webViewActivity.pageTrace != null && webViewActivity.iframeInjected != null + } + + assertTrue(webViewActivity.installed) + assertNotNull(webViewActivity.nativeTrace) + // Read by the page's first synchronous script: present, so there is no race. + assertEquals(webViewActivity.nativeTrace, webViewActivity.pageTrace) + assertFalse(webViewActivity.iframeInjected!!) + scenario.close() + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml index 6bcad62e6de..624520b5b4c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/iframe.html b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/iframe.html new file mode 100644 index 00000000000..05f42a95bbb --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/iframe.html @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/index.html b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/index.html new file mode 100644 index 00000000000..ddccd20a972 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/WebViewTraceActivity.kt b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/WebViewTraceActivity.kt new file mode 100644 index 00000000000..d0a9abee52c --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/WebViewTraceActivity.kt @@ -0,0 +1,66 @@ +package io.sentry.uitest.android + +import android.annotation.SuppressLint +import android.os.Bundle +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AppCompatActivity +import androidx.webkit.WebViewAssetLoader +import io.sentry.Sentry +import io.sentry.android.core.SentryWebViewTracing + +/** Loads a bundled page with WebView trace propagation and records what the page received. */ +class WebViewTraceActivity : AppCompatActivity() { + + /** The trace the app injected. */ + @Volatile var nativeTrace: String? = null + + /** Whether addTracePropagation installed its script. */ + @Volatile var installed: Boolean = false + + /** The sentry-trace meta tag seen by the page's first synchronous script, empty if missing. */ + @Volatile var pageTrace: String? = null + + /** Whether the iframe on the page saw a sentry-trace meta tag. */ + @Volatile var iframeInjected: Boolean? = null + + @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val webView = WebView(this) + setContentView(webView) + + val assetLoader = + WebViewAssetLoader.Builder() + .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)) + .build() + webView.webViewClient = + object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url) + } + webView.settings.javaScriptEnabled = true + webView.addJavascriptInterface(PageBridge(), "SentryTest") + + nativeTrace = Sentry.getTraceparent()?.value + installed = SentryWebViewTracing.addTracePropagation(webView, WebViewAssetLoader.DEFAULT_DOMAIN) + webView.loadUrl("https://${WebViewAssetLoader.DEFAULT_DOMAIN}/assets/webview/index.html") + } + + private inner class PageBridge { + @JavascriptInterface + fun onPageTrace(trace: String) { + pageTrace = trace + } + + @JavascriptInterface + fun onIframeTrace(injected: Boolean) { + iframeInjected = injected + } + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index a9d4ea8ac5f..eed60e4482a 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -210,6 +210,7 @@ dependencies { implementation(libs.androidx.recyclerview) implementation(libs.androidx.viewpager2) implementation(libs.androidx.browser) + implementation(libs.androidx.webkit) implementation(libs.androidx.room3.runtime) implementation(libs.bundles.androidx.room2) implementation(libs.bundles.androidx.sqlite.drivers) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index da8b00d97a0..7a42a1a4ace 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -97,6 +97,11 @@ android:name=".CustomTabsActivity" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html b/sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html new file mode 100644 index 00000000000..9dd12db5f68 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html @@ -0,0 +1,20 @@ + + + + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html b/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html new file mode 100644 index 00000000000..3697cd349e4 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + +

WebView page

+

Injected sentry-trace:

+
(none, not injected)
+

The frame below must not receive the trace:

+ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 9660f52ed36..d63a0f2ca54 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -978,6 +978,16 @@ fun IntegrationsScreen() { } } } + item { + SentryTraced("open_webview_activity") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, WebViewActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open WebView Trace Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("open_camera_activity") { OutlinedButton( diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/WebViewActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/WebViewActivity.kt new file mode 100644 index 00000000000..28839be876d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/WebViewActivity.kt @@ -0,0 +1,143 @@ +package io.sentry.samples.android + +import android.annotation.SuppressLint +import android.graphics.Typeface +import android.os.Bundle +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.webkit.WebViewAssetLoader +import io.sentry.Sentry +import io.sentry.android.core.SentryWebViewTracing + +/** + * Connects the app's trace with a page shown in a WebView. + * + * The page is bundled in `assets/webview` and served over https by [WebViewAssetLoader], so its + * host can be allowed like any other. It runs `@sentry/browser`, which continues the injected trace + * on pageload, and reports back what it received so the result can be checked on the device: + * - the `sentry-trace` meta tag must be present in the first synchronous `` script, and match + * the app's trace; + * - the iframe on the page must not receive it. + */ +class WebViewActivity : AppCompatActivity() { + + private lateinit var receivedView: TextView + private lateinit var statusView: TextView + + private var nativeTrace: String? = null + private var pageTrace: String? = null + private var iframeInjected: Boolean? = null + private var installed = false + + @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val nativeTraceView = valueView() + receivedView = valueView() + statusView = TextView(this).apply { setTypeface(typeface, Typeface.BOLD) } + val webView = WebView(this) + + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(32, systemBars.top + 32, 32, systemBars.bottom) + insets + } + addView(labelView("App trace")) + addView(nativeTraceView) + addView(labelView("WebView received")) + addView(receivedView) + addView(statusView) + addView(webView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + } + ) + + val assetLoader = + WebViewAssetLoader.Builder() + .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)) + .build() + webView.webViewClient = + object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url) + } + webView.settings.javaScriptEnabled = true + // Only the bundled demo page is loaded, so exposing this bridge to it is fine for a sample. + webView.addJavascriptInterface(PageBridge(), "SentrySample") + + // The trace that addTracePropagation injects, read right before it takes its snapshot. + nativeTrace = Sentry.getTraceparent()?.value + nativeTraceView.text = nativeTrace ?: "(no trace)" + + // Before loadUrl: the script applies to documents that start loading after this call. + installed = SentryWebViewTracing.addTracePropagation(webView, ALLOWED_HOST) + updateStatus() + + webView.loadUrl(PAGE_URL) + } + + private fun updateStatus() { + receivedView.text = pageTrace?.ifEmpty { "(none, not injected)" } ?: "(waiting…)" + val connected = pageTrace != null && pageTrace == nativeTrace + statusView.text = + when { + !installed -> "Trace propagation not installed, see the Sentry logs" + pageTrace == null || iframeInjected == null -> "WebView trace not connected" + !connected -> "WebView trace not connected" + iframeInjected == true -> "WebView trace connected, but the iframe received it" + else -> "WebView trace connected ✓" + } + } + + private fun labelView(text: String): TextView = + TextView(this).apply { + this.text = text + setTypeface(typeface, Typeface.BOLD) + setPadding(0, 16, 0, 0) + } + + private fun valueView(): TextView = + TextView(this).apply { + typeface = Typeface.MONOSPACE + setTextIsSelectable(true) + } + + /** Called by the page's scripts, on a background thread. */ + private inner class PageBridge { + @JavascriptInterface fun dsn(): String? = Sentry.getCurrentScopes().options.dsn + + @JavascriptInterface + fun onPageTrace(trace: String) { + runOnUiThread { + pageTrace = trace + updateStatus() + } + } + + @JavascriptInterface + fun onIframeTrace(injected: Boolean) { + runOnUiThread { + iframeInjected = injected + updateStatus() + } + } + } + + private companion object { + const val ALLOWED_HOST = WebViewAssetLoader.DEFAULT_DOMAIN + const val PAGE_URL = "https://$ALLOWED_HOST/assets/webview/index.html" + } +} From 35927232ad4a76d68faf0f268b13e9bc0179492e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 27 Oct 2026 17:02:28 +0100 Subject: [PATCH 2/6] changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bbfc57c8f..90d75064201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Features + +- Connect Android traces with web content in a `WebView` using `SentryWebViewTracing.addTracePropagation(webView, "example.com")` ([#6395](https://github.com/getsentry/sentry-java/pull/6395)) + - Before page scripts run, the current trace is written into pages from the allowed hosts as `sentry-trace` and `baggage` meta tags, so `@sentry/browser` running in the page continues the trace on pageload. + - Requires the `androidx.webkit:webkit` dependency in your app and an Android System WebView that supports document-start scripts; otherwise the call logs a warning and returns `false`. + - Only list hosts whose content you control. Call it on the main thread before loading a URL, and again to propagate a newer trace to a later page load. + ### Fixes - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) From 3c84e98590aa37ccefed169337a7112d6ffe1678 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 28 Oct 2026 09:12:07 +0100 Subject: [PATCH 3/6] fix(android): Keep non-default ports and handle old androidx.webkit A WebView origin rule without a port only matches port 443, so a non-default port in an allowedHosts entry is now kept in the rule. androidx.webkit is compileOnly, so an app may ship an older version than the SDK was compiled against. A LinkageError is now caught, like for androidx.activity in the feedback form, and the call returns false. Co-Authored-By: Claude Opus 5.5 --- .../android/core/SentryWebViewTracing.java | 24 +++++++++- .../core/WebViewTracePropagationScript.java | 44 ++++++++++++++++--- .../android/core/SentryWebViewTracingTest.kt | 22 ++++++++++ .../core/WebViewTracePropagationScriptTest.kt | 25 +++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java index e641ce74ca3..10d4fed5030 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java @@ -57,7 +57,8 @@ public static boolean addTracePropagation( * *

{@code allowedHosts} is a trust boundary: only list hosts whose content you control. A page * matches when its hostname equals an entry or is a subdomain of it, over https. Entries are - * normalized, so a scheme, a path, a port, and a trailing dot are ignored. + * normalized, so a scheme, a path, and a trailing dot are ignored. A port other than 443 limits + * the entry to pages served on that port (for example {@code "example.com:8443"}). * *

The trace is a snapshot taken when this is called. To propagate a newer trace to a later * page load, call this again: it replaces the script Sentry installed earlier and leaves other @@ -114,6 +115,25 @@ static boolean install( return false; } + try { + return replaceScript(webView, allowedHosts, scopes, installer, logger); + } catch (LinkageError e) { + // androidx.webkit is compileOnly, so the version in the app's apk may be older than the one + // we compiled against and lack the APIs used here. + logger.log( + SentryLevel.WARNING, + "androidx.webkit is too old for WebView trace propagation, skipping.", + e); + return false; + } + } + + private static boolean replaceScript( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull WebViewScriptInstaller installer, + final @NotNull ILogger logger) { // Remove Sentry's previous script first, so a failed re-add never leaves a stale trace behind. installer.removeInstalledScript(webView); @@ -151,7 +171,7 @@ static boolean install( try { if (!installer.installScript( - webView, script, WebViewTracePropagationScript.originRules(hosts))) { + webView, script, WebViewTracePropagationScript.originRules(allowedHosts))) { logger.log( SentryLevel.WARNING, "The WebView does not support document-start scripts, skipping WebView trace propagation."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java index b7148d84e1a..66763ce3233 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -86,19 +87,52 @@ private WebViewTracePropagationScript() {} /** * Origin rules for {@code WebViewCompat.addDocumentStartJavaScript}: each host and its subdomains - * over https. IP literals cannot take a subdomain wildcard. + * over https. IP literals cannot take a subdomain wildcard. A rule without a port only matches + * port 443, so a non-default port given in an entry is kept and limits the rule to that port. */ - static @NotNull Set originRules(final @NotNull List normalizedHosts) { + static @NotNull Set originRules(final @NotNull Collection allowedHosts) { final @NotNull Set rules = new LinkedHashSet<>(); - for (final @NotNull String host : normalizedHosts) { - rules.add("https://" + host); + for (final @Nullable String entry : allowedHosts) { + if (entry == null) { + continue; + } + final @NotNull String host = normalizeHost(entry); + if (host.isEmpty()) { + continue; + } + final @Nullable String port = port(entry); + final @NotNull String portSuffix = port == null || "443".equals(port) ? "" : ":" + port; + rules.add("https://" + host + portSuffix); if (!isIpLiteral(host)) { - rules.add("https://*." + host); + rules.add("https://*." + host + portSuffix); } } return rules; } + /** Returns the port of an {@code allowedHosts} entry, or null if it has none. */ + private static @Nullable String port(final @NotNull String entry) { + String hostAndPort = SCHEME.matcher(entry.trim()).replaceFirst(""); + final int pathStart = indexOfAny(hostAndPort, "/?#"); + if (pathStart != -1) { + hostAndPort = hostAndPort.substring(0, pathStart); + } + + final int closingBracket = hostAndPort.indexOf(']'); + final @NotNull String afterHost; + if (closingBracket != -1) { + afterHost = hostAndPort.substring(closingBracket + 1); + } else if (hostAndPort.indexOf(':') != hostAndPort.lastIndexOf(':')) { + // An unbracketed IPv6 literal has no port. + return null; + } else { + final int colon = hostAndPort.indexOf(':'); + afterHost = colon == -1 ? "" : hostAndPort.substring(colon); + } + final @NotNull Matcher matcher = PORT.matcher(afterHost); + return matcher.matches() ? afterHost.substring(1) : null; + } + /** * Removes the {@code sentry-transaction} entry from a {@code baggage} header, keeping every other * entry. Returns null when nothing is left to propagate. diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt index ca84fd71f8f..39076a6d66e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt @@ -30,6 +30,7 @@ class SentryWebViewTracingTest { private class FakeInstaller( var supported: Boolean = true, var installFailure: IllegalArgumentException? = null, + var linkageError: LinkageError? = null, ) : WebViewScriptInstaller { var removeCount = 0 val installed = mutableListOf>>() @@ -37,6 +38,7 @@ class SentryWebViewTracingTest { override fun isDocumentStartScriptSupported(): Boolean = supported override fun removeInstalledScript(webView: WebView) { + linkageError?.let { throw it } removeCount++ } @@ -187,6 +189,26 @@ class SentryWebViewTracingTest { assertThat(installer.installed).isEmpty() } + @Test + fun `returns false when androidx webkit is too old`() { + initSentry() + installer.linkageError = NoSuchMethodError("addDocumentStartJavaScript") + + assertThat(add()).isFalse() + + assertThat(installer.installed).isEmpty() + } + + @Test + fun `passes a non-default port through to the origin rules`() { + initSentry() + + assertThat(add(listOf("example.com:8443"))).isTrue() + + val (_, rules) = installer.installed.single() + assertThat(rules).containsExactly("https://example.com:8443", "https://*.example.com:8443") + } + @Test fun `returns false when the origin rules are rejected`() { initSentry() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt index bd04c3cd78c..707c343aeae 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt @@ -223,6 +223,31 @@ class WebViewTracePropagationScriptTest { assertThat(rules).containsExactly("https://[::1]", "https://10.0.2.2") } + @Test + fun `originRules keep a non-default port`() { + val rules = + WebViewTracePropagationScript.originRules( + listOf("example.com:8443", "https://other.com:443/x", "[::1]:8080", "10.0.2.2:3000") + ) + + assertThat(rules) + .containsExactly( + "https://example.com:8443", + "https://*.example.com:8443", + "https://other.com", + "https://*.other.com", + "https://[::1]:8080", + "https://10.0.2.2:3000", + ) + } + + @Test + fun `originRules skip blank entries and treat unbracketed IPv6 as having no port`() { + val rules = WebViewTracePropagationScript.originRules(listOf(" ", "2001:db8::1")) + + assertThat(rules).containsExactly("https://[2001:db8::1]") + } + @Test fun `stripTransactionFromBaggage removes sentry-transaction and keeps every other entry`() { val baggage = From 23b79a7985ec3d7bd3753f6b71fa2ec5f14918dd Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 28 Oct 2026 09:26:16 +0100 Subject: [PATCH 4/6] fix(android): Remove the previous WebView script after an off-thread call A call off the WebView's thread returns false without touching the WebView, which left Sentry's previous script installed. The removal is now posted to the WebView's thread, and skipped if a call on that thread replaced the script in the meantime. Co-Authored-By: Claude Opus 5.5 --- .../android/core/SentryWebViewTracing.java | 46 +++++++++++++++++-- .../android/core/SentryWebViewTracingTest.kt | 25 ++++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java index 10d4fed5030..eb2700c461b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java @@ -3,6 +3,7 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.os.Build; +import android.os.Handler; import android.os.Looper; import android.webkit.WebView; import androidx.annotation.MainThread; @@ -15,6 +16,7 @@ import io.sentry.util.TracingUtils; import java.util.Arrays; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -35,6 +37,9 @@ public final class SentryWebViewTracing { private static final @NotNull String INTEGRATION_NAME = "WebViewTracePropagation"; + /** Counts calls that replaced Sentry's script on a WebView's thread. */ + private static final @NotNull AtomicLong replacements = new AtomicLong(); + private SentryWebViewTracing() {} /** @@ -112,10 +117,12 @@ static boolean install( logger.log( SentryLevel.WARNING, "addTracePropagation must be called on the WebView's thread, skipping WebView trace propagation."); + removeOnWebViewThread(webView, installer, logger); return false; } try { + replacements.incrementAndGet(); return replaceScript(webView, allowedHosts, scopes, installer, logger); } catch (LinkageError e) { // androidx.webkit is compileOnly, so the version in the app's apk may be older than the one @@ -186,11 +193,40 @@ private static boolean replaceScript( return true; } + /** + * A failed call must not leave a stale trace behind, but off the WebView's thread the script can + * only be removed later, on that thread. The removal is skipped if a call on the WebView's thread + * replaced the script in the meantime, so it never removes a newer script. + */ + private static void removeOnWebViewThread( + final @NotNull WebView webView, + final @NotNull WebViewScriptInstaller installer, + final @NotNull ILogger logger) { + final long replacementsSeen = replacements.get(); + new Handler(webViewLooper(webView)) + .post( + () -> { + if (replacements.get() != replacementsSeen) { + return; + } + try { + installer.removeInstalledScript(webView); + } catch (LinkageError e) { + logger.log( + SentryLevel.WARNING, + "androidx.webkit is too old for WebView trace propagation, skipping.", + e); + } + }); + } + private static boolean isOnWebViewThread(final @NotNull WebView webView) { - final @NotNull Looper webViewLooper = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - ? webView.getWebViewLooper() - : Looper.getMainLooper(); - return Looper.myLooper() == webViewLooper; + return Looper.myLooper() == webViewLooper(webView); + } + + private static @NotNull Looper webViewLooper(final @NotNull WebView webView) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? webView.getWebViewLooper() + : Looper.getMainLooper(); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt index 39076a6d66e..b7e8e5080e6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.core +import android.os.Looper import android.webkit.WebView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -23,6 +24,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.robolectric.Shadows.shadowOf @RunWith(AndroidJUnit4::class) class SentryWebViewTracingTest { @@ -176,8 +178,9 @@ class SentryWebViewTracingTest { } @Test - fun `returns false and touches nothing when called off the WebView's thread`() { + fun `off the WebView's thread returns false and removes the previous script on that thread`() { initSentry() + assertThat(add()).isTrue() var result: Boolean? = null val thread = Thread { result = add() } @@ -185,8 +188,24 @@ class SentryWebViewTracingTest { thread.join() assertThat(result).isFalse() - assertThat(installer.removeCount).isEqualTo(0) - assertThat(installer.installed).isEmpty() + assertThat(installer.removeCount).isEqualTo(1) + shadowOf(Looper.getMainLooper()).idle() + assertThat(installer.removeCount).isEqualTo(2) + assertThat(installer.installed).hasSize(1) + } + + @Test + fun `the removal posted by an off-thread call does not remove a newer script`() { + initSentry() + val thread = Thread { add() } + thread.start() + thread.join() + + assertThat(add()).isTrue() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).hasSize(1) } @Test From 8db8222489d11214b54da4efb8b25532bd79d23f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 28 Oct 2026 10:09:11 +0100 Subject: [PATCH 5/6] fix(android): Track WebView script replacements per WebView The off-thread cleanup compared against one counter shared by all WebViews, bumped before the replacement finished. A concurrent off-thread call could then remove a script that was just installed, and a call on another WebView cancelled this WebView's cleanup. The count is now kept per WebView, with weak keys, and bumped once the replacement is done. Co-Authored-By: Claude Opus 5.5 --- .../android/core/SentryWebViewTracing.java | 28 +++++++++--- .../android/core/SentryWebViewTracingTest.kt | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java index eb2700c461b..a32b250b22e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java @@ -15,8 +15,10 @@ import io.sentry.util.LoadClass; import io.sentry.util.TracingUtils; import java.util.Arrays; +import java.util.Collections; import java.util.List; -import java.util.concurrent.atomic.AtomicLong; +import java.util.Map; +import java.util.WeakHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -37,8 +39,12 @@ public final class SentryWebViewTracing { private static final @NotNull String INTEGRATION_NAME = "WebViewTracePropagation"; - /** Counts calls that replaced Sentry's script on a WebView's thread. */ - private static final @NotNull AtomicLong replacements = new AtomicLong(); + /** + * Counts, per WebView, the calls that finished replacing Sentry's script on the WebView's thread. + * Weak keys, so it doesn't keep WebViews alive. + */ + private static final @NotNull Map replacements = + Collections.synchronizedMap(new WeakHashMap<>()); private SentryWebViewTracing() {} @@ -122,7 +128,6 @@ static boolean install( } try { - replacements.incrementAndGet(); return replaceScript(webView, allowedHosts, scopes, installer, logger); } catch (LinkageError e) { // androidx.webkit is compileOnly, so the version in the app's apk may be older than the one @@ -132,6 +137,10 @@ static boolean install( "androidx.webkit is too old for WebView trace propagation, skipping.", e); return false; + } finally { + // Counted once the replacement is done, so an off-thread call made while it runs doesn't + // later remove the script it installed. Only written on the WebView's thread. + replacements.put(webView, replacementsOf(webView) + 1); } } @@ -196,17 +205,17 @@ private static boolean replaceScript( /** * A failed call must not leave a stale trace behind, but off the WebView's thread the script can * only be removed later, on that thread. The removal is skipped if a call on the WebView's thread - * replaced the script in the meantime, so it never removes a newer script. + * replaced this WebView's script in the meantime, so it never removes a newer script. */ private static void removeOnWebViewThread( final @NotNull WebView webView, final @NotNull WebViewScriptInstaller installer, final @NotNull ILogger logger) { - final long replacementsSeen = replacements.get(); + final long replacementsSeen = replacementsOf(webView); new Handler(webViewLooper(webView)) .post( () -> { - if (replacements.get() != replacementsSeen) { + if (replacementsOf(webView) != replacementsSeen) { return; } try { @@ -220,6 +229,11 @@ private static void removeOnWebViewThread( }); } + private static long replacementsOf(final @NotNull WebView webView) { + final @Nullable Long count = replacements.get(webView); + return count == null ? 0 : count; + } + private static boolean isOnWebViewThread(final @NotNull WebView webView) { return Looper.myLooper() == webViewLooper(webView); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt index b7e8e5080e6..54b84d21232 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt @@ -33,6 +33,7 @@ class SentryWebViewTracingTest { var supported: Boolean = true, var installFailure: IllegalArgumentException? = null, var linkageError: LinkageError? = null, + var onInstall: (() -> Unit)? = null, ) : WebViewScriptInstaller { var removeCount = 0 val installed = mutableListOf>>() @@ -50,6 +51,7 @@ class SentryWebViewTracingTest { allowedOriginRules: Set, ): Boolean { installFailure?.let { throw it } + onInstall?.invoke() installed.add(script to allowedOriginRules) return true } @@ -208,6 +210,47 @@ class SentryWebViewTracingTest { assertThat(installer.installed).hasSize(1) } + @Test + fun `an off-thread call made while a script is installed does not remove it later`() { + initSentry() + installer.onInstall = { + // Runs on the WebView's thread while it installs the script. + val thread = Thread { add() } + thread.start() + thread.join() + } + + assertThat(add()).isTrue() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).hasSize(1) + } + + @Test + fun `a call on another WebView does not cancel the removal posted for this one`() { + initSentry() + assertThat(add()).isTrue() + val otherWebView = WebView(ApplicationProvider.getApplicationContext()) + val thread = Thread { add() } + thread.start() + thread.join() + + assertThat( + SentryWebViewTracing.install( + otherWebView, + listOf("example.com"), + Sentry.getCurrentScopes(), + installer, + ) + ) + .isTrue() + shadowOf(Looper.getMainLooper()).idle() + + // One removal per on-thread call, plus the one posted for the first WebView. + assertThat(installer.removeCount).isEqualTo(3) + } + @Test fun `returns false when androidx webkit is too old`() { initSentry() From bbf1517621454d0fbcc6e2ff3525b9b4e54560c3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 28 Oct 2026 11:15:16 +0100 Subject: [PATCH 6/6] ref(samples): Move the WebView trace screen to the Tracing tab The sample no longer shows an iframe; the instrumented test in sentry-uitest-android still checks that iframes don't get the trace. Co-Authored-By: Claude Opus 5.5 --- .../src/main/assets/webview/iframe.html | 20 ----------------- .../src/main/assets/webview/index.html | 2 -- .../io/sentry/samples/android/MainActivity.kt | 20 ++++++++--------- .../sentry/samples/android/WebViewActivity.kt | 22 ++++--------------- 4 files changed, 14 insertions(+), 50 deletions(-) delete mode 100644 sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html diff --git a/sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html b/sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html deleted file mode 100644 index 9dd12db5f68..00000000000 --- a/sentry-samples/sentry-samples-android/src/main/assets/webview/iframe.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - diff --git a/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html b/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html index 3697cd349e4..889ef2fb270 100644 --- a/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html +++ b/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html @@ -31,8 +31,6 @@

WebView page

Injected sentry-trace:

(none, not injected)
-

The frame below must not receive the trace:

-