Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ cli/ # JVM CLI entry point
### Logging System
- `Logger.None` (default, zero overhead), `Logger.console()`, `KermitLogger`
- Platform-specific console: Logcat (Android), NSLog (iOS), println/stderr (JVM), println (Wasm)
- Lazy lambda evaluation for zero cost when disabled
- `KetchLogger` uses `inline` functions with `Logger.None` fast-path for zero-cost disabled logging
- `Logger` interface accepts `String` messages; lazy evaluation handled by `KetchLogger`

### Error Handling (sealed `KetchError`)
- `Network` (retryable), `Http(code)` (5xx retryable), `Disk`, `Unsupported`,
Expand Down Expand Up @@ -146,12 +147,14 @@ cli/ # JVM CLI entry point
- Test edge cases: 0-byte files, 1-byte files, uneven segment splits

### Logging
- Use `KetchLogger` for all internal logging
- Use `KetchLogger` for all internal logging — instantiate per component:
`private val log = KetchLogger("Coordinator")`
- Tags: "Ketch", "Coordinator", "SegmentDownloader", "RangeDetector", "KtorHttpEngine",
"Scheduler", "ScheduleManager", "SourceResolver"
"Scheduler", "ScheduleManager", "SourceResolver", "HttpSource", "TokenBucket"
- Levels: verbose (segment detail), debug (state changes), info (user events),
warn (retries), error (fatal)
- Use lazy lambdas: `logger.d { "expensive $computation" }`
- Use lazy lambdas: `log.d { "expensive $computation" }`
- Keep log calls on one line when the message is short enough (within 100 chars)

## Current Limitations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

class KetchService : Service() {
private val log = KetchLogger("KetchService")

inner class LocalBinder : Binder() {
val service: KetchService get() = this@KetchService
Expand Down Expand Up @@ -71,7 +72,7 @@ class KetchService : Service() {
downloadConfig = downloadConfig,
deviceName = instanceName,
localServerFactory = { port, apiToken, ketchApi ->
KetchLogger.i(TAG) { "Starting local server on port $port" }
log.i { "Starting local server on port $port" }
val server = KetchServer(
ketchApi,
ServerConfig(
Expand All @@ -82,12 +83,12 @@ class KetchService : Service() {
mdnsServiceName = instanceName,
)
server.start(wait = false)
KetchLogger.i(TAG) { "Local server started on port $port" }
log.i { "Local server started on port $port" }
object : LocalServerHandle {
override fun stop() {
KetchLogger.i(TAG) { "Stopping local server" }
log.i { "Stopping local server" }
server.stop()
KetchLogger.i(TAG) { "Local server stopped" }
log.i { "Local server stopped" }
}
}
},
Expand Down Expand Up @@ -160,7 +161,7 @@ class KetchService : Service() {
if (shouldBeForeground) {
val notification = buildNotification(activeCount, serverState)
if (!isForeground) {
KetchLogger.i(TAG) { "Start notification" }
log.i { "Start notification" }
}
ServiceCompat.startForeground(
this@KetchService,
Expand Down Expand Up @@ -223,7 +224,6 @@ class KetchService : Service() {
}

companion object {
private const val TAG = "KetchService"
private const val CHANNEL_ID = "ketch_service"
private const val NOTIFICATION_ID = 1
private const val ACTION_REPOST_NOTIFICATION = "com.linroid.ketch.app.android.action.REPOST_NOTIFICATION"
Expand Down
31 changes: 15 additions & 16 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,30 +133,25 @@ You can implement your own logger by implementing the `Logger` interface:

```kotlin
class CustomLogger : Logger {
override fun v(message: () -> String) {
// Verbose logging
println("[VERBOSE] ${message()}")
override fun v(message: String) {
println("[VERBOSE] $message")
}

override fun d(message: () -> String) {
// Debug logging
println("[DEBUG] ${message()}")
override fun d(message: String) {
println("[DEBUG] $message")
}

override fun i(message: () -> String) {
// Info logging
println("[INFO] ${message()}")
override fun i(message: String) {
println("[INFO] $message")
}

override fun w(message: () -> String, throwable: Throwable?) {
// Warning logging
println("[WARN] ${message()}")
override fun w(message: String, throwable: Throwable?) {
println("[WARN] $message")
throwable?.printStackTrace()
}

override fun e(message: () -> String, throwable: Throwable?) {
// Error logging
System.err.println("[ERROR] ${message()}")
override fun e(message: String, throwable: Throwable?) {
System.err.println("[ERROR] $message")
throwable?.printStackTrace()
}
}
Expand All @@ -167,7 +162,11 @@ val ketch = Ketch(
)
```

**Note:** Tags are included in the message itself by KetchLogger. Each log message is formatted as `[ComponentTag] message`, so you don't need to handle tags separately.
**Note:** `Logger` receives pre-built `String` messages. Lazy evaluation is handled by
`KetchLogger`'s `inline` functions, which skip message construction entirely when
`Logger.None` is active (the default). Tags are included in the message by `KetchLogger` —
each message is formatted as `[ComponentTag] message`, so you don't need to handle tags
separately.

## Platform-Specific Behavior

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@ import android.util.Log

internal actual fun consoleLogger(minLevel: LogLevel): Logger =
object : Logger {
override fun v(message: () -> String) {
if (minLevel <= LogLevel.VERBOSE) Log.v("Ketch", message())
override fun v(message: String) {
if (minLevel <= LogLevel.VERBOSE) Log.v("Ketch", message)
}

override fun d(message: () -> String) {
if (minLevel <= LogLevel.DEBUG) Log.d("Ketch", message())
override fun d(message: String) {
if (minLevel <= LogLevel.DEBUG) Log.d("Ketch", message)
}

override fun i(message: () -> String) {
if (minLevel <= LogLevel.INFO) Log.i("Ketch", message())
override fun i(message: String) {
if (minLevel <= LogLevel.INFO) Log.i("Ketch", message)
}

override fun w(message: () -> String, throwable: Throwable?) {
if (minLevel <= LogLevel.WARN) Log.w("Ketch", message(), throwable)
override fun w(message: String, throwable: Throwable?) {
if (minLevel <= LogLevel.WARN) Log.w("Ketch", message, throwable)
}

override fun e(message: () -> String, throwable: Throwable?) {
if (minLevel <= LogLevel.ERROR) Log.e("Ketch", message(), throwable)
override fun e(message: String, throwable: Throwable?) {
if (minLevel <= LogLevel.ERROR) Log.e("Ketch", message, throwable)
}
}
46 changes: 21 additions & 25 deletions library/core/src/commonMain/kotlin/com/linroid/ketch/core/Ketch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,19 @@ class Ketch(

override val backendLabel: String = "Core"

private val log = KetchLogger("Ketch")

init {
KetchLogger.setLogger(logger)
KetchLogger.i("Ketch") { "Ketch v${KetchApi.VERSION} initialized" }
log.i { "Ketch v${KetchApi.VERSION} initialized" }
if (!config.speedLimit.isUnlimited) {
KetchLogger.i("Ketch") {
log.i {
"Global speed limit: " +
"${config.speedLimit.bytesPerSecond} bytes/sec"
}
}
if (additionalSources.isNotEmpty()) {
KetchLogger.i("Ketch") {
log.i {
"Additional sources: " +
additionalSources.joinToString { it.type }
}
Expand Down Expand Up @@ -149,7 +151,7 @@ class Ketch(
val isScheduled =
request.schedule !is DownloadSchedule.Immediate ||
request.conditions.isNotEmpty()
KetchLogger.i("Ketch") {
log.i {
"Downloading: taskId=$taskId, url=${request.url}, " +
"connections=${request.connections}, " +
"priority=${request.priority}" +
Expand Down Expand Up @@ -180,7 +182,7 @@ class Ketch(
if (stateFlow.value.isActive) {
coordinator.pause(taskId)
} else {
KetchLogger.d("Ketch") {
log.d {
"Ignoring pause for taskId=$taskId " +
"in state ${stateFlow.value}"
}
Expand All @@ -200,9 +202,7 @@ class Ketch(
)
}
} else {
KetchLogger.d("Ketch") {
"Ignoring resume for taskId=$taskId in state $state"
}
log.d { "Ignoring resume for taskId=$taskId in state $state" }
}
},
cancelAction = {
Expand All @@ -215,9 +215,7 @@ class Ketch(
stateFlow.value = DownloadState.Canceled
}
} else {
KetchLogger.d("Ketch") {
"Ignoring cancel for taskId=$taskId in state $s"
}
log.d { "Ignoring cancel for taskId=$taskId in state $s" }
}
},
removeAction = { removeTaskInternal(taskId) },
Expand All @@ -233,13 +231,13 @@ class Ketch(
rescheduleAction = { schedule, conditions ->
val s = stateFlow.value
if (s.isTerminal) {
KetchLogger.d("Ketch") {
log.d {
"Ignoring reschedule for taskId=$taskId in " +
"terminal state $s"
}
return@DownloadTaskImpl
}
KetchLogger.i("Ketch") {
log.i {
"Rescheduling taskId=$taskId, schedule=$schedule, " +
"conditions=${conditions.size}"
}
Expand All @@ -264,7 +262,7 @@ class Ketch(
url: String,
headers: Map<String, String>,
): ResolvedSource {
KetchLogger.i("Ketch") { "Resolving URL: $url" }
log.i { "Resolving URL: $url" }
val source = sourceResolver.resolve(url)
return source.resolve(url, headers)
}
Expand Down Expand Up @@ -300,11 +298,9 @@ class Ketch(
* - `CANCELED` -> [DownloadState.Canceled]
*/
suspend fun loadTasks() {
KetchLogger.i("Ketch") {
"Loading tasks from persistent storage"
}
log.i { "Loading tasks from persistent storage" }
val records = taskStore.loadAll()
KetchLogger.i("Ketch") { "Found ${records.size} task(s)" }
log.i { "Found ${records.size} task(s)" }

tasksMutex.withLock {
val currentTasks = _tasks.value
Expand Down Expand Up @@ -342,7 +338,7 @@ class Ketch(
if (stateFlow.value.isActive) {
coordinator.pause(record.taskId)
} else {
KetchLogger.d("Ketch") {
log.d {
"Ignoring pause for taskId=${record.taskId} " +
"in state ${stateFlow.value}"
}
Expand All @@ -362,7 +358,7 @@ class Ketch(
)
}
} else {
KetchLogger.d("Ketch") {
log.d {
"Ignoring resume for taskId=${record.taskId} " +
"in state $state"
}
Expand All @@ -378,7 +374,7 @@ class Ketch(
stateFlow.value = DownloadState.Canceled
}
} else {
KetchLogger.d("Ketch") {
log.d {
"Ignoring cancel for taskId=${record.taskId} " +
"in state $s"
}
Expand All @@ -397,13 +393,13 @@ class Ketch(
rescheduleAction = { schedule, conditions ->
val s = stateFlow.value
if (s.isTerminal) {
KetchLogger.d("Ketch") {
log.d {
"Ignoring reschedule for taskId=${record.taskId} in " +
"terminal state $s"
}
return@DownloadTaskImpl
}
KetchLogger.i("Ketch") {
log.i {
"Rescheduling taskId=${record.taskId}, " +
"schedule=$schedule, " +
"conditions=${conditions.size}"
Expand Down Expand Up @@ -501,11 +497,11 @@ class Ketch(
// Apply queue config
scheduler.queueConfig = config.queueConfig

KetchLogger.i("Ketch") { "Config updated: $config" }
log.i { "Config updated: $config" }
}

override fun close() {
KetchLogger.i("Ketch") { "Closing Ketch" }
log.i { "Closing Ketch" }
httpEngine.close()
scope.cancel()
}
Expand Down
Loading
Loading