# WebView Session Correlation

> Correlate native iOS/Android RUM sessions with Browser RUM spans running inside a WebView. Single session timeline across native and web surfaces.

Source: https://last9.io/docs/discover-applications-webview-session-correlation/

WebView session correlation links native iOS/Android RUM sessions with Browser RUM spans running inside a WebView. Once set up, the session detail panel shows a single unified timeline — native view spans alongside WebView network calls and page navigations — queryable by the same `session.id`.

:::note
For propagating a RUM `session.id` from a web or mobile client through backend services and async workers (via W3C baggage), see [Session Correlation](/docs/discover-applications-session-correlation/). That's a different mechanism — header propagation across services, not native ↔ WebView adoption.
:::

## How it works

When a native app navigates into a WebView, the SDK injects the current `session.id`, `view.id`, and `native.view.id` as JavaScript globals. The Browser RUM SDK running inside the WebView reads these globals on every span and adopts the native session ID instead of generating its own.

On the native side, the WebView page view is tagged with `view.type = webview` and `native.view.id` (equal to that view span's own trace ID). Plain native screen views omit `view.type`. The dashboard uses `native.view.id` to join Browser RUM spans — which live in a separate trace — back to the WebView page without a cross-trace lookup.

```
Native app
  └─ View span  [session.id=abc, view.type=webview, native.view.id=xyz]
        └─ WebView
              └─ Browser RUM spans  [session.id=abc, native.view.id=xyz]
              └─ XHR/fetch spans    [session.id=abc, native.view.id=xyz]
```

The Browser SDK fires a `l9rum:session_rollover` event when it adopts a native session. The original browser-generated session ID is preserved as `previous_id` on the session span.

## Prerequisites

| Component                                    | Minimum version |
| -------------------------------------------- | --------------- |
| iOS SDK (`Last9RUM`)                         | 0.5.0           |
| Android SDK (`io.last9:rum-android`)         | 0.5.0           |
| React Native SDK (`@last9/rum-react-native`) | 0.5.0           |
| Flutter SDK (`last9_rum_flutter`)            | 0.5.0           |
| Browser SDK (`@last9/rum`)                   | 2.5.0           |

Use Browser `2.8.0` and mobile SDK `1.6.0` artifacts. The mobile `stable/v1`
channels currently serve `1.6.0`.

The Browser SDK does not require any additional configuration — session adoption happens automatically when native context is detected.

## Setup

**iOS**

Call `L9Rum.shared.instrument(_:)` after creating your `WKWebView`. The SDK attaches a `WKNavigationDelegate` that re-injects native context on every navigation, so session and view IDs stay current as the user moves between pages inside the WebView.

```swift
import WebKit
import Last9RUM

// In your UIViewController or SwiftUI UIViewRepresentable:
let webView = WKWebView(frame: .zero, configuration: configuration)

L9Rum.shared.instrument(webView)
// That's it — injection happens on every navigation automatically.

webView.load(URLRequest(url: url))
```

:::note
Call `instrument(_:)` before `webView.load(...)` so the first navigation is covered.
:::

**Legacy static-script path**

If you cannot use `instrument(_:)` (for example, when building the `WKWebView` outside of your code), you can inject the script manually:

```swift
let js = try L9Rum.shared.getWebViewInjectedJavaScript()
let script = WKUserScript(
    source: js,
    injectionTime: .atDocumentStart,
    forMainFrameOnly: true
)
webView.configuration.userContentController.addUserScript(script)
```

With the static-script path you are responsible for re-injecting when the view or session changes. `instrument(_:)` handles this automatically.

**Android**

Call `L9Rum.instrument(webView)` after inflating or constructing your `WebView`. The SDK wraps the `WebViewClient` with a forwarding client that re-injects native context on every `onPageStarted`, keeping session and view IDs accurate across in-WebView navigations.

```kotlin
import android.webkit.WebView
import io.last9.rum.L9Rum

// In your Activity or Fragment:
val webView: WebView = findViewById(R.id.webView)
webView.settings.javaScriptEnabled = true

L9Rum.instrument(webView)
// Navigation-time injection is handled automatically.

webView.loadUrl(url)
```

:::note
JavaScript must be enabled on the `WebView` for injection to work.
:::

**Legacy static-script path**

```kotlin
val js = L9Rum.getWebViewInjectedJavaScript()
webView.evaluateJavascript(js, null)
```

As with iOS, the static path does not re-inject on navigation. Use `instrument(webView)` unless you have a specific reason to manage injection manually.

**React Native**

Pass a WebView load event's `nativeEvent.target` to `L9Rum.instrumentWebView`. The SDK resolves the underlying native `WKWebView` (iOS) or `android.webkit.WebView` (Android) and delegates to the native `instrument()` call, so navigation-time re-injection applies on both platforms.

1.  **Install `react-native-webview`** if not already present:

    ```bash
    npm install react-native-webview
    cd ios && pod install
    ```

2.  **Call `instrumentWebView` from `onLoadStart`**:

    ```typescript
    import React from 'react';
    import { WebView } from 'react-native-webview';
    import { L9Rum } from '@last9/rum-react-native';

    export function MyWebViewScreen() {
      return (
        <WebView
          source={{ uri: 'https://app.example.com' }}
          onLoadStart={(e) => L9Rum.instrumentWebView(e.nativeEvent.target)}
        />
      );
    }
    ```

    `instrumentWebView` also accepts a numeric reactTag, a ref, or a component instance, but `react-native-webview` >= 13 exposes a methods-only imperative handle that cannot be resolved — prefer the load event.

**Flutter**

`L9Rum.getWebViewInjectedJavaScript()` returns a script that writes the native `session.id` and `native.view.id` into the WebView. Re-run it on every navigation so id rollovers reach the page. There is no `instrumentWebView` API on Flutter.

```dart
import 'package:webview_flutter/webview_flutter.dart';
import 'package:last9_rum_flutter/last9_rum_flutter.dart';

class MyWebViewWidget extends StatefulWidget {
  const MyWebViewWidget({super.key});

  @override
  State<MyWebViewWidget> createState() => _MyWebViewWidgetState();
}

class _MyWebViewWidgetState extends State<MyWebViewWidget> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    L9Rum.startView('WebViewScreen');
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(NavigationDelegate(
        onPageStarted: (_) async {
          final script = await L9Rum.getWebViewInjectedJavaScript();
          await _controller.runJavaScript(script);
        },
      ))
      ..loadRequest(Uri.parse('https://app.example.com'));
  }

  @override
  Widget build(BuildContext context) {
    return WebViewWidget(controller: _controller);
  }
}
```

## Native view tracking on WebView screens

WebView correlation can produce **two correlated but separate views** per navigation:

1. **Native-emitted WebView page view** — the mobile SDK's view for the WebView navigation, associated with the containing iOS `UIViewController` or Android `Activity`.
2. **Web route view** — each in-WebView navigation tracked by Browser RUM `startView()` (for example, an SPA route change inside the WebView).

This is intentional. A WebView can occupy only part of a native screen, so route-level tracking inside the WebView is separate from the native screen view. The dashboard joins them via `session.id` and `native.view.id`.

### Optionally name the native host with `setViewName()`

UIKit and Activity lifecycle tracking already creates the native host view span. You can call `setViewName()` once to give that span a fixed, friendly name. Do not call native `startView()` on a WebView host screen, and do not call `setViewName()` again when its URL changes.

On SDK `1.5.0` and later, `startView` ends the auto-tracked host view before opening a new one, so it no longer leaves a **duplicate** native view. It still replaces the auto-tracked span. `setViewName()` is the API that renames the auto-tracked view in place.

On SDK versions before `1.5.0`, calling `startView()` on an auto-tracked screen opened a second native view span alongside the auto-tracked one.

### Track native WebView pages from the URL

From mobile SDK `1.6.0`, a WebView instrumented with native `instrument(webView)` / `L9Rum.instrumentWebView(...)` auto-names the **native host view** from the current main-frame URL:

- `app.screen.name` is the folded pathname (e.g. `https://www.example.com/health-record?webViewFrame=edgeToEdge` → `/health-record`; `/order/12345` → `/order/?`).
- `view.url` carries the full URL (scheme, host, path, and query) for the session detail panel.

Android updates the name on full loads and in-WebView history changes. From iOS SDK `1.6.9`, the first URL and every later path or hash navigation create a dedicated native-emitted WebView page view. Query-only changes update `view.url` in place. An injected history hook reports `pushState`, `replaceState`, hash, and popstate changes to the native SDK.

On iOS `1.6.9` and later, only the visible WebView that starts a navigation can own its destination page view. Hidden, preloaded, detached, or cross-window WebViews cannot take ownership from the active native screen or WebView page.

An explicit `startView` / `setViewName` still wins; the URL-derived name never overwrites an app-provided one. `view.url` is recorded either way. Do not use either native API to name each WebView URL. `setViewName()` renames the currently active native view immediately; the later navigation commit creates the actual WebView page view. Calling it per navigation can therefore rename the previous native screen and produce two rows with the upcoming URL.

This does **not** apply to Flutter, which uses `getWebViewInjectedJavaScript()` rather than native `instrument(webView)`. Name Flutter WebView host views with `L9Rum.startView(...)`.

**Recommended pattern:**

**iOS**

```swift
// 1. Instrument the WKWebView (once, after creation)
L9Rum.shared.instrument(webView: webView)

// 2. Optional: set one fixed host label. Do not repeat this on URL changes.
L9Rum.shared.setViewName("WebViewActivity")

// 3. Track browser-side SPA route views via Browser RUM
// (inside your WebView JS bridge or page code)
L9RUM.startView({ name: routePath })
```

**Android**

```kotlin
// 1. Instrument the WebView (once, after creation)
L9Rum.instrument(webView)

// 2. Optional: set one fixed host label. Do not repeat this on URL changes.
L9Rum.setViewName("WebViewActivity")

// 3. Track in-WebView SPA routes via Browser RUM only
// (inside your WebView JS bridge or page code)
L9RUM.startView({ name: routePath })
```

:::note
`setViewName()` renames the active native view span in place. `startView()` starts a new root span — use it for fully custom navigation (SwiftUI, Compose destinations, React Native) where automatic lifecycle tracking does not apply, or set `autoViewTrackingEnabled = false` when the app owns all view names. Not for naming an auto-tracked WebView host screen.
:::

**Screen load time** is measured from when the native view span starts (`viewDidAppear` / `onResume` for auto-tracked screens). Browser RUM inside the WebView tracks its own route-level timings separately.

## Auto-load Browser RUM

By default, the native SDK injects context globals but does not load the Browser RUM SDK. Your WebView HTML must already include `@last9/rum` and initialize it.

To have the native SDK auto-load the Browser RUM bundle from Last9's CDN into every WebView, set `webViewAutoLoadBrowserRum = true` in the SDK config:

**iOS**

```swift
let config = L9RumConfig(
    baseUrl: "https://otlp-ext-aps1.last9.io/v1/otlp/organizations/<org>",
    clientToken: "your-client-token",
    serviceName: "my-ios-app",
    serviceVersion: "1.0.0",
    deploymentEnvironment: "production"
)
config.webViewAutoLoadBrowserRum = true
// Optional: override the CDN URL (defaults to Last9's stable CDN)
// config.webViewBrowserRumCdnUrl = "https://your-cdn.example.com/l9.umd.js"

L9Rum.shared.initialize(config: config)
```

**Android**

```kotlin
val config = L9RumConfig(
    baseUrl = "https://otlp-ext-aps1.last9.io/v1/otlp/organizations/<org>",
    clientToken = "your-client-token",
    serviceName = "my-android-app",
    serviceVersion = "1.0.0",
    deploymentEnvironment = "production",
    webViewAutoLoadBrowserRum = true,
    // webViewBrowserRumCdnUrl = "https://your-cdn.example.com/l9.umd.js"
)
L9Rum.initialize(application, config)
```

**React Native**

```typescript
import { Platform } from "react-native";
import { L9Rum } from "@last9/rum-react-native";

const isIos = Platform.OS === "ios";

L9Rum.initialize({
  baseUrl: "https://otlp-ext-aps1.last9.io/v1/otlp/organizations/<org>",
  origin: isIos
    ? "ios://com.yourcompany.yourapp"
    : "android://com.yourcompany.yourapp",
  clientToken: isIos ? "your-ios-client-token" : "your-android-client-token",
  serviceName: "my-rn-app",
  serviceVersion: "1.0.0",
  deploymentEnvironment: "production",
  webViewAutoLoadBrowserRum: true,
  // webViewBrowserRumCdnUrl: "https://your-cdn.example.com/l9.umd.js",
});
```

**Flutter**

```dart
import 'dart:io' show Platform;
import 'package:last9_rum_flutter/last9_rum_flutter.dart';

final isIos = Platform.isIOS;

await L9Rum.initialize(L9RumConfig(
  baseUrl: 'https://otlp-ext-aps1.last9.io/v1/otlp/organizations/<org>',
  origin: isIos
      ? 'ios://com.yourcompany.yourapp'
      : 'android://com.yourcompany.yourapp',
  clientToken: isIos ? 'your-ios-client-token' : 'your-android-client-token',
  serviceName: 'my-flutter-app',
  serviceVersion: '1.0.0',
  deploymentEnvironment: 'production',
  webViewAutoLoadBrowserRum: true,
  // webViewBrowserRumCdnUrl: 'https://your-cdn.example.com/l9.umd.js',
));
```

:::note
`webViewAutoLoadBrowserRum` loads and initializes `@last9/rum` against your app's `baseUrl` and `clientToken`. If your WebView HTML already initializes the Browser SDK manually, leave this flag off to avoid double initialization.

For [React Native](/docs/real-user-monitoring/react-native/) and [Flutter](/docs/real-user-monitoring/flutter/) apps, use the platform-matching `clientToken` and `origin` from separate Android and iOS Client tokens — see each platform's RUM guide.
:::

## Verification

1. Open your app and navigate to a screen that contains a WebView

2. Perform a network request inside the WebView (load a page, trigger an API call)

3. In Last9, open [Discover > Applications](/docs/discover-applications/) and find the session

4. The session detail panel should show both native view spans and WebView spans in a single timeline

5. Native-emitted WebView page spans carry `view.type = webview`, `native.view.id`, `view.url`, and a folded `app.screen.name`. Plain native screens omit `view.type`. Browser RUM spans carry the same `native.view.id`, confirming the join.

---

## Troubleshooting

- **WebView spans appear in web RUM instead of mobile RUM**

  The Browser SDK inside a WebView emits spans with `telemetry.sdk.name: opentelemetry`. If `browser.mobile: "true"` is not set as a resource attribute, the dashboard may classify the session as web. Ensure the Browser SDK is initialized after the native context globals are present — `instrument(webView)` / `instrumentWebView()` handles injection before page load, so timing is correct with that path. On Flutter, inject with `getWebViewInjectedJavaScript()` from `onPageStarted`. The static-script path can have timing issues if the script runs before the globals are written.

- **Native context not being picked up**

  The Browser SDK reads `__L9RumNativeWebViewContext` from the global scope on each span. If the WebView content is served from a different origin than the app's `origin` config value, the injected script may be blocked by the WebView's content security policy. Verify that the WebView's CSP allows inline scripts from the native injection.

- **Session ID changes mid-WebView session**

  If the native session expires (max duration or idle timeout) while the user is inside a WebView, the native SDK writes a new session ID to the global. The Browser SDK reads fresh on each span, so subsequent spans automatically use the new session ID. The `l9rum:session_rollover` event fires on the browser side when this happens.

- **Duplicate view records on WebView screens**

  Seeing one native view plus one Browser RUM route view per navigation is expected. If both rows are native views, remove any native `startView()` **or per-navigation `setViewName()`** calls. Instrumentation already names full-page WebView navigations. Use `setViewName()` at most once for a fixed host label, and use Browser RUM `startView()` inside the WebView for SPA routes. A per-navigation `setViewName()` can rename the previous native view to the upcoming URL before instrumentation creates the real page view. See [Native view tracking on WebView screens](#native-view-tracking-on-webview-screens) above.

- **Events appear under a previous page or plain native screen**

  Use iOS SDK `1.6.9` or later, call `instrument(webView:)` once per `WKWebView` before its first load, and do not call native `startView()` or `setViewName()` for URL changes. Keep hidden or preloaded WebViews instrumented normally; the SDK excludes them from active-page ownership until they become the visible WebView.

Please get in touch with us on [Discord](https://discord.com/invite/Q3p2EEucx9) or [Email](mailto:support@last9.io) if you have any questions.
