Design the analytics instrumentation and KPI dashboard for an article reading app. The app has a free tier (5 articles/month) and a paid pro tier (unlimited articles + offline).
Goal
- Define 5 core KPIs with their measurement method
- Design the event taxonomy for the checkout funnel
- Implement the analytics layer in code
- Describe what the "health dashboard" looks like
Step 1: Define KPIs
| KPI | Definition | Target | How measured |
|---|---|---|---|
| DAU/MAU ratio | Daily actives / Monthly actives | > 30% | Firebase DAO cohort |
| Article completion rate | article_read_complete / article_view | > 60% | Event funnel |
| Free-to-Pro conversion | Users on trial → paid in 14 days | > 8% | User property + subscription_started |
| Crash-free sessions | Sessions without crashes | > 99.5% | Firebase Crashlytics |
| P90 article load time | 90th percentile of article_detail_load trace | < 2s | Firebase Performance |
Step 2: Event Taxonomy
Reading Funnel
// User opens the app
data class AppOpened(val source: String) : AnalyticsEvent() {
override val name = "app_opened"
override val params = mapOf("source" to source) // "notification", "direct", "widget"
}
// Article card visible in feed
data class ArticleImpression(val articleId: String, val position: Int) : AnalyticsEvent() {
override val name = "article_impression"
override val params = mapOf("article_id" to articleId, "list_position" to position)
}
// User taps article card
data class ArticleOpened(val articleId: String, val source: String) : AnalyticsEvent() {
override val name = "article_view"
override val params = mapOf("article_id" to articleId, "source" to source)
}
// User scrolled to 80%+ of article
data class ArticleReadComplete(
val articleId: String,
val timeSpentSeconds: Int,
val wordsRead: Int
) : AnalyticsEvent() {
override val name = "article_read_complete"
override val params = mapOf(
"article_id" to articleId,
"time_spent_seconds" to timeSpentSeconds,
"words_read" to wordsRead
)
}
Monetization Funnel
// User hits paywall (free tier exhausted)
data class PaywallShown(val trigger: String) : AnalyticsEvent() {
override val name = "paywall_shown"
override val params = mapOf("trigger" to trigger) // "article_limit", "offline_gate"
}
// User taps "Start free trial"
data class TrialStarted(val plan: String) : AnalyticsEvent() {
override val name = "trial_started"
override val params = mapOf("plan" to plan) // "monthly", "annual"
}
// Subscription confirmed (server-side event mirrored to analytics)
data class SubscriptionStarted(
val plan: String,
val priceCents: Long,
val isUpgrade: Boolean
) : AnalyticsEvent() {
override val name = "subscription_started"
override val params = mapOf(
"plan" to plan,
"price_cents" to priceCents,
"is_upgrade" to isUpgrade
)
}
// Subscription cancelled
data class SubscriptionCancelled(val plan: String, val reason: String?) : AnalyticsEvent() {
override val name = "subscription_cancelled"
override val params = mapOf("plan" to plan, "reason" to (reason ?: "unknown"))
}
Step 3: Analytics Implementation
@HiltViewModel
class ArticleDetailViewModel @Inject constructor(
private val repository: ArticleRepository,
private val analytics: AnalyticsTracker,
private val perf: FirebasePerformance
) : ViewModel() {
private val openedAt = System.currentTimeMillis()
private var trace: Trace? = null
fun onScreenOpened(articleId: String, source: String) {
trace = perf.newTrace("article_detail_load").also { it.start() }
analytics.track(AnalyticsEvent.ArticleOpened(articleId, source))
loadArticle(articleId)
}
private fun loadArticle(id: String) = viewModelScope.launch {
try {
val article = repository.getArticle(id)
trace?.stop()
_state.value = UiState.Success(article)
} catch (e: Exception) {
trace?.putAttribute("error", e.javaClass.simpleName)
trace?.stop()
_state.value = UiState.Error(e.message ?: "")
}
}
fun onScrolledToEnd(articleId: String, wordCount: Int) {
val timeSpent = ((System.currentTimeMillis() - openedAt) / 1000).toInt()
analytics.track(AnalyticsEvent.ArticleReadComplete(
articleId = articleId,
timeSpentSeconds = timeSpent,
wordsRead = wordCount
))
}
}
Step 4: Health Dashboard Definition
Daily Health Dashboard (checked every morning)
┌─────────────────────────────────────────────────────┐
│ App Health — $(Date) │
├──────────────┬───────────┬──────────────────────────┤
│ Metric │ Today │ vs 7d avg │ Status │
├──────────────┼───────────┼─────────────┼────────────┤
│ Crash-free │ 99.7% │ +0.1% │ ✅ Good │
│ ANR-free │ 99.9% │ 0.0% │ ✅ Good │
│ DAU │ 48,230 │ -2.1% │ ⚠️ Watch │
│ Article done │ 62% │ +3% │ ✅ Good │
│ Paywall conv │ 7.8% │ -0.4% │ ⚠️ Watch │
│ P90 load │ 1.8s │ +0.2s │ ✅ Good │
└──────────────┴───────────┴─────────────┴────────────┘
Alert Thresholds (PagerDuty / Slack)
// Pseudo-code: server-side alert rules
val alerts = listOf(
Alert("crash_free_sessions", threshold = 99.0, comparator = LESS_THAN, severity = P1),
Alert("anr_rate", threshold = 0.5, comparator = GREATER_THAN, severity = P1),
Alert("dau_drop", threshold = 10.0, comparator = GREATER_THAN, unit = PERCENT_DROP, severity = P2),
Alert("paywall_conversion", threshold = 5.0, comparator = LESS_THAN, severity = P2),
Alert("p90_load_time_seconds", threshold = 3.0, comparator = GREATER_THAN, severity = P3)
)
Verification Checklist
[ ] Each KPI has exactly one measurement owner (who queries it each week)
[ ] Every funnel step has a corresponding analytics event
[ ] Analytics events use the abstraction layer (no direct Firebase calls in UI)
[ ] Debug: Firebase DebugView shows events in real time
[ ] User properties set at login: subscription_tier, days_since_signup
[ ] Remote Config: enable_pro_features flag wired with default = false
[ ] Dashboard reviewed in next sprint retro: are these the right KPIs?