Skip to content

Commit

Permalink
Provide FileLogger
Browse files Browse the repository at this point in the history
With customizable rotation and deletion policies.
Also improve documentation overall.

Resolves #23
  • Loading branch information
saschpe committed Apr 24, 2024
1 parent 7f5103d commit 34fc033
Show file tree
Hide file tree
Showing 21 changed files with 662 additions and 19 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
- Provide FileLogger for persistent logging to a file path with rotation and retention policy support.

## [1.2.5] - 2024-03-23
- Provide javadoc artifacts for Sonatype Maven Central
Expand All @@ -13,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Allow empty log messages if you only want to create a log entry about a function being called.
- Add more Apple ARM64 platforms: macOS, tvOS, watchOS
- Provide FileLogger in addition to ConsoleLogger
- Dependency update:
- [Kotlin 1.9.23](https://kotlinlang.org/docs/whatsnew19.html)
- [Gradle-8.7](https://docs.gradle.org/8.7/release-notes.html)
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,25 @@ class MyApplication : Application() {
}
```

## Logging to a file

By default, the library only logs to the current platform's console.
Additionally or instead, add one or multiple file loggers:

```kotlin
// Log with daily rotation and keep five log files at max:
Log.loggers += FileLogger(rotate = Rotate.Daily, limit = Limit.Files(max = 5))

// Log to a custom path and rotate every 1000 lines written:
Log.loggers += FileLogger(rotate = Rotate.After(lines = 1000), logPath = "myLogPath")

// Log with sensible defaults (daily, keep 10 files)
Log.loggers += FileLogger()

// On huge eternal log file:
Log.loggers += FileLogger(rotate = Rotate.Never, limit = Limit.Not)
```

## Custom logger (Android Crashlytics example)
The library provides a cross-platform `ConsoleLogger` by default. Custom loggers can easily be added. For instance, to
send only `ERROR` and `ASSERT` messages to Crashlytics in production builds, you could do the following:
Expand Down
4 changes: 4 additions & 0 deletions log4k/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ kotlin {
applyDefaultHierarchyTemplate()

sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0-RC.2")
implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.3.2")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
Expand Down
10 changes: 10 additions & 0 deletions log4k/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<provider
android:name=".internal.ContextProvider"
android:authorities="${applicationId}.context_provider"
android:exported="false" />
</application>
</manifest>
15 changes: 15 additions & 0 deletions log4k/src/androidMain/kotlin/saschpe/log4k/FileLogger.android.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package saschpe.log4k

import kotlinx.io.files.Path
import saschpe.log4k.internal.ContextProvider.Companion.applicationContext
import java.io.File

internal actual val defaultLogPath: Path
get() = Path(applicationContext.cacheDir.path)

internal actual fun limitFolderToFilesByCreationTime(path: String, limit: Int) {
File(path).listFiles()?.sorted()?.run {
val overhead = kotlin.math.max(0, count() - limit)
take(overhead).forEach { it.delete() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package saschpe.log4k.internal

import android.annotation.SuppressLint
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri

/**
* Hidden initialization content provider.
*
* Does not provide real content but hides initialization boilerplate from the library user.
*
* @link https://firebase.googleblog.com/2016/12/how-does-firebase-initialize-on-android.html
*/
internal class ContextProvider : ContentProvider() {
/**
* Called exactly once before Application.onCreate()
*/
override fun onCreate(): Boolean {
applicationContext = context?.applicationContext ?: throw Exception("Need the context")
return true
}

override fun insert(uri: Uri, values: ContentValues?): Uri? = null

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
args: Array<out String>?,
sortOrder: String?,
): Cursor? = null

override fun update(uri: Uri, values: ContentValues?, selection: String?, args: Array<out String>?): Int = 0

override fun delete(uri: Uri, selection: String?, args: Array<out String>?): Int = 0

override fun getType(uri: Uri): String? = null

companion object {
@SuppressLint("StaticFieldLeak")
lateinit var applicationContext: Context
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package saschpe.log4k

import java.io.File

internal actual val expectedExceptionPackage: String = "java.lang."
internal actual val expectedTraceTag: String = "FileLoggerTest.testMessages"

internal actual fun deleteRecursively(path: String) {
File(path).deleteRecursively()
}

internal actual fun filesInFolder(path: String): Int = File(path).listFiles()?.size ?: 0

This file was deleted.

28 changes: 28 additions & 0 deletions log4k/src/appleMain/kotlin/saschpe/log4k/FileLogger.apple.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package saschpe.log4k

import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.io.files.Path
import kotlinx.io.files.SystemTemporaryDirectory
import platform.Foundation.NSFileManager
import kotlin.math.max

internal actual val defaultLogPath: Path
get() = SystemTemporaryDirectory

@OptIn(ExperimentalForeignApi::class)
internal actual fun limitFolderToFilesByCreationTime(path: String, limit: Int) {
val fm = NSFileManager.defaultManager

@Suppress("UNCHECKED_CAST")
val files = fm.contentsOfDirectoryAtPath(path, null) as List<String>?
println("limitFolderToFilesByCreationTime(path=$path, limit=$limit): files=$files")

files?.sorted()?.run {
val overhead = max(0, size - limit)
println("limitFolderToFilesByCreationTime(path=$path, limit=$limit): overhead=$overhead")
take(overhead).forEach {
println("limitFolderToFilesByCreationTime(path=$path, limit=$limit): remove $it")
fm.removeItemAtPath("$path/$it", null)
}
}
}
16 changes: 16 additions & 0 deletions log4k/src/appleTest/kotlin/saschpe/log4k/FileLoggerTest.apple.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package saschpe.log4k

import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSFileManager

internal actual val expectedExceptionPackage: String = "kotlin."
internal actual val expectedTraceTag: String = "XXX"

@OptIn(ExperimentalForeignApi::class)
internal actual fun deleteRecursively(path: String) {
NSFileManager.defaultManager.removeItemAtPath(path, error = null)
}

@OptIn(ExperimentalForeignApi::class)
internal actual fun filesInFolder(path: String): Int =
NSFileManager.defaultManager.contentsOfDirectoryAtPath(path, null)?.count() ?: 0
4 changes: 0 additions & 4 deletions log4k/src/appleTest/kotlin/saschpe/log4k/LoggedTest.apple.kt

This file was deleted.

143 changes: 143 additions & 0 deletions log4k/src/commonMain/kotlin/saschpe/log4k/FileLogger.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package saschpe.log4k

import kotlinx.datetime.*
import kotlinx.datetime.format.char
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.writeString
import saschpe.log4k.FileLogger.Limit
import saschpe.log4k.FileLogger.Rotate

internal expect val defaultLogPath: Path
internal expect fun limitFolderToFilesByCreationTime(path: String, limit: Int)

/**
* Log to files in [logPath] with [rotate] rotation and imposed retention [limit].
*
* @param rotate Log file rotation, defaults to [Rotate.Daily]
* @param limit Log file retention limit, defaults to [Limit.Files]
* @param logPath Log file path, defaults to platform-specific temporary directory [defaultLogPath]
*/
class FileLogger(
private val rotate: Rotate = Rotate.Daily,
private val limit: Limit = Limit.Files(10),
private val logPath: Path = defaultLogPath,
) : Logger() {
constructor(
rotation: Rotate = Rotate.Daily,
limit: Limit = Limit.Files(),
logPath: String,
) : this(rotation, limit, Path(logPath))

init {
SystemFileSystem.createDirectories(logPath)
}

override fun print(level: Log.Level, tag: String, message: String?, throwable: Throwable?) {
val logTag = tag.ifEmpty { getTraceTag() }
SystemFileSystem.sink(rotate.logFile(logPath), append = true).buffered().apply {
writeString("${level.name.first()}/$logTag: $message")
throwable?.let { writeString(" $throwable") }
writeString("\n")
flush()
rotate.lineWritten()
}
limit.enforce(logPath)
}

private fun getTraceTag(): String = try {
val callSite = Throwable().stackTraceToString().split("\n")[4]
val callSiteCleaned = callSite.split(" ")[1].split("(").first()
val kotlinPackageParts = callSiteCleaned.split(".")
"${kotlinPackageParts[kotlinPackageParts.size - 2]}.${kotlinPackageParts.last()}"
} catch (e: Exception) {
"XXX"
}

/**
* Log file rotation.
*/
sealed class Rotate {
internal abstract fun logFile(logPath: Path): Path
internal abstract fun lineWritten()

/**
* Never rotate the log file.
*
* Use with caution, may lead to a single huge log file.
* Subject to the platforms temporary directory cleanup policy.
*/
data object Never : Rotate() {
override fun logFile(logPath: Path) = Path(logPath, "log.txt")
override fun lineWritten() = Unit
}

/**
* Daily log rotation.
*/
data object Daily : Rotate() {
override fun logFile(logPath: Path): Path {
val today = Clock.System.now().toLocalDateTime(TimeZone.UTC).date
return Path(logPath, "log.daily.${LocalDate.Formats.ISO.format(today)}.txt")
}

override fun lineWritten() = Unit
}

/**
* Rotate the current log file after [lines] number of lines written.
*/
class After(private val lines: Int = 10000) : Rotate() {
private val dateTimeFormat = LocalDateTime.Format {
year()
monthNumber()
dayOfMonth()
char('-')
hour()
minute()
second()
secondFraction()
}
private var linesWritten = 0

override fun logFile(logPath: Path): Path {
val logFile = Path(logPath, "log.after_$lines.txt")
if (SystemFileSystem.exists(logFile) && linesWritten >= lines) {
val now = Clock.System.now().toLocalDateTime(TimeZone.UTC)
val renameTo = Path(logPath, "log.after_$lines.${dateTimeFormat.format(now)}.txt")
SystemFileSystem.atomicMove(logFile, renameTo)
linesWritten = 0
}
return logFile
}

override fun lineWritten() {
linesWritten += 1
}
}
}

/**
* Log file storage limit.
*/
sealed class Limit {
internal abstract fun enforce(logPath: Path)

/**
* There's no limit!
*/
data object Not : Limit() {
override fun enforce(logPath: Path) = Unit
}

/**
* Keep [max] log files in the [logPath].
*
* @param max Number of files to keep, defaults to 10
*/
class Files(private val max: Int = 10) : Limit() {
override fun enforce(logPath: Path) = limitFolderToFilesByCreationTime(logPath.toString(), max)
}
}
}
Loading

0 comments on commit 34fc033

Please sign in to comment.