Skip to content

Commit

Permalink
Rolling log file writer in new io module (#406)
Browse files Browse the repository at this point in the history
* Rolling log file writer in new io module

* initial changes

* Update build.gradle.kts

* Finalizing sample

* reverting datetime as it may cause conflicts with versions

* Update build.gradle.kts

* reverting to api

* Removing Logger from sample

* Revert "Removing Logger from sample"

This reverts commit 1c97ee6.

* Splitting versions for mobile

* renaming files

* merging in temporary changes

* Update build.gradle.kts

* Update ContentView.swift

* Re-order maven repos

---------

Co-authored-by: Kevin Schildhorn <[email protected]>
Co-authored-by: Sam Hill <[email protected]>
  • Loading branch information
3 people authored Nov 20, 2024
1 parent 6364916 commit c9af0b7
Show file tree
Hide file tree
Showing 15 changed files with 424 additions and 15 deletions.
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ koin-core = "3.5.3"
koin-android = "3.5.3"
koin-test = "3.5.3"
coroutines = "1.7.3"
kotlinx-datetime = "0.6.1"
kotlinx-io = "0.5.4"
roboelectric = "4.10.3"
buildConfig = "4.1.2"
mavenPublish = "0.27.0"
Expand Down Expand Up @@ -61,6 +63,8 @@ testhelp = { module = "co.touchlab:testhelp", version.ref = "testhelp" }
koin = { module = "io.insert-koin:koin-core", version.ref = "koin-core" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin-android" }
coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric" }

koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-test" }
Expand Down Expand Up @@ -90,4 +94,4 @@ android = [
"androidx-navigationFragment",
"androidx-navigationUI",
"androidx-coordinatorLayout",
]
]
28 changes: 28 additions & 0 deletions kermit-io/api/android/kermit-io.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
public class co/touchlab/kermit/io/RollingFileLogWriter : co/touchlab/kermit/LogWriter {
public fun <init> (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Lco/touchlab/kermit/MessageStringFormatter;Lkotlinx/datetime/Clock;Lkotlinx/io/files/FileSystem;)V
public synthetic fun <init> (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Lco/touchlab/kermit/MessageStringFormatter;Lkotlinx/datetime/Clock;Lkotlinx/io/files/FileSystem;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun log (Lco/touchlab/kermit/Severity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
}

public final class co/touchlab/kermit/io/RollingFileLogWriterConfig {
public fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)V
public synthetic fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Lkotlinx/io/files/Path;
public final fun component3 ()J
public final fun component4 ()I
public final fun component5 ()Z
public final fun component6 ()Z
public final fun copy (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)Lco/touchlab/kermit/io/RollingFileLogWriterConfig;
public static synthetic fun copy$default (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Ljava/lang/String;Lkotlinx/io/files/Path;JIZZILjava/lang/Object;)Lco/touchlab/kermit/io/RollingFileLogWriterConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getLogFileName ()Ljava/lang/String;
public final fun getLogFilePath ()Lkotlinx/io/files/Path;
public final fun getLogTag ()Z
public final fun getMaxLogFiles ()I
public final fun getPrependTimestamp ()Z
public final fun getRollOnSize ()J
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

28 changes: 28 additions & 0 deletions kermit-io/api/jvm/kermit-io.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
public class co/touchlab/kermit/io/RollingFileLogWriter : co/touchlab/kermit/LogWriter {
public fun <init> (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Lco/touchlab/kermit/MessageStringFormatter;Lkotlinx/datetime/Clock;Lkotlinx/io/files/FileSystem;)V
public synthetic fun <init> (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Lco/touchlab/kermit/MessageStringFormatter;Lkotlinx/datetime/Clock;Lkotlinx/io/files/FileSystem;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun log (Lco/touchlab/kermit/Severity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
}

public final class co/touchlab/kermit/io/RollingFileLogWriterConfig {
public fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)V
public synthetic fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Lkotlinx/io/files/Path;
public final fun component3 ()J
public final fun component4 ()I
public final fun component5 ()Z
public final fun component6 ()Z
public final fun copy (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)Lco/touchlab/kermit/io/RollingFileLogWriterConfig;
public static synthetic fun copy$default (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Ljava/lang/String;Lkotlinx/io/files/Path;JIZZILjava/lang/Object;)Lco/touchlab/kermit/io/RollingFileLogWriterConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getLogFileName ()Ljava/lang/String;
public final fun getLogFilePath ()Lkotlinx/io/files/Path;
public final fun getLogTag ()Z
public final fun getMaxLogFiles ()I
public final fun getPrependTimestamp ()Z
public final fun getRollOnSize ()J
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

100 changes: 100 additions & 0 deletions kermit-io/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2024 Touchlab
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id("com.android.library")
kotlin("multiplatform")
id("com.vanniktech.maven.publish")
}

kotlin {
androidTarget {
publishAllLibraryVariants()
}
jvm()

macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
watchosArm32()
watchosArm64()
watchosSimulatorArm64()
watchosDeviceArm64()
watchosX64()
tvosArm64()
tvosSimulatorArm64()
tvosX64()

mingwX64()
linuxX64()
linuxArm64()

androidNativeArm32()
androidNativeArm64()
androidNativeX86()
androidNativeX64()

@Suppress("OPT_IN_USAGE")
applyDefaultHierarchyTemplate {
common {
group("commonJvm") {
withAndroidTarget()
withJvm()
}
}
}

sourceSets {
commonMain.dependencies {
implementation(project(":kermit-core"))

api(libs.kotlinx.datetime)
api(libs.kotlinx.io)
implementation(libs.coroutines)
}

commonTest.dependencies {
implementation(kotlin("test"))
implementation(project(":kermit-test"))
}

getByName("commonJvmTest").dependencies {
implementation(kotlin("test-junit"))
}

getByName("androidUnitTest").dependencies {
implementation(libs.androidx.runner)
implementation(libs.roboelectric)
}
}
}

android {
namespace = "co.touchlab.kermit.io"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}

tasks.withType<KotlinCompile> {
kotlinOptions.jvmTarget = "1.8"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2024 Touchlab
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/

package co.touchlab.kermit.io

import co.touchlab.kermit.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.datetime.Clock
import kotlinx.datetime.format
import kotlinx.datetime.format.DateTimeComponents
import kotlinx.io.*
import kotlinx.io.files.FileSystem
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem

/**
* Implements a log writer that writes log messages to a rolling file.
*
* It also deletes old log files when the maximum number of log files is reached. We simply keep
* approximately [RollingFileLogWriterConfig.rollOnSize] bytes in each log file,
* and delete the oldest file when we have more than [RollingFileLogWriterConfig.maxLogFiles].
*
* Formatting is governed by the passed [MessageStringFormatter], but we do prepend a timestamp by default.
* Turn this off via [RollingFileLogWriterConfig.prependTimestamp]
*
* Writes to the file are done by a different coroutine. The main reason for this is to make writes to the
* log file sink thread-safe, and so that file rolling can be performed without additional synchronization
* or locking. The channel that buffers log messages is currently unbuffered, so logging threads will block
* until the I/O is complete. However, buffering could easily be introduced to potentially increase logging
* throughput. The envisioned usage scenarios for this class probably do not warrant this.
*
* The recommended way to obtain the logPath on Android is:
*
* ```kotlin
* Path(context.filesDir.path)
* ```
*
* and on iOS this wil return the application's sandboxed document directory:
*
* ```kotlin
* (NSFileManager.defaultManager.URLsForDirectory(NSDocumentDirectory, NSUserDomainMask).last() as NSURL).path!!
* ```
*
* However, you can use any path that is writable by the application. This would generally be implemented by
* platform-specific code.
*/
open class RollingFileLogWriter(
private val config: RollingFileLogWriterConfig,
private val messageStringFormatter: MessageStringFormatter = DefaultFormatter,
private val clock: Clock = Clock.System,
private val fileSystem: FileSystem = SystemFileSystem,
) : LogWriter() {
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
private val coroutineScope = CoroutineScope(
newSingleThreadContext("RollingFileLogWriter") +
SupervisorJob() +
CoroutineName("RollingFileLogWriter") +
CoroutineExceptionHandler { _, throwable ->
// can't log it, we're the logger -- print to standard error
println("RollingFileLogWriter: Uncaught exception in writer coroutine")
throwable.printStackTrace()
}
)

private val loggingChannel: Channel<Buffer> = Channel()

init {
coroutineScope.launch {
writer()
}
}

override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
bufferLog(
formatMessage(
severity = severity,
tag = Tag(tag),
message = Message(message)
), throwable
)
}

private fun bufferLog(message: String, throwable: Throwable?) {
val log = buildString {
append(clock.now().format(DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET))
append(" ")
appendLine(message)
if (throwable != null) {
appendLine(throwable.stackTraceToString())
}
}
loggingChannel.trySendBlocking(Buffer().apply { writeString(log) })
}

private fun formatMessage(severity: Severity, tag: Tag?, message: Message): String =
messageStringFormatter.formatMessage(severity, if (config.logTag) tag else null, message)

private fun maybeRollLogs(size: Long): Boolean {
return if (size > config.rollOnSize) {
rollLogs()
true
} else false
}

private fun rollLogs() {
if (fileSystem.exists(pathForLogIndex(config.maxLogFiles - 1))) {
fileSystem.delete(pathForLogIndex(config.maxLogFiles - 1))
}
(0..<(config.maxLogFiles - 1)).reversed().forEach {
val sourcePath = pathForLogIndex(it)
val targetPath = pathForLogIndex(it + 1)
if (fileSystem.exists(sourcePath)) {
try {
fileSystem.atomicMove(sourcePath, targetPath)
} catch (e: IOException) {
// we can't log it, we're the logger -- print to standard error
println("RollingFileLogWriter: Failed to roll log file $sourcePath to $targetPath (sourcePath exists=${fileSystem.exists(sourcePath)})")
e.printStackTrace()
}
}
}
}

private fun pathForLogIndex(index: Int): Path =
Path(config.logFilePath, if (index == 0) "${config.logFileName}.log" else "${config.logFileName}-$index.log")

private suspend fun writer() {
val logFilePath = pathForLogIndex(0)

if (fileSystem.exists(logFilePath)) {
maybeRollLogs(fileSizeOrZero(logFilePath))
}

fun createNewLogSink(): Sink = fileSystem
.sink(logFilePath, append = true)
.buffered()

var currentLogSink: Sink = createNewLogSink()

while (currentCoroutineContext().isActive) {
// wait for data to be available, flush periodically
val result = loggingChannel.receiveCatching()

// check if logs need rolling
val rolled = maybeRollLogs(fileSizeOrZero(logFilePath))
if (rolled) {
currentLogSink.close()
currentLogSink = createNewLogSink()
}

result.getOrNull()?.transferTo(currentLogSink)

// we could improve performance by flushing less frequently at the cost of potential data loss,
// but this is a safe default
currentLogSink.flush()
}
}

private fun fileSizeOrZero(path: Path) = fileSystem.metadataOrNull(path)?.size ?: 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2024 Touchlab
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/

package co.touchlab.kermit.io

import kotlinx.io.files.Path

data class RollingFileLogWriterConfig(
val logFileName: String,
val logFilePath: Path,
val rollOnSize: Long = 10 * 1024 * 1024, // 10MB
val maxLogFiles: Int = 5,
val logTag: Boolean = true,
val prependTimestamp: Boolean = true,
)
2 changes: 1 addition & 1 deletion samples/sample-production/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ plugins {

allprojects {
repositories {
mavenLocal()
mavenCentral()
maven(url = "https://oss.sonatype.org/content/repositories/snapshots")
google()
mavenLocal()
}
}

Expand Down
Loading

0 comments on commit c9af0b7

Please sign in to comment.