iOS / Swift
Instrument your iOS application with OpenTelemetry to send distributed traces to Last9 using the opentelemetry-swift SDK
Use OpenTelemetry to instrument your iOS application and send distributed traces to Last9. This integration auto-instruments all URLSession network calls and injects W3C traceparent headers for end-to-end distributed tracing with your backend services.
Uses client monitoring tokens with synthetic origins (ios://your.bundle.id) — the same write-only token flow as the Browser RUM SDK.
Prerequisites
Before setting up iOS instrumentation, ensure you have:
- Xcode: 15 or higher
- Swift: 5.9+
- iOS deployment target: 13.0+
- Dependency manager: Swift Package Manager (SPM)
- Last9 account: With a client monitoring token from Ingestion Tokens
Create a Client Monitoring Token
- Navigate to Ingestion Tokens
- Click Create New Token → select Client → Web Browser
- Set the allowed origin to
ios://your.bundle.id(e.g.,ios://com.example.myapp) - Copy the generated token and the endpoint URL
Setup
-
Add SPM Dependencies
In Xcode, go to File → Add Package Dependencies and add these two packages:
Package URL Version https://github.com/open-telemetry/opentelemetry-swift-core.git2.3.0+ https://github.com/open-telemetry/opentelemetry-swift.git2.3.0+ Select these products for your app target:
From
opentelemetry-swift-core:OpenTelemetryApiOpenTelemetrySdk
From
opentelemetry-swift:OpenTelemetryProtocolExporterHTTPURLSessionInstrumentationResourceExtensionNetworkStatus(optional — adds WiFi/Cellular detection)
-
Create the setup file
Add a new file
Last9OTel.swiftto your project:import Foundationimport UIKitimport OpenTelemetryApiimport OpenTelemetrySdkimport OpenTelemetryProtocolExporterHttpimport URLSessionInstrumentationimport ResourceExtensionfinal class Last9OTel {static var shared: Last9OTel?private let tracerProvider: TracerProviderSdkprivate let urlSessionInstrumentation: URLSessionInstrumentation@discardableResultstatic func initialize(endpoint: String,clientToken: String,serviceName: String,environment: String = "production") -> Last9OTel {let instance = Last9OTel(endpoint: endpoint,clientToken: clientToken,serviceName: serviceName,environment: environment)shared = instancereturn instance}private init(endpoint: String,clientToken: String,serviceName: String,environment: String) {let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"let bundleId = Bundle.main.bundleIdentifier ?? "unknown"var resource = DefaultResources().get()resource.merge(other: Resource(attributes: ["service.name": .string(serviceName),"service.version": .string("\(appVersion)+\(build)"),"deployment.environment": .string(environment),]))let otlpConfig = OtlpConfiguration(timeout: 30,compression: .gzip,headers: [("X-LAST9-API-TOKEN", "Bearer \(clientToken)"),("X-LAST9-ORIGIN", "ios://\(bundleId)"),("Client-ID", Self.persistentClientId()),])let traceExporter = OtlpHttpTraceExporter(endpoint: URL(string: "\(endpoint)/v1/traces")!,config: otlpConfig,envVarHeaders: nil)let spanProcessor = BatchSpanProcessor(spanExporter: traceExporter,scheduleDelay: 5,maxQueueSize: 2048,maxExportBatchSize: 512)self.tracerProvider = TracerProviderBuilder().with(resource: resource).add(spanProcessor: spanProcessor).build()OpenTelemetry.registerTracerProvider(tracerProvider: self.tracerProvider)self.urlSessionInstrumentation = URLSessionInstrumentation(configuration: URLSessionInstrumentationConfiguration(shouldInstrument: { request inguard let host = request.url?.host else {return true}return !host.contains("last9.io")},semanticConvention: .stable))}func flush() { tracerProvider.forceFlush() }func shutdown() { tracerProvider.shutdown() }static func tracer(_ name: String = "app") -> Tracer {OpenTelemetry.instance.tracerProvider.get(instrumentationName: name,instrumentationVersion: nil)}/// Stable client ID — persisted across launches.private static func persistentClientId() -> String {if let vendorId = UIDevice.current.identifierForVendor?.uuidString {return vendorId}let key = "io.last9.clientId"if let stored = UserDefaults.standard.string(forKey: key) {return stored}let generated = UUID().uuidStringUserDefaults.standard.set(generated, forKey: key)return generated}} -
Initialize at app launch
func application(_ application: UIApplication,didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {Last9OTel.initialize(endpoint: "<your-endpoint>",clientToken: "<your-client-token>",serviceName: "my-ios-app")return true}@mainstruct YourApp: App {init() {Last9OTel.initialize(endpoint: "<your-endpoint>",clientToken: "<your-client-token>",serviceName: "my-ios-app")}var body: some Scene {WindowGroup { ContentView() }}} -
Handle app lifecycle
Flush pending spans before the app goes to background:
func applicationDidEnterBackground(_ application: UIApplication) {Last9OTel.shared?.flush()}func applicationWillTerminate(_ application: UIApplication) {Last9OTel.shared?.shutdown()}
Verify
- Run the app and trigger any network request
- Open the Traces Explorer in Last9
- Search for your service name — you should see HTTP spans with
url.full,http.request.method, andhttp.response.status_codeattributes
What’s Auto-Instrumented
After calling Last9OTel.initialize(...), the following is captured automatically with zero additional code changes:
| Signal | Details |
|---|---|
| Network requests | Every URLSession call — HTTP method, URL, status code, latency |
| Trace propagation | W3C traceparent and tracestate headers injected into outgoing requests |
| Device context | device.model.identifier, os.name, os.version, service.version |
| Network type | WiFi vs Cellular (when NetworkStatus product is included) |
Custom Instrumentation
Business Events
let tracer = Last9OTel.tracer("auth")let span = tracer.spanBuilder(spanName: "user.login") .setAttribute(key: "auth.method", value: "otp") .startSpan()
// ... your login logic ...
span.setAttribute(key: "auth.success", value: true)span.end()Screen Views
class HomeViewController: UIViewController { private var screenSpan: Span?
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) screenSpan = Last9OTel.tracer("navigation") .spanBuilder(spanName: "screen.view") .setAttribute(key: "screen.name", value: "HomeScreen") .startSpan() }
override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) screenSpan?.end() }}Backend Correlation
The URLSession instrumentation automatically injects W3C traceparent headers into every outgoing HTTP request. If your backend is instrumented with OpenTelemetry (Express.js, NestJS, Flask, Gin, etc.), your iOS network requests will appear as parent spans linked to backend traces in Last9.
No additional configuration is required.
Security
Client monitoring tokens are write-only — they can send telemetry but cannot read or query your data. The synthetic origin (ios://your.bundle.id) is registered when creating the token and validated on every request. This is the same security model used by the Browser RUM SDK.
Example App
See the full working example on GitHub↗.