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
- Add
loggingModuleName=logging to gradle.properties
- Add
loggingModuleName property to ProjectExtensions.kt (build-logic)
- Add
include(":core:logging") to settings.gradle.kts
- Create
core/logging/build.gradle.kts with cvshowcase-library plugin
Step 1B: Interfaces and Models
LogEntry.kt
LogPolicy.kt + MutableLogPolicy
LogSink.kt
CrashReporterSink.kt
exception/NonFatalException.kt
NonFatalReport.kt
Redaction.kt, NoOpRedaction.kt, DefaultRedaction.kt
Step 1C: Pipeline and Sinks
LogPipeline.kt
LogcatSink.kt
InMemoryRingBufferSink.kt
RingBufferExporter.kt
Step 1D: Timber Bridge + Entry Point
LogwoodTimberTree.kt
Logwood.kt + LogwoodBuilder + SinkBuilder
Step 1E: Unit Tests
LogPolicyTest.kt — accepts() with minPriority, enabled, sampleRate, tags, remoteDebugSession
MutableLogPolicyTest.kt — thread-safety, reset()
LogPipelineTest.kt — redaction, global policy, per-sink policy, dispatchNonFatal
InMemoryRingBufferSinkTest.kt — wrapping, snapshot ordering, clear, concurrency
DefaultRedactionTest.kt — email, phone, IP, token, GPS, edge cases
LogwoodTimberTreeTest.kt — correct dispatch
RingBufferExporterTest.kt — plain text and JSON format
LogwoodInstallTest.kt — DSL, Timber tree planted, policy, resetPolicy
Step 1F: App Integration
app/src/real/kotlin/.../CrashlyticsSink.kt
app/src/real/kotlin/.../LoggingModule.kt (Hilt)
app/src/mock/kotlin/.../LoggingModule.kt (Hilt, no Crashlytics)
- Add
implementation(project(":core:logging")) to app/build.gradle.kts
- Update
CvShowcaseApplication.kt with Logwood.install {}
- 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
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 |
Logwood — Production-Grade Logging Library for Android
Why
The project has Timber 5.0.1 declared in every module (via convention plugin) but never initialized (
//TODO TimberinCvApplication). Rather than writing yet another customTimber.Tree, we want a production-grade logging pipeline with:LogSinkreportNonFatal()API decoupled from FirebaseStrategy: Step 1 as
core:loggingmodule inside CvShowcase, stabilized with tests, then extracted to a standalone OSS repo (logwood-android).Architecture
Pipeline
For
reportNonFatal(), a direct path bypasses the log pipeline:Module Structure
DSL API
Installation
Non-Fatal Reporting
Runtime Toggle
Ring Buffer Export
Key Interfaces
LogEntry
LogPolicy
When
remoteDebugSession = true, the effective min priority overrides toVERBOSEregardless ofminPriority. Thread-safe viaMutableLogPolicybacked byAtomicReference.resetPolicy()restores the original policy set at install time.LogSink + CrashReporterSink
NonFatalException (in
exception/package)Extensible for domain-specific exceptions:
NonFatalReport
Redaction
DefaultRedactionscrubs 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
object Logwood(no Hilt)install {}. core:logging stays Hilt-free and extractable.AtomicReferencefor policyReentrantReadWriteLockfor ring bufferremoteDebugSessionin policyresetPolicy()MutableLogPolicystores the original.NonFatalReportseparate fromNonFatalExceptionNonFatalExceptioninexception/package inside core:loggingpolicy {}+sinks {}Privacy & Security
getFilesDir()— never external storageImplementation Plan
Step 1A: Gradle Infrastructure
loggingModuleName=loggingtogradle.propertiesloggingModuleNameproperty toProjectExtensions.kt(build-logic)include(":core:logging")tosettings.gradle.ktscore/logging/build.gradle.ktswithcvshowcase-librarypluginStep 1B: Interfaces and Models
LogEntry.ktLogPolicy.kt+MutableLogPolicyLogSink.ktCrashReporterSink.ktexception/NonFatalException.ktNonFatalReport.ktRedaction.kt,NoOpRedaction.kt,DefaultRedaction.ktStep 1C: Pipeline and Sinks
LogPipeline.ktLogcatSink.ktInMemoryRingBufferSink.ktRingBufferExporter.ktStep 1D: Timber Bridge + Entry Point
LogwoodTimberTree.ktLogwood.kt+LogwoodBuilder+SinkBuilderStep 1E: Unit Tests
LogPolicyTest.kt— accepts() with minPriority, enabled, sampleRate, tags, remoteDebugSessionMutableLogPolicyTest.kt— thread-safety, reset()LogPipelineTest.kt— redaction, global policy, per-sink policy, dispatchNonFatalInMemoryRingBufferSinkTest.kt— wrapping, snapshot ordering, clear, concurrencyDefaultRedactionTest.kt— email, phone, IP, token, GPS, edge casesLogwoodTimberTreeTest.kt— correct dispatchRingBufferExporterTest.kt— plain text and JSON formatLogwoodInstallTest.kt— DSL, Timber tree planted, policy, resetPolicyStep 1F: App Integration
app/src/real/kotlin/.../CrashlyticsSink.ktapp/src/real/kotlin/.../LoggingModule.kt(Hilt)app/src/mock/kotlin/.../LoggingModule.kt(Hilt, no Crashlytics)implementation(project(":core:logging"))toapp/build.gradle.ktsCvShowcaseApplication.ktwithLogwood.install {}Phase 2 (future): DevSettings + Remote Toggle
feature:devsettingsmodule with Compose UIFirebaseRemoteLogToggle(Remote Config + User Property for per-device targeting)LocalPolicyOverride(SharedPreferences)DevSettingsroute + deep linkcvshowcase://devsettingsPhase 3 (future): OSS Extraction
logwood-androidon GitHublogwood-core,logwood-firebase-crashlytics,logwood-timber-bridgeBuild Variant Matrix
Any variant can change policy at runtime via
Logwood.updatePolicy {}orremoteDebugSession = true.Verification Checklist
./gradlew build— all variants compile./gradlew :core:logging:test— all unit tests pass./gradlew test— existing tests not brokenTimber.d("test")appears in LogcatTimber.d("test")does NOT appear,Timber.w("test")doesLogwood.updatePolicy { copy(minPriority = Log.VERBOSE) }→ debug logs appearresetPolicy()restores original policyLogwood.exportRingBuffer()returns last N logsLogwood.reportNonFatal(NonFatalException("CODE", "msg"))→ appears in Crashlytics (real only)[EMAIL],[PHONE]in all sinksremoteDebugSession = true→ all levels pass even in releaseDifferentiation
policy {}+sinks {}updatePolicy/resetPolicyCrashReporterSinkinterfaceInMemoryRingBufferSinkDefaultRedactionsampleRate0.0..1.0remoteDebugSessionflagexportRingBuffer()logwood-core/logwood-crashlytics