DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

OpenTelemetry-Native Android: Wiring OTel SDK to Compose Navigation for Distributed Trace Propagation Without the APM Tax

---
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
---
Enter fullscreen mode Exit fullscreen mode

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) }
    }
}
Enter fullscreen mode Exit fullscreen mode

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) }
                    )
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. One ObserveNavTracing composable at your nav root
  2. One Ktor plugin for automatic W3C header injection
  3. Baggage set once at login — session context flows everywhere
  4. ParentBasedSampler at 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)