Debugging, Code Quality and Performance

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Shipping TrailMate is not the end of the work: it has to keep working correctly and quickly on devices the team never tests directly, and regressions have to be caught before users report them. This page covers the tools for that in three groups: debugging (finding out why something is wrong, right now, on a connected device or emulator), code quality (catching mistakes and inconsistencies automatically, before they are ever run), and profiling and performance (measuring where CPU, memory and frame time actually go, and preventing regressions). It closes with production monitoring — the same signals, aggregated from real users, once the app has shipped. The baseline is the same one used throughout this section: compileSdk = 37, targetSdk = 36, minSdk = 26, Android Studio Quail. Compose-specific performance work — stability, @Stable/@Immutable, strong skipping and how to read a composable’s recomposition count in context — is covered in depth on State, Recomposition and Side Effects in Compose; this page introduces the Layout Inspector’s recomposition counts as a debugging tool and cross-links there for the underlying model. Testing (unit and instrumented) is covered on Unit Testing with JUnit, Mockito, MockK and Robolectric and Instrumented and UI Testing (Espresso, Compose Testing, UI Automator); R8’s role in signing and packaging a release build is covered on Building, Signing and Preparing a Release, and R8’s role in reducing attack surface is covered on Permissions, Privacy and Security. This page focuses on R8 purely as a performance and app-size optimization.

Debugging

The Android Studio Debugger

Android Studio embeds a standard JVM debugger (the same one IntelliJ uses for any Kotlin/Java project), attached to the app process running on a device or emulator. Setting a breakpoint by clicking a line’s gutter, then running the app in debug mode, suspends execution when that line is reached and exposes the usual tools: a call stack, variable inspection (including TrailMate’s own types, such as a suspended TrailListUiState.Success showing its trails list), watches, and stepping (into/over/out). Two features matter specifically for Kotlin coroutines, which dominate TrailMate’s codebase:

  • Coroutine debugger. The Debug tool window’s Coroutines tab lists every coroutine’s state (running, suspended, created) grouped by dispatcher and job hierarchy, which a plain thread view cannot show because many coroutines share the same thread pool. Stepping through a suspend function such as OfflineFirstTrailRepository.refreshTrails follows the logical coroutine, not the thread it happens to resume on.

  • Conditional and logging breakpoints. A breakpoint can carry a condition expression (trailId == "t-42") so it only suspends for one item in a loop over trails, or be turned into a non-suspending "logpoint" that prints an expression to Logcat without stopping execution — useful when stepping through TrailListViewModel.uiState construction would otherwise interfere with the WhileSubscribed(5_000) timing being investigated.

Logcat

Logcat is the system-wide log stream: every Log.d/Log.w/Log.e call, plus framework and system messages (activity lifecycle transitions, ANR reports, uncaught exceptions with full stack traces), tagged by process, priority level and tag string. Android Studio’s Logcat window filters by package (scoped to TrailMate’s process automatically), by tag, by priority, and by a search query; saved filters let a session jump straight to, for example, tag:TrailSyncWorker level:WARN while debugging a sync failure.

// core/data/src/main/kotlin/com/example/trailmate/core/data/sync/TrailSyncWorker.kt
class TrailSyncWorker(
    context: Context,
    params: WorkerParameters,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            trailRepository.refreshTrails()
            Result.success()
        } catch (e: IOException) {
            Log.w(TAG, "Trail sync failed, will retry", e)
            Result.retry()
        }
    }

    companion object {
        private const val TAG = "TrailSyncWorker"
    }
}

See Android Developers — Write and view logs with Logcat and Android Developers — Android Debug Bridge (adb) (adb logcat is the command-line equivalent, useful in CI and shell scripts). A common mistake is leaving verbose Log.d calls with string concatenation in a hot path such as a RecyclerView.onBindViewHolder or a Compose recomposition scope — even when the log level is filtered out on screen, the string is still built on every call unless guarded or removed. Never log tokens, passwords or full user PII to Logcat; on a release build, strip debug/verbose logging with R8 rules (see R8 App Optimization) rather than relying on log level alone.

Layout Inspector

The Layout Inspector attaches to a running app and renders a live, inspectable view of what is actually on screen — not what the source code says should be there. For a legacy TrailListFragment screen it shows the full View hierarchy in 3D, with each view’s resolved layout params, padding and resource IDs, which catches issues like an unexpected ConstraintLayout bias or a view that is present but has zero size. For Compose, it shows the composition tree (TrailListScreen → LazyColumn → each TrailListItem call site) with each composable’s resolved parameter values — the actual Trail passed to a given TrailListItem, not just its declared type.

Two Compose-specific features make it central to debugging performance problems on this page:

  • Recomposition counts. Enabling Show Recomposition Counts annotates each composable in the tree with how many times it recomposed and skipped since the inspector attached. A TrailListItem count climbing on every keystroke in an unrelated search field is the first, fastest signal of an over-broad state read; the why — unstable parameter types, a lambda capturing more than it needs, missing @Stable/@Immutable — is explained on State, Recomposition and Side Effects in Compose.

  • System UI and view attribute inspection, including live editing of some attributes for quick experiments without a rebuild.

App Inspection

The App Inspection tool window groups several live inspectors that attach to a debuggable process without any extra code, backed by the AndroidX Inspection libraries:

Inspector What it shows

Database Inspector

A live, queryable view of TrailMate’s Room database (TrailMateDatabase) while the app runs — browse TrailEntity/PhotoEntity rows, run ad-hoc SQL against them, and see writes reflected immediately, without pulling the .db file off the device.

Network Inspector

Every OkHttp/Retrofit (and Ktor-over-OkHttp) request TrailMate’s TrailApi makes — method, URL, headers, request/response bodies, timing and status code — captured directly from the OkHttp engine with no manual interceptor required.

Background Task Inspector

The state and history of every WorkManager job, including TrailSyncWorker’s scheduling constraints, retries and execution graph — whether it ran, why it was deferred (network/battery constraints unmet) and what `Result it returned.

StrictMode

StrictMode is a developer-only guard that detects accidental main-thread I/O and resource leaks by throwing or logging when a violation happens, rather than letting it silently degrade frame time. It is wired up once, in TrailMateApplication.onCreate, guarded so it never ships to production:

// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
@HiltAndroidApp
class TrailMateApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .build(),
            )
            StrictMode.setVmPolicy(
                StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    .detectLeakedClosableObjects()
                    .penaltyLog()
                    .build(),
            )
        }
    }
}

See Android Developers — StrictMode. A ThreadPolicy violation logged here — for example a SharedPreferences read from TrailListFragment.onCreate before DataStore fully replaced it — almost always maps to real jank on lower-end devices even if it is not visible on a fast development phone.

LeakCanary

LeakCanary is a memory-leak detection library (Square) added only to the debugImplementation configuration, so it never ships in a release build. It hooks Activity/Fragment/ViewModel destruction, waits for garbage collection, and if an instance that should have been collected is still reachable, walks the reference chain and shows it as a notification with the full path to the GC root — for example a TrailDetailFragment retained because RecordHikeService held an anonymous listener referencing its view.

// build.gradle.kts (:app)
dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

See Android Developers — Reduce memory leaks with LeakCanary and LeakCanary documentation. No manual wiring is required beyond the dependency; LeakCanary installs itself via a ContentProvider at app startup. The most common source of a TrailMate leak is a Job/Flow collection started with a lifecycle-unaware CoroutineScope, or a long-lived singleton (a Hilt @Singleton repository) holding a reference to something scoped to an activity or fragment — both are covered from the coroutine-scoping side on ViewModel, Lifecycle-Aware Components, Coroutines and Flow.

Chucker

Chucker is an in-app HTTP inspector: an OkHttp interceptor that captures every request/response TrailMate’s TrailApi makes and surfaces them through a persistent notification and a small in-app UI, browsable directly on the device — useful for inspecting traffic on a physical device with no Android Studio attached, or for handing a QA build to someone outside the team without exposing Android Studio’s Network Inspector.

// build.gradle.kts (:core:network)
dependencies {
    debugImplementation("com.chuckerteam.chucker:library:4.2.0")
    releaseImplementation("com.chuckerteam.chucker:library-no-op:4.2.0")
}

// core/network/src/main/kotlin/com/example/trailmate/core/network/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(ChuckerInterceptor.Builder(context).build())
            .build()
}

See Chucker on GitHub. The library-no-op artifact is a drop-in, no-behavior substitute used for releaseImplementation, so the interceptor call above compiles in release builds without ever recording traffic there. Chucker and the Network Inspector solve the same problem from different angles — Chucker needs an explicit interceptor and works without Android Studio; the Network Inspector needs Android Studio and no code changes.

Code Quality

Automated code-quality checks catch a class of bugs and inconsistencies that tests do not: unused resources, suspicious API usage flagged by the platform itself, and style drift across a growing team. TrailMate wires three tools into its Gradle build so violations surface in every local build and in CI, not only when someone remembers to run them by hand.

Android Lint

Android Lint ships with the Android Gradle Plugin and analyzes both Kotlin/Java source and Android-specific files (manifest, resources, Gradle files) for problems a generic Kotlin compiler cannot see: a missing permission declaration, an unused resource, a hardcoded string that should be externalized for localization, an ImageView missing a contentDescription, a call into an API level higher than minSdk without a guard, or an overload that is deprecated in favor of a newer one. Each check has a severity (error, warning, informational) and can be tuned per-project:

// app/build.gradle.kts
android {
    lint {
        warningsAsErrors = false
        checkReleaseBuilds = true
        disable += setOf("MissingTranslation")
        baseline = file("lint-baseline.xml")
    }
}

Run it with ./gradlew lint (or lintDebug/lintRelease for a single variant); it produces an HTML and XML report under app/build/reports/lint-results-<variant>.html. baseline.xml freezes today’s existing findings so CI only fails on new ones — essential when adopting Lint on a codebase that already has a backlog, which keeps the check useful without requiring an unrelated cleanup before it can be enabled.

ktlint

ktlint enforces the official Kotlin style guide (kotlinlang.org’s coding conventions) as a formatter and linter: indentation, import ordering, trailing commas, blank-line rules. Because it has effectively no configuration surface by design, it removes style debates from code review entirely — either the formatter’s output matches what is checked in, or it does not. TrailMate applies it through the community Gradle plugin:

// build.gradle.kts (root)
plugins {
    id("org.jlleitschuh.gradle.ktlint") version "13.1.0"
}

./gradlew ktlintCheck fails the build on a violation; ./gradlew ktlintFormat rewrites files in place to match. Wiring ktlintFormat as a pre-commit hook (or ktlintCheck as a required CI step) keeps formatting arguments out of pull request reviews, since a diff that only reflects a formatter’s output rarely needs discussion. See ktlint documentation and Kotlin coding conventions.

detekt

detekt is a static-analysis tool for Kotlin that goes beyond formatting into code smells and complexity: long methods, deeply nested conditionals, magic numbers, !! non-null assertions, unused private members, and custom rules a team defines for its own conventions (for example, forbidding direct Context injection into a ViewModel). Findings are grouped into rule sets with configurable severities, and a detekt.yml file tunes which rules apply and their thresholds:

# config/detekt/detekt.yml
complexity:
  LongMethod:
    threshold: 40
  TooManyFunctions:
    active: false
style:
  ForbiddenComment:
    comments:
      - reason: 'Forbidden TODO marker, please fix or file a ticket.'
        value: 'TODO:'
// build.gradle.kts (root)
plugins {
    id("io.gitlab.arturbosch.detekt") version "1.23.8"
}

detekt {
    config.setFrom(file("config/detekt/detekt.yml"))
    buildUponDefaultConfig = true
}

./gradlew detekt runs the analysis and writes a baseline the same way Lint does (detektBaseline/baseline.setFrom(…​)), for the same reason: adopt it on an existing codebase without a mandatory full cleanup first. detekt also ships a Compose rule set (detekt-compose) that flags Compose-specific smells such as a composable that is not named in PascalCase, or Modifier not accepted as the first optional parameter. See detekt documentation. ktlint and detekt are complementary, not overlapping: ktlint governs how code is formatted, detekt governs how it is structured.

Profiling and Performance

Android Studio Profiler

The Profiler window attaches to a running (debuggable or profileable) process and gives real-time graphs for CPU, memory, and network use, each drillable into a dedicated recording:

  • CPU Profiler. Records a System Trace (system-wide, backed by Perfetto — see below), a Callstack Sample (periodic stack sampling, low overhead, statistical), or Java/Kotlin Method Trace (full instrumentation, exact call counts and timings, higher overhead). For diagnosing why TrailListViewModel’s `combine block is slow to produce its first Success state, a System Trace correlates CPU work with the exact frame timeline, while a Method Trace gives exact time spent inside OfflineFirstTrailRepository.refreshTrails versus TrailDao query execution.

  • Memory Profiler. Shows live allocated memory over time, and can trigger a heap dump — a full snapshot of every object on the Kotlin/Java heap at that instant, browsable by class, with retained size and the shortest path to a GC root for any instance. A rising sawtooth that never returns to baseline after repeated navigation into and out of TrailDetailScreen is the classic signature of a leak that LeakCanary would also catch automatically, but the Memory Profiler lets the count and retained size be inspected interactively for cases LeakCanary’s heuristics do not flag.

  • Network Profiler. A lighter-weight, timeline view of TrailMate’s HTTP traffic; the Network Inspector (App Inspection, above) is preferred for actually reading request/response bodies.

Perfetto and System Traces

Perfetto is the underlying system-tracing infrastructure the CPU Profiler’s System Trace mode is built on, and it also runs standalone (https://ui.perfetto.dev, or adb shell perfetto) to capture a trace outside Android Studio — for example from a CI-run Macrobenchmark, or a field device. A trace captures every thread’s scheduling state, binder transactions, and any custom trace sections an app adds with androidx.tracing.Trace.beginSection/ endSection, all on one zoomable timeline, which is what makes it possible to see, in one view, that a dropped frame in TrailListScreen coincided with a TrailDao query running on the main thread rather than a background dispatcher.

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
import androidx.tracing.traceAsync

private var loadTrailsCookie = 0

fun loadTrails() {
    viewModelScope.launch {
        // refreshTrails() suspends and may resume on another thread, so use an async section
        traceAsync("TrailListViewModel#loadTrails", cookie = loadTrailsCookie++) {
            trailRepository.refreshTrails()
        }
    }
}

See Android Developers — Overview of app tracing and Android Developers — Add custom trace events. androidx.tracing:tracing (which since 1.3.0 also contains the former tracing-ktx helpers) provides two block wrappers over the beginSection/endSection pair. trace("label") { } opens a synchronous section, which must begin and end on the same thread, so it only suits a block that doesn’t suspend. A suspend call such as refreshTrails() can suspend on one thread and resume on another (for example after a withContext(Dispatchers.IO)), which would close the section on the wrong thread and corrupt the trace. The suspend overload traceAsync(methodName, cookie) { } records an async section via Trace.beginAsyncSection/endAsyncSection instead, which isn’t tied to a thread; the cookie must be unique among overlapping sections with the same name, which is why each call above gets its own value. See Android Developers — androidx.tracing.

ANRs: Application Not Responding

The system shows an ANR dialog when the main thread does not respond to input within its timeout (roughly five seconds for most kinds of input dispatch, shorter for a broadcast receiver or service). The most common causes in an app like TrailMate are a synchronous database or network call made directly from the main thread (rather than through viewModelScope/Dispatchers.IO), a main-thread Binder call taking too long, or a deadlock between the main thread and a background thread holding a lock it needs. Logcat records an ANR’s full trace (every thread’s stack at the moment of the freeze) even without Android Studio attached, which is the primary tool for diagnosing one that happened on a user’s device rather than during development. StrictMode (above) and avoiding any I/O in a ViewModel constructor or a Compose composition are the main preventive measures; App Inspection’s Background Task Inspector rules out a stuck WorkManager job as the cause.

Rendering and Jank

Android renders at a fixed refresh interval (typically every ~16.6 ms at 60 Hz, ~8.3 ms at 120 Hz); a frame that takes longer than that to produce is dropped, and the previous frame is shown again — perceived as jank, a stutter in scrolling or animation. The Layout Inspector and Profiler’s rendering overlays (and, historically, Developer Options' Profile HDR rendering/GPU rendering profile) show per-frame timing broken into stages (input handling, layout/measure, draw, GPU work), which localizes a dropped frame to, for example, an expensive Modifier.drawWithCache recomputation in TrailListItem, or a RecyclerView binding doing a synchronous Glide disk read on the main thread in TrailViewHolder.bind. Compose recomposition counts (Layout Inspector, above) and stability annotations (covered on State, Recomposition and Side Effects in Compose) are the Compose-specific half of this same problem: an over-broad recomposition scope produces the same symptom — a dropped frame — as an expensive draw call.

App Startup

Startup is classified by what state the process was in beforehand:

Type What happens

Cold start

The system creates a new process: Application.onCreate, then the first activity’s full lifecycle up to drawing its first frame. The slowest and most consequential type to optimize, since it is what a user sees when launching TrailMate from the home screen or a notification after the app was not running.

Warm start

The process already exists (backgrounded, not killed) but the activity has to be recreated; skips Application.onCreate and process creation.

Hot start

The activity is simply brought back to the foreground; the fastest case, with the least room for a developer to influence timing.

Application.onCreate and a launching activity’s onCreate/onResume are the two places TrailMate’s own code can add cold-start latency — most often through eager singleton initialization (Hilt component graph construction, a ContentProvider-based library initializer, or an eagerly created OkHttp client). The App Startup library (androidx.startup) consolidates multiple ContentProvider-based initializers into a single one, and lets TrailMate declare explicit ordering and lazy/on-demand initialization instead of relying on each dependency’s own provider running independently at process start:

// core/data/src/main/kotlin/com/example/trailmate/core/data/startup/TrailDatabaseInitializer.kt
class TrailDatabaseInitializer : Initializer<TrailMateDatabase> {

    override fun create(context: Context): TrailMateDatabase =
        TrailMateDatabase.getInstance(context)

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}
<!-- app/src/main/AndroidManifest.xml -->
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <meta-data
        android:name="com.example.trailmate.core.data.startup.TrailDatabaseInitializer"
        android:value="androidx.startup" />
</provider>

See Android Developers — App Startup and Android Developers — App startup time. The Profiler’s System Trace, or adb shell am start -W, measures actual cold-start time end to end; see Android Developers — Analyze and optimize your app’s startup time for the full diagnostic workflow.

Baseline Profiles

The Android Runtime (ART) compiles code just-in-time on first run and ahead-of-time in the background once a device is idle; a Baseline Profile is a list of classes and methods TrailMate declares as performance-critical (startup, or a frequently-scrolled screen), shipped inside the app so ART pre-compiles exactly those paths at install time instead of waiting for repeated interpretation or a later background compile. This removes a measurable amount of cold-start and first-scroll jank for the very first sessions a real user has with the app — precisely when a bad first impression is most costly.

A Baseline Profile is generated, not hand-written, using the androidx.baselineprofile Gradle plugin and a dedicated benchmark module that exercises the critical paths under instrumentation:

// benchmark/src/main/kotlin/com/example/trailmate/benchmark/BaselineProfileGenerator.kt
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generate() = rule.collect(packageName = "com.example.trailmate") {
        pressHome()
        startActivityAndWait()
        device.findObject(By.text("Trails")).click()
        device.waitForIdle()
    }
}

Running it produces a baseline-prof.txt merged into :app’s release build automatically by the `androidx.baselineprofile plugin. See Android Developers — Baseline profiles and Android Developers — Create Baseline Profiles.

Macrobenchmark and Microbenchmark

Both are androidx.benchmark JUnit test types that produce statistically stable, regression-trackable measurements instead of one-off manual timing:

  • Macrobenchmark measures end-to-end, black-box scenarios on a real or emulated device: cold-start time, scroll jank on TrailListScreen, or the effect a Baseline Profile has (it can run the same scenario CompilationMode.None() versus CompilationMode.Partial() and report the delta directly).

  • Microbenchmark measures a single function’s execution time in tight isolation, with JIT warm-up and statistical outlier handling — for example, comparing two implementations of a Trail list sort/filter operation used inside TrailListViewModel.

// benchmark/src/main/kotlin/com/example/trailmate/benchmark/TrailListStartupBenchmark.kt
@RunWith(AndroidJUnit4::class)
class TrailListStartupBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startup() = benchmarkRule.measureRepeated(
        packageName = "com.example.trailmate",
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD,
    ) {
        pressHome()
        startActivityAndWait()
    }
}

Both run in a dedicated Gradle module (conventionally :benchmark) rather than :app, and both report results that can be tracked over time in CI to catch a performance regression the same way a failing test catches a correctness regression. See Android Developers — Benchmarking and Android Developers — Macrobenchmark and Android Developers — Microbenchmark.

R8 App Optimization

R8 is the AGP’s default compiler and optimizer for release builds: it shrinks (removes unreachable code and resources), obfuscates (renames classes/members to shorter names, harder to reverse-engineer), and optimizes (inlines, removes dead branches, rewrites bytecode for smaller/faster execution). Beyond the security-oriented obfuscation covered on Permissions, Privacy and Security, the performance angle is app size and method count: a smaller APK/AAB installs faster, downloads faster on constrained networks, and a smaller DEX reduces the amount of code ART has to JIT/AOT compile, which feeds back into cold-start time.

// app/build.gradle.kts
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}

For a library module such as :core:network, R8’s library optimization mode (android.experimental. com.android.libraryOptimization in newer AGP, replacing the older consumerProguardFiles-only model) applies the same shrinking/optimization to code that is only ever consumed by :app, rather than deferring all of it to the final app module. See Android Developers — Shrink, obfuscate, and optimize your app and Android Developers — Optimize library code with R8. R8’s shrinking and Baseline Profiles' pre-compilation are independent and compatible — one changes what ships, the other changes when the shipped code gets compiled by ART — and a release build normally applies both.

Production Monitoring

Everything above runs during development or in CI, against a known set of devices. Once TrailMate is in users' hands, the same signals have to come from real sessions on real, unpredictable hardware.

Android Vitals

Android vitals is Google Play’s own aggregation of technical quality signals across every device running TrailMate, visible in the Play Console with no extra SDK required: ANR rate, crash rate, excessive wakeups, stuck partial wake locks, and (where available) startup time and slow/frozen frame rates. Play uses vitals thresholds — crash rate and ANR rate above Google’s "bad behavior" bar — to affect store visibility (a poor rate can reduce an app’s ranking or trigger a warning), which makes vitals both a debugging signal and a distribution-relevant one.

Firebase Crashlytics

Crashlytics is a crash- and non-fatal-error-reporting SDK: it captures every uncaught exception (and, if reported explicitly with recordException, handled ones) with a full, de-obfuscated stack trace (using the mappingFileUpload/R8 mapping file uploaded from the release build), groups crashes into issues by signature, and tracks how many users and sessions each issue affects across app versions.

// core/data/src/main/kotlin/com/example/trailmate/core/data/repository/OfflineFirstTrailRepository.kt
override suspend fun refreshTrails() {
    try {
        val response = trailApi.getTrails()
        trailDao.upsertAll(response.map { it.toEntity() })
    } catch (e: IOException) {
        FirebaseCrashlytics.getInstance().recordException(e)
        throw e
    }
}

FirebaseCrashlytics.getInstance().setCustomKey(…​)/setUserId(…​) (a hashed or internal identifier, never a real name or email) attaches context to a report — for example the active Units preference or onlyFavorites filter at crash time — which narrows down a report that only reproduces under a specific combination of state.

Firebase Performance Monitoring

Performance Monitoring is Crashlytics’s counterpart for latency rather than crashes: it automatically traces app start time, screen rendering (slow/frozen frames per screen), and every network request made through a supported HTTP client, and lets custom traces wrap any operation worth tracking end to end from real user sessions:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
fun loadTrails() {
    val trace = Firebase.performance.newTrace("trail_list_load")
    trace.start()
    viewModelScope.launch {
        try {
            trailRepository.refreshTrails()
        } finally {
            trace.stop()
        }
    }
}

Both Crashlytics and Performance Monitoring aggregate across the full install base the way Baseline Profiles, Macrobenchmark and the Profiler cannot — those tools measure known scenarios on known devices before release, while Crashlytics and Performance Monitoring reveal what actually happens on devices, OS versions and network conditions the team never explicitly tested. See Firebase Documentation — Crashlytics and Firebase Documentation — Performance Monitoring.

References