---
title: "OTel Android: Distributed Tracing in Compose Navigation Without the APM Tax"
published: true
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, kotlin, mobile, architecture
canonical_url: https://mvpfactory.co/blog/otel-android-compose-navigation
---
What We Are Building
Today you will wire OpenTelemetry's Android SDK to Jetpack Compose Navigation. By the end, you will have end-to-end distributed trace propagation — from screen transition to backend span — for roughly $20–$50/month on self-hosted Grafana Tempo at 10M events.
No Datadog. No New Relic. No vendor lock-in. Here is why that matters:
| Approach | Monthly @ 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 |
Prerequisites
- Jetpack Compose project with Navigation component
- Ktor HTTP client
- OpenTelemetry Android SDK on your dependencies
- A Grafana Tempo instance (self-hosted or Grafana Cloud)
Step 1 — Instrument the NavController, Not Individual Screens
Let me show you a pattern I use in every project. NavController.addOnDestinationChangedListener fires on every screen transition — it is your single integration point for the entire nav graph.
@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()
NavTraceRegistry.set(destination.id, span)
}
navController.addOnDestinationChangedListener(listener)
onDispose { navController.removeOnDestinationChangedListener(listener) }
}
}
Call ObserveNavTracing once at your navigation root. That is it — one span per screen, zero per-screen wiring.
Step 2 — Propagate W3C trace-context Through Ktor
Here is the gotcha that will save you hours: most teams instrument the client or the server, never bridging the two. W3C traceparent headers are the contract that stitches them together. Wire this at the Ktor plugin level — never at the call site.
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 once, and every HTTP call made during an active navigation span carries traceparent and tracestate. Your OTel-instrumented backend — Spring Boot, Ktor server, Go — reconstructs the full waterfall in Tempo automatically.
Step 3 — Attach Session Baggage at Login
Spans are ephemeral. User ID, plan tier, experiment cohort — that context needs to travel the full trace tree. That is 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, propagate via withContext(otelContext.asContextElement()) in your coroutines, and every child span inherits it without any extra wiring.
Step 4 — Tune Your Sampler Before You Ship
The docs do not stress this enough. At 10M raw events, 100% sampling degrades both storage and Tempo query performance fast. A parent-based probabilistic sampler at 5% baseline, with 100% for errors and slow spans, is what Google and Uber run in production.
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
This collapses 10M events to roughly 500K–700K stored spans. On Grafana Cloud pay-as-you-go, that lands between $20–$45/month.
Gotchas
Do not hook at the composable level. Per-screen span instrumentation creates fragile coupling and duplicate overhead. addOnDestinationChangedListener covers your full nav graph from one place — composable-level hooks undo that immediately.
Do not inject trace headers per API call. One Ktor plugin intercepts all outbound requests. Manually injecting per call site is an audit failure waiting to happen — you will miss calls under coroutine context switches.
Do not ship at 100% sampling. Validate your Tempo dashboards at 5% first. Adjust the ratio based on actual query patterns, not gut feel.
Wrapping Up
Here is the minimal setup that gets you production-grade distributed tracing on Android without a vendor bill that compounds past the free tier:
- One
ObserveNavTracingcomposable at your nav root - One Ktor plugin for automatic W3C header injection
- Baggage set once at login — session context flows everywhere
-
ParentBasedSamplerat 5% baseline — Tempo stays under $50/month
OpenTelemetry Android SDK docs: opentelemetry.io/docs/android. Grafana Tempo setup: grafana.com/docs/tempo.
Top comments (0)