Skip to content

Commit c9af0b7

Browse files
rocketramanKevinSchildhornsamhill303
authored
Rolling log file writer in new io module (#406)
* 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 <kevin.schildhorn@gmail.com> Co-authored-by: Sam Hill <sam@touchlab.co>
1 parent 6364916 commit c9af0b7

15 files changed

Lines changed: 424 additions & 15 deletions

File tree

gradle/libs.versions.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ koin-core = "3.5.3"
3030
koin-android = "3.5.3"
3131
koin-test = "3.5.3"
3232
coroutines = "1.7.3"
33+
kotlinx-datetime = "0.6.1"
34+
kotlinx-io = "0.5.4"
3335
roboelectric = "4.10.3"
3436
buildConfig = "4.1.2"
3537
mavenPublish = "0.27.0"
@@ -61,6 +63,8 @@ testhelp = { module = "co.touchlab:testhelp", version.ref = "testhelp" }
6163
koin = { module = "io.insert-koin:koin-core", version.ref = "koin-core" }
6264
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin-android" }
6365
coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
66+
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
67+
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
6468
roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric" }
6569

6670
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-test" }
@@ -90,4 +94,4 @@ android = [
9094
"androidx-navigationFragment",
9195
"androidx-navigationUI",
9296
"androidx-coordinatorLayout",
93-
]
97+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
public class co/touchlab/kermit/io/RollingFileLogWriter : co/touchlab/kermit/LogWriter {
2+
public fun <init> (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Lco/touchlab/kermit/MessageStringFormatter;Lkotlinx/datetime/Clock;Lkotlinx/io/files/FileSystem;)V
3+
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
4+
public fun log (Lco/touchlab/kermit/Severity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
5+
}
6+
7+
public final class co/touchlab/kermit/io/RollingFileLogWriterConfig {
8+
public fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)V
9+
public synthetic fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
10+
public final fun component1 ()Ljava/lang/String;
11+
public final fun component2 ()Lkotlinx/io/files/Path;
12+
public final fun component3 ()J
13+
public final fun component4 ()I
14+
public final fun component5 ()Z
15+
public final fun component6 ()Z
16+
public final fun copy (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)Lco/touchlab/kermit/io/RollingFileLogWriterConfig;
17+
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;
18+
public fun equals (Ljava/lang/Object;)Z
19+
public final fun getLogFileName ()Ljava/lang/String;
20+
public final fun getLogFilePath ()Lkotlinx/io/files/Path;
21+
public final fun getLogTag ()Z
22+
public final fun getMaxLogFiles ()I
23+
public final fun getPrependTimestamp ()Z
24+
public final fun getRollOnSize ()J
25+
public fun hashCode ()I
26+
public fun toString ()Ljava/lang/String;
27+
}
28+

kermit-io/api/jvm/kermit-io.api

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
public class co/touchlab/kermit/io/RollingFileLogWriter : co/touchlab/kermit/LogWriter {
2+
public fun <init> (Lco/touchlab/kermit/io/RollingFileLogWriterConfig;Lco/touchlab/kermit/MessageStringFormatter;Lkotlinx/datetime/Clock;Lkotlinx/io/files/FileSystem;)V
3+
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
4+
public fun log (Lco/touchlab/kermit/Severity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
5+
}
6+
7+
public final class co/touchlab/kermit/io/RollingFileLogWriterConfig {
8+
public fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)V
9+
public synthetic fun <init> (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
10+
public final fun component1 ()Ljava/lang/String;
11+
public final fun component2 ()Lkotlinx/io/files/Path;
12+
public final fun component3 ()J
13+
public final fun component4 ()I
14+
public final fun component5 ()Z
15+
public final fun component6 ()Z
16+
public final fun copy (Ljava/lang/String;Lkotlinx/io/files/Path;JIZZ)Lco/touchlab/kermit/io/RollingFileLogWriterConfig;
17+
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;
18+
public fun equals (Ljava/lang/Object;)Z
19+
public final fun getLogFileName ()Ljava/lang/String;
20+
public final fun getLogFilePath ()Lkotlinx/io/files/Path;
21+
public final fun getLogTag ()Z
22+
public final fun getMaxLogFiles ()I
23+
public final fun getPrependTimestamp ()Z
24+
public final fun getRollOnSize ()J
25+
public fun hashCode ()I
26+
public fun toString ()Ljava/lang/String;
27+
}
28+

kermit-io/build.gradle.kts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (c) 2024 Touchlab
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
4+
* in compliance with the License. You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software distributed under the License
9+
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
10+
* or implied. See the License for the specific language governing permissions and limitations under
11+
* the License.
12+
*/
13+
14+
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
15+
16+
plugins {
17+
id("com.android.library")
18+
kotlin("multiplatform")
19+
id("com.vanniktech.maven.publish")
20+
}
21+
22+
kotlin {
23+
androidTarget {
24+
publishAllLibraryVariants()
25+
}
26+
jvm()
27+
28+
macosX64()
29+
macosArm64()
30+
iosX64()
31+
iosArm64()
32+
iosSimulatorArm64()
33+
watchosArm32()
34+
watchosArm64()
35+
watchosSimulatorArm64()
36+
watchosDeviceArm64()
37+
watchosX64()
38+
tvosArm64()
39+
tvosSimulatorArm64()
40+
tvosX64()
41+
42+
mingwX64()
43+
linuxX64()
44+
linuxArm64()
45+
46+
androidNativeArm32()
47+
androidNativeArm64()
48+
androidNativeX86()
49+
androidNativeX64()
50+
51+
@Suppress("OPT_IN_USAGE")
52+
applyDefaultHierarchyTemplate {
53+
common {
54+
group("commonJvm") {
55+
withAndroidTarget()
56+
withJvm()
57+
}
58+
}
59+
}
60+
61+
sourceSets {
62+
commonMain.dependencies {
63+
implementation(project(":kermit-core"))
64+
65+
api(libs.kotlinx.datetime)
66+
api(libs.kotlinx.io)
67+
implementation(libs.coroutines)
68+
}
69+
70+
commonTest.dependencies {
71+
implementation(kotlin("test"))
72+
implementation(project(":kermit-test"))
73+
}
74+
75+
getByName("commonJvmTest").dependencies {
76+
implementation(kotlin("test-junit"))
77+
}
78+
79+
getByName("androidUnitTest").dependencies {
80+
implementation(libs.androidx.runner)
81+
implementation(libs.roboelectric)
82+
}
83+
}
84+
}
85+
86+
android {
87+
namespace = "co.touchlab.kermit.io"
88+
compileSdk = libs.versions.compileSdk.get().toInt()
89+
defaultConfig {
90+
minSdk = libs.versions.minSdk.get().toInt()
91+
}
92+
compileOptions {
93+
sourceCompatibility = JavaVersion.VERSION_1_8
94+
targetCompatibility = JavaVersion.VERSION_1_8
95+
}
96+
}
97+
98+
tasks.withType<KotlinCompile> {
99+
kotlinOptions.jvmTarget = "1.8"
100+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Copyright (c) 2024 Touchlab
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* 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.
9+
*/
10+
11+
package co.touchlab.kermit.io
12+
13+
import co.touchlab.kermit.*
14+
import kotlinx.coroutines.*
15+
import kotlinx.coroutines.channels.Channel
16+
import kotlinx.coroutines.channels.trySendBlocking
17+
import kotlinx.datetime.Clock
18+
import kotlinx.datetime.format
19+
import kotlinx.datetime.format.DateTimeComponents
20+
import kotlinx.io.*
21+
import kotlinx.io.files.FileSystem
22+
import kotlinx.io.files.Path
23+
import kotlinx.io.files.SystemFileSystem
24+
25+
/**
26+
* Implements a log writer that writes log messages to a rolling file.
27+
*
28+
* It also deletes old log files when the maximum number of log files is reached. We simply keep
29+
* approximately [RollingFileLogWriterConfig.rollOnSize] bytes in each log file,
30+
* and delete the oldest file when we have more than [RollingFileLogWriterConfig.maxLogFiles].
31+
*
32+
* Formatting is governed by the passed [MessageStringFormatter], but we do prepend a timestamp by default.
33+
* Turn this off via [RollingFileLogWriterConfig.prependTimestamp]
34+
*
35+
* Writes to the file are done by a different coroutine. The main reason for this is to make writes to the
36+
* log file sink thread-safe, and so that file rolling can be performed without additional synchronization
37+
* or locking. The channel that buffers log messages is currently unbuffered, so logging threads will block
38+
* until the I/O is complete. However, buffering could easily be introduced to potentially increase logging
39+
* throughput. The envisioned usage scenarios for this class probably do not warrant this.
40+
*
41+
* The recommended way to obtain the logPath on Android is:
42+
*
43+
* ```kotlin
44+
* Path(context.filesDir.path)
45+
* ```
46+
*
47+
* and on iOS this wil return the application's sandboxed document directory:
48+
*
49+
* ```kotlin
50+
* (NSFileManager.defaultManager.URLsForDirectory(NSDocumentDirectory, NSUserDomainMask).last() as NSURL).path!!
51+
* ```
52+
*
53+
* However, you can use any path that is writable by the application. This would generally be implemented by
54+
* platform-specific code.
55+
*/
56+
open class RollingFileLogWriter(
57+
private val config: RollingFileLogWriterConfig,
58+
private val messageStringFormatter: MessageStringFormatter = DefaultFormatter,
59+
private val clock: Clock = Clock.System,
60+
private val fileSystem: FileSystem = SystemFileSystem,
61+
) : LogWriter() {
62+
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
63+
private val coroutineScope = CoroutineScope(
64+
newSingleThreadContext("RollingFileLogWriter") +
65+
SupervisorJob() +
66+
CoroutineName("RollingFileLogWriter") +
67+
CoroutineExceptionHandler { _, throwable ->
68+
// can't log it, we're the logger -- print to standard error
69+
println("RollingFileLogWriter: Uncaught exception in writer coroutine")
70+
throwable.printStackTrace()
71+
}
72+
)
73+
74+
private val loggingChannel: Channel<Buffer> = Channel()
75+
76+
init {
77+
coroutineScope.launch {
78+
writer()
79+
}
80+
}
81+
82+
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
83+
bufferLog(
84+
formatMessage(
85+
severity = severity,
86+
tag = Tag(tag),
87+
message = Message(message)
88+
), throwable
89+
)
90+
}
91+
92+
private fun bufferLog(message: String, throwable: Throwable?) {
93+
val log = buildString {
94+
append(clock.now().format(DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET))
95+
append(" ")
96+
appendLine(message)
97+
if (throwable != null) {
98+
appendLine(throwable.stackTraceToString())
99+
}
100+
}
101+
loggingChannel.trySendBlocking(Buffer().apply { writeString(log) })
102+
}
103+
104+
private fun formatMessage(severity: Severity, tag: Tag?, message: Message): String =
105+
messageStringFormatter.formatMessage(severity, if (config.logTag) tag else null, message)
106+
107+
private fun maybeRollLogs(size: Long): Boolean {
108+
return if (size > config.rollOnSize) {
109+
rollLogs()
110+
true
111+
} else false
112+
}
113+
114+
private fun rollLogs() {
115+
if (fileSystem.exists(pathForLogIndex(config.maxLogFiles - 1))) {
116+
fileSystem.delete(pathForLogIndex(config.maxLogFiles - 1))
117+
}
118+
(0..<(config.maxLogFiles - 1)).reversed().forEach {
119+
val sourcePath = pathForLogIndex(it)
120+
val targetPath = pathForLogIndex(it + 1)
121+
if (fileSystem.exists(sourcePath)) {
122+
try {
123+
fileSystem.atomicMove(sourcePath, targetPath)
124+
} catch (e: IOException) {
125+
// we can't log it, we're the logger -- print to standard error
126+
println("RollingFileLogWriter: Failed to roll log file $sourcePath to $targetPath (sourcePath exists=${fileSystem.exists(sourcePath)})")
127+
e.printStackTrace()
128+
}
129+
}
130+
}
131+
}
132+
133+
private fun pathForLogIndex(index: Int): Path =
134+
Path(config.logFilePath, if (index == 0) "${config.logFileName}.log" else "${config.logFileName}-$index.log")
135+
136+
private suspend fun writer() {
137+
val logFilePath = pathForLogIndex(0)
138+
139+
if (fileSystem.exists(logFilePath)) {
140+
maybeRollLogs(fileSizeOrZero(logFilePath))
141+
}
142+
143+
fun createNewLogSink(): Sink = fileSystem
144+
.sink(logFilePath, append = true)
145+
.buffered()
146+
147+
var currentLogSink: Sink = createNewLogSink()
148+
149+
while (currentCoroutineContext().isActive) {
150+
// wait for data to be available, flush periodically
151+
val result = loggingChannel.receiveCatching()
152+
153+
// check if logs need rolling
154+
val rolled = maybeRollLogs(fileSizeOrZero(logFilePath))
155+
if (rolled) {
156+
currentLogSink.close()
157+
currentLogSink = createNewLogSink()
158+
}
159+
160+
result.getOrNull()?.transferTo(currentLogSink)
161+
162+
// we could improve performance by flushing less frequently at the cost of potential data loss,
163+
// but this is a safe default
164+
currentLogSink.flush()
165+
}
166+
}
167+
168+
private fun fileSizeOrZero(path: Path) = fileSystem.metadataOrNull(path)?.size ?: 0
169+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright (c) 2024 Touchlab
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* 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.
9+
*/
10+
11+
package co.touchlab.kermit.io
12+
13+
import kotlinx.io.files.Path
14+
15+
data class RollingFileLogWriterConfig(
16+
val logFileName: String,
17+
val logFilePath: Path,
18+
val rollOnSize: Long = 10 * 1024 * 1024, // 10MB
19+
val maxLogFiles: Int = 5,
20+
val logTag: Boolean = true,
21+
val prependTimestamp: Boolean = true,
22+
)

samples/sample-production/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ plugins {
2323

2424
allprojects {
2525
repositories {
26+
mavenLocal()
2627
mavenCentral()
2728
maven(url = "https://oss.sonatype.org/content/repositories/snapshots")
2829
google()
29-
mavenLocal()
3030
}
3131
}
3232

0 commit comments

Comments
 (0)