Skip to content
Last9
Book demo

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

  1. Navigate to Ingestion Tokens
  2. Click Create New Token → select ClientWeb Browser
  3. Set the allowed origin to ios://your.bundle.id (e.g., ios://com.example.myapp)
  4. Copy the generated token and the endpoint URL

Setup

  1. Add SPM Dependencies

    In Xcode, go to File → Add Package Dependencies and add these two packages:

    Package URLVersion
    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:

    • OpenTelemetryApi
    • OpenTelemetrySdk

    From opentelemetry-swift:

    • OpenTelemetryProtocolExporterHTTP
    • URLSessionInstrumentation
    • ResourceExtension
    • NetworkStatus (optional — adds WiFi/Cellular detection)
  2. Create the setup file

    Add a new file Last9OTel.swift to your project:

    import Foundation
    import UIKit
    import OpenTelemetryApi
    import OpenTelemetrySdk
    import OpenTelemetryProtocolExporterHttp
    import URLSessionInstrumentation
    import ResourceExtension
    final class Last9OTel {
    static var shared: Last9OTel?
    private let tracerProvider: TracerProviderSdk
    private let urlSessionInstrumentation: URLSessionInstrumentation
    @discardableResult
    static func initialize(
    endpoint: String,
    clientToken: String,
    serviceName: String,
    environment: String = "production"
    ) -> Last9OTel {
    let instance = Last9OTel(
    endpoint: endpoint,
    clientToken: clientToken,
    serviceName: serviceName,
    environment: environment
    )
    shared = instance
    return 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 in
    guard 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().uuidString
    UserDefaults.standard.set(generated, forKey: key)
    return generated
    }
    }
  3. 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
    }
  4. 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

  1. Run the app and trigger any network request
  2. Open the Traces Explorer in Last9
  3. Search for your service name — you should see HTTP spans with url.full, http.request.method, and http.response.status_code attributes

What’s Auto-Instrumented

After calling Last9OTel.initialize(...), the following is captured automatically with zero additional code changes:

SignalDetails
Network requestsEvery URLSession call — HTTP method, URL, status code, latency
Trace propagationW3C traceparent and tracestate headers injected into outgoing requests
Device contextdevice.model.identifier, os.name, os.version, service.version
Network typeWiFi 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↗.