OTel Android: Distributed Tracing in Compose Navigation
Meta description: Wire OpenTelemetry’s Android SDK to Jetpack Compose Navigation for distributed trace propagation, W3C trace-context in Ktor headers, and a sampling strategy under $50/month at 10M events.
Tags: android jetpackcompose mobile architecture backend
TL;DR
You don’t need Datadog, New Relic, or Dynatrace to get production-grade distributed tracing on Android. OpenTelemetry’s Android SDK, paired with Jetpack Compose Navigation hooks and a Ktor interceptor, gives you end-to-end trace propagation from screen transition to backend span — for the cost of a self-hosted Grafana Tempo instance. At 10M events per month, that bill stays under $50.
The problem with APM on mobile
In my experience building production systems, the first instinct when adding observability to Android is to drop in a vendor SDK. That decision quietly costs you three things: binary size (~4–8 MB per SDK), opaque data contracts locked to a proprietary backend, and a per-seat or per-event bill that compounds aggressively past the free tier.
The numbers make the case.
| Approach | Monthly cost @ 10M events | SDK size | Vendor lock-in |
|---|---|---|---|
| Datadog Mobile RUM | ~$180–$400 | 6.2 MB | High |
| Firebase Performance | Free (sampled) | 1.1 MB | High |
| New Relic Mobile | ~$200–$350 | 5.8 MB | High |
| OTel SDK + Grafana Tempo | ~$20–$50 | 0.8 MB | None |
OpenTelemetry is the answer. Here’s how it fits together.
Wiring OTel to Compose Navigation
The entry point is NavController.addOnDestinationChangedListener. Every screen transition fires this callback — it’s the right place to open a span.
@Composable
fun ObserveNavTracing(navController: NavController, tracer: Tracer) {
DisposableEffect(navController) {
val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
val span = tracer.spanBuilder("nav.transition")
.setAttribute("screen.route", destination.route ?: "unknown")
.setAttribute("screen.id", destination.id.toString())
.startSpan()
// Store in local state or scope — close on next transition
NavTraceRegistry.set(destination.id, span)
}
navController.addOnDestinationChangedListener(listener)
onDispose { navController.removeOnDestinationChangedListener(listener) }
}
}
This gives you a span per screen. The next step is propagating that trace context outbound — otherwise you have disconnected islands of telemetry.
W3C trace-context in Ktor headers
Here’s what most teams get wrong: they instrument the client or the server, never bridging the two. W3C traceparent headers are the contract that stitches them together.
class OtelTracingPlugin(private val tracer: Tracer) {
companion object : HttpClientPlugin<Unit, OtelTracingPlugin> {
override fun install(plugin: OtelTracingPlugin, scope: HttpClient) {
scope.sendPipeline.intercept(HttpSendPipeline.State) {
val activeSpan = Span.current()
if (activeSpan.spanContext.isValid) {
val propagator = GlobalOpenTelemetry.getPropagators().textMapPropagator
propagator.inject(
Context.current(),
context.request.headers,
{ carrier, key, value -> carrier.append(key, value) }
)
}
}
}
}
}
Install this plugin on your Ktor client and every HTTP call made during an active navigation span carries traceparent and tracestate headers. Your backend — whether Spring Boot, Ktor server, or a Go service — picks these up automatically if it’s OTel-instrumented, and Tempo reconstructs the full waterfall.
Baggage for session correlation
Spans are ephemeral. User session context — user ID, plan tier, experiment cohort — needs to travel across the entire trace tree without being hardcoded onto every span attribute. That’s what W3C Baggage is for.
fun attachSessionBaggage(userId: String, sessionId: String): Context {
return Context.current()
.with(Baggage.builder()
.put("user.id", userId)
.put("session.id", sessionId)
.build())
}
Set this once at login and propagate it through withContext(otelContext.asContextElement()) in your coroutines. Every child span and outbound HTTP call inherits it automatically.
Sampling strategy: keeping Tempo under $50/month
At 10M raw events, 100% sampling is cost-prohibitive even on self-hosted infrastructure — storage and query performance degrade fast. A parent-based probabilistic sampler at 5% for healthy traces, with 100% for errors and slow spans, gives you the coverage you actually need.
val sampler = ParentBasedSampler.builder(
TraceIdRatioBased(0.05) // 5% baseline
)
.setRemoteParentSampled(AlwaysOnSampler.getInstance()) // honor upstream decisions
.build()
// Override: always sample on error or latency > 2s via custom SamplerWrapper
With this configuration, a 10M event month collapses to roughly 500K–700K stored spans. On Grafana Cloud’s pay-as-you-go tier, that lands between $20–$45 depending on trace size.
What to take away
-
Instrument the
NavController, not individual screens.addOnDestinationChangedListeneris a single integration point that covers your entire nav graph — composable-level hooks create fragile coupling and duplicate span overhead. -
Propagate W3C headers at the HTTP client layer, not the call site. A Ktor plugin intercepts once and handles all outbound requests. Manually injecting headers per API call is an audit failure waiting to happen.
-
Tune your sampler before you ship. A 5% baseline with error/latency overrides isn’t a compromise — it’s what Google and Uber run in production. Start there, validate your Tempo dashboards, then adjust the ratio based on actual query patterns, not gut feel.