Repository navigation
feat(android): Connect traces with web content in a WebView #6395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
antonis
wants to merge
6
commits into
main
Choose a base branch
from
feat/webview-trace-propagation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
669c5aa
feat(android): Connect traces with web content in a WebView
antonis 3592723
changelog
antonis 3c84e98
fix(android): Keep non-default ports and handle old androidx.webkit
antonis 23b79a7
fix(android): Remove the previous WebView script after an off-thread โฆ
antonis 8db8222
fix(android): Track WebView script replacements per WebView
antonis bbf1517
ref(samples): Move the WebView trace screen to the Tracing tab
antonis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
47 changes: 47 additions & 0 deletions
47
sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
246 changes: 246 additions & 0 deletions
246
sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()); | ||
| } | ||
|
|
||
| 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(); | ||
| } | ||
| } | ||
26 changes: 26 additions & 0 deletions
26
sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.