# OpenTelemetry for Swift: iOS & Server Tracing with OpenObserve

> Instrument iOS, macOS, and server-side Swift with opentelemetry-swift - traces, metrics, and logs over OTLP to OpenObserve. Setup, code, troubleshooting.

Source: https://openobserve.ai/opentelemetry/swift/
Published: 2026-07-08
Language: Swift

---

Swift telemetry spans two very different worlds: mobile and desktop apps on Apple platforms, and server-side Swift services built with frameworks like Vapor or Hummingbird. The [opentelemetry-swift](https://github.com/open-telemetry/opentelemetry-swift) SDK covers both with one API. This guide walks through instrumenting Swift code for traces - plus what realistically exists today for metrics and logs - and shipping the data to [OpenObserve](https://openobserve.ai/) over OTLP.

If you are new to OpenTelemetry itself - the signals, the collector, OTLP - start with [What is OpenTelemetry? A Complete Guide](/blog/what-is-opentelemetry/) and come back here for the Swift specifics.

## Why OpenTelemetry for Swift

Apple's own observability story (os_log, MetricKit, Instruments) is excellent for local debugging but does not give you fleet-wide, backend-correlated telemetry. OpenTelemetry does:

- **One trace across app and backend.** A checkout tap in your iOS app and the API calls it triggers become a single distributed trace, because `URLSessionInstrumentation` injects W3C `traceparent` headers that your instrumented backend picks up.
- **Vendor-neutral by design.** Instrument once, export anywhere OTLP is accepted - no analytics-SDK lock-in compiled into your app binary.
- **Same API on the server.** A Vapor service and the iOS app that calls it share concepts, semantic conventions, and a backend.

### Signal stability in Swift

Be honest about maturity before betting on a signal. As of mid-2026 (opentelemetry-swift 2.4.x), the status is:

| Signal  | Status | Notes |
|---------|--------|-------|
| Traces  | Stable | API and SDK stable; the workhorse signal in Swift, safe for production |
| Metrics | Development | In development; the API is still moving and the project is migrating toward the current spec (StableMeterProvider), not declared stable; API may change between minor releases |
| Logs    | Development | Functional (LoggerProvider, OTLP log exporter, swift-log bridge) but beta quality; expect changes |

Traces are where the Swift SDK earns its keep, and that is where this guide spends most of its time. Metrics and logs work - the code below runs - but pin your package versions and read release notes when upgrading, because those APIs are not frozen.

One structural note: since the 2.0 release the project is split across **two packages**. `opentelemetry-swift-core` holds the stable heart - `OpenTelemetryApi`, `OpenTelemetrySdk`, plus `StdoutExporter` and `OpenTelemetryConcurrency` - while the main `opentelemetry-swift` package carries everything that moves faster: the OTLP exporters, `URLSessionInstrumentation`, `ResourceExtension`, persistence, and the other instrumentation modules. You will typically declare both.

## Prerequisites

- **Swift 6.0+ toolchain** (swift-tools-version: 6.0; Xcode 16+ on Apple platforms). Minimum deployment targets for the exporter/instrumentation package are iOS 13, macOS 12, tvOS 13, watchOS 6, and visionOS 1.
- **An OpenObserve instance.** Either an [OpenObserve Cloud](https://cloud.openobserve.ai) account or a self-hosted instance. For local testing, a single container is enough:

```bash
docker run -d --name openobserve \
  -p 5080:5080 -p 5081:5081 \
  -e ZO_ROOT_USER_EMAIL="root@example.com" \
  -e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
  public.ecr.aws/zinclabs/openobserve:latest
```

- **Your OpenObserve credentials.** OTLP ingestion uses HTTP Basic auth. Generate the token once:

```bash
echo -n 'root@example.com:Complexpass#123' | base64
```

This prints `cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=` - the value you will pass in the `Authorization: Basic` header.

In OpenObserve Cloud, the ingestion page under **Data Sources** shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.

## Zero-code instrumentation: the short answer for Swift

There is **no runtime agent** for Swift. Ahead-of-time compiled binaries on locked-down mobile platforms leave no room for the bytecode-rewriting tricks the Java agent uses. You initialize the SDK explicitly - in `application(_:didFinishLaunchingWithOptions:)`, your SwiftUI `App` init, or your server's `main` - and that is the normal, supported model.

What the ecosystem gives you instead is a set of drop-in instrumentation modules in the `opentelemetry-swift` package:

- **`URLSessionInstrumentation`** - the important one. It swizzles `URLSession` so every network request produces a client span with HTTP semantic-convention attributes and an injected `traceparent` header. One initialization call covers your entire networking layer, including libraries built on URLSession (such as Alamofire).
- **`ResourceExtension`** - detects device, OS, and app-bundle attributes (`device.model.identifier`, `os.version`, `service.version` from your bundle) so you do not hand-write them.
- **`NetworkStatus`** - attaches connectivity attributes (wifi/cell) to spans on Apple platforms.
- **`SignPostIntegration`** - mirrors spans into os_signpost so your OpenTelemetry spans show up in Xcode Instruments during profiling.

For web frontends, note that [OpenObserve RUM](https://openobserve.ai/docs/user-guide/rum/) exists as a separate browser-side SDK - this guide is about native Swift.

## Manual instrumentation

Add both packages in `Package.swift` (in Xcode: **File → Add Package Dependencies**, add each URL, then pick the products listed below):

```swift
dependencies: [
  .package(url: "https://github.com/open-telemetry/opentelemetry-swift", from: "2.2.0"),
  .package(url: "https://github.com/open-telemetry/opentelemetry-swift-core", from: "2.2.0")
],
targets: [
  .executableTarget(
    name: "CheckoutApp",
    dependencies: [
      .product(name: "OpenTelemetryApi", package: "opentelemetry-swift-core"),
      .product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core"),
      .product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"),
      .product(name: "ResourceExtension", package: "opentelemetry-swift"),
      .product(name: "URLSessionInstrumentation", package: "opentelemetry-swift")
    ]
  )
]
```

Watch the capitalization mismatch: the *product* is `OpenTelemetryProtocolExporterHTTP`, but the *module you import* is `OpenTelemetryProtocolExporterHttp`. It is the number-one "no such module" complaint.

### Traces

The pattern: build an OTLP HTTP exporter pointed at OpenObserve, wrap it in a `BatchSpanProcessor`, attach a `Resource`, and register the `TracerProvider` globally - once, at startup.

```swift
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
import ResourceExtension

func configureOpenTelemetry() {
  // Swift exporters take the FULL per-signal URL (unlike most SDKs,
  // which take a base URL and append /v1/traces themselves).
  let traceEndpoint = URL(string: "http://localhost:5080/api/default/v1/traces")!

  let otlpConfig = OtlpConfiguration(
    timeout: 10,
    headers: [
      ("Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=")
    ]
  )

  let traceExporter = OtlpHttpTraceExporter(
    endpoint: traceEndpoint,
    config: otlpConfig
  )

  // Detected attributes (os, device, bundle version) merged with your own.
  let resource = DefaultResources().get().merging(other: Resource(attributes: [
    "service.name": AttributeValue.string("checkout-app"),
    "service.version": AttributeValue.string("1.4.2"),
    "deployment.environment": AttributeValue.string("production")
  ]))

  let tracerProvider = TracerProviderBuilder()
    .add(spanProcessor: BatchSpanProcessor(spanExporter: traceExporter))
    .with(resource: resource)
    .build()

  OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider)
}
```

Creating spans is a fluent builder API:

```swift
let tracer = OpenTelemetry.instance.tracerProvider.get(
  instrumentationName: "checkout-flow",
  instrumentationVersion: "1.0.0"
)

let span = tracer.spanBuilder(spanName: "process-payment")
  .setSpanKind(spanKind: .client)
  .setActive(true)   // children created on this thread parent automatically
  .startSpan()

span.setAttribute(key: "payment.provider", value: "stripe")
span.setAttribute(key: "cart.items", value: 3)

do {
  try processPayment()
} catch {
  span.status = .error(description: error.localizedDescription)
}
span.end()   // an un-ended span is never exported
```

Two Swift-specific details matter here:

1. **`setActive(true)` uses `os.activity` for context propagation**, which follows threads and dispatch queues - but it does **not** cross `async`/`await` suspension points reliably. In `async` code, either pass the parent span explicitly with `.setParent(span)`, or add the `OpenTelemetryConcurrency` product from `opentelemetry-swift-core`, which provides task-local context that works with structured concurrency.
2. **`span.end()` is not optional.** Only ended spans reach the processor. A span abandoned in an error path is silently lost.

Wire up URLSession instrumentation once, right after provider registration, and every network call becomes a client span that propagates context to your backend:

```swift
import URLSessionInstrumentation

let urlSessionInstrumentation = URLSessionInstrumentation(
  configuration: URLSessionInstrumentationConfiguration(
    shouldInstrument: { request in
      // Only trace your own APIs; skip third-party analytics calls.
      request.url?.host?.hasSuffix("yourcompany.com") ?? false
    }
  )
)
```

Keep a reference to the instrumentation object for the app's lifetime. The configuration also offers `nameSpan`, `spanCustomization`, and `shouldInjectTracingHeaders` hooks when you need finer control.

### Metrics

The metrics implementation in 2.x follows the current OpenTelemetry specification (the pre-2.0, out-of-spec API was removed), but the signal is still officially **in development** - expect API movement between minor versions. The setup mirrors other SDKs: a `PeriodicMetricReader` collects and exports on an interval.

```swift
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp

func configureMetrics(resource: Resource, otlpConfig: OtlpConfiguration) {
  let metricExporter = OtlpHttpMetricExporter(
    endpoint: URL(string: "http://localhost:5080/api/default/v1/metrics")!,
    config: otlpConfig
  )

  let meterProvider = MeterProviderSdk.builder()
    .setResource(resource: resource)
    .registerMetricReader(
      reader: PeriodicMetricReaderBuilder(exporter: metricExporter)
        .setInterval(timeInterval: 15)   // seconds; default is aggressive, set this
        .build()
    )
    .build()

  OpenTelemetry.registerMeterProvider(meterProvider: meterProvider)
}

// Instruments — create once, record from anywhere:
let meter = OpenTelemetry.instance.meterProvider
  .meterBuilder(name: "checkout-app")
  .build()

var ordersCounter = meter.counterBuilder(name: "orders.processed").build()
var paymentLatency = meter.histogramBuilder(name: "payment.duration").build()

ordersCounter.add(value: 1, attributes: [
  "payment.provider": AttributeValue.string("stripe")
])
paymentLatency.record(value: 128.5, attributes: [:])
```

Observable (callback-based) gauges and up-down counters exist too, via `meter.gaugeBuilder(name:).buildWithCallback { ... }`. If metrics API churn is a concern for you today, a pragmatic alternative on the server side is to emit only traces and logs from Swift and derive rates/latencies in OpenObserve from span data.

### Logs

Logs are the least mature signal in Swift - functional, exercised in real deployments, but beta quality. Two paths exist:

- **Direct API**: obtain a `Logger` from the `LoggerProvider` and emit structured records.
- **`OTelSwiftLog` bridge**: a `swift-log` backend, so server-side code already using `Logging` forwards through OpenTelemetry unchanged.

Direct setup with the OTLP HTTP exporter:

```swift
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp

func configureLogs(resource: Resource, otlpConfig: OtlpConfiguration) {
  let logExporter = OtlpHttpLogExporter(
    endpoint: URL(string: "http://localhost:5080/api/default/v1/logs")!,
    config: otlpConfig
  )

  let loggerProvider = LoggerProviderBuilder()
    .with(processors: [BatchLogRecordProcessor(logRecordExporter: logExporter)])
    .with(resource: resource)
    .build()

  OpenTelemetry.registerLoggerProvider(loggerProvider: loggerProvider)
}

let logger = OpenTelemetry.instance.loggerProvider
  .loggerBuilder(instrumentationScopeName: "checkout-app")
  .build()

logger.logRecordBuilder()
  .setSeverity(.info)
  .setBody(AttributeValue.string("order processed"))
  .setAttributes([
    "order_id": AttributeValue.string("ord_8412"),
    "amount_cents": AttributeValue.int(4599)
  ])
  .emit()
```

If a span is active when the record is emitted, its `trace_id`/`span_id` are attached, which is what lets you pivot from a slow trace to its exact log lines in OpenObserve. Because the signal is beta, pin your versions - and if you would rather wait, os_log locally plus backend-side logs is a reasonable interim posture for mobile apps.

## Sending data to OpenObserve

OpenObserve ingests OTLP natively - no collector required in between, though routing mobile traffic through a collector (or your own backend proxy) is a sound architecture when you do not want ingestion credentials inside a shipped app binary. Two transports:

- **OTLP/HTTP** at `<host>/api/<org>/v1/traces`, `/v1/metrics`, and `/v1/logs` (port `5080` self-hosted) - what the `OtlpHttp*Exporter` classes above speak. Note that the opentelemetry-swift README marks OTLP/HTTP as **experimental**; OTLP/gRPC is the only production-ready transport. HTTP is convenient here because OpenObserve accepts both and the per-signal URL model maps cleanly to its endpoints, but treat the HTTP exporter as not-yet-stable when you weigh it against gRPC.
- **OTLP/gRPC** on port `5081` (self-hosted) via the `OpenTelemetryProtocolExporter` product (`OtlpTraceExporter` over a grpc-swift channel) - the production-ready transport in opentelemetry-swift; it additionally requires an `organization` header alongside `Authorization`.

Here is the deviation to internalize: **the Swift SDK does not implement the standard environment-variable configuration scheme.** `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, and `OTEL_TRACES_SAMPLER` are all ignored - endpoint, resource, and sampler live in code, as shown above. The single exception is `OTEL_EXPORTER_OTLP_HEADERS`, which the OTLP exporters parse (useful for server-side Swift; if you use it, remember the value format is `Authorization=Basic%20<token>` with the space percent-encoded). On iOS, environment variables are only settable through Xcode schemes in debug runs, so in-code configuration is the only real option for shipped apps.

**Self-hosted OpenObserve** - full signal URLs, org in the path, no trailing slash:

```swift
let traceEndpoint  = URL(string: "http://localhost:5080/api/default/v1/traces")!
let metricEndpoint = URL(string: "http://localhost:5080/api/default/v1/metrics")!
let logEndpoint    = URL(string: "http://localhost:5080/api/default/v1/logs")!

let otlpConfig = OtlpConfiguration(
  timeout: 10,
  headers: [("Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=")]
)
```

**OpenObserve Cloud** - same shape, your org name in the path, token from the Data Sources page:

```swift
let traceEndpoint = URL(string: "https://api.openobserve.ai/api/<your-org-name>/v1/traces")!

let otlpConfig = OtlpConfiguration(
  timeout: 10,
  headers: [("Authorization", "Basic <your-base64-token>")]
)
```

Three rules that prevent most setup failures:

1. **Use the full per-signal path.** Swift exporters do not append `/v1/traces` for you - omitting it sends protobuf to `/api/default` and gets an error, not a trace.
2. **Literal space in code, `%20` only in the env var.** In `OtlpConfiguration(headers:)` write `"Basic <token>"` normally; the percent-encoding rule applies only inside `OTEL_EXPORTER_OTLP_HEADERS`.
3. **The organization is part of the URL path.** `/api/default` targets the `default` organization; replace it with your org name in OpenObserve Cloud.

On iOS, remember App Transport Security: `http://localhost:5080` works in the simulator, but a device build talking to a plain-HTTP LAN instance needs an ATS exception - production endpoints should be HTTPS anyway. For a broader tour of exporter behavior, see [A Beginner's Guide to OTLP Exporters](/blog/otel-exporters-introduction/).

## Verifying data arrival in OpenObserve

Run the app (simulator is fine), exercise a flow that creates spans, then open the OpenObserve UI (`http://localhost:5080` self-hosted, or your cloud URL):

1. **Streams** - a traces stream (plus metrics/logs streams if you enabled them) appearing at all means ingestion works end to end.
2. **Traces** - pick `checkout-app` on the Traces page and open a trace. You should see your `process-payment` span, and if `URLSessionInstrumentation` is active, HTTP client spans for each network call - nested under the same trace as your backend's spans if the backend is instrumented too.
3. **Logs** - query the log stream and confirm records carry `trace_id` and `span_id` when emitted inside an active span.
4. **Metrics** - `orders.processed` and `payment.duration` appear as metric streams you can chart on dashboards or alert on.

Nothing after ~30 seconds? The batch processor's 5-second delay plus export time means patience for one minute, then jump to Troubleshooting. Temporarily swapping in `StdoutSpanExporter` (from the `StdoutExporter` product) or a `SimpleSpanProcessor` confirms whether spans are being *created* before you debug the network leg.

## Production tips

- **Keep `BatchSpanProcessor`, tune for mobile.** `SimpleSpanProcessor` fires one HTTP request per span - acceptable in a debug build, hostile to batteries in production. The batch processor's `scheduleDelay` (default 5 s), `maxQueueSize` (2048), and `maxExportBatchSize` (512) are constructor parameters; on mobile, a *longer* delay (10–30 s) trades latency for fewer radio wakeups.
- **Flush on app lifecycle transitions.** iOS can suspend your process seconds after backgrounding, taking queued spans with it. On `UIApplication.didEnterBackgroundNotification` (or the `scenePhase` change in SwiftUI), call `forceFlush()` on your `TracerProviderSdk` - wrapped in a `beginBackgroundTask` so the OS grants time for the upload. Server-side, call `shutdown()` on all providers before process exit.
- **Consider `PersistenceExporter` for offline sessions.** Mobile devices go offline routinely. The `PersistenceExporter` product wraps any exporter with disk buffering, so telemetry recorded in a tunnel ships when connectivity returns instead of vanishing.
- **Sample if your install base is large.** A million devices tracing every interaction is a lot of ingest. Head sampling is code-configured (no env vars, remember):

  ```swift
  let tracerProvider = TracerProviderBuilder()
    .add(spanProcessor: BatchSpanProcessor(spanExporter: traceExporter))
    .with(sampler: Samplers.parentBased(root: Samplers.traceIdRatio(ratio: 0.1)))
    .with(resource: resource)
    .build()
  ```

  `parentBased` keeps distributed traces intact by honoring upstream sampling decisions.
- **Invest in resource attributes.** `service.name`, `service.version`, and `deployment.environment` are the dimensions every OpenObserve query filters on. `DefaultResources().get()` adds device and OS attributes on Apple platforms - merge, don't replace, as shown in the traces section.
- **Mind Swift concurrency.** `setActive(true)` context does not survive `await`. In async code, pass parents explicitly or adopt `OpenTelemetryConcurrency`; otherwise you get orphaned single-span traces and wonder where your hierarchy went.
- **Don't ship root credentials in an app binary.** Anything embedded in an IPA can be extracted. Use a scoped ingestion token, or better, have mobile clients send OTLP to your own backend or an OpenTelemetry Collector that adds the OpenObserve credentials server-side.
- **Pin both packages together.** `opentelemetry-swift` and `opentelemetry-swift-core` are developed in lockstep; letting SPM float one but not the other is the fastest route to puzzling build errors, especially while metrics and logs APIs are still moving.

## Troubleshooting

**Symptom: "No such module 'OpenTelemetryProtocolExporterHTTP'" at build time.**
Fix: the import name differs from the product name. Depend on the product `OpenTelemetryProtocolExporterHTTP` in Package.swift, but write `import OpenTelemetryProtocolExporterHttp` in source. Also confirm both `opentelemetry-swift` and `opentelemetry-swift-core` are declared - `OpenTelemetryApi`/`OpenTelemetrySdk` come from core, exporters from the main package.

**Symptom: exports return 404 or "stream not found" and nothing appears in OpenObserve.**
Fix: the endpoint URL is wrong. Swift exporters need the *full* signal path - `http://localhost:5080/api/default/v1/traces`, not `http://localhost:5080` or `.../api/default`. Check for a trailing slash and confirm the org segment matches an organization that actually exists in your instance.

**Symptom: `401 Unauthorized` on every export.**
Fix: regenerate the token with `echo -n 'email:password' | base64` (the `-n` matters - a trailing newline corrupts it) and pass it via `OtlpConfiguration(headers: [("Authorization", "Basic <token>")])` with a literal space. If you used `OTEL_EXPORTER_OTLP_HEADERS` on a server, the value there must be `Authorization=Basic%20<token>` instead.

**Symptom: spans are created (visible via StdoutExporter) but never arrive from a real device.**
Fix: usually ATS blocking cleartext HTTP to a non-localhost host, or the app being suspended before the batch exported. Use HTTPS for device builds, add `forceFlush()` on backgrounding, and consider `PersistenceExporter` so interrupted batches retry on next launch.

**Symptom: traces from async/await code show up as disconnected one-span fragments.**
Fix: `os.activity`-based context does not propagate across suspension points. Set parents explicitly with `spanBuilder(...).setParent(parentSpan)` or use the `OpenTelemetryConcurrency` module's task-local context so child spans in `Task` blocks attach to the right trace.

**Symptom: app-to-backend traces don't join - the iOS spans and the server spans are separate traces.**
Fix: context isn't crossing the wire. Confirm `URLSessionInstrumentation` is initialized (and its `shouldInstrument` filter isn't excluding your API host), that the backend's OpenTelemetry setup uses the W3C Trace Context propagator, and that no proxy between them strips the `traceparent` header.

Wire the SDK up once at launch, let `URLSessionInstrumentation` cover the network layer, and point the exporters at OpenObserve: your Swift app's traces land in the same backend as your servers' telemetry - one distributed trace from tap to database and back, with no vendor-specific code in the app.
