MVP Factory
ai startup development

OTel Android: Distributed Tracing in Compose Navigation

KW
Krystian Wiewiór · · 4 min read

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.

ApproachMonthly cost @ 10M eventsSDK sizeVendor lock-in
Datadog Mobile RUM~$180–$4006.2 MBHigh
Firebase PerformanceFree (sampled)1.1 MBHigh
New Relic Mobile~$200–$3505.8 MBHigh
OTel SDK + Grafana Tempo~$20–$500.8 MBNone

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

  1. Instrument the NavController, not individual screens. addOnDestinationChangedListener is a single integration point that covers your entire nav graph — composable-level hooks create fragile coupling and duplicate span overhead.

  2. 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.

  3. 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.


Share: Twitter LinkedIn