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.
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 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 over OTLP.
If you are new to OpenTelemetry itself — the signals, the collector, OTLP — start with What is OpenTelemetry? A Complete Guide 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
URLSessionInstrumentationinjects W3Ctraceparentheaders 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 account or a self-hosted instance. For local testing, a single container is enough:
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:
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 swizzlesURLSessionso every network request produces a client span with HTTP semantic-convention attributes and an injectedtraceparentheader. 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.versionfrom 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 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):
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.
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:
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:
setActive(true)usesos.activityfor context propagation, which follows threads and dispatch queues — but it does not crossasync/awaitsuspension points reliably. Inasynccode, either pass the parent span explicitly with.setParent(span), or add theOpenTelemetryConcurrencyproduct fromopentelemetry-swift-core, which provides task-local context that works with structured concurrency.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:
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.
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
Loggerfrom theLoggerProviderand emit structured records. OTelSwiftLogbridge: aswift-logbackend, so server-side code already usingLoggingforwards through OpenTelemetry unchanged.
Direct setup with the OTLP HTTP exporter:
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(port5080self-hosted) — what theOtlpHttp*Exporterclasses 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 theOpenTelemetryProtocolExporterproduct (OtlpTraceExporterover a grpc-swift channel) — the production-ready transport in opentelemetry-swift; it additionally requires anorganizationheader alongsideAuthorization.
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:
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:
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:
- Use the full per-signal path. Swift exporters do not append
/v1/tracesfor you — omitting it sends protobuf to/api/defaultand gets an error, not a trace. - Literal space in code,
%20only in the env var. InOtlpConfiguration(headers:)write"Basic <token>"normally; the percent-encoding rule applies only insideOTEL_EXPORTER_OTLP_HEADERS. - The organization is part of the URL path.
/api/defaulttargets thedefaultorganization; 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.
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):
- Streams — a traces stream (plus metrics/logs streams if you enabled them) appearing at all means ingestion works end to end.
- Traces — pick
checkout-appon the Traces page and open a trace. You should see yourprocess-paymentspan, and ifURLSessionInstrumentationis active, HTTP client spans for each network call — nested under the same trace as your backend’s spans if the backend is instrumented too. - Logs — query the log stream and confirm records carry
trace_idandspan_idwhen emitted inside an active span. - Metrics —
orders.processedandpayment.durationappear 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.SimpleSpanProcessorfires one HTTP request per span — acceptable in a debug build, hostile to batteries in production. The batch processor’sscheduleDelay(default 5 s),maxQueueSize(2048), andmaxExportBatchSize(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 thescenePhasechange in SwiftUI), callforceFlush()on yourTracerProviderSdk— wrapped in abeginBackgroundTaskso the OS grants time for the upload. Server-side, callshutdown()on all providers before process exit. -
Consider
PersistenceExporterfor offline sessions. Mobile devices go offline routinely. ThePersistenceExporterproduct 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):
let tracerProvider = TracerProviderBuilder() .add(spanProcessor: BatchSpanProcessor(spanExporter: traceExporter)) .with(sampler: Samplers.parentBased(root: Samplers.traceIdRatio(ratio: 0.1))) .with(resource: resource) .build()parentBasedkeeps distributed traces intact by honoring upstream sampling decisions. -
Invest in resource attributes.
service.name,service.version, anddeployment.environmentare 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 surviveawait. In async code, pass parents explicitly or adoptOpenTelemetryConcurrency; 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-swiftandopentelemetry-swift-coreare 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.
Frequently asked questions
Is OpenTelemetry for Swift production-ready?
Traces are stable and safe for production — the tracing API and SDK have been stable for years and are widely used in iOS apps and server-side Swift. Metrics and logs are less mature: both are officially in development status, meaning the APIs work but can change between releases. Many teams ship traces from Swift today and keep metrics and logs pinned to specific versions or handled by other means until those signals stabilize.
Does Swift have automatic (zero-code) OpenTelemetry instrumentation?
No. There is no runtime agent for Swift the way there is for Java or Python — you initialize the SDK explicitly in code, typically at app launch or server startup. The closest thing to automatic instrumentation is the URLSessionInstrumentation module, which hooks URLSession so every network request produces a client span and propagates trace context downstream without changes to your networking code.
What endpoint do I use to send OTLP data from Swift to OpenObserve?
Unlike most OpenTelemetry SDKs, the Swift OTLP HTTP exporters take the full per-signal URL, not a base URL. For self-hosted OpenObserve use http://localhost:5080/api/default/v1/traces (and /v1/metrics, /v1/logs), and for OpenObserve Cloud use https://api.openobserve.ai/api/your-org-name/v1/traces. The organization name is part of the URL path, and there must be no trailing slash.
How do I authenticate Swift OTLP exports to OpenObserve?
OpenObserve uses HTTP Basic authentication. Base64-encode email:password (or an ingestion token), then pass it as an Authorization header through OtlpConfiguration's headers parameter, for example headers: [("Authorization", "Basic <base64-token>")] with a literal space after Basic. The %20 percent-encoding rule only applies if you set the OTEL_EXPORTER_OTLP_HEADERS environment variable instead, which the Swift exporters also honor.
Does opentelemetry-swift read standard OTEL_* environment variables?
Mostly no, and this trips up people coming from other SDKs. The Swift SDK does not read OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, or the sampler variables — endpoints, resources, and samplers are all configured in code. The one exception is OTEL_EXPORTER_OTLP_HEADERS, which the OTLP exporters do parse. On iOS this matters little anyway, since environment variables are only practical in Xcode debug schemes, not in App Store builds.
Can I use OpenTelemetry in an iOS app submitted to the App Store?
Yes. opentelemetry-swift is a normal Swift package with no private API usage, and it supports iOS, macOS, tvOS, watchOS, and visionOS. The practical concerns are operational, not policy: batch and flush telemetry around app lifecycle events so backgrounding does not drop spans, sample if your user base is large, and consider the PersistenceExporter so telemetry from offline sessions is retried later instead of lost.
Keep reading
Ship your OpenTelemetry data somewhere better
OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.