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 22, 2024
1 parent 7f5103d commit ad4480b
Show file tree
Hide file tree
Showing 21 changed files with 451 additions and 19 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package saschpe.log4k

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

internal actual val defaultLogPath: Path
get() = Path(applicationContext.cacheDir.path)
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,3 @@
package saschpe.log4k

internal actual val exceptionPackageName: String = "java.lang."

This file was deleted.

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

import kotlinx.io.files.Path
import kotlinx.io.files.SystemTemporaryDirectory

internal actual val defaultLogPath: Path
get() = SystemTemporaryDirectory
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package saschpe.log4k

internal actual val exceptionPackageName: String = "kotlin."
4 changes: 0 additions & 4 deletions log4k/src/appleTest/kotlin/saschpe/log4k/LoggedTest.apple.kt

This file was deleted.

151 changes: 151 additions & 0 deletions log4k/src/commonMain/kotlin/saschpe/log4k/FileLogger.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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

internal expect val defaultLogPath: Path

/**
* 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 {
// val trace = Exception().stackTrace[6]
// val className = trace.className.split(".").last()
// return "$className.${trace.methodName}"
return "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) {
// TODO: Go native!
}
}

/**
* Keep [max] megabytes worth of files in the [logPath].
*
* @param max Megabytes worth keeping, defaults to 10MB
*/
class MegaBytes(private val max: Int = 10) : Limit() {
override fun enforce(logPath: Path) {
// TODO: Go native!
}
}
}
}
Loading

0 comments on commit ad4480b

Please sign in to comment.