OpenTelemetry in Kotlin Multiplatform: Tracing Across Android and iOS
By Gemma Lara Savill
Published at March 7, 2026
A while ago I wrote about getting started with OpenTelemetry on Android.
Since then I’ve had the opportunity to work with observability tooling more deeply in mobile apps, and it has made me appreciate the unique challenges of instrumenting code on platforms where battery life, network stability, and main-thread performance are always top of mind.
Recently I decided to revisit the topic with a small experiment.
This time, instead of instrumenting a regular Android app, I wanted to see what it would look like inside a Kotlin Multiplatform project.
The project I used for this experiment is headline-duel-kmp, the Kotlin Multiplatform version of my Headline Duel game. It’s powered by a DistilBERT model that I fine-tuned using PEFT (LoRA) to classify news into categories like World, Sports, and Business. It’s a perfect candidate for tracing because it involves a real-world flow: a user interaction, a network request to a model hosted on Hugging Face, and a classification result.
Why instrument a small project?
Observability is often associated with large systems, but even tiny applications benefit from visibility.
In this case I wanted to answer questions like:
- How long does classification take?
- What attributes might be useful to capture?
- How could instrumentation be structured in shared code?
More importantly, I wanted to explore how to do this without hurting the mobile user experience. Mobile instrumentation must be careful about startup time, main thread blocking, and battery impact. These constraints shape how telemetry SDKs are designed.
A tiny tracing abstraction
Rather than scattering telemetry calls everywhere, I started with a very small abstraction in commonMain.
The syntax allows us to start a span and automatically handle its lifecycle while providing a handle to attach domain-specific data:
trace(“headline_classification”) { span ->
span.setAttribute("headline_length", text.length.toLong())
val result = classifier.classify(headline)
span.setAttribute("classification_result", result.label)
span.setAttribute("confidence_score", (result.score * 100).toLong())
result
}
The idea is simple:
- Start a span
- Execute the block
- Attach attributes if needed
- Finish the span automatically when the block completes
This keeps the instrumentation readable and easy to remove if needed.
Platform-specific implementations
For this experiment, I wanted to ensure telemetry was functional on both Android and iOS.
On Android, I used the official OpenTelemetry SDK. I configured it with a LoggingSpanExporter, which means traces appear directly in the Android logs (Logcat). This is perfect for local testing and verifying that the spans are being generated correctly without needing an external collector.
On iOS, I implemented a native tracer that uses Foundation APIs to calculate durations and print debug information to the console. This demonstrates how KMP allows us to bridge to platform-specific logic while maintaining a unified API in our shared code—providing a lightweight foundation that can be easily evolved into a full wrapper for the official OpenTelemetry Swift SDK.
Example output from the logs:
Span: headline_classification
Attributes: headline_length=42 classification_result=engaged confidence_score=85
Duration=120ms
In a real production system, these spans would not be printed to logs. Instead, they would be exported using the OTLP protocol to an observability pipeline.
Adding a real exporter
In this experiment I exported spans to the logs to keep things simple, local, and easy to verify for this post.
In a production setup, telemetry would usually be sent to a collector using the OTLP protocol from OpenTelemetry. From there the data can be processed and forwarded to an observability backend such as Grafana or a tracing system like Grafana Tempo.
Conceptually the pipeline would look like this:
Mobile App
↓
OpenTelemetry SDK
↓
OTLP exporter
↓
OpenTelemetry Collector
↓
Tracing backend
Configuring this mainly involves replacing the console/log exporter with an OTLP exporter and pointing it to a collector endpoint.
For example, on Android:
val exporter = OtlpGrpcSpanExporter.builder()
.setEndpoint(“http://localhost:4317”)
.build()
The endpoint typically points to an instance of the OpenTelemetry Collector, which acts as a gateway for telemetry data. The collector can then batch, filter, or enrich spans before sending them to a tracing backend.
Mobile constraints
When instrumenting mobile applications the biggest challenge is not collecting telemetry but doing so without harming the user experience.
A few design considerations stood out during this experiment:
Avoid main-thread work
Span export should never block the UI thread. Even small delays can affect rendering performance. In the Android implementation, I used the SDK's built-in support for background processing and lazy initialization.
Keep initialization lazy
Telemetry systems should not slow down application startup. I used two techniques to ensure this:
- Compose Lifecycle: In the UI layer, I wrapped the telemetry initialization inside a
LaunchedEffect(Unit). This ensures the SDK setup happens asynchronously after the first composition, keeping the main thread free to render the initial screen as quickly as possible. - Lazy Delegation & No-Op: The platform-specific SDKs are configured using Kotlin's
by lazydelegate, meaning the heavy lifting only happens when the first span is actually started. By using aNoOpTraceras the default, the app remains stable and performant even if a tracing call is triggered before the SDK is fully "plugged in." This approach also provides a foundation for more advanced patterns, such as temporarily buffering early-lifecycle events and replaying them once the platform-specific tracer is ready to take over.
Prefer batching
Production-ready SDKs typically buffer spans and export them in batches to reduce network usage and battery consumption. While our "log exporter" approach doesn't require this, it's a key part of any real OTLP-based setup.
Why this was interesting in KMP
One thing I found particularly interesting was deciding where the instrumentation should live. Putting tracing helpers in shared code keeps the API consistent, while platform-specific implementations handle the actual SDK configuration or native bridging. This separation works beautifully with Kotlin Multiplatform’s structure, allowing each platform to handle telemetry in the most efficient way possible.
Final thoughts
This was a small experiment, but it reminded me how much thought goes into the design of good instrumentation libraries. Capturing useful telemetry is only part of the challenge. Doing so in a way that is lightweight, unobtrusive, and pleasant for developers to use is where the real work happens.
If you’re curious, the code for this experiment is available here: https://github.com/GemmaLaraSavill/headline-duel-kmp
And if you want to learn more about getting started with mobile observability, my earlier post on Android instrumentation might also be helpful.