Introduction
Your app crashes in production and you have no idea until angry 1-star reviews start flooding in. That's the reality for teams without proper crash reporting, and it's a painful lesson most of us learn once before implementing something like Crashlytics. Crash reporting with Crashlytics: lessons from production teaches us that knowing when your app breaks—and why—is non-negotiable for maintaining user trust and a healthy release cycle.
We've integrated Crashlytics across multiple Android apps handling millions of transactions daily, and the difference between guessing at problems and having a complete picture is the difference between firefighting and being proactive. In this article, we'll walk through setting up Crashlytics properly, extracting meaningful context from crashes, measuring what matters, and prioritizing fixes based on real impact.
Automatic Crash Reports
Crashlytics integrates into your Android app with minimal code. Firebase handles the heavy lifting: it captures unhandled exceptions, ANRs (Application Not Responding), and fatal errors automatically. The moment a crash occurs, the stack trace is transmitted securely, timestamped, and indexed so you can search and filter across versions, device types, and Android versions.
Getting started requires adding the Firebase dependency and initializing it. Here's what a basic setup looks like:
// In build.gradle.kts (app level)
dependencies {
implementation(platform("com.google.firebase:firebase-bom:32.4.0"))
implementation("com.google.firebase:firebase-crashlytics-ktx")
}
// In your Application class or MainActivity
import com.google.firebase.crashlytics.FirebaseCrashlytics
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// Crashlytics is auto-initialized via manifest; no explicit call needed
// But you can disable collection until user opts in:
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(true)
}
}That's genuinely it for automatic reporting. Every unhandled exception that crashes your app is now sent to Crashlytics. You'll see it within seconds on the Firebase Console dashboard. You get the stack trace, the Android version, the device model, the app version, and a timeline of events leading up to the crash.
The dashboard groups crashes by similarity, so you're not drowning in duplicates. A stack overflow on Samsung Galaxy S21 devices running Android 13 running version 1.2.3 is grouped separately from the same crash on Pixel 6 devices, which is crucial for triaging.
Custom Keys and Logs for Context
A stack trace tells you what failed, but not why. If your app crashes during a checkout flow, the stack trace might show a null pointer exception in a formatter, but it won't tell you whether the user was on WiFi or mobile data, whether they had a saved payment method, or what state the order was in. This is where custom keys and logs shine.
Custom keys are key-value pairs you attach to every crash from that point forward. Logs are chronological breadcrumbs recorded before a crash. Together, they give you narrative context:
import com.google.firebase.crashlytics.FirebaseCrashlytics
class CheckoutViewModel {
private val crashlytics = FirebaseCrashlytics.getInstance()
fun processPayment(orderId: String, amount: Double, paymentMethod: String) {
// Set custom keys so every crash logged after this carries context
crashlytics.setCustomKey("order_id", orderId)
crashlytics.setCustomKey("payment_amount", amount)
crashlytics.setCustomKey("payment_method", paymentMethod)
crashlytics.setCustomKey("user_network_type", getNetworkType())
crashlytics.setCustomKey("app_state_clean", isAppStateClean())
try {
// Log breadcrumbs of what you're doing
crashlytics.log("Starting payment processing for order: $orderId")
val paymentResult = repository.charge(orderId, amount, paymentMethod)
crashlytics.log("Payment gateway returned: ${paymentResult.status}")
if (paymentResult.requiresAuth) {
crashlytics.log("3DS authentication required")
// Handle auth flow...
}
crashlytics.log("Payment completed successfully")
} catch (e: Exception) {
crashlytics.log("Payment processing failed with exception")
throw e // Crash will be reported with all context attached
}
}
private fun getNetworkType(): String {
// Determine WiFi, cellular, or none
return "cellular"
}
private fun isAppStateClean(): Boolean {
// Check for leaked resources, orphaned requests, etc.
return true
}
}When this code crashes, the Crashlytics dashboard shows not just the stack trace, but also:
- The exact order ID and amount involved
- Which payment method was used
- Whether the user was on cellular or WiFi
- A log of breadcrumbs showing exactly which step failed
This turns a vague "NullPointerException in CurrencyFormatter" into "User with order 98765 tried to pay $99.99 via Apple Pay on cellular, but the formatter crashed when trying to display a null exchange rate." Now you have something to act on.
A word of caution: don't log sensitive data like payment tokens, passwords, or full credit card numbers. Crashlytics data is encrypted in transit and at rest, but you still want to follow defense-in-depth principles. Log the category of data, not the data itself.
Track Crash-Free Users
Raw crash counts are misleading. If your app has 100 daily active users and crashes 10 times a day, is that better or worse than 10,000 daily active users with 100 crashes? Crash-free users is the metric that actually matters: it's the percentage of your user base who didn't experience a crash in a given period.
Crashlytics calculates this automatically. The dashboard shows you crash-free user percentage over time, broken down by app version. You can set a threshold—many teams aim for 99% crash-free users—and alert on it when it dips.
Here's what we've learned about interpreting this metric:
- Version stability matters more than total crash count. A new version with 5 crashes affecting 500 users (99.5% crash-free) is worse than an old version with 20 crashes affecting the same users who already updated.
- Crash-free users reveals the Pareto principle. Often 80% of crashes come from 20% of your user base—usually older devices or edge-case configurations. Fixing those 20% of crashes can jump your crash-free percentage from 95% to 98%.
- Watch for version adoption lag. If a new version fixes a major crash but only 30% of users have updated, your overall crash-free metric will still look bad. This guides your push notification strategy.
In production, we track crash-free users as a KPI on par with user retention. A release that trades a 2% feature uplift for a 3% drop in crash-free users is a bad trade. The Crashlytics dashboard makes this tradeoff visible.
Prioritize by Impact
Not all crashes are equal. A crash affecting one elderly user on a Nexus 5 running Android 6.0 once per month is different from a crash hitting 5,000 Pixel 6 Pro users daily. Crashlytics helps you sort crashes by impact so you fix what matters first.
The dashboard shows crashes sorted by several dimensions:
- Affected users: How many unique users hit this crash?
- Crash events: How many total times did it occur?
- Crash-free impact: How much would fixing this crash improve your crash-free user percentage?
Let's say you have three crashes in your backlog:
| Crash | Affected Users | Total Events | Crash-Free Impact | Devices |
|---|---|---|---|---|
| NullPointerException in formatter | 50 | 150 | +0.1% | Mixed, mostly old |
| OutOfMemoryError in image cache | 2,000 | 8,000 | +2.5% | Pixel 6 Pro with low RAM |
| ANR in database query | 500 | 2,000 | +1.2% | Android 12+, all brands |
You'd fix the OutOfMemoryError first. It affects 40x more users than the formatter crash and would move your needle the most. The ANR would be second. The formatter crash might go unfixed if resources are tight, because the impact is tiny.
This is ruthless prioritization, and it's the right approach. Your users care that the app works, not that every edge case is perfect. Crashlytics forces you to make that choice visible and defensible.
Common Mistakes
Over-logging. We've seen teams log every function entry and exit, creating noise. Logs are searchable but not free—excessive logging makes it harder to find the signal. Log state changes, not normal flow. Log before risky operations, not inside loops.
Logging PII. A stack trace with customer email addresses or order IDs exposes data unnecessarily. Use hashed or anonymized identifiers in logs. Custom keys for internal IDs are fine; breadcrumb logs should avoid sensitive details.
Ignoring crashes on old devices. A crash affecting 0.1% of users on Android 6 devices feels irrelevant, but if those users are all in a single emerging market important to your business, ignoring them damages trust. Use the device/OS filters to understand who's affected.
Not setting crash-free thresholds. Without an explicit target, crash-free metrics drift. A regression from 99% to 97% might go unnoticed. Set a threshold in your CI/CD to fail releases that drop crash-free below your target.
Forgetting to test crash reporting. Does Crashlytics actually work in your app? Force a crash in development and verify it shows up on the dashboard within a minute. We've seen teams ship code that didn't send crashes at all because they never validated the plumbing.
Key Takeaways
- Set up Crashlytics automatic reporting immediately; it requires almost no code and catches unhandled exceptions you'd otherwise never know about.
- Add custom keys and breadcrumb logs before risky operations so crashes carry narrative context, not just stack traces.
- Track crash-free user percentage, not raw crash count, as your primary stability metric; it reflects real impact on your user base.
- Prioritize crashes by affected users and crash-free improvement, not by frequency; one crash hitting 1,000 users is worse than ten crashes each hitting 10 users.
- Treat crash-free as a KPI that influences release decisions; a feature that trades stability for functionality often isn't worth shipping.
- Validate that Crashlytics works in your app before shipping; forced crashes in staging should appear on the dashboard within seconds.






Comments
Loading comments…