---
title: "Bringing WebView observability into mobile sessions"
slug: "announcing-webview-support"
blurb: "bitdrift Capture now supports WebView instrumentation on Android and iOS, bringing page views, network requests, Core Web Vitals, and JavaScript errors from inside the WebView into the same mobile session as your native events. Users gain full fidelity insight into your app."
metaDescription: "bitdrift Capture brings WebView page views, network requests, Core Web Vitals, and JavaScript errors into the same mobile session as native events."
cover:
  url: "/assets/posts/announcing-webview-support/feature-webview-observability-desktop@1x.webp"
  alt: "Illustration of a Capture timeline showing WebView page spans, network requests, and web vitals"
socialThumbnail:
  url: "/assets/posts/announcing-webview-support/feature-webview-observability-desktop@1x.webp"
  alt: "Capture timeline with WebView page spans and network timings"
author:
  - "jackson"
tags:
  - "feature"
  - "mobile"
  - "android"
  - "web"
  - "engineering"
publishedDate: "2026-11-06T12:00:00Z"
modifiedDate: "2026-11-06T12:00:00Z"

---

A user journey within your mobile app, like checking out, can move from native UI into a WebView, but the investigation should stay in one session. We recently added WebView support to Capture, bringing WebView activity into the mobile session and giving you full fidelity insight into your mobile application.

Imagine you're debugging a checkout flow that starts in your native app and ends inside a WebView. You can see the tap that opened the checkout, and you can see the user leave. What happened in between? Was the page slow, did a request fail, or did JavaScript throw? Native instrumentation alone struggles to tell you.

Our WebView instrumentation brings page views, network requests, Core Web Vitals, long tasks, JavaScript errors, console output, and user interactions into the same session as the native events around them. You don't even need to add a snippet to the page your app opens!

We gave a lightning talk on this at droidCon Berlin. Here's the talk, followed by a closer look at the implementation.

<YoutubePlayer title="Supercharged WebView Observability at droidCon Berlin 2026" src="https://www.youtube.com/embed/JQH0ckbCflE" overlayText="Watch our WebView observability lightning talk" overlayImage="/assets/posts/announcing-webview-support/webview-talk-cover.webp" aspectRatio="16 / 9" sx={{ mt: 4, mb: 6 }} />

## Why are WebViews a blind spot?

A native SDK can see app lifecycle events, screen transitions, and requests made by native networking libraries. The page inside a WebView runs in a different environment. Its `fetch` calls, DOM events, JavaScript errors, and rendering metrics happen in the browser runtime. A `WebViewClient` or `WKNavigationDelegate` tells us about navigation, but lacks context on a lot of what happens within the page.

One approach would be to ask you to install a web SDK on every site your app loads. That would work for pages you control, but it doesn't help with third-party pages, and it leaves you with the job of connecting web events back to the mobile session. We wanted to capture activity *inside* the page while letting the native Capture SDK own the session, providing a unified session which might flow from native to WebView and back several times over within the user journey.

<Image alt="Native app and WebView signals appear together in one Capture session." altAsCaption maxWidth asset="/assets/posts/announcing-webview-support/native-webview-one-session@1x.webp" />

## Getting the script into the page

The WebView instrumentation starts as a TypeScript bundle that we build into the Android and iOS SDKs. The native SDK injects it at document start, before the page's own JavaScript runs. We used this approach for several reasons:

1. The bundle is tightly coupled to the SDK version, so a remote script change cannot break communication between the JavaScript and native layers.
2. No remote script means no additional network latency or CORS configuration to navigate.
3. We can change the communication protocol freely, updating the JavaScript and native layers in the same SDK release.

On Android, we use `WebViewCompat` to register a message listener and inject the script. Here's the relevant part of the SDK setup, shortened for readability:

```kotlin
WebViewCompat.addWebMessageListener(webview, BRIDGE_NAME, setOf("*"), bridgeHandler)
val script = WebViewBridgeScript.getScript(config)
WebViewCompat.addDocumentStartJavaScript(webview, script, setOf("*"))
```

Before doing this, Capture checks that the required Android WebView features are available and that the view hasn't already been instrumented. The `setOf("*")` arguments are [allowed-origin rules](https://developer.android.com/reference/androidx/webkit/WebViewCompat): `*` lets any page loaded in an instrumented WebView use the injected bridge, including third-party pages. Android applies the rule to each frame, so third-party subframes can use it too. Messages arriving through that bridge must be treated as untrusted input; the native handler checks the protocol version and message type before dispatching them.

iOS follows the same basic approach with `WKUserScript`:

```swift
let script = WKUserScript(
    source: Self.scriptMarker + WebViewBridgeScript.getScript(configuration: instrumentationConfig),
    injectionTime: .atDocumentStart,
    forMainFrameOnly: false
)
userContentController.addUserScript(script)
```

The marker lets us recognize a configuration we've already instrumented. `forMainFrameOnly: false` means the script runs in subframes too. Both of these snippets are SDK internals; your app only needs to enable the integration or select a WebView for instrumentation.

Once the script runs, it detects `window.BitdriftLogger` on Android or `window.webkit.messageHandlers.BitdriftLogger` on iOS to determine how to send events through the bridge. These events are sent as JSON with a tag, protocol version, timestamp, type, and the fields for that event. The native handler turns them into Capture logs and spans, so they inherit the mobile session's buffering and upload behavior. Because of this, they’re also evaluated by the workflow engine, which means workflows can be built to react to WebView activity alongside native app events.

## Keeping signals attached to the right page

When page-view capture is enabled, we initialize it before the other monitors so they have a parent span to attach to. The script starts a page view for the initial document, then sends start and end messages as the page navigates, hides, or unloads. It also wraps `history.pushState` and `history.replaceState` and listens for `popstate`. That gives single-page app transitions their own page-view spans, even when the WebView doesn't load a new document.

Each page view gets an ID in JavaScript, which travels across the bridge and is used to correlate native `webview.pageView` spans with their child signals. For messages without an explicit parent, the bridge attaches the active page-view ID:

```ts
if (message.type === 'pageView' || message.parentSpanId) {
    return message;
}

const parentSpanId = currentPageViewSpanId?.();
return parentSpanId ? { ...message, parentSpanId } : message;
```

Why allow a message to specify its own parent? Consider a request that starts on the checkout page, then finishes after a single-page app transition. If we used the *current* page view when it completed, the request would appear under the wrong page. We capture the parent when the request starts and carry it through to the final message.

Web Vitals have a similar timing problem. We record the start and end of each page view using the same clock as the browser's Performance entries. If an LCP measurement from checkout arrives after the app has moved to a receipt page, the entry's `startTime` still falls within checkout's page-view interval. This lookup gives us checkout's span ID rather than the currently active receipt span:

```ts
const firstEntry = metric.entries[0];
parentSpanId = firstEntry
    ? getPageSpanIdAtTime(firstEntry.startTime)
    : metric.navigationType === 'back-forward-cache'
      ? getLatestPageSpanId()
      : initialPageSpanId;
```

The other branches handle metrics without a Performance entry: a back-forward-cache metric uses the latest page view, and other metrics fall back to the initial page view. We include the chosen ID as `parentSpanId` in the `webVital` message and remember it by metric ID for later updates. The page-view history is bounded, and Resource Timing entries use the same start-time approach. On the native side, LCP, FCP, TTFB, and INP become spans under that page view; CLS is logged as an event because it is a cumulative score rather than a duration.

The result is a timeline you can actually follow: the native tap, a WebView page view, slow LCP, a failed API call, a JavaScript error, a retry, and the next native screen. Those events are already in the same session, rather than in two datasets you have to line up by timestamp.

## What about network requests?

There isn't one browser API that reports every request. We use three paths:

1. Wrap `window.fetch` for requests made with `fetch`.
2. Wrap `XMLHttpRequest.open` and `send` for code using XHR.
3. Observe `resource` timing entries for images, stylesheets, fonts, scripts, and other loads initiated by the browser.

For `fetch`, the important constraint is that our instrumentation shouldn't change the response or error the page receives. The success path calls the original function, records the request inside `safeCall`, and returns the same response:

```ts
const response = await originalFetch.call(window, input, init);
safeCall(() => {
    markAsJsInitiated(url);
    log(createMessage({
        type: 'networkRequest',
        parentSpanId,
        requestId,
        method: method.toUpperCase(),
        url,
        statusCode: response.status,
        durationMs: Math.round(performance.now() - startTime),
        success: response.ok,
        requestType: 'fetch',
        timing: getResourceTiming(url),
    }));
});
return response;
```

The failure path records the error and rethrows it. XHR follows the same principle: call the browser's original methods and observe completion or failure without taking over the request.

There's one extra consideration here: a request intercepted through `fetch` or XHR can also show up as a Resource Timing entry. We keep track of JavaScript-initiated requests in a size-capped map to prevent unbounded growth, then correlate them with Resource Timing entries by URL and completion time, deduping them into one request/response pair.

## What else? JavaScript errors, Web Vitals, and rage clicks

Network activity is only part of the story. The bundle also listens for uncaught JavaScript errors and unhandled promise rejections, wraps console methods, observes long tasks, and reports LCP, CLS, INP, FCP, and TTFB through `web-vitals`. A `pointerdown` listener records interactions on clickable elements and, with some additional logic, detects repeated taps near a non-interactive element as a rage click. These are the signals that help explain why a page felt broken even when navigation itself succeeded.

## Staying out of the page's way

If the observability code throws, checkout still needs to work. Browser API wrappers call the originals, and telemetry runs in guarded callbacks so an error in our observer doesn't escape into the site's code. We also bound the maps used for request deduplication and page-view correlation, and serialize console arguments with limits for long strings and nested objects.

The monitors are separate modules in the bundled script. On Android, runtime feature flags can enable or disable WebView signal groups without a new app release. As with any browser instrumentation, a signal is only available when the underlying WebView supports the relevant API.

## How to enable WebView instrumentation on Android and iOS

On Android, you can instrument a particular `WebView` before loading a page:

```kotlin
import io.bitdrift.capture.webview.WebViewCapture

WebViewCapture.instrument(webView)
webView.loadUrl("https://example.com/checkout")
```

If you'd rather instrument `loadUrl` calls automatically, configure the Capture Gradle plugin:

```kotlin
bitdrift {
    instrumentation {
        webViewAutomaticInstrumentationScope = JS_ENABLED
    }
}
```

The plugin handles both `loadUrl(String)` and `loadUrl(String, Map)` call sites. `JS_ENABLED` instruments views where your app already enabled JavaScript. `ALL` can enable JavaScript on detected views, as does manual instrumentation, so choose the scope that fits your app's WebView policy. The Android manual call and automatic scope are currently marked experimental SDK APIs, meaning their signatures may change.

On iOS, enable the integration before creating the `WKWebView`s you want to observe:

```swift
Logger.start(withAPIKey: "<your-api-key>", sessionStrategy: .fixed())?
    .enableIntegrations([.webView()])
```

The integration instruments `WKWebView` configurations as views are created. To instrument selected views manually, leave `.webView()` out of `enableIntegrations` and call `logger.instrument(webView: webView)` on each view.

You can add your own event from a page when the bridge is present:

```js
window.bitdrift?.log?.('info', 'Checkout step opened', { step: 'payment' });
```

The bridge adds the current page-view parent to that custom log. None of the automatic capture requires changes to the page.

If you've ever had to debug a journey that disappears into a WebView, Capture lets you follow it through the same session. Have a look at the [Capture SDK source](https://github.com/bitdriftlabs/capture-sdk/tree/main/platform/webview/src), try the [sandbox](https://bitdrift.io/sandbox), or [get in touch](https://bitdrift.io/contact-us) to see what it captures in your app.

---

## Frequently asked questions

### What is WebView observability?

WebView observability is the ability to see what happens inside a WebView embedded in a mobile app, such as page loads, network requests, JavaScript errors, and rendering performance. bitdrift Capture records this activity in the same session as the app’s native events, so engineers can follow a user journey from native UI into a WebView and back.

### Do I need to add a JavaScript snippet to my web pages to use bitdrift WebView instrumentation?

No. The bitdrift Capture SDK for Android and iOS injects its instrumentation script into the WebView at document start, so no changes to the page are required. Teams can optionally log custom events from the page through the bitdrift bridge.

### Does bitdrift capture activity from third-party pages loaded in a WebView?

Yes. Because the bitdrift Capture SDK injects its script natively, it can instrument any page loaded in an instrumented WebView, including third-party pages, without installing a web SDK on those sites.

### What WebView signals does bitdrift Capture collect?

bitdrift Capture collects page views (including single-page app navigations), fetch and XHR network requests, resource loads, Core Web Vitals (LCP, CLS, INP, FCP, and TTFB), long tasks, JavaScript errors, unhandled promise rejections, console output, user interactions, and rage clicks.

### Can bitdrift workflows react to WebView events?

Yes. WebView events become Capture logs and spans in the mobile session, so bitdrift’s workflow engine evaluates them alongside native app events. Teams can build workflows that trigger on WebView activity, such as a failed checkout request or a JavaScript error.
