Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V
Expand Down
4 changes: 4 additions & 0 deletions sentry-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions sentry-android-core/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package io.sentry.android.core;

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;
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.Collections;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
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}.
*
* <p>{@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.
*
* <p>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";

/**
* 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<WebView, Long> replacements =
Collections.synchronizedMap(new WeakHashMap<>());

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.
*
* <p>{@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, 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"}).
*
* <p>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.
*
* <p>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<String> allowedHosts) {
return addTracePropagation(webView, allowedHosts, Sentry.getCurrentScopes(), new LoadClass());
}

static boolean addTracePropagation(
final @NotNull WebView webView,
final @NotNull List<String> 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());
Comment thread
cursor[bot] marked this conversation as resolved.
}

static boolean install(
final @NotNull WebView webView,
final @NotNull List<String> 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.");
removeOnWebViewThread(webView, installer, logger);
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;
} 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);
}
}

private static boolean replaceScript(
final @NotNull WebView webView,
final @NotNull List<String> 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);

final @NotNull List<String> 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(allowedHosts))) {
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;
}

/**
* 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 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 = replacementsOf(webView);
new Handler(webViewLooper(webView))
.post(
() -> {
if (replacementsOf(webView) != 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 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);
}

private static @NotNull Looper webViewLooper(final @NotNull WebView webView) {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? webView.getWebViewLooper()
: Looper.getMainLooper();
}
}
Original file line number Diff line number Diff line change
@@ -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<String> allowedOriginRules);
}
Loading
Loading