Skip to content

Configure Timber Logging with Debug/Release Trees and Firebase Crashlytics Integration #7

Description

@alexfin90

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:

  1. No structured logging is available during development
  2. Production errors are invisible — no crash reporting for non-fatal exceptions
  3. 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:

  1. 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.
  2. 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 NonFatalExceptioncore/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 CvShowcaseDebugTreeapp/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 CvShowcaseReleaseTreeapp/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

  • ./gradlew :app:assembleRealDebug compiles successfully
  • ./gradlew :app:assembleRealRelease compiles successfully
  • ./gradlew :app:assembleMockDebug compiles successfully
  • ./gradlew :app:assembleMockRelease compiles successfully
  • ./gradlew test — all existing tests pass
  • Debug build: all Timber log levels appear in Logcat with ClassName:lineNumber tags
  • Release build: only WARN/ERROR/ASSERT appear in Logcat
  • Release + real flavor: Timber.e(NonFatalException(...), "msg") appears in Firebase Crashlytics dashboard with custom keys
  • Release + real flavor: Timber.e(IOException(...), "msg") does NOT create a recordException in Crashlytics (handled by automatic crash handler instead)

Future Enhancements

  • Remote log toggle via Firebase Remote Config (enable verbose logging in release on-demand)
  • Extractable logging library for reuse across projects

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions