Skip to content

feat: Logwood — production-grade logging library for Android #9

Description

@alexfin90

Logwood — Production-Grade Logging Library for Android

Unified configuration, privacy-first, modular sinks, runtime control.

Why

The project has Timber 5.0.1 declared in every module (via convention plugin) but never initialized (//TODO Timber in CvApplication). Rather than writing yet another custom Timber.Tree, we want a production-grade logging pipeline with:

  • Modular sinks: each output (Logcat, Crashlytics, Sentry, File, RingBuffer) is a pluggable LogSink
  • Runtime policy control: change log priority, enable/disable, sampling, tag filtering — without recompiling
  • Backend-agnostic non-fatal reporting: reportNonFatal() API decoupled from Firebase
  • Privacy-first: PII redaction before any sink, in-memory ring buffer only, no permanent storage by default
  • Simple DSL for configuration

Strategy: Step 1 as core:logging module inside CvShowcase, stabilized with tests, then extracted to a standalone OSS repo (logwood-android).


Architecture

Pipeline

App / Feature Module
        │
        ▼
   Logwood.log(...)                    ← direct API
   Logwood.reportNonFatal(...)         ← non-fatal reporting
        │
   Timber.d() / Timber.e()            ← or via Timber bridge
        │
        ▼
   LogwoodTimberTree                   ← converts Timber call to LogEntry
        │
        ▼
   Redaction                           ← DefaultRedaction scrubs PII
        │
        ▼
   LogPolicy (runtime)                 ← AtomicReference, thread-safe
   minPriority? enabled? sampleRate?
   tagsBlocklist? remoteDebugSession?
        │
   ┌────┴──────┬───────────────┬──────────────────┐
   ▼           ▼               ▼                  ▼
LogcatSink  RingBufferSink  CrashlyticsSink    (future sinks)
   │           │               │
   ▼           ▼               ▼
Per-sink     Per-sink        Per-sink
policy       policy          policy

For reportNonFatal(), a direct path bypasses the log pipeline:

Logwood.reportNonFatal(throwable, attrs, fingerprint)
  → sinks.filterIsInstance<CrashReporterSink>()
  → CrashReporterSink.reportNonFatal(NonFatalReport)

Module Structure

core:logging (NEW Android library)
  depends on: timber (already in convention plugin)
  ZERO Firebase/Hilt dependencies
  │
  ├── Logwood.kt                        — entry point + DSL
  ├── LogEntry.kt                       — data class (priority, tag, message, throwable, timestamp, thread)
  ├── LogPolicy.kt                      — data class + MutableLogPolicy (AtomicReference)
  ├── LogSink.kt                        — base interface
  ├── CrashReporterSink.kt              — extends LogSink + reportNonFatal
  ├── NonFatalReport.kt                 — model (throwable + attributes + fingerprint)
  ├── exception/
  │   └── NonFatalException.kt          — base exception with code field
  ├── pipeline/
  │   └── LogPipeline.kt                — orchestrator: redact → policy → dispatch
  ├── sink/
  │   ├── LogcatSink.kt                 — android.util.Log output
  │   └── InMemoryRingBufferSink.kt     — circular buffer, thread-safe, exportable
  ├── redaction/
  │   ├── Redaction.kt                  — fun interface
  │   ├── NoOpRedaction.kt
  │   └── DefaultRedaction.kt           — email, phone, IP, token, GPS regex
  ├── timber/
  │   └── LogwoodTimberTree.kt          — Timber.Tree bridge
  └── export/
      └── RingBufferExporter.kt         — plain text + JSON lines

app (existing)
  depends on: core:logging
  │
  ├── src/real/kotlin/.../logging/
  │   ├── CrashlyticsSink.kt            — implements CrashReporterSink via Firebase
  │   └── LoggingModule.kt              — Hilt: provides sinks
  ├── src/mock/kotlin/.../logging/
  │   └── LoggingModule.kt              — Hilt: no Crashlytics
  └── CvShowcaseApplication.kt          — Logwood.install {}

DSL API

Installation

// In CvApplication.onCreate()
Logwood.install {
    policy {
        minPriority = if (BuildConfig.DEBUG) Log.VERBOSE else Log.WARN
        enabled = true
        sampleRate = 1.0f
        tagsBlocklist = setOf("NoisyTag", "NetworkPoll")
    }

    sinks {
        logcat()
        ringBuffer(maxEntries = 500)
        crashlytics(myCrashlyticsSink, nonFatalMinPriority = Log.WARN)
    }

    redaction(DefaultRedaction())
}

Non-Fatal Reporting

// Direct API (no Timber dependency needed)
Logwood.reportNonFatal(
    throwable = NetworkNonFatalException(
        code = "PAYMENT_FAILED",
        message = "Timeout on checkout",
        httpStatusCode = 504,
    ),
    attributes = mapOf("userId" to userId, "screen" to "checkout"),
    fingerprint = "payment-flow-error",
)

// Or via Timber (backward-compatible)
Timber.e(NonFatalException(code = "GENERIC", message = "..."))

Runtime Toggle

// Enable debug logging for a diagnostic session
Logwood.updatePolicy { copy(minPriority = Log.DEBUG, remoteDebugSession = true) }

// Restore original policy
Logwood.resetPolicy()

Ring Buffer Export

val logDump: String = Logwood.exportRingBuffer()
// Attachable to email, share sheet, or manual upload

Key Interfaces

LogEntry

data class LogEntry(
    val priority: Int,
    val tag: String?,
    val message: String,
    val throwable: Throwable?,
    val timestamp: Long = System.currentTimeMillis(),
    val threadName: String = Thread.currentThread().name,
)

LogPolicy

data class LogPolicy(
    val minPriority: Int = Log.WARN,
    val enabled: Boolean = true,
    val sampleRate: Float = 1.0f,
    val tagsAllowlist: Set<String> = emptySet(),
    val tagsBlocklist: Set<String> = emptySet(),
    val remoteDebugSession: Boolean = false,
)

When remoteDebugSession = true, the effective min priority overrides to VERBOSE regardless of minPriority. Thread-safe via MutableLogPolicy backed by AtomicReference. resetPolicy() restores the original policy set at install time.

LogSink + CrashReporterSink

interface LogSink {
    val name: String
    val policyOverride: LogPolicy? get() = null
    fun log(entry: LogEntry)
    fun flush() {}
}

interface CrashReporterSink : LogSink {
    fun reportNonFatal(report: NonFatalReport)
}

NonFatalException (in exception/ package)

open class NonFatalException(
    open val code: String,
    override val message: String,
    cause: Throwable? = null,
) : Exception(message, cause)

Extensible for domain-specific exceptions:

class NetworkNonFatalException(
    override val code: String,
    override val message: String,
    val httpStatusCode: Int,
) : NonFatalException(code, message)

NonFatalReport

data class NonFatalReport(
    val throwable: Throwable,
    val attributes: Map<String, String> = emptyMap(),
    val fingerprint: String? = null,
)

Redaction

fun interface Redaction {
    fun redact(entry: LogEntry): LogEntry
}

DefaultRedaction scrubs PII with regex patterns (email, phone, IP, tokens, GPS coordinates) before any sink receives the log entry. This is a safety net — even if a developer accidentally logs PII, sinks never receive it in clear text.


Design Decisions

Decision Rationale
object Logwood (no Hilt) Logging is global by nature. The app uses Hilt to build sinks, then passes them to install {}. core:logging stays Hilt-free and extractable.
AtomicReference for policy Reads vastly outnumber writes. Lock-free for read, CAS for write. No suspend/Mutex needed in hot path.
ReentrantReadWriteLock for ring buffer Fixed-size with wrapping semantics. Allows concurrent snapshots without blocking writers.
remoteDebugSession in policy Global override to VERBOSE without touching minPriority. Single flag activatable from Remote Config.
resetPolicy() Returns to the original policy after a debug session. MutableLogPolicy stores the original.
NonFatalReport separate from NonFatalException Report has attributes and fingerprint that don't belong on the exception. Separates the model from the throwable.
NonFatalException in exception/ package inside core:logging It's a library concept, not app domain. Travels with the module when extracted to OSS. Dedicated package keeps it confined.
Nested DSL: policy {} + sinks {} Clean separation between policy config and output config. More readable than flat parameters.
Redaction before sinks PII scrubbed once, not per-sink. Guarantees no sink receives cleartext PII.

Privacy & Security

  • Ring buffer: in-memory only, not persisted across sessions
  • FileSink (future): will use getFilesDir() — never external storage
  • DefaultRedaction: applied BEFORE any sink receives data
  • No automatic upload by design: every export is user-initiated or event-triggered (non-fatal only)
  • remoteDebugSession: should be protected by an authenticated flag (e.g., Firebase Remote Config with user-specific rules)
  • Sampling: reduces data volume in production

Implementation Plan

Step 1A: Gradle Infrastructure

  1. Add loggingModuleName=logging to gradle.properties
  2. Add loggingModuleName property to ProjectExtensions.kt (build-logic)
  3. Add include(":core:logging") to settings.gradle.kts
  4. Create core/logging/build.gradle.kts with cvshowcase-library plugin

Step 1B: Interfaces and Models

  1. LogEntry.kt
  2. LogPolicy.kt + MutableLogPolicy
  3. LogSink.kt
  4. CrashReporterSink.kt
  5. exception/NonFatalException.kt
  6. NonFatalReport.kt
  7. Redaction.kt, NoOpRedaction.kt, DefaultRedaction.kt

Step 1C: Pipeline and Sinks

  1. LogPipeline.kt
  2. LogcatSink.kt
  3. InMemoryRingBufferSink.kt
  4. RingBufferExporter.kt

Step 1D: Timber Bridge + Entry Point

  1. LogwoodTimberTree.kt
  2. Logwood.kt + LogwoodBuilder + SinkBuilder

Step 1E: Unit Tests

  1. LogPolicyTest.kt — accepts() with minPriority, enabled, sampleRate, tags, remoteDebugSession
  2. MutableLogPolicyTest.kt — thread-safety, reset()
  3. LogPipelineTest.kt — redaction, global policy, per-sink policy, dispatchNonFatal
  4. InMemoryRingBufferSinkTest.kt — wrapping, snapshot ordering, clear, concurrency
  5. DefaultRedactionTest.kt — email, phone, IP, token, GPS, edge cases
  6. LogwoodTimberTreeTest.kt — correct dispatch
  7. RingBufferExporterTest.kt — plain text and JSON format
  8. LogwoodInstallTest.kt — DSL, Timber tree planted, policy, resetPolicy

Step 1F: App Integration

  1. app/src/real/kotlin/.../CrashlyticsSink.kt
  2. app/src/real/kotlin/.../LoggingModule.kt (Hilt)
  3. app/src/mock/kotlin/.../LoggingModule.kt (Hilt, no Crashlytics)
  4. Add implementation(project(":core:logging")) to app/build.gradle.kts
  5. Update CvShowcaseApplication.kt with Logwood.install {}
  6. Uncomment ProGuard rules for SourceFile + LineNumber

Phase 2 (future): DevSettings + Remote Toggle

  • feature:devsettings module with Compose UI
  • FirebaseRemoteLogToggle (Remote Config + User Property for per-device targeting)
  • LocalPolicyOverride (SharedPreferences)
  • DevSettings route + deep link cvshowcase://devsettings

Phase 3 (future): OSS Extraction

  • New repo logwood-android on GitHub
  • Multi-module: logwood-core, logwood-firebase-crashlytics, logwood-timber-bridge
  • Maven Central / JitPack publishing
  • README, sample app, CI

Build Variant Matrix

Variant Active Sinks Crashlytics Logcat RingBuffer
mockDebug Logcat + RingBuffer No VERBOSE+ Yes
realDebug Logcat + RingBuffer + Crashlytics Yes VERBOSE+ Yes
mockRelease Logcat + RingBuffer No WARN+ Yes
realRelease Logcat + RingBuffer + Crashlytics Yes WARN+ Yes

Any variant can change policy at runtime via Logwood.updatePolicy {} or remoteDebugSession = true.


Verification Checklist

  • ./gradlew build — all variants compile
  • ./gradlew :core:logging:test — all unit tests pass
  • ./gradlew test — existing tests not broken
  • Debug: Timber.d("test") appears in Logcat
  • Release: Timber.d("test") does NOT appear, Timber.w("test") does
  • Runtime toggle: Logwood.updatePolicy { copy(minPriority = Log.VERBOSE) } → debug logs appear
  • resetPolicy() restores original policy
  • Logwood.exportRingBuffer() returns last N logs
  • Logwood.reportNonFatal(NonFatalException("CODE", "msg")) → appears in Crashlytics (real only)
  • Redaction: log with email/phone → [EMAIL], [PHONE] in all sinks
  • remoteDebugSession = true → all levels pass even in release
  • Thread safety: concurrent tests on MutableLogPolicy and InMemoryRingBufferSink pass

Differentiation

Aspect Timber (raw) Timber + custom Tree Logwood
Unified DSL configuration - - policy {} + sinks {}
Runtime toggle without recompile - - updatePolicy / resetPolicy
Backend-agnostic non-fatal - Crashlytics only CrashReporterSink interface
Local ring buffer - - InMemoryRingBufferSink
Built-in PII redaction - - DefaultRedaction
Sampling rate - - sampleRate 0.0..1.0
Remote debug session - - remoteDebugSession flag
Diagnostics export - - exportRingBuffer()
Separate core/firebase modules N/A - logwood-core / logwood-crashlytics

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions