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.
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
- Open Last9 → Settings → Ingestion Tokens
- Click Create Token → choose type Client
- Set the allowed origin to
ios://com.yourcompany.yourapp— use your app’s exact bundle ID - Copy the token and the OTLP endpoint URL
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.8.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.8.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.8.0/Last9RUM.xcframework.zip.sha256 |
The major-pinned stable/v1 channel currently serves iOS RUM SDK 1.8.0. The
latest versioned release is 1.8.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
-
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.8.0to pin a reproducible build - Up to Next Major from
1.8.0to accept any1.xrelease ≥1.8.0 - Branch
stable/v1to always resolve the latest non-breaking release within the major
- Exact Version
-
Or add it to
Package.swiftdependencies: [// Pin an exact version….package(url: "https://github.com/last9/last9-rum-ios.git", exact: "1.8.0"),// …or a minimum within the major (any 1.x ≥ 1.8.0)…// .package(url: "https://github.com/last9/last9-rum-ios.git", from: "1.8.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.
-
Add to your
Podfilepod 'Last9RUM', :podspec => 'https://cdn.last9.io/rum-sdk/ios/builds/1.8.0/Last9RUM.podspec' -
Install
pod install
-
Fetch the checksum
curl -sL https://cdn.last9.io/rum-sdk/ios/builds/1.8.0/Last9RUM.xcframework.zip.sha256 -
Add a binary target to
Package.swift.binaryTarget(name: "Last9RUM",url: "https://cdn.last9.io/rum-sdk/ios/builds/1.8.0/Last9RUM.xcframework.zip",checksum: "<sha256 from previous step>")
Initialization
import Last9RUM
@mainclass 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 }}import Last9RUMimport SwiftUI
@mainstruct 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 |
| View vitals | Per-view refresh rate, slow/frozen frames, and memory on each View span (v1.7.0+) |
| 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.
Per-view performance vitals (v1.7.0+)
When resourceMonitoringEnabled is on (the default), each View span carries per-view frame and memory vitals, so you can find which screens janked or ran hot without extra instrumentation.
| Attribute | Type | Description |
|---|---|---|
view.refresh_rate_average | float | Average refresh rate over the view, normalized to 0–60 Hz so 60 Hz and 120 Hz devices compare directly |
view.refresh_rate_min | float | Lowest refresh rate seen during the view (the worst dip) |
view.frame.slow | int | Slow frames — inter-tick interval above 1.5× the display refresh period (a skipped vsync) |
view.frame.frozen | int | Frozen frames — longer than 700 ms |
view.memory_average | int | Average resident memory bytes over the view |
view.memory_max | int | Peak resident memory bytes during the view |
process.cpu.usage | float | Process CPU usage averaged onto the view from the periodic resource samples |
- Frame timing comes from a
CADisplayLinkframe-rate tracker. Per-view memory is sampled (mach resident) at each view start and end, so even short screens — including WebView page rotations — get coverage. - Frame tracking pauses while the app is backgrounded, so a background gap is never counted as a frozen frame.
- Periodic
resource.samplespans now carryview.idwhile a view is active, so process-level samples can be correlated to the screen that was active. - On very short-lived views (shorter than the sampling interval)
process.cpu.usagemay be absent. - No API change: the vitals ride on the existing
resourceMonitoringEnabledflag. Set it tofalseto turn off resource sampling and these vitals together.
Where to see it: open a session in the Last9 RUM UI, select a view on the timeline, and check the Attributes tab. Views that carry these attributes show a MOBILE VITALS section with rows such as Refresh Rate (avg), Slow Frames, Frozen Frames, Memory (avg), and CPU Usage, formatted with units. Web views are unaffected.

Configuration
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 consoleconfig.debugLogs = false
// Automatically trace HTTP requests via URLProtocolconfig.networkInstrumentation = true
// Automatically capture unhandled exceptionsconfig.errorInstrumentation = true
// Max spans per export batchconfig.maxExportBatchSize = 100
// How long batch processors wait before flushing queued spans/logs (ms).// Unset: 5000 in production, 1000 when debugLogs is true (`v1.4.0+`).config.scheduleDelayMs = nil
// When false, viewDidAppear / viewWillDisappear no longer open or close// view spans — the app owns view names via startView / setViewName (`v1.5.0+`).config.autoViewTrackingEnabled = true
// Export timeout in millisecondsconfig.exportTimeoutMs = 30_000
// Periodically sample memory and CPUconfig.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 spanconfig.resourceAttributes = [ "app.platform": "ios",]
// W3C Baggage propagation on outgoing requestsconfig.baggage = L9BaggageConfig()config.baggage.enabled = falseconfig.baggage.allowedKeys = ["session.id", "user.id"]config.baggage.maxTotalBytes = 8192config.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 = .preservePer-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.
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
L9Rum.shared.identify(userId: "user-123", attributes: [ "email": "user@example.com", "plan": "premium",])Clear user on sign-out
L9Rum.shared.clearUser()Capture errors
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:
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
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, andsession.id/user attributes. When a view is active the log is correlated to it viatrace.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.
In session details, a custom-event log appears under Unattributed Events when it has no view context or its referenced view span is not available yet. The event remains visible at the session level instead of waiting for another event or view to arrive.
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
// Inject attributes into every spanL9Rum.shared.spanAttributes([ "experiment": "checkout_v2", "feature_flag": "new_cart",])
// ClearL9Rum.shared.spanAttributes(nil)Session ID
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. Login or identify(...) also preserves the current session unless the app explicitly shuts down and reinitializes the SDK. Rollover still happens on inactivity timeout, max duration, app termination, or explicit shutdown(). 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.
Every span carries session.start_time (epoch ms) alongside session.id, so the backend can read the real start time of a session from any span. The SDK reads session.id, session.start_time, and session.previous_id from one session snapshot, so a span never mixes attributes from two sessions. The Session Start, Session End, and process-death tombstone spans carry the start time of the session they belong to. When a session rolls over on inactivity timeout or max duration, Session End and session.time_spent use the last recorded activity, so idle and background time is not counted, and the new session keeps session.previous_id. exit, shutdown, and process_death ends are unchanged.
Flush pending data
L9Rum.shared.flush()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:
// 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(), callinginitialize()again starts a fresh flow (supported re-init cycle). Global hooks (method swizzling,NotificationCenterobservers, the uncaught-exception handler) are installed once and are not duplicated or reversed across initialize/shutdown cycles.
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.
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 = .preserveWebView correlation
Inject the active native session and view IDs into a WKWebView so Browser RUM spans share the same session.id:
// 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.
- Browser RUM spans that adopt the native
session.iddo not carrysession.start_time. The native-context bridge does not send a start time. - Cross-origin iframes do not receive the session ID.
- Calling
instrument(webView:)beforeinitialize(config:)emits a warning and is a no-op. - Plain native views omit
view.type. Native-emitted WebView page views carryview.type=webview; Browser RUM views use the same value and join throughsession.idandnative.view.id. - In-WebView routes are tracked separately via Browser RUM
startView()inside the WebView. One native-emitted WebView page view plus one browser route view per navigation is expected. - Do not call native
startView()orsetViewName()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.9, the first URL and each later path or hash navigation create a dedicated native-emitted WebView page view. Query-only changes updateview.urlin place. The injected history hook coverspushState,replaceState, hash, and popstate changes; use Browser RUMstartView()inside the page when you also need a browser-side route view. - Breaking (
1.6.2): the per-navigation WebView page views replace the earlierwebview_page_loadspan event. Move any dashboards or alerts that queriedwebview_page_loadto theview.type=webviewview events. - 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.
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 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 — performance, errors, and sessions.
For the version history of this SDK, see the RUM changelog.
Troubleshooting
Please get in touch with us on Discord or Email if you have any questions.