Skip to main content

iOS

Scout for iOS is a native Swift SDK that ships zero-config OpenTelemetry RUM. One Scout.start(...) call auto-captures the full Real User Monitoring event set — taps, screens, native crashes, app hangs, jank, startup, lifecycle, HTTP — and exports it as OTLP traces, metrics, and logs to a Scout collector.

import ScoutKit

Scout.start(
serviceName: "my-app",
endpoint: "https://otel.example.com"
)

That's the only code you write. The SDK is a thin Swift layer (ScoutKit) over a Kotlin/Native engine (ScoutNative) that does all the instrumentation — you import ScoutKit and call the Scout type it declares.

What You Get

CapabilitySignalMechanism
App startupapp_startup span + app_vital (vital.name = fbc) on first screenProcess-start nanos → first init
Lifecycleapp_lifecycle.changedUIApplicationDidBecomeActive / DidEnterBackground notifications; drives session foreground/background
Screen viewsscreen_view spanUIViewController.viewDidAppear IMP swizzle; class name (last path component); container controllers (Nav / Tab / Split / Page) skipped
Screen loadscreen_load spanviewDidLoadviewDidAppear timing via CACurrentMediaTime
View sessionview_session spanTime-on-screen, emitted on screen change
Navigation vitalapp_vital (vital.name = inv)Tap → next-screen latency within 5 s
Tap trackinguser_interaction span (ui.type = tap, target, x/y)Non-consuming UITapGestureRecognizer on every key window; label resolved via hit-test accessibility label / identifier / button title / UILabel, then the deepest accessibility element, then a cleaned class name
HTTP requestshttp.request span with method, URL, status, durationGlobal pass-through NSURLProtocol; times every request; skips the collector host to avoid loops; covers URLSession + Ktor/Darwin
Distributed tracingW3C traceparent header injected into requests to firstPartyHostsThe http span's trace/span id is propagated so backend spans join the same trace. First-party only — third-party hosts get no header
Long task (UI hang)long_task spanCADisplayLink on the main runloop; frame interval ≥ 100 ms
Frozen framefrozen_frame spanSame CADisplayLink; frame interval ≥ 700 ms
App hang / ANRanr span with a mach main-thread backtrace + breadcrumbsAppHangWatchdog — a background-queue heartbeat to the main queue; fires once at threshold cross (default 5 s). MetricKit MXHangDiagnostic also → anr
Native crashesnative_crash and app_crash spans with crash.reason, registers, mach exception, symbolicated callstack tree, binary images, breadcrumbsKSCrash 2.x (all monitors: mach exception, signal, C++ exception, NSException, main-thread deadlock, user-reported). Drained on the next launch. MetricKit MXCrashDiagnostic also → native_crash
Errors (handled)error spanScout.reportError(...)
Memory (opt-in)process.memory.usage gauge (By)mach task_info(TASK_VM_INFO) phys_footprint — the value iOS jetsam charges against the per-app limit and that Xcode's memory gauge shows. Polled every vitalsCollectionIntervalSeconds. Off by default (enableMemoryMetrics)
CPU (opt-in)process.cpu.usage gaugemach thread_info non-idle CPU sum. Off by default (enableCpuMetrics)
LogsOTLP logsScout.log*()
Anonymous user iduser.anonymous_id on every spanUUID minted on first launch, persisted

Prerequisites

RequirementVersion
iOS deployment target≥ 13.0
Xcode≥ 15 (Swift tools 5.9)
Swift Package ManagerBundled with Xcode

Installation

The iOS SDK is distributed via Swift Package Manager from the scout-kotlin-multiplatform repository. It exposes two products: Scout (the Swift ScoutKit layer — your public API) and ScoutNative (the Kotlin/Native engine, delivered as a hosted xcframework). Adding Scout pulls in ScoutNative transitively.

Release tags are ios-<version>not semver — so pin by revision, not a version range:

// Package.swift
dependencies: [
.package(
url: "https://github.com/base-14/scout-kotlin-multiplatform",
revision: "ios-0.1.9"
)
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "Scout", package: "scout-kotlin-multiplatform")
]
)
]

In Xcode: File → Add Package Dependencies…, enter https://github.com/base-14/scout-kotlin-multiplatform, set the dependency rule to Commit / Branch = ios-0.1.9, and add the Scout library product.

note

The product you depend on is Scout, but the module you import is ScoutKit:

import ScoutKit // not `import Scout`

Scout is the name of the Kotlin/Native engine framework inside ScoutNative. import Scout compiles, but it gives you the engine types (ScoutEngine, ScoutConfig) rather than the Scout entry point with start(...), so the call in your app won't resolve.

Initialization

Call Scout.start(...) once, as early as possible — in application(_:didFinishLaunchingWithOptions:) — so the crash handler is armed and cold-start timing is anchored:

import ScoutKit
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Scout.start(
serviceName: "my-app",
endpoint: "https://otel.example.com",
headers: ["Authorization": "Bearer …"]
)
return true
}
}

start is idempotent — a second call is a no-op for both the engine and the hang watchdog.

Screen tracking in SwiftUI

Screen tracking swizzles UIViewController.viewDidAppear, so a UIKit app gets screen names for free. A SwiftUI app is hosted inside a single UIHostingController, so the automatic name collapses to that one host. Set the name yourself at each navigation point:

Scout.setScreen("Checkout")

The same applies to tap labels: give tappable views an .accessibilityLabel(...) or .accessibilityIdentifier(...) so user_interaction spans carry something friendlier than a class name.

Setting user identity

Scout.setUser(id: "user-123", attributes: [
"email": "jane@example.com",
"plan": "pro",
])

// On logout:
Scout.clearUser()

Setting session attributes

Scout.setSessionAttributes(["tenant": "acme", "ab_bucket": "B"])
Scout.clearSessionAttributes()

They attach to every subsequent span, metric, and log for the rest of the session.

Configuration

Scout.start(...) takes its configuration as named parameters. Only serviceName and endpoint are required.

Identity

ParameterTypeDefaultDescription
serviceNameString(required)Logical app identifier. Used as service.name. Must be non-blank.
endpointString(required)OTLP-HTTP collector URL. /v1/traces, /v1/metrics, /v1/logs are appended automatically. Must be non-blank.
serviceVersionString?nilMaps to service.version.
environmentString?nilDeployment environment (e.g. production).
headers[String: String][:]Extra HTTP headers on every OTLP export. Use for auth.
resourceAttributes[String: String][:]Extra attributes merged into every signal's Resource.

Sessions

ParameterTypeDefaultDescription
sessionSampleRateDouble (0-100)1.0Percent of sessions sampled — default 1%. Decided once per session; a sampled session sends everything (spans, metrics, logs), an unsampled one sends nothing.
alwaysCaptureErrorsBooltrueError / crash / ANR-class spans bypass sessionSampleRate and are always exported.
sessionTimeoutMinutesInt30Inactivity timeout before a new session.id is minted.
maxSessionDurationMinutesInt60Hard cap on session lifetime.

Network

ParameterTypeDefaultDescription
firstPartyHosts[String][]Hosts that receive a W3C traceparent header for distributed tracing. Exact match or *.host wildcards. Empty = no propagation.
ignoreUrlPatterns[String][]URL substrings excluded from HTTP tracking.

Thresholds

ParameterTypeDefaultDescription
anrThresholdMsDouble5000Main-thread hang duration that fires an anr span (drives AppHangWatchdog).
longTaskThresholdMsInt100Frame interval that qualifies as a long_task.
frozenFrameThresholdMsInt700Frame interval that qualifies as a frozen_frame.

Batching & export (applies to spans, metrics, AND logs)

ParameterTypeDefaultDescription
exportIntervalSecondsInt30One export cadence for spans, metrics, and logs (coerced ≥ 1).
maxExportBatchSizeInt512Max items per export batch, per signal.
maxQueueSizeInt2048Max items buffered awaiting export; overflow is dropped.
maxRetriesInt0Delivery attempts after a failed export. Default 0 = at-most-once.
metricExportIntervalSecondsInt-1Metrics-only override of exportIntervalSeconds. Any value ≤ 0 means "inherit".
vitalsCollectionIntervalSecondsInt60How often memory / CPU gauges are polled (when enabled).

Per-metric switches

The SDK ships no metrics by default — each gauge is opt-in.

ParameterDefaultDescription
enableMetricstrueMaster switch for the metrics pipeline. Individual gauges still need their own switch below.
enableMemoryMetricsfalseprocess.memory.usage gauge.
enableCpuMetricsfalseprocess.cpu.usage gauge.
enableFrameMetricsfalseAccepted for parity with Android, but iOS emits no frame gauge. Frame timing arrives as long_task / frozen_frame spans instead, governed by enableJankTracking.

Auto-instrumentation toggles

Every auto-instrumentation can be turned off independently. Span and log instrumentation defaults to on; metric collection defaults to off (see Per-metric switches).

ToggleDefaultWhat you lose when false
enableScreenTrackingtruescreen_view / screen_load / view_session spans.
enableTapTrackingtrueAll user_interaction spans.
enableHttpTrackingtruehttp.request spans from the pass-through NSURLProtocol.
enableErrorTrackingtrueerror spans — including manual Scout.reportError(...) calls, which become no-ops.
enableCrashReportingtrueKSCrash native crash capture and the MetricKit subscriber.
enableAnrTrackingtrueanr spans from AppHangWatchdog.
enableJankTrackingtruelong_task / frozen_frame spans.
enableLifecycleTrackingtrueapp_lifecycle.changed spans.
enableStartupTrackingtrueapp_startup spans and the FBC vital.
enableLoggingtrueScout.log*() calls become no-ops.

Crash reporting and hang detection are separate switches. Setting enableCrashReporting = false leaves AppHangWatchdog running, and setting enableAnrTracking = false leaves KSCrash installed.

Offline buffer

Offline buffering is fully disabled by default — nothing is written to disk, and a batch that fails to export is dropped (strict at-most-once delivery).

ParameterTypeDefaultDescription
offlineBufferEnabledBoolfalseMaster toggle. Persist failed batches and replay them on the next launch.
offlineMaxTraceItemsInt0Accepted, not yet enforced.
offlineMaxMetricItemsInt0Accepted, not yet enforced.
offlineMaxLogItemsInt0Accepted, not yet enforced.
maxOfflineStorageMbInt5Accepted, not yet enforced.

The four cap parameters are part of the config surface but nothing reads them yet. When offlineBufferEnabled is on, the persisted queue is bounded by maxQueueSize and maxExportBatchSize — the same limits the in-memory path uses. Size the buffer with those.

Diagnostics

ParameterTypeDefaultDescription
debugLoggingBoolfalsePrint SDK-internal export logging to the console. Use it to confirm batches are leaving the device; leave it off in release builds.

Native crash setup

No app-side setup is required — KSCrash and the MetricKit subscriber install automatically when enableCrashReporting is on (the default).

KSCrash

On start, the engine installs KSCrash 2.x with all monitors: mach exceptions, POSIX signals, C++ exceptions, NSException, and main-thread deadlock. KSCrash writes reports out-of-process at crash time. On the next launch, Scout drains any persisted reports and emits both a native_crash span (full crash detail) and an app_crash span (error.handled = false, error.source.type = ios) — the latter is parity with Android's app_crash. Captured attributes include:

  • crash.reason, crash.type, crash.signal
  • crash.registers_json — full CPU register dump
  • crash.mach_exception / crash.mach_code / crash.mach_subcode
  • crash.callstack_tree_json — symbolicated stack tree of every thread
  • crash.binary_images_json — loaded image list for offline symbolication
  • The prior session's breadcrumb trail + last screen

MetricKit

An MXMetricManagerSubscriber (iOS 14+) collects asynchronous MXCrashDiagnostic (→ native_crash) and MXHangDiagnostic (→ anr) payloads Apple delivers the morning after — useful for kernel-killed crashes KSCrash couldn't intercept.

App hangs

AppHangWatchdog is a Swift-side heartbeat that detects a hung main thread and emits an anr span with a mach frame-pointer backtrace once the hang crosses anrThresholdMs (default 5 s). It is gated by enableAnrTracking, not enableCrashReporting — the two run independently.

Because crashes drain on the next launch, to test: trigger a real fault (fatalError(), an out-of-bounds access — never exit(), which is graceful), relaunch the app, then check the collector.

Manual API

All methods are static on the Scout type.

// Errors & logs
Scout.reportError(error) // → `error` span
Scout.logInfo("checkout started", attributes: ["cart.size": "3"])
Scout.logError("payment failed")
Scout.logWarning("…"); Scout.logDebug("…")
Scout.logEvent("promo_applied", attributes: ["code": "SAVE10"])

// Identity
Scout.setUser(id: "user-123", attributes: ["plan": "pro"])
Scout.setUserAttributes(["role": "admin"]); Scout.clearUser()

// Session / account / feature flags
Scout.setSessionAttributes(["tenant": "acme"]); Scout.clearSessionAttributes()
Scout.setAccount(id: "org-1", name: "Acme"); Scout.clearAccount()
Scout.setFeatureFlag(name: "new_checkout", value: "true")
Scout.clearFeatureFlags()

// Screens & custom spans
Scout.setScreen("Checkout")
Scout.recordScreenLoad(name: "Checkout", durationMs: 320)
Scout.recordViewSession(name: "Checkout", durationMs: 8400)
Scout.recordSpan(name: "sync", durationMs: 120, attributes: ["items": "12"])

// Manual instrumentation
// reportHttp also takes `responseSize:` (default -1) and
// `errorMessage:` (default nil) between statusCode and the timestamps.
Scout.reportHttp(method: "GET", url: "https://api…", statusCode: 200,
startEpochNanos: t0, endEpochNanos: t1) // → `http.request`
Scout.reportLongTask(durationMs: 180) // → `long_task`
Scout.reportTap(target: "Buy", targetType: "Button", x: 40, y: 88)
Scout.emitGauge(name: "queue.depth", value: 12, unit: "1")

// Vitals / operations / breadcrumbs
Scout.addTiming("first_paint")
Scout.startVital("checkout"); Scout.endVital("checkout", description: "ok")
Scout.recordOperationStep(name: "checkout", step: "payment")
Scout.addBreadcrumb(type: "tap", message: "Buy")

What happens when export fails

Delivery is at-most-once by default (maxRetries: 0): a batch gets one attempt, and a failed batch is dropped rather than risking a duplicate delivery. A retried timeout whose first request the collector already ingested would store the same events twice.

A batch counts as delivered only on an HTTP 2xx. Any other status, or a transport exception, is a failure.

FailureWhat Scout does (defaults)
Any export failure, maxRetries: 0 (default)One attempt; batch dropped. No duplicates, ever.
maxRetries: n configuredUp to n + 1 attempts total, retried back-to-back with no backoff. Duplicate risk on ambiguous failures.
Failure with offlineBufferEnabled: trueBatch persisted to disk and replayed on a later launch.
Queue overflow (maxQueueSize, default 2048)Oldest items dropped before they are ever exported.
Process dies mid-intervalAnything emitted since the last export is lost — there is no flush-on-background hook. Crash evidence is the exception: KSCrash writes it at crash time and Scout drains it on the next launch.

Set debugLogging: true to print each batch's destination and HTTP status to the console.

Troubleshooting

SymptomLikely cause + fix
native_crash not appearing after a crashKSCrash writes asynchronously; the report drains on the next launch. Force-quit and relaunch, then check the collector.
Crash button gives a graceful shutdownYou're calling exit(), which no crash reporter intercepts. Trigger a real fault (fatalError(), out-of-bounds access).
anr never firesThe main thread genuinely isn't hanging, or anrThresholdMs is higher than your hang. Try a 6 s Thread.sleep on the main thread to confirm the watchdog is armed.
Tap labels are class names like ComposeCanvasThe tapped surface is a single canvas view (SwiftUI/Compose) with no per-widget accessibility label. Add .accessibilityLabel(...) / .accessibilityIdentifier(...) to the tappable view.
SPM can't resolve the packagePin by revision: "ios-0.1.9" — the tags are not semver, so from: / exact: version rules won't match.
HTTP spans missing for a custom sessionThe pass-through NSURLProtocol covers URLSession.shared and default configs; a session with a custom protocolClasses that omits it won't be traced.
No telemetry at allSet debugLogging: true to print export attempts and their HTTP status, then confirm the endpoint is reachable from the device. Remember the default sessionSampleRate is 1%.

Performance considerations

  • Unified 30 s batching. Spans, metrics, and logs each flush once per exportIntervalSeconds (default 30 s).
  • No metrics unless enabled. The default configuration ships zero metric data points; the memory / CPU gauges are opt-ins.
  • Zero disk usage by default. Offline buffering is off; the only disk writes are crash evidence (KSCrash reports, breadcrumbs).
  • Sampling. sessionSampleRate drops full sessions — never individual events.

Security considerations

  • Custom headers for auth. Pass headers: ["Authorization": "Bearer …"] — sent on every OTLP export.
  • No beforeSend on this path. The core beforeSend filter is not exposed as a Scout.start(...) parameter, so attribute scrubbing is not available to a pure-Swift app. Reaching it means driving the SDK from shared Kotlin — see Kotlin Multiplatform.
  • No telemetry-to-disk PII by default. The offline buffer is off; crash reports contain register / stack detail, not app data.
  • TLS. Use an https:// endpoint.

FAQ

What's the difference between native_crash and app_crash?

iOS emits both for one crash: native_crash carries the full KSCrash detail (registers, mach exception, symbolicated tree), and app_crash is the cross-platform crash record (parity with Android). Dedupe in dashboards by crash fingerprint if you want a single row.

Do I need to add ScoutNative separately?

No — depending on the Scout product pulls the ScoutNative xcframework in transitively.

Can I add custom spans, metrics, or logs?

Yes — recordSpan, emitGauge, and log* are the manual entry points.

What's next

References

Was this page helpful?