# iOS / Swift

> Install and configure the Last9 iOS RUM SDK. Swift API, Swift Package Manager from git, CDN-hosted CocoaPods podspec and XCFramework, automatic instrumentation for sessions, views, network, errors, and resource sampling.

Source: https://last9.io/docs/real-user-monitoring/ios/

Real User Monitoring for iOS apps. Automatic instrumentation for sessions, views, network requests, errors, and resource metrics via OpenTelemetry.

## Prerequisites

- iOS 15.1+
- Swift 5.9+
- Dependencies (resolved transitively): `OpenTelemetry-Swift-Api ~> 1.10`, `OpenTelemetry-Swift-Sdk ~> 1.10`
- A Last9 RUM client token and OTLP endpoint

## Create a Client Monitoring Token

1. Open [Last9 → Settings → Ingestion Tokens](https://app.last9.io/control-plane/ingestion-tokens)
2. Click **Create Token** → choose type **Client**
3. Set the **allowed origin** to `ios://com.yourcompany.yourapp` — use your app's exact bundle ID
4. Copy the **token** and the **OTLP endpoint URL**

:::note
The origin is a scope guard — requests are rejected if the `X-LAST9-ORIGIN` header doesn't match. Use `ios://` prefix followed by your bundle identifier (e.g., `ios://com.acme.myapp`). Building with [React Native](/docs/real-user-monitoring/react-native/) or [Flutter](/docs/real-user-monitoring/flutter/)? Create separate Android and iOS Client tokens — see those guides.
:::

## CDN artifacts

| Artifact    | Stable URL                                                                          | Versioned URL                                                                   |
| ----------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Podspec     | `https://cdn.last9.io/rum-sdk/ios/builds/stable/v1/Last9RUM.podspec`                | `https://cdn.last9.io/rum-sdk/ios/builds/1.6.0/Last9RUM.podspec`                |
| XCFramework | `https://cdn.last9.io/rum-sdk/ios/builds/stable/v1/Last9RUM.xcframework.zip`        | `https://cdn.last9.io/rum-sdk/ios/builds/1.6.0/Last9RUM.xcframework.zip`        |
| Checksum    | `https://cdn.last9.io/rum-sdk/ios/builds/stable/v1/Last9RUM.xcframework.zip.sha256` | `https://cdn.last9.io/rum-sdk/ios/builds/1.6.0/Last9RUM.xcframework.zip.sha256` |

The major-pinned `stable/v1` channel currently serves iOS RUM SDK `1.6.0`. The
latest versioned release is `1.6.0`; staging builds use the `-alpha` suffix and
explicit versioned URLs. From `1.5.1`, Swift Package Manager can also resolve
`Last9RUM` from `https://github.com/last9/last9-rum-ios.git` (recommended for
new Xcode integrations — no checksum to manage by hand).

## Installation

**Swift Package Manager (Git)**

1.  **Add the package in Xcode**

    **File → Add Package Dependencies…**, paste `https://github.com/last9/last9-rum-ios.git`, and pick a rule:

    - **Exact Version** `1.6.0` to pin a reproducible build
    - **Up to Next Major** from `1.6.0` to accept any `1.x` release ≥ `1.6.0`
    - **Branch** `stable/v1` to always resolve the latest non-breaking release within the major

2.  **Or add it to `Package.swift`**

    ```swift
    dependencies: [
        // Pin an exact version…
        .package(url: "https://github.com/last9/last9-rum-ios.git", exact: "1.6.0"),
        // …or a minimum within the major (any 1.x ≥ 1.6.0)…
        // .package(url: "https://github.com/last9/last9-rum-ios.git", from: "1.6.0"),
        // …or track the stable branch:
        // .package(url: "https://github.com/last9/last9-rum-ios.git", branch: "stable/v1"),
    ]
    ```

The package wraps the same binary xcframework as the CDN, so there is no checksum to manage yourself.

**CocoaPods**

1.  **Add to your `Podfile`**

    ```ruby
    pod 'Last9RUM', :podspec => 'https://cdn.last9.io/rum-sdk/ios/builds/1.6.0/Last9RUM.podspec'
    ```

2.  **Install**

    ```bash
    pod install
    ```

**SPM (CDN binary)**

1.  **Fetch the checksum**

    ```bash
    curl -sL https://cdn.last9.io/rum-sdk/ios/builds/1.6.0/Last9RUM.xcframework.zip.sha256
    ```

2.  **Add a binary target to `Package.swift`**

    ```swift
    .binaryTarget(
        name: "Last9RUM",
        url: "https://cdn.last9.io/rum-sdk/ios/builds/1.6.0/Last9RUM.xcframework.zip",
        checksum: "<sha256 from previous step>"
    )
    ```

:::note
Use the versioned URL for a manual CDN binary target, not the `stable/v1` channel. SPM's `checksum` pins the exact artifact bytes — when the stable channel updates to a new release, a stable-URL binary target fails to resolve with a checksum mismatch. To upgrade, bump the version in the URL and update the checksum. Prefer the Git package tab to skip this step.
:::

## Initialization

**UIKit (AppDelegate)**

```swift
import Last9RUM

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions:
            [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        var 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"
        )
        // Use your app's bundle ID in ios:// format — must match the origin
        // allowlist on your Last9 client token.
        config.origin = "ios://com.example.myapp"

        L9Rum.shared.initialize(config: config)
        return true
    }
}
```

**SwiftUI (@main App)**

```swift
import Last9RUM
import SwiftUI

@main
struct MyApp: App {
    init() {
        var 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.origin = "ios://com.example.myapp"
        L9Rum.shared.initialize(config: config)
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}
```

## What's captured automatically

| Signal                 | Details                                                                                                       |
| ---------------------- | ------------------------------------------------------------------------------------------------------------- |
| Network requests       | Every `URLSession` call — latency, status code, URL                                                           |
| Screen views (UIKit)   | `UIViewController` lifecycle callbacks                                                                        |
| Screen views (SwiftUI) | `.trackView(name:)` modifier                                                                                  |
| Sessions               | 15m inactivity / 4h max, persisted across restarts                                                            |
| App launch time        | Cold and warm start duration                                                                                  |
| Resource metrics       | Memory and CPU sampled periodically                                                                           |
| Errors                 | Unhandled exceptions and crashes                                                                              |
| ANR detection          | Main thread blocks beyond threshold                                                                           |
| `view.ttfd`            | Time from last HTTP response to next rendered frame — measures post-API render latency on data-driven screens |

## View time-to-full-display (`view.ttfd`)

`view.ttfd` measures how long it takes for the screen to render after the last API response completes. It captures the delta between the HTTP response timestamp and the next `CADisplayLink` callback — giving you end-to-end visibility on data-driven screens where content appears only after a network call.

| Attribute   | Type    | Description                                                    |
| ----------- | ------- | -------------------------------------------------------------- |
| `view.ttfd` | `float` | Milliseconds between HTTP response end and next rendered frame |
| `view.ttid` | `float` | Milliseconds from screen open to first frame (unchanged)       |

**How it works:** after any `L9URLProtocol` response callback fires, the SDK schedules a `CADisplayLink` on the main run loop. On the next display frame, the delta is recorded as `view.ttfd` on the active View span.

- Works on both SwiftUI and UIKit with no app code changes.
- Applies to the View span that was active when the HTTP call was made.
- If no View span is active when the response arrives, the measurement is skipped.

:::note
`view.ttfd` tracks the _first_ display frame after the response — it does not wait for full layout completion. On screens with heavy view recomposition, you may see a slightly optimistic value.
:::

## Configuration

```swift
var config = L9RumConfig(
    // --- Required ---------------------------------------------------------
    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"
)

// --- Optional -------------------------------------------------------------

// Origin sent as X-LAST9-ORIGIN header.
// Required for client_monitoring tokens. Use ios://com.your.bundle.id —
// must match the origin allowlist configured on your Last9 client token.
config.origin = "ios://com.example.myapp"

// Specific build identifier (maps to app.build_id)
config.appBuildId = "1.0.0-build-42"

// Optional override for the app.installation.id resource attribute.
// The Client-ID header always uses the SDK-generated per-install UUID.
config.appInstallationId = nil

// Session sampling rate: 0-100 (percentage). 100 = sample everything.
config.sampleRate = 100

// Print debug logs to console
config.debugLogs = false

// Automatically trace HTTP requests via URLProtocol
config.networkInstrumentation = true

// Automatically capture unhandled exceptions
config.errorInstrumentation = true

// Max spans per export batch
config.maxExportBatchSize = 100

// How long batch processors wait before flushing queued spans/logs (ms).
// Unset: 5000 in production, 1000 when debugLogs is true (`1.4.0+`).
config.scheduleDelayMs = nil

// When false, viewDidAppear / viewWillDisappear no longer open or close
// view spans — the app owns view names via startView / setViewName (`1.5.0+`).
config.autoViewTrackingEnabled = true

// Export timeout in milliseconds
config.exportTimeoutMs = 30_000

// Periodically sample memory and CPU
config.resourceMonitoringEnabled = true

// Interval between resource samples (ms)
config.resourceSamplingIntervalMs = 30_000

// Setting this to true will hide network requests (and their
// DNS/TCP/TLS/TTFB phase child spans) from the Last9 dashboard's
// Sessions → APIs tab. Each request would get its own traceId
// instead of sharing the current view's traceId, and that tab
// only fetches spans that share the View's traceId. Keep this
// false unless you specifically need per-request trace isolation.
config.isolateTracePerRequest = false

// Custom resource attributes added to every span
config.resourceAttributes = [
    "app.platform": "ios",
]

// W3C Baggage propagation on outgoing requests
config.baggage = L9BaggageConfig()
config.baggage.enabled = false
config.baggage.allowedKeys = ["session.id", "user.id"]
config.baggage.maxTotalBytes = 8192
config.baggage.trackedUrlPatterns = []
config.baggage.warnAtPercentage = 80

// Substring patterns — matching URLs are skipped before span creation.
// Prefer ignorePatterns below for regex support and hostname/pathname targeting.
config.excludedUrlPatterns = [".jpg", ".png", ".pdf", "cdn.example.com"]

// Fine-grained network ignore rules. Matched URLs are dropped before span
// creation. .contains uses substring matching; .regex uses regex search semantics.
// Takes precedence over excludedUrlPatterns.
config.ignorePatterns = L9NetworkIgnorePatterns(
    fullUrl: [
        .contains("https://cdn.example.com"),
        .regex("^https://.*\\.example\\.com", options: [.caseInsensitive]),
    ],
    pathname: [
        .contains(".pdf"),
        .contains(".jpg"),
        .regex("^/internal/metrics"),
    ],
    hostname: [
        .contains("cdn.example.com"),
        .regex("(^|\\.)assets\\.example\\.com$", options: [.caseInsensitive]),
    ]
)

// Trace header propagation for ignored URLs.
// .preserve (default): keep traceparent on ignored requests.
// .strip: remove traceparent from ignored requests (e.g. third-party CDNs).
config.propagationMode = .preserve
```

:::caution
Never put secrets or PII in `baggage.allowedKeys`. Baggage travels in plain HTTP headers.
:::

### Per-install Client-ID

Starting with `0.8.0`, the `Client-ID` ingestion header is an SDK-generated per-install UUID, not `serviceName`. The UUID is generated on first launch and stored in `UserDefaults`. This keeps each app install in its own ingestion rate-limit bucket.

`appInstallationId` only overrides the `app.installation.id` resource attribute. It does not override the `Client-ID` header, so the header and the resource attribute can differ when you set `appInstallationId` manually.

:::note
Because the generated UUID is stored in `UserDefaults`, encrypted device backups can carry it to a restored device. Android stores the value in backup-excluded storage.
:::

## Network phase child spans

When URLSession instrumentation is enabled, each parent HTTP span includes child spans for individual network phases:

| Child span      | What it measures                              |
| --------------- | --------------------------------------------- |
| `dns`           | DNS lookup duration                           |
| `tcp_connect`   | TCP connection establishment                  |
| `tls_handshake` | TLS negotiation                               |
| `ttfb`          | Time from request sent to first response byte |

No SDK config change is required. The SDK reads `URLSessionTaskMetrics.transactionMetrics` from the URLSession task delegate and emits phase child spans under the parent HTTP span automatically.

Reused connections skip DNS, TCP, and TLS work. For those requests the SDK emits zero-duration child spans with `l9rum.network.phase.skipped=true` so the waterfall shape stays consistent.

## GraphQL network observability

When URLSession instrumentation is enabled, GraphQL requests are enriched automatically — `L9URLProtocol` parses the operation name and type from the request body. No extra configuration is required.

| Span attribute           | Example                               | Notes                                        |
| ------------------------ | ------------------------------------- | -------------------------------------------- |
| Span name                | `GraphQL: "GetUserPreferences" query` | Renamed to a descriptive name                |
| `graphql.operation.name` | `GetUserPreferences`                  | Operation name parsed from the request body  |
| `graphql.operation.type` | `query`                               | `query`, `mutation`, or `subscription`       |
| `l9.span.category`       | `network`                             | Categorizes the span for dashboard filtering |

GraphQL servers often return errors with an HTTP `200` status and an `errors[]` array in the response body. The SDK detects these and marks the span as an error:

| Span attribute        | Value          | Notes                                                 |
| --------------------- | -------------- | ----------------------------------------------------- |
| `error.type`          | `GraphQLError` | Set when the response contains a non-empty `errors[]` |
| `graphql.error.count` | number         | Number of entries in the `errors[]` array             |

For cross-cutting customization of any network span (GraphQL or REST), set `networkSpanHook` on `L9RumConfig` to inspect and enrich spans before they are exported.

From `1.3.0`, REST network span names fold fully-numeric and UUID path segments to `?` (for example `GET /workspaces/1` becomes `GET /workspaces/?`). Version-like segments such as `v2` are preserved. GraphQL span names are unaffected. The raw URL remains on `url.full`.

## API reference

### Identify a user

```swift
L9Rum.shared.identify(userId: "user-123", attributes: [
    "email": "user@example.com",
    "plan": "premium",
])
```

### Clear user on sign-out

```swift
L9Rum.shared.clearUser()
```

### Capture errors

```swift
do {
    try riskyOperation()
} catch {
    L9Rum.shared.captureError(error, context: ["screen": "checkout"])
}
```

### Track views (SwiftUI / Custom Navigation)

UIKit views are tracked automatically. For SwiftUI or custom navigation:

```swift
L9Rum.shared.startView("ProductDetailsScreen")
L9Rum.shared.setViewName("Product #42")
```

From `1.5.0`, `startView` ends whichever native view is currently open before opening a new one, so mixing a manual `startView` with UIKit auto-tracking no longer leaves a duplicate native view. `setViewName` renames the **active** view in place — including auto-tracked child view controllers such as React Native's `RNSScreen` — with no extra span. From `1.6.0`, instrumented WebView host views are also auto-named from the folded URL pathname (`view.url` holds the full URL); `setViewName` still wins. Set `autoViewTrackingEnabled = false` when the app owns all view names.

### Custom events

```swift
L9Rum.shared.addEvent("purchase_completed", attributes: [
    "product_id": "12345",
    "amount": 29.99,
])
```

Each call dual-emits:

- A **span event** on the active view span, so the event shows up on the view's timeline in RUM. The span event is attached to the active view span itself, so custom events surface even when the active view is an auto-tracked child view controller (for example, React Native's `RNSScreen`) rather than the window's root view controller.
- An **OTLP log record** carrying `event.type=custom`, `event.name`, your attributes, and `session.id`/user attributes. When a view is active the log is correlated to it via `trace.id`/`span.id`/`view.id`, so you can pivot between the log and the RUM session.

Log emission is unconditional. If no view is active when you call `addEvent`, the span event is dropped (no view is fabricated to attach it to) but the log is still sent as an orphan, omitting `trace.id`/`span.id`/`view.id`. A custom event never silently vanishes because it fired outside a view.

From `1.5.2`, an `addEvent` fired while a screen is being torn down attaches to **that** screen (a 1000 ms grace window after the view ends), not the next one. After the window, and whenever no view is current, the OTLP log still correlates to the last view for the rest of the session. A manual `startView(...)` ends the previous view immediately. Prefer firing screen summaries in `viewWillDisappear` rather than `viewDidDisappear` / `deinit`.

### Global span attributes

```swift
// Inject attributes into every span
L9Rum.shared.spanAttributes([
    "experiment": "checkout_v2",
    "feature_flag": "new_cart",
])

// Clear
L9Rum.shared.spanAttributes(nil)
```

### Session ID

```swift
let sessionId: String? = L9Rum.shared.getSessionId()
```

From `1.1.9`, session start is synchronous: `initialize(config:)` does not return until the session exists, so `getSessionId()` returns a usable session id on the next line. It returns `nil` (never an empty string) when there is no active session, and `addSessionIdObserver`'s initial callback reports the same value — both read through one accessor, so an internal placeholder id is never surfaced.

From `1.4.1`, backgrounding the app (Home button, `didEnterBackground`, or a system file picker) no longer ends the RUM session. Returning within the 30-minute inactivity window resumes the same session. Rollover still happens on inactivity timeout, max duration, or app termination. A force-quit, crash, or OS kill backfills `Session End` on the next cold start — backdated to last activity, with `session.end_reason=process_death` — then starts a fresh session chained via `session.previous_id`.

### Flush pending data

```swift
L9Rum.shared.flush()
```

:::note
From `1.1.9`, `flush()` ends and restarts the session synchronously — it blocks the calling thread until the new session exists (bounded by creating and enqueuing one span). Prefer calling it off the main thread.
:::

### Embedded per-flow lifecycle

For embedded integrations scoped to a single flow (for example, RUM that should only run while a specific feature or mini-app is open), use `shutdown()` and `isActive()` to control the SDK lifecycle:

```swift
// Scope RUM to a single flow, then tear it down so the next flow starts clean.
L9Rum.shared.initialize(config: config)

// Attributes known only after the flow starts apply to every later span,
// and are cleared on shutdown().
L9Rum.shared.spanAttributes(["tenant.id": "acme", "feature.flag": "beta"])

if L9Rum.shared.isActive() {
    L9Rum.shared.shutdown() // flush + full teardown; a later initialize() re-arms RUM
}
```

- **`shutdown()`** flushes pending spans and fully tears RUM down. It ends the active view and emits the session-end span before flushing, so short per-flow sessions export cleanly.
- **`isActive()`** reports whether RUM is currently running.
- After `shutdown()`, calling `initialize()` again starts a fresh flow (supported re-init cycle). Global hooks (method swizzling, `NotificationCenter` observers, the uncaught-exception handler) are installed once and are not duplicated or reversed across initialize/shutdown cycles.

:::note
The SDK is a process-wide singleton. Do not run a direct integration and an embedded one simultaneously — a second `initialize()` while already active is ignored (with a warning), and `shutdown()` tears down whichever integration is running.
:::

### Network ignore patterns

Skip noisy URLs before span creation by matching against full URL, pathname, or hostname. `.contains` uses substring matching; `.regex` uses regex search semantics.

```swift
config.ignorePatterns = L9NetworkIgnorePatterns(
    fullUrl: [
        .contains("https://cdn.example.com"),
        .regex("^https://.*\\.example\\.com", options: [.caseInsensitive]),
    ],
    pathname: [
        .contains(".pdf"),
        .contains(".jpg"),
        .regex("^/internal/metrics"),
    ],
    hostname: [
        .contains("cdn.example.com"),
        .regex("(^|\\.)assets\\.example\\.com$", options: [.caseInsensitive]),
    ]
)
// PRESERVE (default): keep traceparent on ignored requests.
// STRIP: remove traceparent from ignored requests.
config.propagationMode = .preserve
```

:::note
`excludedUrlPatterns` (simple substring list) still works but `ignorePatterns` supersedes it when both are set. Set before calling `L9Rum.shared.initialize(config:)`.
:::

### WebView correlation

Inject the active native session and view IDs into a `WKWebView` so Browser RUM spans share the same `session.id`:

```swift
// After creating the WKWebView — call once per WKWebView instance.
L9Rum.shared.instrument(webView: webView)

// Optional: set one fixed, friendly name on the auto-tracked native host view.
// Do not call this again when the WebView URL changes.
L9Rum.shared.setViewName("WebViewActivity")
```

- Session and view IDs are re-injected on every navigation commit and view change.
- Cross-origin iframes do **not** receive the session ID.
- Calling `instrument(webView:)` before `initialize(config:)` emits a warning and is a no-op.
- In-WebView SPA routes are tracked separately via Browser RUM `startView()` inside the WebView. One native host view plus one web route view per navigation is expected — they are joined by `session.id` and `native.view.id`.
- Do not call native `startView()` or `setViewName()` when the WebView URL changes. `setViewName()` is only for an optional fixed host label; calling it with the upcoming URL renames whichever native view is currently active. The navigation commit then creates the actual WebView page view, which can make a native screen appear missing and show two rows with the same URL.
- From `1.6.0`, an instrumented WebView host view is auto-named from the committed main-frame URL: `app.screen.name` becomes the folded pathname, and `view.url` carries the full URL. Client-side SPA route changes (`pushState` / `replaceState` / hash) do not re-name the view. Use Browser RUM `startView()` inside the page for those routes.

Requires the Browser RUM JS SDK on the page. The JS SDK adopts the native session ID and fires `l9rum:session_rollover` when the session rotates, rotating the view span accordingly.

See the [WebView Session Correlation guide](/docs/discover-applications-webview-session-correlation/) for the full integration pattern, React Native/Flutter setup, the static-script path, auto-load Browser RUM, and verification steps.

## Security

Client monitoring tokens are **write-only** and **origin-scoped** to your app's bundle ID. Safe to ship in the app binary.

## Next steps

Once data is flowing, explore it in [Discover > Applications](/docs/discover-applications/) — performance, errors, and sessions.

---

## Troubleshooting

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