Observability and Instrumentation
Overview
You cannot fix what you cannot see. This skill makes production behavior observable: crashes and ANRs reported with enough context to fix, startup and jank measured against Play Vitals thresholds, and logs structured so they help without leaking user data. Instrumentation is part of the feature, not an afterthought bolted on when the first bad review arrives.
When to Use
- Adding a feature whose failures would be invisible without instrumentation (payments, sync, background work)
- Setting up or auditing crash reporting and performance monitoring
- Before any release that will be watched during staged rollout (see
shipping-and-launch)
- Investigating field-only issues where local reproduction failed (see
debugging-and-error-recovery)
Skip when: Prototypes or internal builds that will never reach users — but wire observability in before the first external release, not after.
Core Process
Step 1: Crash and ANR Reporting
- Crashlytics (or Sentry) with context, not just stack traces:
// Attach the state that turns a stack trace into a diagnosis
FirebaseCrashlytics.getInstance().apply {
setCustomKey("screen", "task_detail")
setCustomKey("sync_state", syncState.name)
setUserId(pseudonymousId) // NEVER an email or real identifier
recordException(NonFatalSyncError(cause)) // non-fatals for handled-but-wrong paths
}
- Reporting rules:
- Record non-fatal exceptions for caught-but-abnormal paths — a swallowed exception is an invisible bug
- Custom keys over log spam: state at crash time beats a breadcrumb trail
- Upload the R8 mapping file automatically in the release pipeline (Crashlytics Gradle plugin does this) — an obfuscated stack trace is noise
- ANRs are surfaced by Play Vitals, not your crash SDK — watch both
Step 2: Play Vitals Thresholds
- Know the numbers Google judges you by (Play Console → Android Vitals):
| Metric |
Bad-behavior threshold |
| User-perceived ANR rate |
0.47% |
| User-perceived crash rate |
1.09% |
| Excessive wakeups / stuck wake locks |
per-device-hour budgets |
Exceeding a threshold suppresses your Play Store visibility. Vitals is the scoreboard; your in-app instrumentation exists to explain why a number moved.
Step 3: Structured Logging
- Timber with a release tree — logs are for debug builds, telemetry is for release:
class App : Application() {
override fun onCreate() {
super.onCreate()
Timber.plant(
if (BuildConfig.DEBUG) Timber.DebugTree()
else CrashReportingTree() // routes WARN/ERROR to Crashlytics, drops the rest
)
}
}
// GOOD: structured, no PII
Timber.w("sync_failed attempt=%d reason=%s", attempt, reason.name)
// BAD: PII in a log line — logcat is world-readable on rooted devices
Timber.d("sync failed for user %s token %s", email, token)
- Logging rules:
- No PII, tokens, or request bodies at any level
Log.d/Log.v stripped in release via R8 (-assumenosideeffects, see references/security-checklist.md)
- One event, one line, stable key=value shape — greppable beats prose
Step 4: Performance Instrumentation
- Measure startup honestly with
reportFullyDrawn:
// The system's TTID stops at first frame; report when content is actually usable
class TaskListActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
if (uiState is TaskListUiState.Success) {
LaunchedEffect(Unit) { reportFullyDrawn() }
}
TaskListContent(uiState)
}
}
}
- Custom traces for the flows that matter (Firebase Performance or
androidx.tracing):
val trace = Firebase.performance.newTrace("checkout_flow")
trace.start()
// ...
trace.putMetric("items", cart.size.toLong())
trace.stop()
- Gate regressions in CI: run Macrobenchmark on the release candidate and fail on startup/jank regressions against the previous baseline (see
performance-optimization and ci-cd-and-automation). A regression caught in CI costs a re-run; caught in Vitals it costs users.
Step 5: Release Health During Rollout
- Staged rollout is only as good as what you watch (see
shipping-and-launch):
- Define the abort criteria before rolling: e.g. "halt at crash rate > 0.5% or ANR > 0.3% on the new version"
- Compare version-over-version, not absolute: a new crash cluster at 5% rollout predicts the 100% disaster
- Watch: Crashlytics velocity alerts, Vitals per-version, key business events (did sign-ins drop?)
Common Rationalizations
| Shortcut |
Why It Fails |
| "We'll add monitoring after launch" |
The launch is exactly when you need it. Post-hoc instrumentation can't explain last week's spike. |
| "Crashlytics is set up, we're covered" |
Crash reporting without custom keys, non-fatals, and mapping uploads produces unactionable noise. |
| "Logs are enough" |
Release builds strip logs, and users don't send logcat. Telemetry is what you actually get from the field. |
| "PII in logs is fine, it's just debug" |
Debug logs leak into bug reports, screenshots, and third-party SDK capture. Treat every log line as public. |
| "Vitals looks fine, ship it" |
Vitals lags by days. Version-scoped Crashlytics velocity is your early-warning system during rollout. |
Red Flags
catch (e: Exception) { } with no recordException — swallowed failures are invisible
- Log lines containing emails, tokens, or request bodies
- Release builds still planting
Timber.DebugTree()
- No mapping file upload in the release pipeline
- Staged rollout with no written abort criteria
- Startup "measured" only by TTID with no
reportFullyDrawn
- Performance claims in PRs with no Macrobenchmark or trace evidence
Verification
1---2name: observability-and-instrumentation3description: Use when adding logging, crash reporting, or performance monitoring, or before shipping a release that must be watched in production. Covers Crashlytics, Play Vitals thresholds, structured logging with Timber, reportFullyDrawn, release-health monitoring, and CI performance gates.4---56# Observability and Instrumentation78## Overview910You cannot fix what you cannot see. This skill makes production behavior observable: crashes and ANRs reported with enough context to fix, startup and jank measured against Play Vitals thresholds, and logs structured so they help without leaking user data. Instrumentation is part of the feature, not an afterthought bolted on when the first bad review arrives.1112## When to Use1314- Adding a feature whose failures would be invisible without instrumentation (payments, sync, background work)15- Setting up or auditing crash reporting and performance monitoring16- Before any release that will be watched during staged rollout (see `shipping-and-launch`)17- Investigating field-only issues where local reproduction failed (see `debugging-and-error-recovery`)1819**Skip when:** Prototypes or internal builds that will never reach users — but wire observability in before the first external release, not after.2021## Core Process2223### Step 1: Crash and ANR Reporting24251. **Crashlytics (or Sentry) with context, not just stack traces:**2627```kotlin28// Attach the state that turns a stack trace into a diagnosis29FirebaseCrashlytics.getInstance().apply {30 setCustomKey("screen", "task_detail")31 setCustomKey("sync_state", syncState.name)32 setUserId(pseudonymousId) // NEVER an email or real identifier33 recordException(NonFatalSyncError(cause)) // non-fatals for handled-but-wrong paths34}35```36372. **Reporting rules:**38 - Record **non-fatal** exceptions for caught-but-abnormal paths — a swallowed exception is an invisible bug39 - Custom keys over log spam: state at crash time beats a breadcrumb trail40 - Upload the R8 mapping file automatically in the release pipeline (Crashlytics Gradle plugin does this) — an obfuscated stack trace is noise41 - ANRs are surfaced by Play Vitals, not your crash SDK — watch both4243### Step 2: Play Vitals Thresholds44453. **Know the numbers Google judges you by** (Play Console → Android Vitals):4647| Metric | Bad-behavior threshold |48|--------|------------------------|49| User-perceived ANR rate | 0.47% |50| User-perceived crash rate | 1.09% |51| Excessive wakeups / stuck wake locks | per-device-hour budgets |5253Exceeding a threshold suppresses your Play Store visibility. Vitals is the scoreboard; your in-app instrumentation exists to explain *why* a number moved.5455### Step 3: Structured Logging56574. **Timber with a release tree — logs are for debug builds, telemetry is for release:**5859```kotlin60class App : Application() {61 override fun onCreate() {62 super.onCreate()63 Timber.plant(64 if (BuildConfig.DEBUG) Timber.DebugTree()65 else CrashReportingTree() // routes WARN/ERROR to Crashlytics, drops the rest66 )67 }68}6970// GOOD: structured, no PII71Timber.w("sync_failed attempt=%d reason=%s", attempt, reason.name)7273// BAD: PII in a log line — logcat is world-readable on rooted devices74Timber.d("sync failed for user %s token %s", email, token)75```76775. **Logging rules:**78 - No PII, tokens, or request bodies at any level79 - `Log.d`/`Log.v` stripped in release via R8 (`-assumenosideeffects`, see `references/security-checklist.md`)80 - One event, one line, stable key=value shape — greppable beats prose8182### Step 4: Performance Instrumentation83846. **Measure startup honestly with `reportFullyDrawn`:**8586```kotlin87// The system's TTID stops at first frame; report when content is actually usable88class TaskListActivity : ComponentActivity() {89 override fun onCreate(savedInstanceState: Bundle?) {90 super.onCreate(savedInstanceState)91 setContent {92 val uiState by viewModel.uiState.collectAsStateWithLifecycle()93 if (uiState is TaskListUiState.Success) {94 LaunchedEffect(Unit) { reportFullyDrawn() }95 }96 TaskListContent(uiState)97 }98 }99}100```1011027. **Custom traces for the flows that matter** (Firebase Performance or `androidx.tracing`):103104```kotlin105val trace = Firebase.performance.newTrace("checkout_flow")106trace.start()107// ...108trace.putMetric("items", cart.size.toLong())109trace.stop()110```1111128. **Gate regressions in CI:** run Macrobenchmark on the release candidate and fail on startup/jank regressions against the previous baseline (see `performance-optimization` and `ci-cd-and-automation`). A regression caught in CI costs a re-run; caught in Vitals it costs users.113114### Step 5: Release Health During Rollout1151169. **Staged rollout is only as good as what you watch** (see `shipping-and-launch`):117 - Define the abort criteria *before* rolling: e.g. "halt at crash rate > 0.5% or ANR > 0.3% on the new version"118 - Compare version-over-version, not absolute: a new crash cluster at 5% rollout predicts the 100% disaster119 - Watch: Crashlytics velocity alerts, Vitals per-version, key business events (did sign-ins drop?)120121## Common Rationalizations122123| Shortcut | Why It Fails |124|----------|-------------|125| "We'll add monitoring after launch" | The launch is exactly when you need it. Post-hoc instrumentation can't explain last week's spike. |126| "Crashlytics is set up, we're covered" | Crash reporting without custom keys, non-fatals, and mapping uploads produces unactionable noise. |127| "Logs are enough" | Release builds strip logs, and users don't send logcat. Telemetry is what you actually get from the field. |128| "PII in logs is fine, it's just debug" | Debug logs leak into bug reports, screenshots, and third-party SDK capture. Treat every log line as public. |129| "Vitals looks fine, ship it" | Vitals lags by days. Version-scoped Crashlytics velocity is your early-warning system during rollout. |130131## Red Flags132133- `catch (e: Exception) { }` with no `recordException` — swallowed failures are invisible134- Log lines containing emails, tokens, or request bodies135- Release builds still planting `Timber.DebugTree()`136- No mapping file upload in the release pipeline137- Staged rollout with no written abort criteria138- Startup "measured" only by TTID with no `reportFullyDrawn`139- Performance claims in PRs with no Macrobenchmark or trace evidence140141## Verification142143- [ ] Crash reporting captures custom keys and non-fatals for the changed flows144- [ ] R8 mapping file uploaded automatically on release builds145- [ ] No PII/tokens in any log statement (grep the diff for log calls)146- [ ] Release log tree drops DEBUG/VERBOSE; R8 strips `Log.d`/`Log.v`147- [ ] `reportFullyDrawn` called when primary content is usable148- [ ] Macrobenchmark (or trace) evidence attached for performance-sensitive changes149- [ ] Rollout abort criteria written down with owner and thresholds