Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

53 Commits

Folders and files

Repository files navigation

Android FileLogger

Production-oriented Android file logging with bounded async writes, failure-aware flush, hybrid retention, structured context, write-time redaction, user-controlled collection, crash capture, support exports, diagnostics, and optional OkHttp, remote-upload, Crashlytics, OpenTelemetry, and Compose viewer modules.

Türkçe ve başlangıç seviyesinde entegrasyon anlatımı için Sıfırdan Kurulum ve Kullanım Rehberi belgesine bakın.

Why not only Logcat or Timber?

Logcat is excellent while a device is attached, and Timber is excellent for routing logs during development. FileLogger focuses on the support case after the event: bounded on-device persistence, session folders, crash-adjacent durability, searchable stored records, redacted ZIP export, and offline remote delivery. It can be used next to Timber rather than replacing an application's existing logging facade.

Modules

Module Purpose
filelogger-core Kotlin Multiplatform logging API and engine for Android, JVM, iOS, and Wasm
filelogger Core file, Logcat, crash, export, query, retention, and diagnostics APIs
filelogger-okhttp Privacy-safe OkHttp request/response logging
filelogger-remote Batching, gzip, retry/backoff, and persistent offline spool
filelogger-crashlytics Bounded custom logs and non-fatal reports for Firebase Crashlytics
filelogger-otel Structured OpenTelemetry logs with trace-context correlation support
filelogger-ui Compose viewer with search and level filters
app Interactive sample and device reliability tests

Install

For a source checkout:

dependencies {
    implementation(project(":filelogger-core")) // common Kotlin code
    implementation(project(":filelogger"))
    implementation(project(":filelogger-okhttp")) // optional
    implementation(project(":filelogger-remote")) // optional
    implementation(project(":filelogger-crashlytics")) // optional
    implementation(project(":filelogger-otel"))        // optional
    implementation(project(":filelogger-ui"))     // optional
}

Maven Central is the primary distribution channel. Android projects normally already have mavenCentral() in dependencyResolutionManagement.repositories:

repositories {
    google()
    mavenCentral()
}

dependencies {
    implementation("io.github.sevcanucan:filelogger-core:1.0.0-rc7") // KMP common code
    implementation("io.github.sevcanucan:filelogger:1.0.0-rc7")
    implementation("io.github.sevcanucan:filelogger-okhttp:1.0.0-rc7") // optional
    implementation("io.github.sevcanucan:filelogger-remote:1.0.0-rc7") // optional
    implementation("io.github.sevcanucan:filelogger-crashlytics:1.0.0-rc7") // optional
    implementation("io.github.sevcanucan:filelogger-otel:1.0.0-rc7")         // optional
    implementation("io.github.sevcanucan:filelogger-ui:1.0.0-rc7")     // optional
}

These are library modules, not separate applications. Gradle packages only the selected modules into the host application's single APK or AAB. Do not add every module unless the application uses it.

1.0.0-rc7 is the current Maven Central release candidate and includes the Kotlin Multiplatform core, Crashlytics destination, and OpenTelemetry destination. 1.0.0-rc6 remains the first Maven Central release candidate. Final 1.0.0 publication remains deferred until the candidate has completed external application validation. The coordinates become resolvable after the first Central Portal deployment is approved and published.

JitPack remains available as an alternative for existing users:

repositories { maven("https://jitpack.io") }

dependencies {
    implementation("com.github.SevcanUcan.android-file-logger:filelogger:1.0.0-rc4")
}

Maintainers can follow the Maven Central release guide.

Kotlin Multiplatform core

filelogger-core exposes a platform-neutral logger, structured records, tag and level filtering, failure-isolated composite destinations, and a thread-safe bounded memory destination. It publishes Gradle module metadata plus Android, JVM, iOS device, iOS simulator, and Wasm artifacts.

val recentLogs = BoundedMemoryLogDestination(capacity = 200)
val logger = DefaultLogger(
    destination = recentLogs,
    config = LoggerConfig(
        minimumLevel = LogLevel.INFO,
        context = mapOf("build" to "1.0.0")
    )
)

logger.i("Sync", "Upload started", mapOf("items" to "12"))

On Android, common Kotlin records can use the full file pipeline after the regular FileLogger.install(...) call:

val sharedLogger = com.filelogger.core.DefaultLogger(
    destination = com.filelogger.kmp.FileLoggerDestination(),
    config = com.filelogger.core.LoggerConfig(processName = "android")
)

See Multiplatform architecture for targets, publication, CI coverage, and current platform limitations.

Quick start

Initialize once from Application using explicit environment defaults:

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        FileLogger.install(
            this,
            if (BuildConfig.DEBUG) LoggerConfig.dev() else LoggerConfig.prod()
        )
    }
}
FileLogger.d("Startup", "Application ready")
FileLogger.i("Startup", "Initial data loaded")
FileLogger.w("Sync", "Retry scheduled")
FileLogger.e("Checkout", "Payment failed", throwable)

dev() uses readable DEBUG logs and longer retention. prod() uses WARN+ JSON logs and tighter storage limits. Both use a bounded queue, process-specific files, session folders, background flush, crash capture, and synchronous ERROR fallback.

Structured context and privacy

Attach stable context once and event-specific attributes at the call site:

FileLogger.putContext(
    mapOf(
        "build" to BuildConfig.VERSION_NAME,
        "accountTier" to "pro"
    )
)

FileLogger.i(
    tag = "Checkout",
    message = "Payment submitted",
    attributes = mapOf("orderId" to order.id, "provider" to "card")
)

Event attributes override global context with the same key. Attribute values are redacted by the configured LogRedactor before any Logcat, file, memory, custom, or remote destination receives them. LoggerConfig.prod() enables LogRedactor.DEFAULT_SENSITIVE; custom policies can be supplied with LoggerConfig.Builder().setRedactor(...). Tags and attribute keys should remain non-sensitive identifiers because they are intentionally preserved for filtering.

Collection can follow the host application's consent state:

FileLogger.setCollectionEnabled(consentGranted)

// Consent withdrawal or account deletion:
FileLogger.setCollectionEnabled(false, deleteExistingData = true)
// Or explicitly remove local logs, exports, and pending remote batches:
FileLogger.deleteAllLogs(includeExports = true)

When collection is disabled, new records and crash snapshots are dropped. Remote destinations persist their pause before consent withdrawal reports success. Deletion does not flush pending remote data to the network; it pauses delivery first, clears eligible local data, and fails if an active upload or foreign process claim remains.

Reliability and retention

val config = LoggerConfig.Builder()
    .setMaxFileSize(5 * 1024 * 1024)
    .setMaxTotalLogSize(50 * 1024 * 1024)
    .setMaxLogAgeMillis(TimeUnit.DAYS.toMillis(7))
    .setAsyncQueueCapacity(2_048)
    .setAsyncOverflowStrategy(AsyncOverflowStrategy.DROP_OLDEST)
    .setErrorSyncFallbackEnabled(true)
    .build()

FileLogger.flush() places a barrier after records accepted before the call. It returns true only when those records reached the file destination without a write failure and FileDescriptor.sync() completed for the last written file. A failed interval returns false; after that failure has been reported, a later recovered interval may succeed. ERROR records first drain earlier async records, then write and sync directly so file order is preserved.

The result does not cover non-flushable custom destinations and is not a permanent power-loss guarantee. Android, the filesystem, or storage hardware can still lose data after an abrupt process/native crash, OS kill, device reset, or hardware failure.

With session logging, age and total-size retention apply globally to eligible FileLogger files below the configured log root. The active session is protected. Cleanup is deterministic by modification time and relative path. If the configured limit cannot be met without deleting active data, the write interval and its flush report failure. FileLogger storage operations use a process-local mutex plus an OS file lock; keep the root in app-private internal storage. Remote spool files are not retention candidates.

Query stored logs

val errors = FileLogger.readLogs(
    LogQuery(
        levels = setOf(LogLevel.ERROR),
        tags = setOf("Checkout"),
        keyword = "payment",
        limit = 200
    )
)

val olderSession = FileLogger.readLogs("support-session-42", LogQuery(limit = 500))

The reader supports built-in JSON and plain-text formats, structured attributes, rotated files, throwable continuation lines, timestamp ranges, exact attribute matching, and malformed-line isolation.

Support report and share

Create one bounded, privacy-filtered support package with logs, logger diagnostics, non-identifying app/device metadata, a user note, and application-defined state:

val archive = FileLogger.createSupportReport(
    SupportReportOptions(
        userNote = feedbackText,
        sections = mapOf(
            "feature-flags" to enabledFlags.joinToString("\n"),
            "sync-state" to "pending=$pendingCount\nlastResult=$lastSyncResult"
        )
    )
)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", archive)
context.startActivity(FileLogger.createShareIntent(uri))

The ZIP contains support-report.json, diagnostics.json, optional user-note.txt, optional sections/*.txt, and the current logs. Supplemental content is redacted before export and protected by section-count, per-section, and aggregate byte limits. Section names are path-safe identifiers and must not contain sensitive data. Set includeLogs = false for a metadata-only report. The automatic environment manifest deliberately excludes persistent device identifiers, account data, network addresses, and location.

Breadcrumb timeline

Record the small user and application transitions that explain how the app reached its current state:

FileLogger.addBreadcrumb(
    message = "Opened checkout",
    category = "navigation",
    attributes = mapOf("source" to "cart")
)

Breadcrumbs use the configured redactor at collection time, are held in a thread-safe in-memory ring, and written to breadcrumbs.json in the support report. The default policy keeps the latest 64 records, permits 16 attributes and 8 KiB per record, and preserves the timeline in crash logs before delegating to the previous crash handler. Category and attribute names are non-sensitive identifiers. Collection consent and deleteAllLogs() apply to breadcrumbs; includeBreadcrumbs = false excludes them from an individual report.

For a logs-only archive, use the lower-level export API:

val archive = FileLogger.exportLogs()
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", archive)
context.startActivity(FileLogger.createShareIntent(uri))

Exports include diagnostics.json and apply LogRedactor.DEFAULT_SENSITIVE by default, including to historical lines that were stored without write-time redaction. Export reads and sanitizes each line while writing the ZIP; it does not modify the source log files. LogRedactor.NONE remains an explicit opt-out and can expose stored secrets. The built-in policy covers common email, bearer/JWT, JSON secret-field, API-key, cookie, phone/address-field, and long-number patterns; regex redaction is defense in depth, not a guarantee that arbitrary sensitive data will be recognized. The host application owns its FileProvider policy.

OkHttp

val client = OkHttpClient.Builder()
    .addInterceptor(
        FileLoggerInterceptor(
            config = NetworkLogConfig(
                logRequestHeaders = true,
                logResponseHeaders = true
            )
        )
    )
    .build()

Bodies are disabled by default. All URL query values are masked in request, response, and failure logs. Authorization, cookies, proxy authorization, and API key headers are redacted case-insensitively. Email and JWT path segments are redacted, and the segment following a recognized credential operation such as reset-password or token is fully masked. Arbitrary application-specific opaque path secrets cannot be identified reliably and should not be placed in URLs. Enabled repeatable text bodies use a bounded capture sink; one-shot, duplex, and binary bodies are not evaluated for logging, and the original request is always passed to OkHttp unchanged. The limit bounds FileLogger's capture buffer, but cannot prevent memory allocated internally by a custom RequestBody. A repeatable body may still be evaluated once for logging and once by OkHttp, so body logging should remain disabled for side-effecting body implementations. Disabled collection avoids body evaluation for the default FileLogger; a custom Logger has no loggability contract, so the interceptor cannot infer whether that logger will discard a message.

Safe file paths

logFolder accepts nested relative paths. File names, session ids, session folder prefixes, and ZIP prefixes must be single relative names. Absolute paths, .., current-directory segments, and both slash styles used for traversal are rejected instead of normalized silently. Resolved paths are checked against their canonical root with path-component-aware containment, including readLogs(sessionId). Canonical checks detect links that already exist when resolution occurs. As with ordinary Java file APIs, an attacker able to replace an app-private directory between validation and opening could still create a TOCTOU race; keep log roots in Android internal storage and do not expose them to untrusted writers.

Remote destination

val remote = RemoteLogDestination(
    uploader = RemoteLogUploader { batch ->
        api.upload(batch.payload, batch.contentEncoding)
        true
    },
    config = RemoteLogConfig(
        spoolDirectory = File(noBackupFilesDir, "pending_logs"),
        batchSize = 50,
        maxRetries = 3
    )
)

FileLogger.init(
    this,
    LoggerConfig.prod().copy(customDestinations = listOf(remote))
)

Batches are written to a uniquely named temporary file, synced, and renamed to a committed batch_* file before upload. Existing batch_* files remain recoverable. The default policy never evicts an unacknowledged committed batch to make room. When the spool quota is full, the new memory batch is rejected for persistence, the barrier fails, and diagnostics report the failure while existing committed files remain. Accepted but not-yet-persisted records are bounded by the configured queue capacity plus one batch. A spool failure makes flush() return false and the in-memory batch is retried by a later flush or shutdown; an abrupt process kill before that retry can still lose that memory-only batch.

Committed remote payloads have an internal 16 MiB safety ceiling. Empty or oversized files are not loaded into the heap or silently deleted, make delivery barriers fail, and remain available for operator inspection. A rejected file is skipped for the current scan so later healthy batches can still be delivered. The spool format does not contain a payload checksum, so arbitrary non-empty truncation cannot always be distinguished from valid content.

For RemoteLogDestination, flush() == true means every record before the barrier was persisted, the application-provided uploader returned true, and no committed spool batch remained. It does not independently prove server-side durable storage: the uploader must return true only after the backend acknowledgement it considers authoritative. Offline local spooling therefore keeps the batch but returns false. Cooperating workers use an atomic claim name plus an OS file lock, so one committed file has one local uploader owner at a time. A process can still crash after the server accepts a batch but before the local claim is deleted. The same batch may then be sent again after restart. For idempotent uploads, opt in without changing RemoteLogBatch:

val remote = RemoteLogDestination.withIdentifiedUploader(
    uploader = IdentifiedRemoteLogUploader { batch, context ->
        api.upload(batch.payload, batch.contentEncoding, idempotencyKey = context.batchId)
    },
    config = RemoteLogConfig(File(noBackupFilesDir, "pending_logs"))
)

batchId remains stable across retry, claim recovery, and restart. attemptId names one uploader invocation and changes for each retry. Optional CancellableIdentifiedRemoteLogUploader cancellation targets only that attempt; it does not recall data already received by a server. Exactly-once effects still require the backend to atomically deduplicate batchId values.

setCollectionEnabled(false, erasePendingData = false) durably pauses new collection, persistence, claims, uploads, and retries while preserving already accepted data. Explicit re-enablement resumes those pending batches. With erasePendingData = true, success means local committed, temporary, and claim data was removed. An unresolved upload or another process claim makes the operation fail within its timeout. Within one destination, revoke linearizes before a new upload attempt can register. Across processes, the durable pause-marker update is the linearization point; another process may finish work started before that update, but no new cooperating claim may start after a successful revoke returns.

Keep a durable remote spool under noBackupFilesDir. If an application intentionally uses filesDir, it must exclude that path from Android cloud backup and device transfer. The sample excludes logs/, log_exports/, and pending_logs/; applications using different folder names must add matching exclusions to their own backup rules.

Crashlytics destination

Complete the normal Firebase/Crashlytics setup in the host application, then attach the optional destination. The adapter sends bounded WARN and ERROR records by default. Redacted throwable text is reported as a non-fatal exception.

val crashlytics = CrashlyticsLogDestination(
    config = CrashlyticsLogConfig(
        minimumLogLevel = LogLevel.WARN,
        recordNonFatalExceptions = true
    )
)

FileLogger.install(
    this,
    LoggerConfig.prod().copy(customDestinations = listOf(crashlytics))
)

OpenTelemetry destination

The host application owns the OpenTelemetry SDK, OTLP exporter, authentication, endpoint, and sampling policy. FileLogger only emits structured records through the configured OpenTelemetry instance, so the same adapter works with a vendor or a self-hosted Collector.

OpenTelemetry Java requires Android applications to enable core library desugaring. Follow the current Android desugaring setup in the consuming app module.

val otel = OpenTelemetryLogDestination(
    openTelemetry = openTelemetrySdk,
    config = OpenTelemetryLogConfig(
        minimumLogLevel = LogLevel.INFO,
        instrumentationVersion = BuildConfig.VERSION_NAME
    )
)

FileLogger.install(
    this,
    LoggerConfig.prod().copy(customDestinations = listOf(otel))
)

Destinations can be combined. For example, an enterprise application can keep local files, report errors to Crashlytics, and send structured logs to OpenTelemetry in the same APK. Never place SMTP credentials or remote service secrets in the mobile app.

Compose viewer

@Composable
fun SupportLogsScreen() {
    FileLoggerViewer()
}

The viewer provides keyword and attribute search, DEBUG/INFO/WARN/ERROR chips, refresh, session/process metadata, attribute and throwable display, and lazy scrolling.

Diagnostics

FileLogger.diagnostics() reports record counts by level, queue pressure and drops, file writes/latency/failures/rotations, current files, and remote uploaded/failed/ pending batches. Diagnostics are also included in every export archive.

Test strategy

The repository includes unit and Android device tests for:

  • bounded queue overflow, concurrency, flush, close, and ordering
  • file rotation, age/size retention, disk-full/write/sync/rename/delete failures
  • dev/prod configuration, write-time redaction, consent/deletion, support reports, bounded breadcrumbs, export, query, metrics, structured context, and session behavior
  • OkHttp privacy, body limits, timing, status, and network failures
  • remote batching, gzip, retry/backoff, offline recovery, and shutdown
  • viewer filtering and sample actions
  • two-process concurrent writes and real secondary-process crash capture

Run local verification:

./gradlew test lintDebug assembleDebug
./gradlew connectedDebugAndroidTest
./gradlew publishAllPublicationsToTestRepository

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages