Applying the iOS agent
Applying the WhaTap mobile agent to an iOS app lets you collect app start performance, screen loading, network calls, crashes, resource usage, and WebView page performance.
This document is for app developers and covers the whole procedure, from adding the library to verifying that data is collected. Collecting the web pages inside a WebView also requires work from the web owner. That is covered in Setting up WebView page collection.
Prerequisites
Build environment
| Item | Value |
|---|---|
| iOS | 15.0 or later |
| Xcode | 16.0 or later |
| Language | Swift 5.9 or later, or Objective-C |
Deployment target
Set the app's deployment target (IPHONEOS_DEPLOYMENT_TARGET) to iOS 15.0 or later. The platforms declaration of the distribution package is also .iOS(.v15), so an app below 15.0 is blocked at the package resolution stage when you add it with Swift Package Manager.
If you add the XCFramework directly, there is no check at the resolution stage, and it appears only as a warning at link time.
building for iOS x.y, but linking with dylib … which was built for newer version 15.0
The build passes, but operation on devices below 15.0 is not guaranteed.
Xcode 15 and earlier are not supported.
The module interface refers to SwiftUICore, and that module was newly separated in the iOS 18 SDK (Xcode 16), so it does not exist in earlier SDKs.
Versions required for WebView integration
To collect the WebView pages inside the app as well, both the iOS agent and the browser agent must meet the required versions. If either one is lower, some items are not collected. In that case no error is left in either the app log or the web log.
| Component | Minimum version | Owner |
|---|---|---|
| iOS agent | 2.7.15 | App developer |
| Browser agent | 3.2.0 | Web developer |
Below 2.7.15, WebView data does not appear on the dashboard. After finishing the setup, check the version actually running in the agent_version value on the dashboard. Replacing the file alone does not tell you whether the new version took effect.
For a framework downloaded as a .zip, check that the checksum matches the value you were given.
swift package compute-checksum WhatapAgent.xcframework.zip
Check the version of the browser agent file in the banner of the first 4 lines.
head -4 whatap-browser-agent.js
The iOS WebView installation screen in the console shows the SPM package version as 2.7.3 and the browser agent script address with the v1 path. WebView integration requires the versions above, so do not follow the values on that screen as they are.
Installing the agent
Proceed in the following order.
1. Adding the library
Choose one of three methods. The Swift Package Manager method is recommended.
Swift Package Manager (recommended)
-
Open the project in Xcode and select the File > Add Package Dependencies menu.
-
Enter the following address in the Package URL field.
https://github.com/whatap/WhatapIOSAgent-Release -
Check the latest version in Releases, select that version, and click the Add Package button. Do not specify a
branchor use an unboundedfrom:. -
Add the WhatapAgent library to your app target.
For a project managed with Package.swift, specify it as follows.
dependencies: [
.package(url: "https://github.com/whatap/WhatapIOSAgent-Release.git", exact: "<latest version>")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "WhatapAgent", package: "WhatapIOSAgent-Release")
]
)
]
The Swift Package Manager method also requires CDN access.
The GitHub repository contains no binary; binaryTarget(url:checksum:) points to a CDN. Both github.com and repo.whatap-mobile-agent.io must be open on the build machine. If the CDN is blocked by a firewall, the swift package resolve step fails. In that case, use local file installation.
Manual XCFramework installation
-
Download the XCFramework. Put the version you checked in Releases in place of
<latest version>.curl -L -o WhatapAgent.xcframework.zip \
https://repo.whatap-mobile-agent.io/uploads/<latest version>/WhatapAgent.xcframework.zip
unzip WhatapAgent.xcframework.zip -
Add it to the Xcode project.
- Select the project target and select the General tab.
- Click the + button in the Frameworks, Libraries, and Embedded Content section.
- Select Add Other > Add Files and select
WhatapAgent.xcframework. - Check the Embed & Sign setting.
Local file installation (closed network, internal network)
In an environment with no access to external repositories, get the zip file provided by WhaTap, extract it, and add it to the project. After that, add it in Xcode with Embed & Sign in the same way as the manual XCFramework installation.
unzip WhatapAgent.xcframework.zip
mv WhatapAgent.xcframework /path/to/YourApp/
2. Initializing the agent
The SDK must be initialized when the app starts. If initialization is late, performance data from the early stage of app start, such as pre-main time and initial memory usage, is not collected.
| Framework | Initialization location |
|---|---|
| SwiftUI | init() of the @main struct. The point at which the app struct is created |
| UIKit | application:willFinishLaunchingWithOptions:. Called before didFinishLaunching |
Always call initialize() after build().
build() only composes the settings. Even if you do not call initialize(), setupWebViewBridge() still works and injects window.whatapBridge, so the browser agent decides that the bridge is fine and does not fall back to HTTP either. As a result both web and native data are lost, and only the following error is left in the app log.
[ERROR] [WhatapWebviewBridge] WhatapAgent is not initialized.
initialize() is ignored from the second call onward. If you are adding WebView integration to an app already in operation, modify the existing code rather than writing new initialization code. Splitting initialization across two places does not apply the later call.
Swift
A SwiftUI app initializes in the init() of the @main struct.
import SwiftUI
import WhatapAgent
@main
struct MyApp: App {
init() {
let agent = WhatapAgentBuilder()
.setProjectKey("<PROJECT_ACCESS_KEY>")
.setPCode(12345)
.setServerUrl("https://<collection server domain>/m") // trailing /m required
.build()
agent.initialize()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
For a UIKit app, initializing in application:willFinishLaunchingWithOptions: of AppDelegate is recommended.
import UIKit
import WhatapAgent
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let agent = WhatapAgentBuilder()
.setProjectKey("<PROJECT_ACCESS_KEY>")
.setPCode(12345)
.setServerUrl("https://<collection server domain>/m")
.build()
agent.initialize()
return true
}
}
Objective-C
Initialization works in either willFinishLaunchingWithOptions or didFinishLaunchingWithOptions. The following example initializes in didFinishLaunchingWithOptions.
#import <WebKit/WebKit.h> // before the WhatapAgent header
#import <WhatapAgent/WhatapAgent-Swift.h>
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
WhatapAgentBuilder *b = [[WhatapAgentBuilder alloc] init];
(void)[b setProjectKey:@"issued access key"];
(void)[b setPCode:12345];
(void)[b setServerUrl:@"https://collection-server-address/m"]; // include /m, no trailing /
(void)[b setSamplingRate:1.0]; // 0.0 – 1.0 (differs from the web's 0 – 100)
WhatapIOSAgent *agent = [b build];
[agent initialize]; // without this call no data is sent
return YES;
}
Header import order. Put #import <WebKit/WebKit.h> before #import <WhatapAgent/WhatapAgent-Swift.h>. The generated header only forward-declares WKWebView and does not bring in its definition. In a project with modules disabled (CLANG_ENABLE_MODULES = NO), every file that imports this header must import WebKit first. Otherwise a cannot find protocol declaration for 'WKNavigationDelegate' error occurs and the build fails. The same applies to an AppDelegate that does not handle WebViews.
Use the #import form instead of @import WhatapAgent;. @import also works, but in a project with modules disabled it causes a use of '@import' when modules are disabled error and the build fails. It also exposes declarations that are not public through the umbrella header in code completion.
All builder setters are warn_unused_result. If you break the chain into several lines as in the example above and omit the (void) cast, a -Wunused-result warning occurs on each line. In a project that uses -Werror, the build fails because of these warnings. If you write it as a single-line chain, no cast is needed.
3. Verifying the installation
Run the app and check the initialization log in the Xcode Console. To see detailed logs in a debug build, add the following setting.
#if DEBUG
WhatapLogger.isDebug = true
#endif
If no data is collected, check the project key, PCode, server URL, the initialize() call, the sampling rate, and the disk buffer.
Collected items
Calling build() and initialize() automatically collects the following items.
- App start performance (cold start, warm start, pre-main)
- Crashes, exceptions, signals
- CPU, memory, battery, thermal state
The following three items are not turned on automatically just by calling build() and initialize(). They require separate work.
| Item | Required work |
|---|---|
| Loading time per screen | Apply one of the four methods in Screen tracking |
| Network request and response information | Specify setCollectNetwork(true) in Collection and HTTP options |
| WebView page load and performance information | Register the bridge in Setting up WebView page collection |
Loading time per screen is not turned on automatically.
Screen tracking based on the UIViewController lifecycle is off by default. The default of setCollectScreenLoading is true, but the agent's disableAutoScreenTracking default is also true. Because the builder does not reset this value, screen tracking is not turned on automatically. For how to turn it on, see Screen tracking.
WebView data is collected normally even if you do not turn on automatic screen tracking. 0.3 seconds after initialize() is called, one default ScreenGroup starts with the app name (CFBundleName). WebView page loads are linked to that group. If you do not use automatic tracking, you simply do not get the separation by native screen.
Builder options
All builder options are optional; if you do not specify one, the default value applies.
Transmission and buffer options
| Method | Default | Description |
|---|---|---|
setFlushInterval(_:) | 10.0 | Batch transmission interval (seconds) |
setQueueSize(_:) | 1000 | Memory queue size |
setMaxDiskBytes(_:) | 512000 | Offline disk buffer limit (bytes) |
setMaxDiskFiles(_:) | 5 | Number of disk buffer files |
Collection and HTTP options
| Method | Default | Description |
|---|---|---|
setCollectScreenLoading(_:) | true | Screen loading collection. Also requires the Screen tracking setting |
setCollectNetwork(_:) | false | Automatic network collection |
setKeepAlive(_:) | true | HTTP keep-alive |
setMaxConnectionsPerHost(_:) | 4 | Maximum number of connections per host |
Network collection is opt-in.
From iOS SDK 2.7.3, the default of automatic network collection is false. Enabling this feature instruments requests through URLProtocol. So if your app relies on certificate pinning, a custom URLSessionDelegate, its own cookies or authentication headers, or HTTP/2 negotiation, check the impact first. For security-sensitive requests, separating them into a dedicated custom URLSession is recommended.
Custom endpoints
| Method | Default | Description |
|---|---|---|
setLogServerUrl(_:) | serverUrl + "/log" | Use it only when specifying the log endpoint directly |
setSpanServerUrl(_:) | serverUrl + "/trace" | Use it only when specifying the trace endpoint directly |
Even if you specify setLogServerUrl(_:) and setSpanServerUrl(_:) directly, you must still set setServerUrl. For details, see Checking the collection server address.
ScreenGroup and tracking options
| Method | Default | Description |
|---|---|---|
setGroupWaitingInterval(_:) | 3.0 | Screen group close delay (seconds) |
setExcludeLifecycleEventsFromScreenGroup(_:) | false | Excludes lifecycle events from the screen group |
enableMethodTracing(_:) | false | Whether to use Method Tracing |
setSamplingRate(_:) | 1.0 | Collection rate. On a 0.0 – 1.0 scale. Drawn per session |
setSingleSessionPerLaunch(_:) | false | If true, the session issued at app launch is kept until the process ends |
setSamplingRate(_:) uses a value in the 0.0 – 1.0 range, whereas the browser agent's sampleRate uses a value in the 0 – 100 range. So you must not enter the mobile agent's value into the browser agent as is. Putting the mobile 1.0 into the web as is makes the collection rate 1%.
An out-of-range value is ignored and full collection remains in effect.
If you enter a value outside 0.0 – 1.0, it is not clamped to the upper or lower bound; the last valid value that was set is kept as is. If it was never set, the default is 1.0. In other words, an invalid value does not turn collection off — full collection remains in effect — and the symptom appears as "sampling is not applied."
The following warning is left in that case, but it is visible only if you turn on WhatapLogger.isDebug. If the collected volume does not match your setting, turn on the debug log and check for this message.
[WARN] [SESSION] configure: invalid samplingRate=50.0 - keeping 1.0
How sessions are divided
With the defaults, a session is newly issued when there is no user activity for 15 minutes or when 4 hours have passed since it started. Restarting the app also creates a new session. The sampling draw is per session, so it is drawn again each time a session is newly issued, and the data in one session is either all collected or not collected at all.
setSingleSessionPerLaunch(true) does not apply the two expiration conditions above, so only one session is created per app launch and the sampling draw happens only once at app launch. In a SaaS project billed by session count, keep the default false. It is an option to turn on only in on-premises environments where the session count has nothing to do with billing.
WhatapAgentBuilder()
.setProjectKey("<PROJECT_ACCESS_KEY>")
.setPCode(12345)
.setServerUrl("https://<collection server domain>/m")
.setFlushInterval(120)
.setMaxDiskBytes(2 * 1024 * 1024)
.setMaxDiskFiles(5)
.setQueueSize(1000)
.setKeepAlive(true)
.setMaxConnectionsPerHost(4)
.setGroupWaitingInterval(3.0)
.build()
Screen tracking
To collect the loading time of each native screen, use one of the following four methods.
Method 1. Lower the automatic tracking flag. With this method every UIViewController is tracked automatically. Set it before calling initialize().
// Method 1) Lower the flag before initialize(). Tracks every UIViewController automatically
WhatapIOSAgent.disableAutoScreenTracking = false
agent.initialize()
// Method 2) Register only specific screens (works without lowering the flag)
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
WhatapIOSAgent.trackViewController(self)
}
WhatapIOSAgent.disableAutoScreenTracking = NO; // Method 1, before calling initialize
[WhatapIOSAgent trackViewController:self]; // Method 2
Method 3. SwiftUI modifier. On SwiftUI screens, use the dedicated modifier. trackViewController(_:) is an API that applies swizzling to the class of the instance it receives, so passing a UIHostingController created on the spot may not capture the screen you intended.
struct CheckoutView: View {
var body: some View {
ContentView()
.whatapScreenTracking("Checkout screen") // onAppear / onDisappear connected automatically
}
}
To control it yourself, call WhatapIOSAgent.trackScreen("Checkout screen") and WhatapIOSAgent.endScreenTracking("Checkout screen") as a pair.
Method 4. Control the screen task directly. This uses no swizzling at all. These are all instance methods.
guard let taskId = WhatapIOSAgent.shared?.startScreenTaskFor(self) else { return }
// or startScreenTaskWithName("Checkout screen")
WhatapIOSAgent.shared?.endScreenTaskWithTaskId(taskId)
To use a name other than the class name in automatic screen tracking, adopt the WhatapScreenNaming protocol in the ViewController and return whatapScreenName. This protocol does not apply to WebView screen names. For how to specify a WebView screen name, see Specifying the screen name.
Manual instrumentation
Manual Task API
You can record an asynchronous job within one screen, such as a payment or image loading, as a separate span.
WhatapIOSAgent.startTask("checkout", taskId: "task-001")
WhatapIOSAgent.endTask("task-001")
Method Tracing
Method Tracing is an opt-in feature. Add enableMethodTracing(true) to the builder and then record the performance of the methods you need.
methodStart and methodEnd
func validateBiometric(
agent: WhatapIOSAgent,
validate: () throws -> Void
) rethrows {
agent.methodStart(className: "AuthService", methodName: "validateBiometric")
defer {
agent.methodEnd(className: "AuthService", methodName: "validateBiometric")
}
try validate()
}
StackSpan
func charge(
agent: WhatapIOSAgent,
operation: () async throws -> Void
) async rethrows {
let stackSpan = agent.start(className: "PaymentService", methodName: "charge")
do {
try await operation()
stackSpan.end()
} catch {
stackSpan.endWithError(error)
throw error
}
}
Global context
The values in ExtrasStore are attached automatically to every log and span.
import WhatapAgent
ExtrasStore.shared.setExtra(key: "user_id", value: userId)
ExtrasStore.shared.removeExtra(key: "user_id")
ExtrasStore.shared.clearExtras()
[[ExtrasStore shared] setExtraValue:userId forKey:@"user_id"];
For Android compatibility, a key stored in ExtrasStore automatically gets a .c suffix when it is sent. user_id is sent as user_id.c.
Screen groups
Groups a flow that spans several screens into one group.
ChainView.shared.startChain(chainName: "LoginFlow")
ChainView.shared.endChain()
Network and crashes
Network security settings
An HTTPS collection endpoint does not require an App Transport Security exception. If you must use a legacy HTTP endpoint, apply an exception separately only to the single domain you need. Do not allow subdomains or arbitrary loads.
Crash reporting
Crashes are collected automatically and sent the next time the app runs. The default is the built-in reporter. Using PLCrashReporter requires a separate library.
WhatapAgentBuilder()
.useNativeCrashReporter()
.build()
WhatapAgentBuilder()
.usePLCrashReporter()
.build()
Setting up WebView page collection
Web pages displayed in the app's WKWebView are collected by the browser agent, not by the native SDK. To link native screens and WebView pages into the same session, you must configure both the app and the web page.
The integration is complete only when all three steps are done. If any one of them is missing, data that cannot reach the bridge falls back to standard HTTP transmission (the browser agent option webViewHttpFallback, default true). In that case the data itself is still collected. However, the dashboard classifies it as a regular browser (BROWSER) and it is not linked to the native session. The fallback is only a safety net against data loss; it does not mean the integration is complete.
WebView integration requires iOS agent 2.7.15 or later and browser agent 3.2.0 or later. Check Versions required for WebView integration first. The initialize() call in Initializing the agent must also be done. If you do not call initialize(), the bridge is injected but all data is lost.
Collected items by combination
These are the results verified on a physical iOS 26.5 device against a real collection server.
| Item | Standard setup | Integrated setup, bridge connected | Integrated setup, bridge not reached |
|---|---|---|---|
| Page load | ○ | ○ | ○ (fallback) |
| Resources, AJAX | ○ | ○ | ○ (fallback) |
| Screen transitions | ○ | ○ | ○ (fallback) |
| 5 types of JS errors | ○ | ○ | ○ (fallback) |
| User events | ○ | ○ | ○ (fallback) |
| Session replay | ○ | ○ | ○ (fallback) |
| Core Web Vitals | ○ | ○ | ○ (fallback) |
| Custom logs | ○ | ✗ | ○ (fallback) |
| Dashboard classification | BROWSER | WEBVIEW | BROWSER |
| Project it lands in | Web page pcode | The app's mobile project | Web page pcode |
| Session integration with native | - | ○ | ✗ |
The standard setup is the state where the browser agent is initialized without isWebView, and the integrated setup is the state where it is initialized with isWebView: true. The 5 types of JS errors are uncaught errors, unhandled promise rejections, console.error(), noticeError(), and CSP violations.
Custom logs are not collected in the integrated setup.
The browser agent sends custom logs (enableCustomLog and logger) through the bridge method log. However, the mobile bridge has no such interface, so custom logs are silently dropped. On iOS nothing is shown even in debug mode, so you cannot tell from the console that logs are being dropped. Leave pages that need custom logs in the standard setup, without specifying isWebView.
The project the data lands in
While the integration is working, web data lands in the app's mobile project. It does not go into the pcode or projectAccessKey set on the page. Data that goes through the bridge has the project identifier in the transmission body meta aligned with the app's setPCode and setProjectKey values just before upload. sessionID and userID are also unified to the native values. To see native screens and WebView screens in one session, both sets of data must be in the same project. For this, both components use the same specification.
| Case | Project to look in |
|---|---|
Standard setup (isWebView not set) | The web page's pcode |
| Integrated setup, bridge connected | The app's mobile project pcode |
| Integrated setup, bridge not reached (fallback) | The web page's pcode |
So the page's pcode and projectAccessKey are used only for fallback transmission. They are not used while the integration is working normally. Still, you must enter valid values of a browser (RUM) project in case the bridge is broken. An arbitrary value or the app's mobile pcode also passes the format check at the initialization stage, but data sent by the fallback then lands nowhere.
If the iOS app and the Android app use different mobile projects, the WebView data of the same web page also lands separately per platform. You do not need to split the HTML per app. To see the full metrics of one web screen, look in both projects.
1. Checking the collection server address
The value to check here is setServerUrl of the mobile agent. WebView data is also sent to this address, so it is especially important for WebView integration.
| Rule | Example |
|---|---|
Ends with scheme, host (port), then /m | https://collection-server-address:9443/m |
| A reverse proxy path may be included | https://internal-host:9443/22/m |
Do not add a trailing / | https://…/m/ becomes …/m//pageLoad and fails |
iOS does not append /m automatically, so always include /m in serverUrl. Even if you specify setLogServerUrl and setSpanServerUrl directly, you must still set setServerUrl. WebView data does not use those two values; instead it appends the endpoint after serverUrl. So if you leave serverUrl empty, WebView data silently disappears and the app may terminate on the first span transmission.
You may also put an internal reverse proxy in front and use a value that includes the proxy path. The agent appends the path after this value as is, so you only need to configure the proxy to forward all of the following paths.
| Data | Path appended after <serverUrl> |
|---|---|
| Native traces, logs | /trace /log |
| WebView page load | /pageLoad |
| Resources, screen transitions, errors, events, memory | /resource /routeChange /onError /event /memory |
| Web Vitals | /webVitals /v2/webVitals |
| Session replay | /sessionreplay /v2/sessionreplay |
2. Registering the WebView
Register it on the screen that displays the WebView. You must register it on the main thread and before calling load(). Call it only once per WebView instance.
UIKit app
import WebKit
import WhatapAgent
final class WebViewController: UIViewController {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
webView = WKWebView(frame: view.bounds)
view.addSubview(webView)
// Register the bridge. Pass the ViewController that owns the WebView as context
WhatapIOSAgent.setupWebViewBridge(webView, context: self)
// Call it on the next run loop so the load happens after the registration takes effect
DispatchQueue.main.async { [weak self] in
guard let self, let url = URL(string: "https://your.web.app") else { return }
self.webView.load(URLRequest(url: url))
}
}
}
#import <WebKit/WebKit.h> // before the WhatapAgent header
#import <WhatapAgent/WhatapAgent-Swift.h>
- (void)viewDidLoad {
[super viewDidLoad];
self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
[self.view addSubview:self.webView];
// This is a class method (+). On the main thread, before load, only once per instance
[WhatapIOSAgent setupWebViewBridge:self.webView context:self];
// Call it on the next run loop so the load happens after the registration takes effect
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
NSURL *url = [NSURL URLWithString:@"https://your.web.app"];
[weakSelf.webView loadRequest:[NSURLRequest requestWithURL:url]];
});
}
If your structure receives an @IBOutlet WKWebView *webView from a Storyboard or XIB, just leave out the code that creates the WebView. Call setupWebViewBridge:context: in viewDidLoad as is.
Registration rules
| Rule | If you do not follow it |
|---|---|
Defer load() to the next run loop (recommended) | setupWebViewBridge handles handler registration and script injection inside DispatchQueue.main.async. Calling load() right away in the same flow usually still applies the injection first, but the timing can differ for cached pages, file://, and slow devices. It is a safeguard that costs three lines, so keeping it in new code is recommended |
Register only once per WKWebView instance (not in viewWillAppear) | The second call is skipped, but the screen group linkage runs once more and creates an unnecessary WebView_* group. Fix it to once, at WebView creation time. If the WebView is recreated, register the new instance again |
Call it as [WhatapIOSAgent setupWebViewBridge:context:] | WhatapWebViewBridge (uppercase V) and WhatapWebViewManager are not public APIs. Using them fails at link time with Undefined symbols for architecture …: "_OBJC_CLASS_$_WhatapWebViewBridge". Use WhatapWebviewBridge with a lowercase v only when you must handle the bridge instance directly |
Do not use enableWebViewAutoBridge(_:) | It is a discontinued option and does nothing when called. Setting it to YES does not turn the bridge on |
Do not call startDataUploadTimer() yourself | It starts automatically when the bridge is created. Calling it again only creates one more timer that does the same thing |
| Do not turn JavaScript off | The bridge runs on JavaScript. WKWebView has it on by default, but the bridge does not work if the app turned it off |
| Register only WebViews that load your own content | Content loaded inside a registered WebView, including iframes, can access the bridge. The place to block leaving your origin is webView(_:decidePolicyFor:decisionHandler:) of WKNavigationDelegate |
The data the bridge receives is queued and sent to the collection server every 5 seconds. This timer starts automatically when the bridge is created, so there is nothing for the app to do. If the bridge instance is still alive after you leave the WebView screen, the timer keeps running.
On iOS, page loads are tracked from the web side. setupWebViewBridge() does not change navigationDelegate, so the existing delegate is kept as is. There is no page load interval created by the app. If you have a WebView whose load finishes before it is attached to the screen, call WhatapRUM.endPageLoad() when the screen is displayed.
Specifying the screen name
setupWebViewBridge() uses the title of the ViewController that owns the WebView as the screen name on the dashboard. If there is no title, it uses the class name. To specify the screen name yourself, create and use a bridge instance. The collection result is the same as when using setupWebViewBridge(); only the screen name differs.
private var bridge: WhatapWebviewBridge! // keep it as an instance (not a local variable)
override func viewDidLoad() {
super.viewDidLoad()
bridge = WhatapWebviewBridge(context: self, screenName: "Checkout screen")
bridge.configureWebView(webView)
}
// In @interface or a class extension. Keep it as an instance, not a local variable
@interface MyWebViewController ()
@property (nonatomic, strong) WhatapWebviewBridge *bridge;
@end
// Inside @implementation
- (void)viewDidLoad {
[super viewDidLoad];
self.bridge = [[WhatapWebviewBridge alloc] initWithContext:self screenName:@"Checkout screen"];
[self.bridge configureWebView:self.webView];
}
The screen name is stored in the screen_name and activity_name attributes of the collected data in the form screen name (URL). In an app with several WebView screens, use this name to tell the screens apart on the dashboard. If you do not need to set a name, use the shorter setupWebViewBridge().
The WhatapScreenNaming protocol of automatic screen tracking does not apply to WebView screen names. A WebView uses the screenName of WhatapWebviewBridge(context:screenName:); if it is not specified, it uses the ViewController's title, and if that is absent, the class name.
SwiftUI app
Using setupWebViewBridge(_:context:) requires a UIViewController. So the registration method depends on how you wrap WKWebView in SwiftUI.
| Wrapping method | Registration method |
|---|---|
UIViewControllerRepresentable (recommended) | There is a ViewController, so use setupWebViewBridge(webView, context: self) as is |
UIViewRepresentable | There is no ViewController, so use WhatapWebviewBridge(screenName:) and configureWebView(webView) |
UIViewControllerRepresentable lets you use the same code as UIKit, so this method is recommended.
import SwiftUI
import WebKit
import WhatapAgent
struct WhatapWebView: UIViewControllerRepresentable {
let urlString: String
final class Host: UIViewController {
var webView: WKWebView!
var urlString = ""
override func viewDidLoad() {
super.viewDidLoad()
title = "Checkout screen" // becomes the screen name on the dashboard
webView = WKWebView(frame: view.bounds)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
WhatapIOSAgent.setupWebViewBridge(webView, context: self)
if let url = URL(string: urlString) { webView.load(URLRequest(url: url)) }
}
}
func makeUIViewController(context: Context) -> Host {
let vc = Host()
vc.urlString = urlString
return vc
}
func updateUIViewController(_ vc: Host, context: Context) {}
}
If you can only use UIViewRepresentable, initialize with screenName. In that case the bridge creates a substitute ViewController internally to fill the context slot.
struct WhatapWebView: UIViewRepresentable {
let urlString: String
final class Coordinator {
var bridge: WhatapWebviewBridge?
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView(frame: .zero)
let bridge = WhatapWebviewBridge(screenName: "Checkout screen")
bridge.configureWebView(webView)
context.coordinator.bridge = bridge // see the description below
if let url = URL(string: urlString) { webView.load(URLRequest(url: url)) }
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {}
}
Keeping the bridge in the Coordinator is recommended. It also works as a local variable because WebKit retains it as a message handler, but managing the bridge's lifetime explicitly in code is safer.
In SwiftUI too, register only once when the WebView is created. makeUIView and makeUIViewController are called only when the view is recreated, but updateUIView is called every time. So putting the registration code in update… registers it repeatedly.
A SwiftUI app has no AppDelegate, so the SDK is also initialized in the init() of App. See Initializing the agent.
3. Adding the browser agent script
Put the following script at the very top of the head tag of every HTML page the WebView opens. This is work carried out by the web owner.
<script>
(function(w, h, _a, t, a) {
w = w[a] = w[a] || {};
w.config = {
projectAccessKey: "{PROJECT_ACCESS_KEY}",
pcode: {PCODE},
sampleRate: 100,
isWebView: true,
proxyBaseUrl: "https://rum-ap-northeast-2.whatap-browser-agent.io/"
};
a = h.createElement(_a);
a.async = 1;
a.src = t;
t = h.getElementsByTagName(_a)[0];
t.parentNode.insertBefore(a, t);
})(window, document, 'script', 'https://repo.whatap-browser-agent.io/rum/prod/v2/whatap-browser-agent.js', 'WhatapBrowserAgent');
</script>
<script>
window.WhatapBrowserAgent = {
config: {
projectAccessKey: "{PROJECT_ACCESS_KEY}",
pcode: {PCODE},
sampleRate: 100,
isWebView: true,
proxyBaseUrl: "https://rum-ap-northeast-2.whatap-browser-agent.io/"
}
};
</script>
<script src="https://repo.whatap-browser-agent.io/rum/prod/v2/whatap-browser-agent.js"></script>
Copy the configuration keys and the script address exactly.
isWebViewis case-sensitive. If you write it asisWebview, it is ignored as an unknown key without a warning and is not linked to the native session.- The script address for WebView is
/rum/prod/v2/whatap-browser-agent.js. Thev1address has no bridge integration. - Do not change the fifth argument
'WhatapBrowserAgent'of the async snippet. That value is the name of the global object that holds the configuration. If you replace the fifth argument with a bundle path, the agent cannot find the configuration. In that case no message is left in the console either, and initialization does not happen. If you self-host the agent file, change the fourth argument, which specifies the bundle path.
| Config | Description |
|---|---|
projectAccessKey | Access key of the browser (RUM) project. Used for fallback transmission |
pcode | Project code of the browser (RUM) project. Used for fallback transmission |
sampleRate | Collection rate (%). On a 0 – 100 scale, which differs from the mobile agent's setSamplingRate (0.0 – 1.0). Use 100 while verifying the setup |
isWebView | The switch that delegates data transmission to the mobile agent. Set it to true in the integrated setup |
proxyBaseUrl | The address to send data to on fallback transmission. Required even in the integrated setup. If it is not set, no data is sent at all |
You can check the projectAccessKey, pcode, and proxyBaseUrl values on the Management > Agent installation screen of the browser (RUM) project. Each item is filled in with the current project's value.
Checking the security policy
Hybrid apps often declare a CSP (Content Security Policy) in index.html.
<meta http-equiv="Content-Security-Policy"
content="connect-src 'self' https://collection-server-address;
script-src 'self'">
| Directive | Origin to allow |
|---|---|
connect-src | The origin of the collection server. It is always needed in the standard setup, and it is needed in the integrated setup too, because if webViewHttpFallback (default true) is on, data is sent over this path when the bridge is not reached. You can omit it only if you turned the fallback off with webViewHttpFallback: false |
script-src | The origin of the agent file. If you keep the agent file on the same origin as the page, 'self' is enough; if you put it on a different origin, add that origin |
In a CSP, host sources with different ports are treated as different origins, so write the port too, as in :9443. If you write the initialization script inline, a nonce or a hash is required.
Fallback transmission uses POST with Content-Type: text/plain, so there is no OPTIONS preflight. Check that there is no WAF rule blocking the request because it is not JSON. Also check that the pageLoad, routeChange, resource, onError, event, v2/webVitals, sessionreplay, v2/sessionreplay, and rumlog paths are allowed.
The data the mobile agent sends from native code is not subject to the WebView's CSP. So connect-src should allow the host of the web page's proxyBaseUrl, not the mobile agent's collection host.
When WebView data is not collected
| Symptom | Cause | Action |
|---|---|---|
| WebView data seems to be nowhere | You are looking in the web page's pcode | Look in the app's mobile project |
| Both web and native data are lost | initialize() was not called | WhatapAgent is not initialized is left in the app log. Check Initializing the agent |
| Data arrives but is classified as a regular browser | The WebView registration is later than the page load | Check the order: register, then load. The browser agent determines the WebView environment only once at initialization, so injecting the bridge after the load does not change the transmission path |
| No data is collected at all | proxyBaseUrl is not set | Set it in the integrated setup too |
| WebView data silently disappears | setServerUrl is empty | Fill in setServerUrl even if you specified the log and span URLs directly |
| Every request returns 404 | A trailing / in setServerUrl creates a // in the path | Remove the trailing / |
| Almost no data arrives (about 1%) | The mobile 1.0 was entered into the web sampleRate as is | The web uses a 0 – 100 scale. Specify sampleRate: 100 |
Undefined symbols at link time | WhatapWebViewBridge or WhatapWebViewManager with an uppercase V was used | Use [WhatapIOSAgent setupWebViewBridge:context:] or WhatapWebviewBridge with a lowercase v |
cannot find protocol declaration for 'WKNavigationDelegate' | WebKit/WebKit.h comes after WhatapAgent-Swift.h | Change the header order. Apply it to every file that imports that header |
| Only custom logs are not collected | In the integrated setup the bridge does not handle log | Keep that page in the standard setup |
Unnecessary WebView_* groups are created | Duplicate registration in viewWillAppear | Fix the registration to once, at WebView creation time |
WebView already configured - skipping duplicate in the app log | The bridge is not injected into that WebView | Check which WebView instance you registered |
First check the initialization log and the bridge connection log in the Xcode Console. Then check the WhatapRUM.VERSION and WhatapRUM.getAgentContext()?.platform values in the Safari Web Inspector console.
When the integration works correctly, native screens and WebView pages appear in the same session in chronological order on the Mobile dashboard. On the WebView dashboard you can check page loads, Web Vitals, and resource timing.
Troubleshooting
SDK initialization failure
- Check that the correct values are set for the project key and PCode.
- Check that the device is connected to the internet.
- Check that the collection server address you were given is correct. It must end with
/m. - Check that you called
initialize()afterbuild().
Data is not collected
- Check that
setSamplingRate(1.0)is set so that everything is collected. - Check that the App Transport Security setting in
Info.plistis correct. - If the loading time per screen is not shown, check Screen tracking. Automatic tracking is off by default.
- If network data is not shown, check whether you specified
setCollectNetwork(true). The default isfalse.
Crash reports are not sent
- Crashes are sent on the next app run. Try running the app again.
- Some crashes may not be captured on the simulator. Testing on a physical device is recommended.
Build errors
- For the
building for iOS x.y, but linking with dylib …warning, check the deployment target. - For header-related errors in an Objective-C project, check the header order guidance in the Objective-C section.
Requesting support
If a problem occurs, contact us through the following channels.
- Email: support@whatap.io
- Technical documentation: https://docs.whatap.io
Providing the following information when you request technical support helps resolve the issue faster.
- Project key
- iOS version and Xcode version
- Agent version
- Error message or Xcode Console log