Crashlytics & Firebase Performance Deep Dive
Crashlytics and Firebase Performance are the two halves of "see what your app does in production." Crashes + ANRs + slow renders + business KPIs — all attributed per user, version, device, and SDK level. This chapter covers the patterns that turn raw events into actionable diagnostics.
Crashlytics — beyond stack traces
Bootstrap pattern
@Singleton
class CrashReportingBootstrap @Inject constructor(
@ApplicationContext private val context: Context,
private val authSession: AuthSession,
private val deviceInfo: DeviceInfo,
private val scope: CoroutineScope
) {
fun install() {
val crashlytics = Firebase.crashlytics
// Collection on/off
crashlytics.setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
// App-version-level custom keys
crashlytics.setCustomKeys {
key("app.flavor", BuildConfig.FLAVOR)
key("app.build_type", BuildConfig.BUILD_TYPE)
key("device.abi", Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown")
key("device.api_level", Build.VERSION.SDK_INT)
key("device.locale", Locale.getDefault().toLanguageTag())
key("device.timezone", TimeZone.getDefault().id)
}
// Live user attribution
scope.launch {
authSession.userFlow.collect { user ->
if (user != null) {
crashlytics.setUserId(user.id)
crashlytics.setCustomKey("user.tier", user.tier.name)
crashlytics.setCustomKey("user.signup_days", daysSince(user.createdAt))
} else {
crashlytics.setUserId("")
}
}
}
}
}
Call install() from Application.onCreate after Hilt is ready.
Custom keys — the three non-negotiables
Every crash should answer "who, where, what was happening":
crashlytics.setCustomKeys {
key("user.id", user.id) // who (also setUserId)
key("last.screen", currentScreen) // where
key("session.duration_s", sessionLengthSec) // how long
}
In the Crashlytics console, you can filter by these keys — "show me all crashes from premium users on Pixel 7 during checkout." Without keys, you debug from generic stack traces.
Track screen navigation
@Singleton
class ScreenAttributionTracker @Inject constructor(
private val crashlytics: FirebaseCrashlytics
) {
fun onScreen(screenName: String) {
crashlytics.setCustomKey("last.screen", screenName)
crashlytics.log("Screen → $screenName") // also appears in crash logs as breadcrumb
}
}
// Hook into Compose navigation
@Composable
fun TrackScreen(name: String, tracker: ScreenAttributionTracker) {
LaunchedEffect(name) { tracker.onScreen(name) }
}
Or hook into navController once:
navController.addOnDestinationChangedListener { _, destination, _ ->
crashlytics.setCustomKey("last.screen", destination.route ?: "unknown")
}
When a crash happens, you see exactly where the user was.
Breadcrumbs — log()
crashlytics.log("Started checkout flow with ${cart.items.size} items")
crashlytics.log("Payment method = ${paymentMethod.type}")
crashlytics.log("Server returned 402")
Logs accumulate per session; up to ~64KB are attached to the next crash. Crashlytics console shows them as breadcrumbs leading up to the crash:
17:32:01 Started checkout flow with 3 items
17:32:04 Payment method = card
17:32:05 Server returned 402
17:32:05 → CRASH
Distinguishes a "random" crash from a "user did X then Y then it died" crash.
Non-fatal exceptions
try {
parseDeepLink(uri)
} catch (e: MalformedUrlException) {
crashlytics.recordException(e)
fallbackToHome()
}
recordException reports an error without crashing. Appears in the
console as a separate event grouped by stack trace. Useful for:
- Recoverable errors (parse failures, transient network)
- Code paths that "shouldn't happen" but you want to know if they do
- Cancellation of expensive operations
Don't over-record — Crashlytics has a 64 non-fatals/session limit.
Logging tree integration
class CrashlyticsTimberTree @Inject constructor(
private val crashlytics: FirebaseCrashlytics
) : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority < Log.WARN) return
crashlytics.log("${priorityLetter(priority)}/$tag: $message")
if (t != null && priority >= Log.ERROR) {
crashlytics.recordException(t)
}
}
private fun priorityLetter(p: Int) = when (p) {
Log.ERROR -> "E"
Log.WARN -> "W"
else -> "?"
}
}
// In Application
Timber.plant(CrashlyticsTimberTree(crashlytics))
Every Timber.e(...) becomes a breadcrumb + recorded exception
automatically. No per-call ceremony.
ANR tracking
Crashlytics catches ANRs automatically on modern Firebase versions. To
go deeper, use ApplicationExitInfo:
@Singleton
class AnrTracker @Inject constructor(
@ApplicationContext private val context: Context,
private val crashlytics: FirebaseCrashlytics,
private val dataStore: DataStore<Preferences>
) {
private val lastCheckKey = longPreferencesKey("anr_last_check")
suspend fun reportRecentAnrs() = withContext(Dispatchers.IO) {
val am = context.getSystemService(ActivityManager::class.java)
val lastCheck = dataStore.data.first()[lastCheckKey] ?: 0
val reasons = am.getHistoricalProcessExitReasons(null, 0, 20)
.filter { it.reason == ApplicationExitInfo.REASON_ANR }
.filter { it.timestamp > lastCheck }
reasons.forEach { info ->
crashlytics.log("ANR at ${info.timestamp}: ${info.description}")
info.traceInputStream?.use { stream ->
val trace = stream.readBytes().toString(Charsets.UTF_8)
crashlytics.recordException(AnrException(trace))
}
}
if (reasons.isNotEmpty()) {
dataStore.edit { it[lastCheckKey] = System.currentTimeMillis() }
}
}
}
class AnrException(traceLog: String) : Exception(traceLog.take(2000))
Run on app start. Recently-recorded ANRs (the system stores them for ~7 days) are pulled and reported with their main-thread stack trace.
NDK crashes
For apps using native code (NDK / JNI):
// build.gradle
plugins {
id("com.google.firebase.crashlytics") version "3.0.2"
}
android {
buildTypes {
release {
firebaseCrashlytics {
nativeSymbolUploadEnabled = true
unstrippedNativeLibsDir = file("$buildDir/intermediates/merged_native_libs/release/out/lib")
}
}
}
}
Native crashes are now captured + symbolicated automatically (Crashlytics
uses the unstripped .so files to recover function names).
Crashlytics + Compose
For Compose-specific exceptions:
@Composable
fun ErrorBoundary(
content: @Composable () -> Unit
) {
val crashlytics = LocalCrashlytics.current
val state = remember { mutableStateOf<Throwable?>(null) }
if (state.value != null) {
FallbackUI(state.value!!) { state.value = null }
return
}
try {
content()
} catch (t: Throwable) {
crashlytics.recordException(t)
state.value = t
}
}
Wraps risky composables; reports crashes; shows a fallback. See Error Handling.
De-duping and grouping
Crashlytics auto-groups crashes by stack trace fingerprint. If two crashes share a top frame, they merge into one "issue."
When the auto-grouping is wrong
If unrelated crashes share a frame (e.g., a top-level dispatcher throws both for network errors AND for database failures), they merge incorrectly.
Fix at source: wrap each call site so the stack trace differs:
// ❌ Same top-level wrapper
class GenericErrorWrapper {
inline fun <T> wrap(block: () -> T): T = try { block() }
catch (e: Throwable) { throw WrappedException(e) }
}
// ✅ Specific wrappers per failure mode
class NetworkErrorWrapper {
inline fun <T> wrap(block: () -> T): T = try { block() }
catch (e: IOException) { throw NetworkException(e) }
}
class DatabaseErrorWrapper {
inline fun <T> wrap(block: () -> T): T = try { block() }
catch (e: SQLException) { throw DatabaseException(e) }
}
Different exception types → different Crashlytics issues → distinct triage.
Velocity alerts
Configure in the Crashlytics console:
- Crash-free users below 99.5% → page on-call
- New issue affecting > 1% of users → Slack
#android-incidents - Issue retention rate > 50% → flag for triage
Velocity alerts catch regressions in 15-30 minutes — much faster than manually monitoring graphs.
Triage workflow
- Daily: 15-min Crashlytics review. New issues + spikes in known issues.
- Per crash, ask:
- Frequency: how many users? How many sessions per user?
- Attribution: which version, device, locale, build?
- Reproducibility: can I reproduce on a similar device?
- Fix or defer:
- Fix: ticket + assignee + estimated complexity
- Defer: link to existing ticket OR mark as "won't fix" with reason
- Verify: after fix ships, the issue's "crash-free in version X" metric should drop.
For a team of 5 engineers shipping weekly, expect 5-15 new issues per week. Most are minor / one-off; 2-3 need real attention.
Firebase Performance Monitoring
What it tracks automatically
Out of the box:
- App start — cold, warm, hot
- Screen rendering — slow frames, frozen frames per Activity / Fragment
- HTTP requests — duration, payload size, error rate
Per the breakdown:
- Slow rendering = > 16ms (60 Hz)
- Frozen rendering = > 700ms (catastrophic)
Custom traces
val trace = Firebase.performance.newTrace("checkout_flow")
trace.start()
try {
val cart = cartRepo.current()
trace.putMetric("items_count", cart.items.size.toLong())
val receipt = paymentApi.charge(cart.total, method)
val order = ordersRepo.create(cart, receipt)
trace.putAttribute("order_id", order.id)
} finally {
trace.stop()
}
Custom traces report duration + metrics per call. Use for:
- Business flows (checkout, sign-up, search)
- Long-running operations
- Anything spanning multiple HTTP calls
Coroutine-friendly extension
suspend inline fun <T> measureTrace(name: String, crossinline block: suspend () -> T): T {
val trace = Firebase.performance.newTrace(name)
trace.start()
return try {
block()
} finally {
trace.stop()
}
}
// Usage
val result = measureTrace("load_user_profile") {
userRepo.fetch(id)
}
Network monitoring
Auto-traces every HttpURLConnection / OkHttp request. Includes:
- Request size, response size
- Duration P50/P90/P99
- Error rate
- Per-domain breakdown
Custom URL traces if you want fine-grained:
val request = chain.request()
val metric = HttpMetric(request.url.toString(), FirebasePerformance.HttpMethod.GET)
metric.start()
val response = chain.proceed(request)
metric.setHttpResponseCode(response.code)
metric.setResponseContentType(response.body?.contentType()?.toString())
metric.setResponsePayloadSize(response.body?.contentLength() ?: 0)
metric.stop()
Not usually necessary — auto-instrumentation covers most cases.
Custom screens
Compose screens aren't auto-tracked. Manual:
@Composable
fun TrackedScreen(name: String, content: @Composable () -> Unit) {
val trace = remember(name) { Firebase.performance.newTrace("screen.$name") }
DisposableEffect(name) {
trace.start()
onDispose { trace.stop() }
}
content()
}
Wrap each top-level screen. Now Firebase Performance shows per-screen load times.
Putting them together
@Composable
fun CheckoutRoute(viewModel: CheckoutViewModel = hiltViewModel()) {
TrackedScreen("checkout") {
val state by viewModel.state.collectAsStateWithLifecycle()
val crashlytics = LocalCrashlytics.current
LaunchedEffect(state) {
crashlytics.setCustomKey("checkout.phase", state.phase.name)
}
ErrorBoundary {
when (state.phase) {
CheckoutPhase.Cart -> CartContent(state, viewModel::onIntent)
CheckoutPhase.Payment -> PaymentContent(state, viewModel::onIntent)
CheckoutPhase.Review -> ReviewContent(state, viewModel::onIntent)
CheckoutPhase.Submitting -> SubmittingContent()
CheckoutPhase.Done -> DoneContent(state)
}
}
}
}
Crash → known last screen + checkout phase + user ID. Performance → per-screen + per-phase timing. Diagnoses are 5-minute jobs.
DebugView (test before shipping)
adb shell setprop debug.firebase.analytics.app com.myapp
adb shell setprop log.tag.FirebaseCrashlytics DEBUG
Firebase console → DebugView. Events stream in real time from your test device. Verify your custom keys / logs / non-fatals before shipping.
SLO definitions
slos:
- name: crash_free_sessions
target: 99.5%
window: 30d
- name: anr_free_sessions
target: 99.8%
window: 30d
- name: cold_start_p95
target_ms: 1500
window: 30d
- name: screen_load_p75
target_ms: 1200
window: 30d
applies_to: critical_screens
- name: checkout_success
target: 99.0%
window: 7d
Track these in your observability backend (Crashlytics dashboards + Firebase Performance + a custom analytics layer). Alert when error budget burns too fast — see Observability overview.
Common anti-patterns
Diagnosability problems
- No custom keys (every crash looks the same)
- No setUserId (can't reach affected users)
- recordException in tight loops (quota exceeded)
- PII in custom keys (privacy issue)
- Logging tree disabled in release (no breadcrumbs)
- Auto-grouping wrong because of generic wrappers
Diagnosable apps
- user.id + last.screen + key business-state on every crash
- setUserId tied to authSession Flow
- Reserve recordException for genuinely exceptional paths
- Hashed / bucketed user identifiers; never raw PII
- Crashlytics-aware Timber tree active in release
- Per-failure-mode exception types — distinct grouping
Key takeaways
Practice exercises
- 01
Bootstrap with custom keys
Add CrashReportingBootstrap setting user.id, last.screen, app.flavor. Force a crash and verify keys appear in Crashlytics.
- 02
Timber → Crashlytics
Plant a CrashlyticsTimberTree. Make 5 Timber.e calls; verify they appear as recorded exceptions + breadcrumbs.
- 03
Custom Performance trace
Wrap your checkout flow in a Firebase Performance trace with attributes for cart size + payment method. Verify in Performance console.
- 04
ANR investigation
Cause an intentional 6-second main-thread sleep in debug. Verify the ANR appears + the ANR tracker reports the trace.
- 05
Velocity alert
Configure a Crashlytics alert for crash-free below 99.5% over 24h. Test the alert pipeline (Slack / email).
Next
Return to Module 18 Overview or continue to Module 19 — Enterprise UX.