# Crash Reporting (Firebase Crashlytics / Sentry)

Required:
- SDK-specific code lives only in `core:data` (or `core:analytics`); feature modules call a `CrashReporter` interface from `core:domain`.
- Provider initialization happens once, in the `app` module.
- Swap providers by changing the Hilt binding + convention plugin only - never by touching feature code.
- Play Vitals is optional store-level signal, not a Crashlytics replacement: see [android-performance.md → Optional: Play Vitals observability](android-performance.md#optional-play-vitals-observability-play-developer-reporting-api).

## Table of Contents

1. [Architecture Placement](#architecture-placement)
2. [Provider-Agnostic Interface](#provider-agnostic-interface)
3. [Implementation Examples](#implementation-examples)
4. [Sentry Setup (Convention Plugin + Compose)](#sentry-setup-convention-plugin--compose)
5. [Firebase Crashlytics Setup (Convention Plugin + Compose)](#firebase-crashlytics-setup-convention-plugin--compose)
6. [Wiring in the App Module](#wiring-in-the-app-module)
7. [Rules](#rules)
8. [ProGuard/R8 Mapping Upload](#proguardr8-mapping-upload)
9. [Breadcrumbs](#breadcrumbs)
10. [Network Request Tracking](#network-request-tracking)
11. [Testing Crash Reporting](#testing-crash-reporting)
12. [Data Scrubbing (Privacy/GDPR)](#data-scrubbing-privacygdpr)
13. [Gradle & Setup Guidance](#gradle--setup-guidance)

## Architecture Placement

| Layer       | Module                            | Responsibility                                  |
|-------------|-----------------------------------|-------------------------------------------------|
| Contract    | `core:domain` / `core:common`     | `CrashReporter` interface, event models         |
| Adapter     | `core:data` / `core:analytics`    | Firebase or Sentry implementation               |
| Composition | `app`                             | Init + Hilt binding for chosen provider         |

## Provider-Agnostic Interface

```kotlin
// core/domain/analytics/CrashReporter.kt
interface CrashReporter {
    fun setUserId(id: String?)
    fun setUserProperty(key: String, value: String)
    fun log(message: String)
    fun recordException(throwable: Throwable, context: Map<String, String> = emptyMap())
}
```

## Implementation Examples

### Firebase Crashlytics

```kotlin
// core/data/analytics/FirebaseCrashReporter.kt
class FirebaseCrashReporter @Inject constructor(
    private val crashlytics: FirebaseCrashlytics
) : CrashReporter {
    override fun setUserId(id: String?) {
        crashlytics.setUserId(id ?: "")
    }

    override fun setUserProperty(key: String, value: String) {
        crashlytics.setCustomKey(key, value)
    }

    override fun log(message: String) {
        crashlytics.log(message)
    }

    override fun recordException(
        throwable: Throwable,
        context: Map<String, String>
    ) {
        context.forEach { (k, v) -> crashlytics.setCustomKey(k, v) }
        crashlytics.recordException(throwable)
    }
}
```

### Sentry

```kotlin
// core/data/analytics/SentryCrashReporter.kt
class SentryCrashReporter @Inject constructor() : CrashReporter {
    override fun setUserId(id: String?) {
        val user = User().apply { this.id = id }
        Sentry.setUser(user)
    }

    override fun setUserProperty(key: String, value: String) {
        Sentry.setTag(key, value)
    }

    override fun log(message: String) {
        Sentry.addBreadcrumb(message)
    }

    override fun recordException(
        throwable: Throwable,
        context: Map<String, String>
    ) {
        Sentry.withScope { scope ->
            context.forEach { (k, v) -> scope.setTag(k, v) }
            Sentry.captureException(throwable)
        }
    }
}
```

Use `Sentry.withScope` (Isolated/Local Scope) so per-call tags do not leak into subsequent events.

## Sentry Setup (Convention Plugin + Compose)

Apply the Sentry convention plugin. It applies the Gradle plugins, adds the Sentry SDK, and wires Compose integration.

```kotlin
// app/build.gradle.kts
plugins {
    alias(libs.plugins.app.android.application)
    alias(libs.plugins.app.android.application.compose)
    alias(libs.plugins.app.hilt)
    alias(libs.plugins.app.sentry)
}
```

`app.sentry` (`assets/convention/SentryConventionPlugin.kt`) applies:
- `io.sentry.android.gradle` (core SDK + mapping upload)
- `io.sentry.kotlin.compiler.gradle` (`@Composable` auto-tagging)
- `sentry-android` and `sentry-compose-android` dependencies

Two independent version lines: catalog `sentry` is the **SDK** and `sentryPlugin` is the **Gradle
plugin**; they do not share a version. Keep `autoInstallation.sentryVersion` pinned to `sentry` so the
plugin does not silently resolve a different SDK than the catalog declares.

**Manual setup (if not using convention plugin):**

```kotlin
plugins {
    alias(libs.plugins.app.android.application)
    alias(libs.plugins.app.android.application.compose)
    alias(libs.plugins.sentry.android)
    alias(libs.plugins.sentry.kotlin.compiler)
}

dependencies {
    implementation(libs.sentry.android)
    implementation(libs.sentry.compose.android)
}
```

### Manifest Configuration

Sentry uses a ContentProvider for auto-initialization. Configure via `AndroidManifest.xml`.

```xml
<application>
    <meta-data android:name="io.sentry.dsn" android:value="YOUR_DSN_HERE" />
    <meta-data android:name="io.sentry.traces.sample-rate" android:value="1.0" />
    <meta-data android:name="io.sentry.traces.user-interaction.enable" android:value="true" />
    <meta-data android:name="io.sentry.attach-view-hierarchy" android:value="true" />
    <meta-data android:name="io.sentry.attach-screenshot" android:value="true" />
</application>
```

### Application Initialization (Sentry)

Enable `options.logs.isEnabled` so StrictMode `.penaltyLog()` events can be shipped. Pick exactly one of `profilesSampleRate` or `profileSessionSampleRate` - never both.

```kotlin
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        SentryAndroid.init(this) { options ->
            options.dsn = "YOUR_DSN_HERE"
            options.logs.isEnabled = true
            options.environment = if (BuildConfig.DEBUG) "debug" else "production"
            options.release = BuildConfig.VERSION_NAME
            options.tracesSampleRate = 1.0
            // options.tracesSampler = { 0.2 } // Use a sampler when traces need dynamic sampling rates.
            // options.tracePropagationTargets = listOf("api.example.com", "https://auth.example.com")
            // options.propagateTraceparent = true
            // options.traceOptionsRequests = false
            
            // Profiling configuration:
            // profilesSampleRate: % of transactions to profile (requires tracesSampleRate > 0)
            // Use this for production profiling of sampled transactions
            options.profilesSampleRate = 1.0
            
            // Use profileSessionSampleRate to profile a percent of sessions (not per-transaction)
            // Only use ONE of profilesSampleRate OR profileSessionSampleRate, not both
            // options.profileSessionSampleRate = 0.2
            
            // options.profileLifecycle = SentryOptions.ProfileLifecycle.TRACE
            // options.startProfilerOnAppStart = true
            options.enableAutoSessionTracking = true
            options.sendDefaultPii = false
            // options.sampleRate = 1.0 // Error event sampling.
            // options.maxBreadcrumbs = 100
            // options.attachStacktrace = true
            // options.attachThreads = false
            // options.collectAdditionalContext = true
            // options.inAppIncludes = listOf("com.example")
            // options.inAppExcludes = listOf("com.example.core.testing")
        }
    }
}
```

Optional knobs (use only when justified): `tracesSampler`, `tracePropagationTargets`, `propagateTraceparent`, `traceOptionsRequests`, `profileLifecycle`, `startProfilerOnAppStart`, `maxBreadcrumbs`, `attachStacktrace`, `attachThreads`, `inAppIncludes`, `inAppExcludes`.

### Jetpack Compose Specifics

- Navigation breadcrumbs + transactions are auto-recorded when `androidx.navigation` is on the classpath.
- `sentry-kotlin-compiler` tags composables by function name; do not add `Modifier.sentryTag()` manually.
- Wrap critical screens with `SentryTraced` for explicit transactions.

```kotlin
import io.sentry.compose.SentryTraced

@Composable
fun AuthProfileScreen(userId: String) {
    SentryTraced(name = "auth_profile_screen") {
        Column {
            Text("User: $userId")
        }
    }
}
```

## Firebase Crashlytics Setup (Convention Plugin + Compose)

Apply the Firebase convention plugin. The separate `-ktx` artifact is not required with the Firebase BoM.

```kotlin
// app/build.gradle.kts
plugins {
    alias(libs.plugins.app.android.application)
    alias(libs.plugins.app.android.application.compose)
    alias(libs.plugins.app.hilt)
    alias(libs.plugins.app.firebase)
}
```

`app.firebase` (`assets/convention/FirebaseConventionPlugin.kt`) applies:
- `com.google.gms.google-services` and `com.google.firebase.crashlytics`
- Firebase BoM (centralized version)
- `firebase-analytics` + `firebase-crashlytics`
- Mapping upload on release builds, skipped for debug

**Native symbol upload is opt-in.** `nativeSymbolUploadEnabled` defaults to `false` because enabling it
requires a symbol generator and unstripped `.so` inputs - on an app with no native code it fails the
release build. Turn it on only for projects that ship native libraries:

```properties
# gradle.properties
app.crashlytics.nativeSymbols=true
```

**Forbidden:** setting `mappingFileUploadEnabled = false` on a release build. Without the mapping file,
production stack traces cannot be de-obfuscated ([android-debugging.md](android-debugging.md#matching-a-trace-to-its-mapping-file)).

**Manual setup (if not using convention plugin):**

```kotlin
// build.gradle.kts (project-level)
plugins {
    alias(libs.plugins.google.services) apply false
    alias(libs.plugins.firebase.crashlytics) apply false
}

// app/build.gradle.kts
plugins {
    alias(libs.plugins.app.android.application)
    alias(libs.plugins.app.android.application.compose)
    alias(libs.plugins.google.services)
    alias(libs.plugins.firebase.crashlytics)
}

dependencies {
    val bom = libs.firebase.bom
    implementation(platform(bom))
    implementation(libs.firebase.analytics)
    implementation(libs.firebase.crashlytics)
}
```

### Application Initialization (Firebase)

```kotlin
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        FirebaseApp.initializeApp(this)
    }
}
```

### Compose Screen Tracking (Navigation3)

Crashlytics breadcrumbs do not automatically include Compose destination names.
Log screen transitions in the **app-level** `AppNavigation()` coordinator.
See the centralized navigation setup in `references/android-navigation.md`.

```kotlin
@Composable
fun AppNavigation(
    analytics: Analytics // Injected via Hilt
) {
    val navigationState = rememberNavigationState(
        startRoute = TopLevelRoute.Auth,
        topLevelRoutes = setOf(
            TopLevelRoute.Auth,
            TopLevelRoute.Profile,
            TopLevelRoute.Settings
        )
    )

    LaunchedEffect(navigationState.topLevelRoute) {
        val currentStack = navigationState.backStacks[navigationState.topLevelRoute]
        val currentRoute = currentStack?.last()
        currentRoute?.let { route ->
            analytics.logScreenView(
                screenName = route::class.simpleName ?: "Unknown",
                screenClass = "MainActivity"
            )
        }
    }

    val entryProvider = entryProvider {
        authGraph(/* navigator */)
        profileGraph(/* navigator */)
        settingsGraph(/* navigator */)
    }

    NavDisplay(
        entries = navigationState.toEntries(entryProvider),
        onBack = { navigator.goBack() }
    )
}
```

### Capturing UI State (Delegation)

Use delegation to standardize custom keys and logs across ViewModels. See `references/kotlin-delegation.md` for more patterns.

```kotlin
interface CrashlyticsStateLogger {
    fun logUiState(key: String, value: String)
    fun logAction(message: String)
}

class FirebaseCrashlyticsStateLogger @Inject constructor(
    private val crashlytics: FirebaseCrashlytics
) : CrashlyticsStateLogger {
    override fun logUiState(key: String, value: String) {
        crashlytics.setCustomKey(key, value)
    }

    override fun logAction(message: String) {
        crashlytics.log(message)
    }
}

@HiltViewModel
class AuthViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    crashReporter: CrashReporter, // No private - delegated only
    logger: CrashlyticsStateLogger // No private - delegated only
) : ViewModel(), 
    CrashReporter by crashReporter,
    CrashlyticsStateLogger by logger {

    fun onRoleSelected(role: String) {
        logUiState("auth_role", role)
        logAction("Auth role selected: $role")
    }
    
    fun onLoginFailed(error: Throwable) {
        recordException(
            error,
            mapOf("action" to "login", "screen" to "auth")
        )
    }
}
```

### Non-fatal Exceptions in Coroutines

```kotlin
val crashHandler = CoroutineExceptionHandler { _, exception ->
    Firebase.crashlytics.recordException(exception)
}

viewModelScope.launch(crashHandler) {
    repository.refreshSession()
}
```

## Wiring in the App Module

Use DI bindings to switch providers without changing feature code.

### Using Firebase Crashlytics

```kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class CrashReporterModule {
    @Binds
    abstract fun bindCrashReporter(
        impl: FirebaseCrashReporter
    ): CrashReporter
    
    @Provides
    @Singleton
    fun provideFirebaseCrashlytics(): FirebaseCrashlytics =
        FirebaseCrashlytics.getInstance()
}
```

Apply the Firebase convention plugin in `app/build.gradle.kts`:
```kotlin
plugins {
    alias(libs.plugins.app.firebase)
}
```

### Using Sentry

```kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class CrashReporterModule {
    @Binds
    abstract fun bindCrashReporter(
        impl: SentryCrashReporter
    ): CrashReporter
}
```

Apply the Sentry convention plugin in `app/build.gradle.kts`:
```kotlin
plugins {
    alias(libs.plugins.app.sentry)
}
```

**Switching providers:** Change the binding and convention plugin. Feature modules remain unchanged.

## Rules

Required:
- Initialize the provider exactly once, in the `app` module.
- Use `Sentry.withScope` for per-call tags. `Sentry.configureScope` mutates Global Scope on main thread and Thread Scope on background threads - avoid it for one-off context.
- Sample tracing / profiling in production (`tracesSampleRate`, `profilesSampleRate` < 1.0) when traffic is non-trivial.
- Record non-fatals only for actionable failures (network failures, parse errors, recoverable exceptions).
- Pair crash data with analytics events on user-facing flows to surface pre-crash context.
- Upload ProGuard/R8 mapping files on every release build; keep `SourceFile` and `LineNumberTable`.

Forbidden:
- PII (email, phone, raw user objects, auth tokens) in tags, breadcrumbs, or messages.
- Recording every method call or coroutine launch as a breadcrumb.
- Letting the same exception be logged at multiple layers without context to dedupe it.

## ProGuard/R8 Mapping Upload

Both providers require mapping file upload for symbolicated crashes in release builds. See [gradle-setup.md](gradle-setup.md#r8-and-proguard-configuration) for R8 build configuration and `assets/proguard-rules.pro.template` for all keep rules.

### Firebase Crashlytics

The Firebase Crashlytics Gradle plugin automatically uploads mapping files during the build. Apply the convention plugin and enable minification:

```kotlin
plugins {
    alias(libs.plugins.app.firebase)
}
```

**Important**: To ensure your stack traces include exact line numbers, you must keep the `SourceFile` and `LineNumberTable` attributes. This is already included in the provided `assets/proguard-rules.pro.template`.

### Sentry

The Sentry Gradle plugin handles mapping upload automatically. Configure it in the app module:

```kotlin
plugins {
    alias(libs.plugins.app.sentry)
}

sentry {
    includeSourceContext.set(true)
    autoInstallation.sentryVersion.set(libs.versions.sentry.get())
    includeProguardMapping.set(true)
    autoUploadProguardMapping.set(true)
    org.set("your-org")
    projectName.set("your-project")
    authToken.set(System.getenv("SENTRY_AUTH_TOKEN"))
}
```

## Breadcrumbs

Record only:
- User navigation (`category = "navigation"`).
- Discrete UI interactions (`category = "ui.click"`, with stable element id in `data`).
- Auth / session / network state transitions (`category = "state"`).

Do not record:
- Internal implementation events (coroutine launches, dispatcher hops, lifecycle ticks).
- Per-method-call traces (`getUserId() called`).
- Raw object dumps or anything containing PII.

Reference shape:

```kotlin
Sentry.addBreadcrumb(Breadcrumb().apply {
    message = "User clicked logout button"
    category = "ui.click"
    level = SentryLevel.INFO
    data = mapOf("button_id" to "logout_btn")
})
```

Both providers auto-track Jetpack Compose navigation when `androidx.navigation` is on the classpath. Add custom breadcrumbs in the app-level navigation coordinator only.

## Network Request Tracking

### Failed Network Requests

Track failed API calls to understand network-related crashes.

```kotlin
// In OkHttp interceptor or repository layer
class AuthRepository @Inject constructor(
    crashReporter: CrashReporter // No private - delegated only
) : CrashReporter by crashReporter {
    suspend fun login(email: String, password: String): Result<AuthToken> {
        return try {
            val response = authApi.login(email, password)
            Result.success(response)
        } catch (e: IOException) {
            // Network error
            log("Network error during login: ${e.message}")
            recordException(e, mapOf(
                "endpoint" to "auth/login",
                "error_type" to "network"
            ))
            Result.failure(e)
        } catch (e: HttpException) {
            // HTTP error (4xx, 5xx)
            log("HTTP error during login: ${e.code()}")
            recordException(e, mapOf(
                "endpoint" to "auth/login",
                "status_code" to e.code().toString(),
                "error_type" to "http"
            ))
            Result.failure(e)
        }
    }
}
```

For Sentry + OkHttp use the `sentry-okhttp` integration for automatic network breadcrumbs: https://docs.sentry.io/platforms/android/integrations/okhttp/

## Testing Crash Reporting

### Test Crashes in Development

Add a debug-only method to test crash reporting:

```kotlin
@HiltViewModel
class DebugViewModel @Inject constructor(
    crashReporter: CrashReporter // No private - delegated only
) : ViewModel(), CrashReporter by crashReporter {
    
    // Only available in debug builds
    fun testCrash() {
        if (BuildConfig.DEBUG) {
            // Test non-fatal exception
            recordException(
                RuntimeException("Test crash from debug menu"),
                mapOf("test" to "true", "source" to "debug_menu")
            )
            
            // Test fatal crash (uncomment to test)
            // throw RuntimeException("Test fatal crash")
        }
    }
}

// In your debug/settings screen
@Composable
fun DebugScreen(viewModel: DebugViewModel = hiltViewModel()) {
    Button(onClick = { viewModel.testCrash() }) {
        Text("Test Non-Fatal Crash")
    }
}
```

### Disable Crash Reporting in Debug (Optional)

To avoid polluting production data with debug crashes:

**Firebase:**
```xml
<application>
  <meta-data
    android:name="firebase_crashlytics_collection_enabled"
    android:value="false" 
  />
</application>
```

Enable at runtime:
```kotlin
if (BuildConfig.DEBUG) {
    FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(false)
}
```

**Sentry:**
```kotlin
SentryAndroid.init(this) { options ->
    options.environment = if (BuildConfig.DEBUG) "debug" else "production"
    // Optional: disable entirely in debug
    options.dsn = if (BuildConfig.DEBUG) "" else "YOUR_DSN_HERE"
}
```

## Data Scrubbing (Privacy/GDPR)

Remove sensitive information before sending to crash reporters.

### Built-in Scrubbing (Sentry)

Sentry automatically scrubs common PII fields:

```kotlin
SentryAndroid.init(this) { options ->
    // Disable automatic PII scrubbing if you need custom control
    options.sendDefaultPii = false
    
    // Add custom data scrubbing
    options.setBeforeSend { event, hint ->
        // Scrub email addresses from exception messages
        event.message?.message = event.message?.message?.replace(
            Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"),
            "[REDACTED_EMAIL]"
        )
        
        // Strip tags that can carry PII
        event.removeTag("user_email")
        event.removeExtra("raw_user_data")
        
        event
    }
}
```

### Custom Scrubbing for Both Providers

Implement scrubbing in your `CrashReporter` wrapper:

```kotlin
class PrivacyAwareCrashReporter @Inject constructor(
    crashReporter: CrashReporter // No private - delegated only
) : CrashReporter by crashReporter {
    
    private val emailRegex = Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
    private val sensitiveKeys = setOf("password", "token", "secret", "key", "auth")
    
    override fun recordException(
        throwable: Throwable,
        context: Map<String, String>
    ) {
        // Scrub context
        val scrubbedContext = context.filterKeys { key ->
            !sensitiveKeys.any { key.contains(it, ignoreCase = true) }
        }.mapValues { (_, value) ->
            value.replace(emailRegex, "[REDACTED_EMAIL]")
        }
        
        // Use super to call the delegated implementation
        super.recordException(throwable, scrubbedContext)
    }
    
    override fun log(message: String) {
        val scrubbedMessage = message.replace(emailRegex, "[REDACTED_EMAIL]")
        super.log(scrubbedMessage)
    }
}
```

Wire it in DI:

```kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class CrashReporterModule {
    @Binds
    abstract fun bindCrashReporter(
        impl: PrivacyAwareCrashReporter
    ): CrashReporter
    
    @Provides
    @Singleton
    fun providePrivacyAwareCrashReporter(
        @Named("raw") rawReporter: CrashReporter
    ): PrivacyAwareCrashReporter = PrivacyAwareCrashReporter(rawReporter)
    
    @Provides
    @Singleton
    @Named("raw")
    fun provideRawCrashReporter(): CrashReporter = FirebaseCrashReporter(
        FirebaseCrashlytics.getInstance()
    )
}
```

## Gradle & Setup Guidance

- Keep SDK dependencies in the version catalog (`assets/libs.versions.toml.template`).
- Follow `references/gradle-setup.md` for plugin configuration patterns.
- For provider-specific setup, follow the official docs:
  - Sentry Android install and configuration: https://docs.sentry.io/platforms/android/
  - Sentry manual setup + plugin details: https://docs.sentry.io/platforms/android/manual-setup/
  - Firebase Crashlytics setup: https://firebase.google.com/docs/crashlytics/android/get-started
  - Crashlytics + Compose example: https://firebase.blog/posts/2022/06/adding-crashlytics-to-jetpack-compose-app/
  - Sentry + Compose integration: https://docs.sentry.io/platforms/android/integrations/jetpack-compose/
