Context
Timber 5.0.1 is already declared as a dependency but not yet initialized — there's a //TODO Timber in CvApplication.onCreate(). Firebase Crashlytics is also integrated (BOM 34.9.0) but not yet connected to the logging pipeline.
We need to configure Timber with build-type-specific trees:
- Debug: Full logging with rich tags (class name + line number)
- Release: Filtered logging (WARN/ERROR/ASSERT only) with Firebase Crashlytics integration for non-fatal error reporting
Problem
Without proper logging configuration:
- No structured logging is available during development
- Production errors are invisible — no crash reporting for non-fatal exceptions
- Debug/verbose logs would leak into production builds, impacting performance and potentially exposing implementation details
Design Decisions
Why not use Timber.DebugTree() in release?
DebugTree internally creates a Throwable and walks the stack trace on every log call to extract the calling class name via createStackElementTag(). This has two problems in release:
- R8 obfuscation: The project has
isMinifyEnabled = true in release. R8 renames classes, so createStackElementTag() returns obfuscated names like a or b.c instead of meaningful class names — making the tag useless.
- Unnecessary overhead: Stack trace generation on every log call is wasted work when we don't need class-name tags in production.
Solution: Use Timber.Tree() (not DebugTree) as the base for the release tree with a fixed application tag.
Why keep WARN/ERROR/ASSERT in production?
- ERROR/ASSERT represent genuine failures (API errors, unexpected state, caught exceptions) that would be invisible without logging
- WARN represents degraded behavior worth monitoring
- DEBUG/INFO/VERBOSE are development noise that should be stripped in release
NonFatalException pattern — controlled Crashlytics reporting
Not all exceptions should be sent to Firebase Crashlytics. Only exceptions that explicitly extend NonFatalException are reported. This is a deliberate design choice:
- Regular exceptions (NPE, IllegalStateException, etc.) → handled by Crashlytics' automatic crash handler. No need to duplicate them via
recordException().
- NonFatalException subclasses → these are expected error conditions that the app catches and handles gracefully, but we still want visibility in the Crashlytics dashboard. Each has a
code property for categorization.
// Base class for all non-fatal exceptions reported to Crashlytics
open class NonFatalException(
open val code: String,
override val message: String,
) : Exception(message) {
companion object {
const val GENERIC_NON_FATAL_ERROR = "GENERIC_NON_FATAL_ERROR"
}
}
// Example: a specific non-fatal for video player errors
class VideoPlayerException(
override val message: String,
override val code: String = VIDEO_PLAYER_ERROR,
cause: Throwable? = null
) : NonFatalException(code = code, message = message) {
companion object {
const val VIDEO_PLAYER_ERROR = "VIDEO_PLAYER_ERROR"
}
init {
cause?.let { initCause(it) }
}
}
Usage at call site:
// ✅ This gets sent to Crashlytics (NonFatalException)
Timber.e(VideoPlayerException("Codec not supported"), "Playback failed")
// ❌ This does NOT get sent to Crashlytics (regular exception)
// Crashlytics' automatic crash handler deals with these
Timber.e(IOException("timeout"), "Network error")
// ❌ No Throwable → nothing sent to Crashlytics
Timber.e("Something went wrong")
Implementation Plan
1. Create NonFatalException — core/domain/src/main/java/.../exception/NonFatalException.kt
package com.alexfin90.cvshowcase.domain.exception
open class NonFatalException(
open val code: String,
override val message: String,
) : Exception(message) {
companion object {
const val GENERIC_NON_FATAL_ERROR = "GENERIC_NON_FATAL_ERROR"
}
}
Placed in core/domain because it's a pure Kotlin class with no Android dependencies, and domain exceptions are part of the business layer. Feature modules and data modules can create their own subclasses.
2. Create CvShowcaseDebugTree — app/src/debug/java/.../logging/CvShowcaseDebugTree.kt
package com.alexfin90.cvshowcase.logging
import timber.log.Timber
object CvShowcaseDebugTree : Timber.DebugTree() {
override fun createStackElementTag(element: StackTraceElement): String {
return "${super.createStackElementTag(element)}:${element.lineNumber}"
}
}
- Extends
DebugTree for full createStackElementTag support
- Custom tag format:
ClassName:42 (class name + line number)
- Logs ALL priority levels to Logcat
3. Create CvShowcaseReleaseTree — app/src/release/java/.../logging/CvShowcaseReleaseTree.kt
package com.alexfin90.cvshowcase.logging
import android.util.Log
import com.alexfin90.cvshowcase.domain.exception.NonFatalException
import com.google.firebase.crashlytics.FirebaseCrashlytics
import timber.log.Timber
object CvShowcaseReleaseTree : Timber.Tree() {
private const val TAG = "CvShowcase"
private const val CRASHLYTICS_KEY_PRIORITY = "priority"
private const val CRASHLYTICS_KEY_TAG = "tag"
private const val CRASHLYTICS_KEY_MESSAGE = "message"
private const val CRASHLYTICS_KEY_ERROR_CODE = "code"
override fun isLoggable(tag: String?, priority: Int): Boolean {
return priority >= Log.WARN
}
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
val logTag = tag ?: TAG
// Always output to Logcat for WARN/ERROR/ASSERT
Log.println(priority, logTag, message)
// Send ONLY NonFatalException to Crashlytics
if (t is NonFatalException) {
recordToCrashlytics(priority, logTag, message, t)
}
}
private fun recordToCrashlytics(
priority: Int,
tag: String,
message: String,
t: NonFatalException,
) {
try {
val crashlytics = FirebaseCrashlytics.getInstance()
crashlytics.setCustomKey(CRASHLYTICS_KEY_PRIORITY, priorityLabel(priority))
crashlytics.setCustomKey(CRASHLYTICS_KEY_TAG, tag)
crashlytics.setCustomKey(CRASHLYTICS_KEY_MESSAGE, message)
crashlytics.setCustomKey(CRASHLYTICS_KEY_ERROR_CODE, t.code)
crashlytics.log("$tag: $message")
crashlytics.recordException(t)
} catch (_: IllegalStateException) {
// Crashlytics not initialized (e.g., mock flavor)
}
}
private fun priorityLabel(priority: Int): String = when (priority) {
Log.ERROR -> "ERROR"
Log.WARN -> "WARN"
Log.ASSERT -> "ASSERT"
else -> "UNKNOWN"
}
}
Key differences from previous version:
t is NonFatalException check — only NonFatalException subclasses are reported to Crashlytics
code custom key — the NonFatalException.code is sent as a Crashlytics custom key for categorization/filtering in the dashboard
- No
RuntimeException(message) fallback — if there's no Throwable, or the Throwable isn't a NonFatalException, nothing is sent to Crashlytics. Regular crashes are handled automatically by Crashlytics' crash handler.
4. Create bridge function in both source sets
Since CvApplication lives in the main source set, it cannot directly reference classes from debug or release source sets. A bridge function with the same signature in both source sets solves this:
app/src/debug/java/.../logging/TimberConfiguration.kt
package com.alexfin90.cvshowcase.logging
import timber.log.Timber
fun plantTimberTree() {
Timber.plant(CvShowcaseDebugTree)
}
app/src/release/java/.../logging/TimberConfiguration.kt
package com.alexfin90.cvshowcase.logging
import timber.log.Timber
fun plantTimberTree() {
Timber.plant(CvShowcaseReleaseTree)
}
5. Update CvApplication.onCreate()
override fun onCreate() {
super.onCreate()
plantTimberTree() // Initialize Timber before anything else
setStrictModePolicy()
}
6. Enable line numbers in ProGuard for Crashlytics
Uncomment in app/proguard-rules.pro:
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
This preserves line numbers in stack traces sent to Crashlytics while hiding actual source file names.
Crashlytics Flow Diagram
Timber.e(throwable, "message")
│
▼
ReleaseTree.isLoggable() ── priority < WARN? ──► DISCARD (no-op)
│
▼ (WARN/ERROR/ASSERT)
ReleaseTree.log()
│
├──► Log.println() → Logcat output
│
└──► t is NonFatalException?
│ │
NO YES
│ │
▼ ▼
SKIP recordToCrashlytics()
(regular ├─ setCustomKey("priority", "ERROR")
crashes ├─ setCustomKey("tag", ...)
handled ├─ setCustomKey("message", ...)
by auto ├─ setCustomKey("code", t.code)
crash ├─ log(breadcrumb)
handler) └─ recordException(t)
Build Variant Matrix
| Variant |
Tree |
Logcat |
Crashlytics |
Log Levels |
| mockDebug |
DebugTree |
✅ All levels |
❌ Not available |
V/D/I/W/E/A |
| realDebug |
DebugTree |
✅ All levels |
Available but unused |
V/D/I/W/E/A |
| mockRelease |
ReleaseTree |
✅ W/E/A only |
❌ try-catch skips |
W/E/A |
| realRelease |
ReleaseTree |
✅ W/E/A only |
✅ NonFatalException only |
W/E/A |
Acceptance Criteria
Future Enhancements
- Remote log toggle via Firebase Remote Config (enable verbose logging in release on-demand)
- Extractable logging library for reuse across projects
Context
Timber 5.0.1 is already declared as a dependency but not yet initialized — there's a
//TODO TimberinCvApplication.onCreate(). Firebase Crashlytics is also integrated (BOM 34.9.0) but not yet connected to the logging pipeline.We need to configure Timber with build-type-specific trees:
Problem
Without proper logging configuration:
Design Decisions
Why not use
Timber.DebugTree()in release?DebugTreeinternally creates aThrowableand walks the stack trace on every log call to extract the calling class name viacreateStackElementTag(). This has two problems in release:isMinifyEnabled = truein release. R8 renames classes, socreateStackElementTag()returns obfuscated names likeaorb.cinstead of meaningful class names — making the tag useless.Solution: Use
Timber.Tree()(notDebugTree) as the base for the release tree with a fixed application tag.Why keep WARN/ERROR/ASSERT in production?
NonFatalException pattern — controlled Crashlytics reporting
Not all exceptions should be sent to Firebase Crashlytics. Only exceptions that explicitly extend
NonFatalExceptionare reported. This is a deliberate design choice:recordException().codeproperty for categorization.Usage at call site:
Implementation Plan
1. Create
NonFatalException—core/domain/src/main/java/.../exception/NonFatalException.ktPlaced in
core/domainbecause it's a pure Kotlin class with no Android dependencies, and domain exceptions are part of the business layer. Feature modules and data modules can create their own subclasses.2. Create
CvShowcaseDebugTree—app/src/debug/java/.../logging/CvShowcaseDebugTree.ktDebugTreefor fullcreateStackElementTagsupportClassName:42(class name + line number)3. Create
CvShowcaseReleaseTree—app/src/release/java/.../logging/CvShowcaseReleaseTree.ktKey differences from previous version:
t is NonFatalExceptioncheck — onlyNonFatalExceptionsubclasses are reported to Crashlyticscodecustom key — theNonFatalException.codeis sent as a Crashlytics custom key for categorization/filtering in the dashboardRuntimeException(message)fallback — if there's no Throwable, or the Throwable isn't aNonFatalException, nothing is sent to Crashlytics. Regular crashes are handled automatically by Crashlytics' crash handler.4. Create bridge function in both source sets
Since
CvApplicationlives in themainsource set, it cannot directly reference classes fromdebugorreleasesource sets. A bridge function with the same signature in both source sets solves this:app/src/debug/java/.../logging/TimberConfiguration.ktapp/src/release/java/.../logging/TimberConfiguration.kt5. Update
CvApplication.onCreate()6. Enable line numbers in ProGuard for Crashlytics
Uncomment in
app/proguard-rules.pro:This preserves line numbers in stack traces sent to Crashlytics while hiding actual source file names.
Crashlytics Flow Diagram
Build Variant Matrix
Acceptance Criteria
./gradlew :app:assembleRealDebugcompiles successfully./gradlew :app:assembleRealReleasecompiles successfully./gradlew :app:assembleMockDebugcompiles successfully./gradlew :app:assembleMockReleasecompiles successfully./gradlew test— all existing tests passClassName:lineNumbertagsTimber.e(NonFatalException(...), "msg")appears in Firebase Crashlytics dashboard with custom keysTimber.e(IOException(...), "msg")does NOT create arecordExceptionin Crashlytics (handled by automatic crash handler instead)Future Enhancements