Skip to content

Commit

Permalink
Provide FileLogger
Browse files Browse the repository at this point in the history
And improve documentation overall.

Resolves #23
  • Loading branch information
saschpe committed Mar 25, 2024
1 parent 2ccccca commit 4e9bed1
Show file tree
Hide file tree
Showing 16 changed files with 411 additions and 0 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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ 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
import java.nio.channels.FileLock

// 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"?>

Check warning

Code scanning / mobsfscan

The flag [android:allowBackup] should be set to false. By default it is set to true and allows anyone to backup your application data via adb. It allows users who have enabled USB debugging to copy application data off of the device. Warning

The flag [android:allowBackup] should be set to false. By default it is set to true and allows anyone to backup your application data via adb. It allows users who have enabled USB debugging to copy application data off of the device.
<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."
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."
149 changes: 149 additions & 0 deletions log4k/src/commonMain/kotlin/saschpe/log4k/FileLogger.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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()
}
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 newLine()

/**
* 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 newLine() = 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 newLine() = Unit
}

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

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

override fun newLine() {
linesWritten.inc()
}
}
}

/**
* 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!
}
}
}
}
111 changes: 111 additions & 0 deletions log4k/src/commonTest/kotlin/saschpe/log4k/FileLoggerTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package saschpe.log4k

import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
import saschpe.log4k.FileLogger.Rotate
import kotlin.test.*

internal expect val exceptionPackageName: String

class FileLoggerTest {
@BeforeTest
fun beforePaths() {
SystemFileSystem.delete(TEST_DAILY_ROTATION_LOG_FILE, mustExist = false)
}

@Test
fun rotate_daily_logFileName() {
val today = Clock.System.now().toLocalDateTime(TimeZone.UTC).date
val expectedFileName = "log.daily.${LocalDate.Formats.ISO.format(today)}.txt"
assertEquals(expectedFileName, TEST_DAILY_ROTATION_LOG_FILE.name, "Like 'log.daily.2024-03-25.txt")
assertFalse { TEST_DAILY_ROTATION_LOG_FILE.isAbsolute }
}

@Test
fun rotate_after_logFileName() {
assertEquals("log.after.txt", TEST_AFTER_ROTATION_LOG_FILE.name)
assertFalse { TEST_AFTER_ROTATION_LOG_FILE.isAbsolute }
}

@Test
fun log_rotate_daily() {
// Arrange
val logger = FileLogger(rotate = Rotate.Daily, logPath = TEST_LOG_PATH)

// Act
logger.log(Log.Level.Verbose, "TAG1", "Verbose message", null)
logger.log(Log.Level.Debug, "TAG1", "Debug message", Exception("Test exception!"))
logger.log(Log.Level.Info, "TAG2", "Info message", null)
logger.log(Log.Level.Warning, "TAG3", "Warning message", IllegalStateException("Illegal test state!"))
logger.log(Log.Level.Error, "TAG1", "Error message", null)
logger.log(Log.Level.Assert, "TAG1", "Assert message", null)

// Assert
assertTrue { SystemFileSystem.metadataOrNull(TEST_LOG_PATH)?.isDirectory ?: false }
assertTrue { SystemFileSystem.exists(TEST_DAILY_ROTATION_LOG_FILE) }
assertTrue { SystemFileSystem.metadataOrNull(TEST_DAILY_ROTATION_LOG_FILE)?.isRegularFile ?: false }
assertEquals(TEST_LOG_CONTENT, SystemFileSystem.source(TEST_DAILY_ROTATION_LOG_FILE).buffered().readString())
}

@Test
fun log_rotate_after_10_lines() {
// Arrange
val logger = FileLogger(rotation = Rotate.After(lines = 10), logPath = TEST_LOG_PATH_STRING)

// Act
logger.log(Log.Level.Verbose, "TAG1", "Verbose message", null)
logger.log(Log.Level.Debug, "TAG1", "Debug message", Exception("Test exception!"))
logger.log(Log.Level.Info, "TAG2", "Info message", null)
logger.log(Log.Level.Warning, "TAG3", "Warning message", IllegalStateException("Illegal test state!"))
logger.log(Log.Level.Error, "TAG1", "Error message", null)
logger.log(Log.Level.Assert, "TAG1", "Assert message", null)

// Assert
assertTrue { SystemFileSystem.metadataOrNull(TEST_LOG_PATH)?.isDirectory ?: false }
assertTrue { SystemFileSystem.exists(TEST_DAILY_ROTATION_LOG_FILE) }
assertTrue { SystemFileSystem.metadataOrNull(TEST_DAILY_ROTATION_LOG_FILE)?.isRegularFile ?: false }
assertEquals(TEST_LOG_CONTENT, SystemFileSystem.source(TEST_AFTER_ROTATION_LOG_FILE).buffered().readString())
}

@Test
fun log_rotate_after_5_lines() {
// Arrange
val logger = FileLogger(rotate = Rotate.After(lines = 10), logPath = TEST_LOG_PATH)

// Act
logger.log(Log.Level.Verbose, "TAG1", "Verbose message", null)
logger.log(Log.Level.Debug, "TAG1", "Debug message", Exception("Test exception!"))
logger.log(Log.Level.Info, "TAG2", "Info message", null)
logger.log(Log.Level.Warning, "TAG3", "Warning message", IllegalStateException("Illegal test state!"))
logger.log(Log.Level.Error, "TAG1", "Error message", null)
logger.log(Log.Level.Assert, "TAG1", "Assert message", null)

// Assert
assertTrue { SystemFileSystem.metadataOrNull(TEST_LOG_PATH)?.isDirectory ?: false }
assertTrue { SystemFileSystem.exists(TEST_DAILY_ROTATION_LOG_FILE) }
assertTrue { SystemFileSystem.metadataOrNull(TEST_DAILY_ROTATION_LOG_FILE)?.isRegularFile ?: false }
assertEquals(TEST_LOG_CONTENT, SystemFileSystem.source(TEST_AFTER_ROTATION_LOG_FILE).buffered().readString())
}

companion object {
private val TEST_LOG_PATH_STRING = "build/${FileLogger::class.simpleName}"
private val TEST_LOG_PATH = Path(TEST_LOG_PATH_STRING)
private val TEST_DAILY_ROTATION_LOG_FILE = Rotate.Daily.logFile(TEST_LOG_PATH)
private val TEST_AFTER_ROTATION_LOG_FILE = Rotate.After(100).logFile(TEST_LOG_PATH)
private val TEST_LOG_CONTENT = """
V/TAG1: Verbose message
D/TAG1: Debug message ${exceptionPackageName}Exception: Test exception!
I/TAG2: Info message
W/TAG3: Warning message ${exceptionPackageName}IllegalStateException: Illegal test state!
E/TAG1: Error message
A/TAG1: Assert message
""".trimIndent()
}
}
Loading

0 comments on commit 4e9bed1

Please sign in to comment.