├── docs
├── cli_scr1.png
├── IOS-XCFramework.md
└── RELEASE.md
├── kmpertrace-runtime
└── src
│ ├── androidMain
│ ├── AndroidManifest.xml
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── log
│ │ └── ThreadInfo.kt
│ │ ├── trace
│ │ ├── TraceContextStorage.android.kt
│ │ ├── android
│ │ │ └── HandlerTrace.android.kt
│ │ └── LoggingBindingStorage.android.kt
│ │ └── platform
│ │ └── PlatformLogSink.kt
│ ├── iosMain
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── log
│ │ └── ThreadInfo.kt
│ │ ├── trace
│ │ ├── LoggingBindingStorage.ios.kt
│ │ └── TraceContextStorage.ios.kt
│ │ ├── swift
│ │ ├── KmperTraceSnapshot.kt
│ │ ├── KmperTraceSwift.kt
│ │ └── KmperLogger.kt
│ │ └── platform
│ │ └── PlatformLogSink.kt
│ ├── wasmJsMain
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── log
│ │ └── ThreadInfo.kt
│ │ ├── platform
│ │ └── PlatformLogSink.kt
│ │ └── trace
│ │ ├── LoggingBindingStorage.wasm.kt
│ │ └── TraceContextStorage.wasm.kt
│ ├── jvmMain
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── log
│ │ └── ThreadInfo.kt
│ │ ├── platform
│ │ └── PlatformLogSink.kt
│ │ └── trace
│ │ ├── TraceSnapshot.jvm.kt
│ │ ├── TraceContextStorage.jvm.kt
│ │ └── LoggingBindingStorage.jvm.kt
│ ├── commonMain
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── core
│ │ ├── LogRecordKind.kt
│ │ ├── SpanKind.kt
│ │ ├── Level.kt
│ │ ├── TraceContext.kt
│ │ └── StructuredLogRecord.kt
│ │ ├── trace
│ │ ├── SpanMessageDefaults.kt
│ │ ├── TraceContextStorage.kt
│ │ ├── LoggingBinding.kt
│ │ ├── TraceDispatchers.kt
│ │ └── TraceSnapshot.kt
│ │ ├── platform
│ │ └── PlatformLogSink.kt
│ │ └── log
│ │ ├── LogSink.kt
│ │ ├── KmperTrace.kt
│ │ └── LoggerConfig.kt
│ ├── iosTest
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── trace
│ │ ├── IosCollectingBackend.kt
│ │ ├── ComponentInheritanceIosTest.kt
│ │ └── LoggingBindingIsolationIosTest.kt
│ │ └── platform
│ │ ├── PlatformLogSinkSmokeTest.kt
│ │ ├── ChunkingSmokeTest.kt
│ │ └── IosPlatformBackendTest.kt
│ ├── androidUnitTest
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── trace
│ │ ├── AndroidCollectingBackend.kt
│ │ ├── ComponentInheritanceAndroidTest.kt
│ │ └── LoggingBindingIsolationTest.kt
│ │ └── platform
│ │ └── AndroidPlatformBackendTest.kt
│ ├── wasmJsTest
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ ├── trace
│ │ └── ComponentInheritanceWasmTest.kt
│ │ └── platform
│ │ └── PlatformLogBackendWasmTest.kt
│ ├── commonTest
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ └── testutil
│ │ └── StructuredSuffixParsing.kt
│ └── jvmTest
│ └── kotlin
│ └── dev
│ └── goquick
│ └── kmpertrace
│ └── trace
│ └── TraceSnapshotJvmTest.kt
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── sample-app
├── src
│ ├── androidMain
│ │ ├── res
│ │ │ ├── values
│ │ │ │ └── strings.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── kotlin
│ │ │ └── dev
│ │ │ │ └── goquick
│ │ │ │ └── kmpertrace
│ │ │ │ └── sampleapp
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── iosMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── goquick
│ │ │ └── kmpertrace
│ │ │ └── sampleapp
│ │ │ └── MainViewController.kt
│ ├── commonTest
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── goquick
│ │ │ └── kmpertrace
│ │ │ └── sampleapp
│ │ │ └── ComposeAppCommonTest.kt
│ ├── jvmMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── goquick
│ │ │ └── kmpertrace
│ │ │ └── sampleapp
│ │ │ └── main.kt
│ ├── wasmJsMain
│ │ ├── kotlin
│ │ │ └── dev
│ │ │ │ └── goquick
│ │ │ │ └── kmpertrace
│ │ │ │ └── sampleapp
│ │ │ │ └── MainWasm.kt
│ │ └── resources
│ │ │ └── index.html
│ └── commonMain
│ │ ├── kotlin
│ │ └── dev
│ │ │ └── goquick
│ │ │ └── kmpertrace
│ │ │ └── sampleapp
│ │ │ ├── model
│ │ │ └── Models.kt
│ │ │ └── data
│ │ │ ├── ProfileRepository.kt
│ │ │ └── FakeDownloader.kt
│ │ └── composeResources
│ │ └── drawable
│ │ └── compose-multiplatform.xml
├── iosApp
│ ├── iosApp
│ │ ├── Assets.xcassets
│ │ │ ├── Contents.json
│ │ │ ├── AppIcon.appiconset
│ │ │ │ ├── app-icon-1024.png
│ │ │ │ └── Contents.json
│ │ │ └── AccentColor.colorset
│ │ │ │ └── Contents.json
│ │ ├── Preview Content
│ │ │ └── Preview Assets.xcassets
│ │ │ │ └── Contents.json
│ │ ├── iOSApp.swift
│ │ ├── Info.plist
│ │ └── ContentView.swift
│ └── Configuration
│ │ └── Config.xcconfig
├── gradle.properties
└── build.gradle.kts
├── tmp
└── out.txt
├── kmpertrace-cli
├── src
│ ├── main
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── goquick
│ │ │ └── kmpertrace
│ │ │ └── cli
│ │ │ ├── ConsoleWidth.kt
│ │ │ ├── CliErrors.kt
│ │ │ ├── SpanAttrs.kt
│ │ │ ├── Help.kt
│ │ │ ├── TuiInputMapping.kt
│ │ │ ├── ansi
│ │ │ └── Ansi.kt
│ │ │ ├── CliParsing.kt
│ │ │ ├── source
│ │ │ ├── SourceCommands.kt
│ │ │ └── IosDiscovery.kt
│ │ │ └── TuiStateMachine.kt
│ └── test
│ │ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ └── cli
│ │ ├── HelpTest.kt
│ │ ├── FormatTimestampTest.kt
│ │ ├── TuiRunnerRawLevelTest.kt
│ │ ├── TuiInputMappingTest.kt
│ │ ├── TuiStateMachineTest.kt
│ │ ├── CliParsingTest.kt
│ │ ├── StatusLineTest.kt
│ │ ├── FollowModeTest.kt
│ │ ├── TuiControllerTest.kt
│ │ ├── TuiRawFilterTest.kt
│ │ ├── SourcesTest.kt
│ │ ├── IosTargetResolverTest.kt
│ │ └── IdeviceSyslogLineProcessorTest.kt
├── build.gradle.kts
└── README.md
├── kmpertrace-parse
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── dev
│ │ │ └── goquick
│ │ │ └── kmpertrace
│ │ │ └── parse
│ │ │ ├── LogRecordKind.kt
│ │ │ ├── ParsedLogRecord.kt
│ │ │ ├── SpanModels.kt
│ │ │ ├── Logfmt.kt
│ │ │ ├── FieldNormalization.kt
│ │ │ ├── StructuredSuffixFramer.kt
│ │ │ ├── HumanPrefix.kt
│ │ │ └── ParseApi.kt
│ └── commonTest
│ │ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ └── parse
│ │ └── HeadPreferenceTest.kt
└── build.gradle.kts
├── kmpertrace-analysis
├── build.gradle.kts
└── src
│ ├── test
│ └── kotlin
│ │ └── dev
│ │ └── goquick
│ │ └── kmpertrace
│ │ └── analysis
│ │ ├── ChunkAssemblerTest.kt
│ │ ├── AnalysisEngineMessagePreferenceTest.kt
│ │ ├── StructuredSuffixFramerTest.kt
│ │ ├── IosUnifiedLogChunkingRegressionTest.kt
│ │ ├── AnalysisEngineUnescapeTest.kt
│ │ └── AndroidMultilineGrouperTest.kt
│ └── main
│ └── kotlin
│ └── dev
│ └── goquick
│ └── kmpertrace
│ └── analysis
│ ├── ChunkAssembler.kt
│ ├── FilterState.kt
│ ├── IosUnifiedLogMultiline.kt
│ └── StructuredFraming.kt
├── gradle.properties
├── Package.swift.template
├── Package.swift
├── kotlin-js-store
└── wasm
│ └── yarn.lock
├── src
└── main
│ └── kotlin
│ └── Main.kt
├── .github
└── workflows
│ ├── gradle.yml
│ └── compute-spm-checksum.yml
├── .gitignore
├── settings.gradle.kts
├── plugin
├── build.gradle.kts
└── src
│ └── main
│ └── kotlin
│ └── dev
│ └── goquick
│ └── kmpertrace
│ ├── GenerateKmperTraceTask.kt
│ └── KmperTracePlugin.kt
└── gradlew.bat
/docs/cli_scr1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/docs/cli_scr1.png
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | SampleApp
3 |
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/sample-app/iosApp/Configuration/Config.xcconfig:
--------------------------------------------------------------------------------
1 | TEAM_ID=
2 |
3 | PRODUCT_NAME=SampleApp
4 | PRODUCT_BUNDLE_IDENTIFIER=dev.goquick.kmpertrace.sampleapp
5 |
6 | CURRENT_PROJECT_VERSION=1
7 | MARKETING_VERSION=1.0
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mobiletoly/kmpertrace/HEAD/sample-app/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
--------------------------------------------------------------------------------
/tmp/out.txt:
--------------------------------------------------------------------------------
1 | trace trace-1
2 | └─ root (20 ms)
3 | └─ ❌ 2025-01-01T00:00:01Z Api: boom
4 | java.lang.IllegalStateException: boom
5 | at A.foo(A.kt:10)
6 | at B.bar(B.kt:20)
7 |
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/iOSApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct iOSApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosMain/kotlin/dev/goquick/kmpertrace/log/ThreadInfo.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.log
2 |
3 | @PublishedApi
4 | internal actual fun currentThreadNameOrNull(): String? = null // K/N doesn't expose a stable thread name
5 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/wasmJsMain/kotlin/dev/goquick/kmpertrace/log/ThreadInfo.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.log
2 |
3 | @PublishedApi
4 | internal actual fun currentThreadNameOrNull(): String? = null // JS/Wasm has no thread identity to report
5 |
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/sample-app/src/iosMain/kotlin/dev/goquick/kmpertrace/sampleapp/MainViewController.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp
2 |
3 | import androidx.compose.ui.window.ComposeUIViewController
4 |
5 | fun MainViewController() = ComposeUIViewController { App() }
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/jvmMain/kotlin/dev/goquick/kmpertrace/log/ThreadInfo.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.log
2 |
3 | @PublishedApi
4 | internal actual fun currentThreadNameOrNull(): String? = Thread.currentThread().name // desktop JVM exposes thread names
5 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/ConsoleWidth.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | internal fun detectConsoleWidth(): Int {
4 | System.getenv("COLUMNS")?.toIntOrNull()?.let { if (it > 0) return it }
5 | return 80
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidMain/kotlin/dev/goquick/kmpertrace/log/ThreadInfo.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.log
2 |
3 | @PublishedApi
4 | internal actual fun currentThreadNameOrNull(): String? = Thread.currentThread().name // Android threads are named by default
5 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Nov 08 17:03:42 WET 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/core/LogRecordKind.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.core
2 |
3 | /**
4 | * Structured log record kind used in log output.
5 | */
6 | enum class LogRecordKind {
7 | SPAN_START,
8 | SPAN_END,
9 | LOG
10 | }
11 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/LogRecordKind.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | /**
4 | * Structured log record category encoded in logfmt suffix.
5 | */
6 | enum class LogRecordKind {
7 | SPAN_START,
8 | SPAN_END,
9 | LOG
10 | }
11 |
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CADisableMinimumFrameDurationOnPhone
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | }
4 |
5 | kotlin {
6 | jvmToolchain(17)
7 | }
8 |
9 | dependencies {
10 | implementation(project(":kmpertrace-parse"))
11 | testImplementation(kotlin("test"))
12 | }
13 |
14 | repositories {
15 | mavenCentral()
16 | }
17 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 | android.useAndroidX=true
3 | org.jetbrains.compose.experimental.uikit.enabled=true
4 | org.gradle.jvmargs=-Xms512m -Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC
5 | kotlin.daemon.jvm.options=-Xmx4096m
6 | kmpertraceGroup=dev.goquick.kmpertrace
7 | kmpertraceVersion=0.2.3
8 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/core/SpanKind.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.core
2 |
3 | /**
4 | * Span semantic type (aligned with OpenTelemetry kinds).
5 | */
6 | enum class SpanKind {
7 | INTERNAL,
8 | SERVER,
9 | CLIENT,
10 | PRODUCER,
11 | CONSUMER
12 | }
13 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/core/Level.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.core
2 |
3 | /**
4 | * Log severity levels ordered from least to most critical.
5 | */
6 | enum class Level {
7 | VERBOSE,
8 | DEBUG,
9 | INFO,
10 | WARN,
11 | ERROR,
12 | ASSERT
13 | }
14 |
--------------------------------------------------------------------------------
/sample-app/gradle.properties:
--------------------------------------------------------------------------------
1 | #Kotlin
2 | kotlin.code.style=official
3 | kotlin.daemon.jvmargs=-Xmx3072M
4 |
5 | #Gradle
6 | org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
7 | org.gradle.configuration-cache=true
8 | org.gradle.caching=true
9 |
10 | #Android
11 | android.nonTransitiveRClass=true
12 | android.useAndroidX=true
--------------------------------------------------------------------------------
/sample-app/src/commonTest/kotlin/dev/goquick/kmpertrace/sampleapp/ComposeAppCommonTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class ComposeAppCommonTest {
7 |
8 | @Test
9 | fun example() {
10 | assertEquals(3, 1 + 2)
11 | }
12 | }
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/jvmMain/kotlin/dev/goquick/kmpertrace/platform/PlatformLogSink.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import dev.goquick.kmpertrace.log.LogRecord
4 | import dev.goquick.kmpertrace.log.LogSink
5 |
6 | actual object PlatformLogSink : LogSink {
7 | actual override fun emit(record: LogRecord) {
8 | println(record.line)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/CliErrors.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import com.github.ajalt.clikt.core.CliktError
4 | import com.github.ajalt.clikt.core.UsageError
5 |
6 | internal fun usageError(message: String): Nothing = throw UsageError(message)
7 |
8 | internal fun cliError(message: String): Nothing = throw CliktError(message)
9 |
10 |
--------------------------------------------------------------------------------
/sample-app/src/jvmMain/kotlin/dev/goquick/kmpertrace/sampleapp/main.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp
2 |
3 | import androidx.compose.ui.window.Window
4 | import androidx.compose.ui.window.application
5 |
6 | fun main() = application {
7 | Window(
8 | onCloseRequest = ::exitApplication,
9 | title = "SampleApp",
10 | ) {
11 | App()
12 | }
13 | }
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosTest/kotlin/dev/goquick/kmpertrace/trace/IosCollectingBackend.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.log.LogRecord
4 | import dev.goquick.kmpertrace.log.LogSink
5 |
6 | internal class IosCollectingSink : LogSink {
7 | val records = mutableListOf()
8 | override fun emit(record: LogRecord) {
9 | records += record
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidUnitTest/kotlin/dev/goquick/kmpertrace/trace/AndroidCollectingBackend.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.log.LogRecord
4 | import dev.goquick.kmpertrace.log.LogSink
5 |
6 | internal class AndroidCollectingSink : LogSink {
7 | val records = mutableListOf()
8 | override fun emit(record: LogRecord) {
9 | records += record
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/trace/SpanMessageDefaults.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.LogRecordKind
4 |
5 | internal fun defaultSpanMessage(kind: LogRecordKind, spanLabel: String?): String? = when (kind) {
6 | LogRecordKind.SPAN_START -> spanLabel?.let { "+++ $it" }
7 | LogRecordKind.SPAN_END -> spanLabel?.let { "--- $it" }
8 | else -> null
9 | }
10 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/platform/PlatformLogSink.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import dev.goquick.kmpertrace.log.LogRecord
4 | import dev.goquick.kmpertrace.log.LogSink
5 |
6 | /**
7 | * Default platform sink that writes rendered KmperTrace lines to the platform console/log system.
8 | */
9 | expect object PlatformLogSink : LogSink {
10 | override fun emit(record: LogRecord)
11 | }
12 |
--------------------------------------------------------------------------------
/sample-app/src/wasmJsMain/kotlin/dev/goquick/kmpertrace/sampleapp/MainWasm.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp
2 |
3 | import androidx.compose.ui.ExperimentalComposeUiApi
4 | import androidx.compose.ui.window.ComposeViewport
5 | import kotlinx.browser.document
6 | import org.w3c.dom.HTMLDivElement
7 |
8 | @OptIn(ExperimentalComposeUiApi::class)
9 | fun main() {
10 | ComposeViewport(document.getElementById("root") as HTMLDivElement) {
11 | App()
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/kmpertrace-parse/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlinMultiplatform)
3 | }
4 |
5 | kotlin {
6 | jvm()
7 | jvmToolchain(17)
8 |
9 | sourceSets {
10 | commonMain.dependencies {
11 | }
12 | commonTest.dependencies {
13 | implementation(kotlin("test"))
14 | }
15 | jvmTest.dependencies {
16 | implementation(kotlin("test"))
17 | }
18 | }
19 | }
20 |
21 | repositories {
22 | mavenCentral()
23 | }
24 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/HelpTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertTrue
5 |
6 | class HelpTest {
7 | @Test
8 | fun help_mentions_current_raw_cycle_order() {
9 | val text = renderHelp(colorize = false)
10 | assertTrue(
11 | text.contains("r : cycle raw logs (off → all → debug → info → warn → error → off)"),
12 | "help text should match nextRawLevel behavior:\n$text"
13 | )
14 | }
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/Package.swift.template:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "KmperTraceRuntime",
6 | platforms: [
7 | .iOS(.v13)
8 | ],
9 | products: [
10 | .library(
11 | name: "KmperTraceRuntime",
12 | targets: ["KmperTraceRuntime"]
13 | )
14 | ],
15 | targets: [
16 | .binaryTarget(
17 | name: "KmperTraceRuntime",
18 | url: "__URL__",
19 | checksum: "__CHECKSUM__"
20 | )
21 | ]
22 | )
23 |
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import ComposeApp
4 |
5 | struct ComposeView: UIViewControllerRepresentable {
6 | func makeUIViewController(context: Context) -> UIViewController {
7 | MainViewControllerKt.MainViewController()
8 | }
9 |
10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
11 | }
12 |
13 | struct ContentView: View {
14 | var body: some View {
15 | ComposeView()
16 | .ignoresSafeArea()
17 | }
18 | }
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosTest/kotlin/dev/goquick/kmpertrace/platform/PlatformLogSinkSmokeTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertTrue
5 |
6 | class PlatformLogSinkSmokeTest {
7 | @Test
8 | fun emitPossiblyChunked_with_percent_and_long_line_does_not_crash() {
9 | val long = "prefix 100% " + "x".repeat(950)
10 |
11 | // This test intentionally exercises the real NSLog path.
12 | // If it segfaults, Gradle will report a crashed test process.
13 | PlatformLogSink.emitPossiblyChunked(long)
14 | assertTrue(true)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "KmperTraceRuntime",
6 | platforms: [
7 | .iOS(.v13)
8 | ],
9 | products: [
10 | .library(
11 | name: "KmperTraceRuntime",
12 | targets: ["KmperTraceRuntime"]
13 | )
14 | ],
15 | targets: [
16 | .binaryTarget(
17 | name: "KmperTraceRuntime",
18 | url: "https://github.com/mobiletoly/kmpertrace/releases/download/v0.2.3/KmperTraceRuntime.xcframework.zip",
19 | checksum: "d626d35f466eef6e54e1e80e2f15a94beb346473f934309678e5934041f7a1f5"
20 | )
21 | ]
22 | )
23 |
--------------------------------------------------------------------------------
/kotlin-js-store/wasm/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | "@js-joda/core@3.2.0":
6 | version "3.2.0"
7 | resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
8 | integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
9 |
10 | "@js-joda/core@5.5.2":
11 | version "5.5.2"
12 | resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.5.2.tgz#3d7953004d70adf6a0a4140a78c948de758284e0"
13 | integrity sha512-retLUN4TwCJ0QJDi9OCJwYVaXAz93NeOkEtEQL98M2bykBOxmURlP0YlfsuE46kItOOVZIWRYC3KsSLhQ1R2Qw==
14 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonTest/kotlin/dev/goquick/kmpertrace/parse/HeadPreferenceTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 | import kotlin.test.assertNotNull
6 |
7 | class HeadPreferenceTest {
8 | @Test
9 | fun prefers_head_when_human_message_differs() {
10 | val line =
11 | "1765257462.958 22999 22999 I ProfileViewModel: ProfileViewModel: starting now |{ ts=2025-12-09T05:17:42.958222Z lvl=info head=\"starting now\" src=ProfileViewModel svc=sample-app thread=\"main\" }|"
12 | val evt = parseLine(line)
13 | assertNotNull(evt)
14 | assertEquals("starting now", evt.message)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/sample-app/src/wasmJsMain/resources/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | KMP Sample (Plugin + Library, Wasm)
7 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/sample-app/src/androidMain/kotlin/dev/goquick/kmpertrace/sampleapp/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.tooling.preview.Preview
9 |
10 | class MainActivity : ComponentActivity() {
11 | override fun onCreate(savedInstanceState: Bundle?) {
12 | enableEdgeToEdge()
13 | super.onCreate(savedInstanceState)
14 |
15 | setContent {
16 | App()
17 | }
18 | }
19 | }
20 |
21 | @Preview
22 | @Composable
23 | fun AppAndroidPreview() {
24 | App()
25 | }
--------------------------------------------------------------------------------
/src/main/kotlin/Main.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace
2 |
3 | //TIP To Run code, press or
4 | // click the icon in the gutter.
5 | fun main() {
6 | val name = "Kotlin"
7 | //TIP Press with your caret at the highlighted text
8 | // to see how IntelliJ IDEA suggests fixing it.
9 | println("Hello, " + name + "!")
10 |
11 | for (i in 1..5) {
12 | //TIP Press to start debugging your code. We have set one breakpoint
13 | // for you, but you can always add more by pressing .
14 | println("i = $i")
15 | }
16 | }
--------------------------------------------------------------------------------
/.github/workflows/gradle.yml:
--------------------------------------------------------------------------------
1 | name: Gradle CI
2 |
3 | on:
4 | push:
5 | branches: ["**"]
6 | pull_request:
7 | branches: ["**"]
8 |
9 | concurrency:
10 | group: gradle-ci-${{ github.ref }}
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 |
21 | - name: Set up JDK 17
22 | uses: actions/setup-java@v4
23 | with:
24 | distribution: temurin
25 | java-version: 17
26 |
27 | - name: Setup Gradle
28 | uses: gradle/actions/setup-gradle@v3
29 |
30 | - name: Run checks
31 | run: ./gradlew --stacktrace :kmpertrace-runtime:check :plugin:check :kmpertrace-cli:test :kmpertrace-parse:allTests
32 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/core/TraceContext.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.core
2 |
3 | import kotlin.coroutines.CoroutineContext
4 |
5 | /**
6 | * CoroutineContext element holding the active span and trace identifiers.
7 | */
8 | data class TraceContext(
9 | val traceId: String,
10 | val spanId: String,
11 | val parentSpanId: String?,
12 | val spanName: String,
13 | val attributes: Map = emptyMap(),
14 | val sourceComponent: String? = null,
15 | val sourceOperation: String? = null,
16 | val sourceLocationHint: String? = null
17 | ) : CoroutineContext.Element {
18 |
19 | companion object Key : CoroutineContext.Key
20 |
21 | override val key: CoroutineContext.Key<*> get() = Key
22 | }
23 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/jvmMain/kotlin/dev/goquick/kmpertrace/trace/TraceSnapshot.jvm.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import java.util.concurrent.Executor
4 |
5 | /**
6 | * Wrap this [Runnable] so that logs emitted while it runs remain attached to [snapshot]'s trace/span (if any).
7 | */
8 | fun Runnable.withTrace(snapshot: TraceSnapshot = captureTraceSnapshot()): Runnable {
9 | val original = this
10 | return Runnable { snapshot.withTraceSnapshot { original.run() } }
11 | }
12 |
13 | /**
14 | * Execute [block] with [snapshot] installed on the executor thread for the duration of the callback.
15 | */
16 | fun Executor.executeWithTrace(snapshot: TraceSnapshot = captureTraceSnapshot(), block: () -> Unit) {
17 | execute { snapshot.withTraceSnapshot(block) }
18 | }
19 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/ParsedLogRecord.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | /**
4 | * Parsed representation of a structured KmperTrace log line.
5 | */
6 | data class ParsedLogRecord(
7 | val traceId: String,
8 | val spanId: String,
9 | val parentSpanId: String?,
10 | val logRecordKind: LogRecordKind,
11 | val spanName: String?,
12 | val durationMs: Long?,
13 | val loggerName: String?,
14 | val timestamp: String?,
15 | val message: String?,
16 | val sourceComponent: String?,
17 | val sourceOperation: String?,
18 | val sourceLocationHint: String?,
19 | val sourceFile: String?,
20 | val sourceLine: Int?,
21 | val sourceFunction: String?,
22 | val rawFields: Map
23 | )
24 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/SpanAttrs.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | enum class SpanAttrsMode { OFF, ON }
4 |
5 | internal fun validateSpanAttrsMode(value: String) {
6 | parseSpanAttrsMode(value) // will throw if invalid
7 | }
8 |
9 | internal fun parseSpanAttrsMode(value: String?): SpanAttrsMode =
10 | when (value?.lowercase()) {
11 | null, "off", "0", "false" -> SpanAttrsMode.OFF
12 | "on", "1", "true" -> SpanAttrsMode.ON
13 | else -> throw IllegalArgumentException("Invalid --span-attrs value: $value (use off|on)")
14 | }
15 |
16 | internal fun nextSpanAttrsMode(current: SpanAttrsMode): SpanAttrsMode =
17 | when (current) {
18 | SpanAttrsMode.OFF -> SpanAttrsMode.ON
19 | SpanAttrsMode.ON -> SpanAttrsMode.OFF
20 | }
21 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/FormatTimestampTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import java.time.ZoneOffset
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 |
7 | class FormatTimestampTest {
8 | @Test
9 | fun converts_epoch_seconds_to_time_only() {
10 | val ts = "1765254324.631" // 2025-12-09T04:25:24.631Z
11 | assertEquals("04:25:24.631", formatTimestamp(ts, TimeFormat.TIME_ONLY, ZoneOffset.UTC))
12 | assertEquals("2025-12-09T04:25:24.631Z", formatTimestamp(ts, TimeFormat.FULL))
13 | }
14 |
15 | @Test
16 | fun extracts_time_from_logcat_month_day() {
17 | val ts = "12-08 17:56:03.806"
18 | assertEquals("17:56:03.806", formatTimestamp(ts, TimeFormat.TIME_ONLY))
19 | assertEquals(ts, formatTimestamp(ts, TimeFormat.FULL))
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/sample-app/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "app-icon-1024.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | },
9 | {
10 | "appearances" : [
11 | {
12 | "appearance" : "luminosity",
13 | "value" : "dark"
14 | }
15 | ],
16 | "idiom" : "universal",
17 | "platform" : "ios",
18 | "size" : "1024x1024"
19 | },
20 | {
21 | "appearances" : [
22 | {
23 | "appearance" : "luminosity",
24 | "value" : "tinted"
25 | }
26 | ],
27 | "idiom" : "universal",
28 | "platform" : "ios",
29 | "size" : "1024x1024"
30 | }
31 | ],
32 | "info" : {
33 | "author" : "xcode",
34 | "version" : 1
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/jvmMain/kotlin/dev/goquick/kmpertrace/trace/TraceContextStorage.jvm.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.TraceContext
4 | import kotlinx.coroutines.asContextElement
5 | import kotlin.coroutines.ContinuationInterceptor
6 | import kotlin.coroutines.CoroutineContext
7 |
8 | private val traceLocal: ThreadLocal = ThreadLocal() // per-thread active span context
9 |
10 | actual object TraceContextStorage {
11 | actual fun get(): TraceContext? = traceLocal.get()
12 | actual fun set(value: TraceContext?) {
13 | traceLocal.set(value)
14 | }
15 |
16 | actual fun element(
17 | value: TraceContext?,
18 | downstream: ContinuationInterceptor?
19 | ): CoroutineContext = traceLocal.asContextElement(value) // coroutines restore this ThreadLocal across suspends
20 | }
21 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidMain/kotlin/dev/goquick/kmpertrace/trace/TraceContextStorage.android.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.TraceContext
4 | import kotlinx.coroutines.asContextElement
5 | import kotlin.coroutines.CoroutineContext
6 | import kotlin.coroutines.ContinuationInterceptor
7 |
8 | private val traceLocal: ThreadLocal = ThreadLocal() // per-thread active span context
9 |
10 | actual object TraceContextStorage {
11 | actual fun get(): TraceContext? = traceLocal.get()
12 | actual fun set(value: TraceContext?) {
13 | traceLocal.set(value)
14 | }
15 |
16 | actual fun element(
17 | value: TraceContext?,
18 | downstream: ContinuationInterceptor?
19 | ): CoroutineContext = traceLocal.asContextElement(value) // coroutines restore this ThreadLocal on dispatch
20 | }
21 |
--------------------------------------------------------------------------------
/sample-app/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Build system artifacts
2 | .gradle/
3 | **/build/
4 | **/out/
5 | !gradle/wrapper/gradle-wrapper.jar
6 |
7 | # Local environment overrides
8 | local.properties
9 | *.log
10 |
11 | # IDE files
12 | .idea/
13 | .kotlin/
14 | .konan/
15 | .vscode/
16 | *.iml
17 | *.ipr
18 | *.iws
19 |
20 | # Eclipse / NetBeans leftovers
21 | .apt_generated
22 | .classpath
23 | .factorypath
24 | .project
25 | .settings
26 | .springBeans
27 | .sts4-cache
28 | bin/
29 | /nbproject/private/
30 | /nbbuild/
31 | /dist/
32 | /nbdist/
33 | /.nb-gradle/
34 |
35 | # macOS
36 | .DS_Store
37 |
38 | # Xcode / iOS host artifacts
39 | **/*.xcworkspacedata
40 | **/*.xcuserstate
41 | **/*.xcuserdatad/
42 | **/*.xcworkspace/xcuserdata/
43 | **/*.xcodeproj/project.xcworkspace/xcuserdata/
44 | sample-app/iosApp/DerivedData/
45 | sample-app/iosApp/Frameworks/
46 |
47 | /AGENTS.md
48 | /docs/AGENT-BOOTSTRAP.md
49 | /docs/TUI-Plan.md
50 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/SpanModels.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | /**
4 | * Span node in a reconstructed trace tree.
5 | */
6 | data class SpanNode(
7 | val spanId: String,
8 | val parentSpanId: String?,
9 | val spanName: String,
10 | val durationMs: Long?,
11 | val startTimestamp: String?,
12 | val sourceComponent: String?,
13 | val sourceOperation: String?,
14 | val sourceLocationHint: String?,
15 | val sourceFile: String?,
16 | val sourceLine: Int?,
17 | val sourceFunction: String?,
18 | val attributes: Map,
19 | val records: List,
20 | val children: List
21 | )
22 |
23 | /**
24 | * Root trace tree containing span hierarchy for a traceId.
25 | */
26 | data class TraceTree(
27 | val traceId: String,
28 | val spans: List
29 | )
30 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | includeBuild("plugin")
3 | repositories {
4 | gradlePluginPortal()
5 | google()
6 | mavenCentral()
7 | }
8 | plugins {
9 | id("org.jetbrains.kotlin.jvm") version "2.2.21"
10 | id("org.jetbrains.compose") version "1.9.3"
11 | id("com.android.application") version "8.13.2"
12 | id("com.android.kotlin.multiplatform.library") version "8.13.2"
13 | id("com.android.library") version "8.13.2"
14 | id("com.android.lint") version "8.13.2"
15 | }
16 | }
17 |
18 | plugins {
19 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
20 | }
21 |
22 | rootProject.name = "kmpertrace"
23 | include("sample-app")
24 | include("kmpertrace-runtime")
25 | include("kmpertrace-cli")
26 | include("kmpertrace-parse")
27 | include("kmpertrace-analysis")
28 | includeBuild("plugin")
29 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/wasmJsMain/kotlin/dev/goquick/kmpertrace/platform/PlatformLogSink.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalWasmJsInterop::class)
2 |
3 | package dev.goquick.kmpertrace.platform
4 |
5 | import dev.goquick.kmpertrace.core.Level
6 | import dev.goquick.kmpertrace.log.LogRecord
7 | import dev.goquick.kmpertrace.log.LogSink
8 |
9 | @JsFun("line => console.log(line)")
10 | private external fun consoleLog(line: String)
11 |
12 | @JsFun("line => console.warn(line)")
13 | private external fun consoleWarn(line: String)
14 |
15 | @JsFun("line => console.error(line)")
16 | private external fun consoleError(line: String)
17 |
18 | actual object PlatformLogSink : LogSink {
19 | actual override fun emit(record: LogRecord) {
20 | val line = record.line
21 | when (record.level) {
22 | Level.ERROR -> consoleError(line)
23 | Level.WARN -> consoleWarn(line)
24 | else -> consoleLog(line)
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/trace/TraceContextStorage.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.TraceContext
4 |
5 | /**
6 | * Platform-specific holder for the active [TraceContext].
7 | *
8 | * Each platform chooses its own propagation strategy:
9 | * - JVM/Android: store in a ThreadLocal and use a ThreadContextElement to save/restore across coroutine dispatch.
10 | * - iOS/Native: store in a ThreadLocal and wrap the continuation to re-install the value when the coroutine resumes.
11 | * - JS/Wasm: no ThreadLocal support; stash the value directly in the coroutine context.
12 | */
13 | internal expect object TraceContextStorage {
14 | fun get(): TraceContext?
15 | fun set(value: TraceContext?)
16 | fun element(
17 | value: TraceContext?,
18 | downstream: kotlin.coroutines.ContinuationInterceptor? = null // existing interceptor chain to preserve
19 | ): kotlin.coroutines.CoroutineContext
20 | }
21 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/TuiRunnerRawLevelTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class TuiRunnerRawLevelTest {
7 | @Test
8 | fun cycles_raw_levels_off_all_debug_info_warn_error_off() {
9 | val seq = mutableListOf()
10 | var enabled = false
11 | var level = RawLogLevel.OFF
12 | repeat(6) {
13 | level = nextRawLevel(enabled, level)
14 | enabled = level != RawLogLevel.OFF
15 | seq += level
16 | }
17 | // Sequence after six presses starting from off
18 | assertEquals(
19 | listOf(
20 | RawLogLevel.ALL,
21 | RawLogLevel.DEBUG,
22 | RawLogLevel.INFO,
23 | RawLogLevel.WARN,
24 | RawLogLevel.ERROR,
25 | RawLogLevel.OFF
26 | ),
27 | seq
28 | )
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosMain/kotlin/dev/goquick/kmpertrace/trace/LoggingBindingStorage.ios.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import kotlin.coroutines.CoroutineContext
4 | import kotlin.native.concurrent.ThreadLocal
5 |
6 | private class LoggingBindingElement(private val value: LoggingBinding) : CoroutineContext.Element {
7 | companion object Key : CoroutineContext.Key
8 | override val key: CoroutineContext.Key<*> get() = Key
9 | }
10 |
11 | @ThreadLocal
12 | private var currentBinding: LoggingBinding = LoggingBinding.Unbound // one binding flag per native thread
13 | // Binding is reinstalled by TraceContextStorage's continuation wrapper when resuming.
14 |
15 | internal actual object LoggingBindingStorage {
16 | actual fun get(): LoggingBinding = currentBinding
17 | actual fun set(value: LoggingBinding) {
18 | currentBinding = value
19 | }
20 |
21 | actual fun element(value: LoggingBinding): CoroutineContext = LoggingBindingElement(value)
22 | }
23 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/wasmJsMain/kotlin/dev/goquick/kmpertrace/trace/LoggingBindingStorage.wasm.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import kotlin.coroutines.CoroutineContext
4 |
5 | // On JS/Wasm there is no ThreadLocal; we stash the binding flag directly inside the coroutine context.
6 | private class LoggingBindingElement(val value: LoggingBinding) : CoroutineContext.Element {
7 | companion object Key : CoroutineContext.Key
8 | override val key: CoroutineContext.Key<*> get() = Key
9 | }
10 |
11 | private var currentBinding: LoggingBinding = LoggingBinding.Unbound // JS/Wasm is single-threaded, so a plain var works
12 |
13 | internal actual object LoggingBindingStorage {
14 | actual fun get(): LoggingBinding = currentBinding
15 | actual fun set(value: LoggingBinding) {
16 | currentBinding = value
17 | }
18 |
19 | actual fun element(value: LoggingBinding): CoroutineContext = LoggingBindingElement(value) // store flag in coroutine context on JS/Wasm
20 | }
21 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/test/kotlin/dev/goquick/kmpertrace/analysis/ChunkAssemblerTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class ChunkAssemblerTest {
7 |
8 | @Test
9 | fun assembles_three_chunks() {
10 | val asm = ChunkAssembler()
11 | val first = "abc123 first part | |{ ts=... } (abc123:kmpert...)"
12 | val mid = "middle text (abc123:kmpert...)"
13 | val last = "tail }| (abc123:kmpert!)"
14 |
15 | val out1 = asm.feed(first)
16 | val out2 = asm.feed(mid)
17 | val out3 = asm.feed(last)
18 |
19 | val combined = (out1 + out2 + out3)
20 | assertEquals(1, combined.size)
21 | assertEquals("abc123 first part | |{ ts=... } middle text tail }|", combined.first())
22 | }
23 |
24 | @Test
25 | fun passes_through_non_chunked() {
26 | val asm = ChunkAssembler()
27 | val line = "plain line"
28 | assertEquals(listOf(line), asm.feed(line))
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/log/LogSink.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.log
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import kotlin.time.Instant
5 |
6 | /**
7 | * Public sink API for receiving KmperTrace output.
8 | *
9 | * The stable contract is the rendered structured suffix (`|{ ... }|`) and/or the full rendered line.
10 | * The internal structured record model is intentionally not exposed.
11 | */
12 | fun interface LogSink {
13 | fun emit(record: LogRecord)
14 | }
15 |
16 | /**
17 | * A rendered log record.
18 | *
19 | * - [structuredSuffix] always contains the `|{ ... }|` wrapper and is intended to be parseable.
20 | * - [line] is a default human-friendly line that includes [structuredSuffix].
21 | * - Platform sinks may choose to ignore [line] and reformat using other fields.
22 | */
23 | data class LogRecord(
24 | val timestamp: Instant,
25 | val level: Level,
26 | val tag: String,
27 | val message: String,
28 | val line: String,
29 | val structuredSuffix: String
30 | )
31 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/test/kotlin/dev/goquick/kmpertrace/analysis/AnalysisEngineMessagePreferenceTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 | import kotlin.test.assertTrue
6 |
7 | class AnalysisEngineMessagePreferenceTest {
8 |
9 | @Test
10 | fun `prefers parsed message over truncated head`() {
11 | val engine = AnalysisEngine()
12 | val rawLine =
13 | """12-08 00:54:42.528 5330 5330 D Downloader: Download DownloadA progress 33% (jobId=-123) |{ ts=2025-12-08T23:05:29.500817Z lvl=debug trace=trace-1 span=span-1 parent=parent-1 kind=LOG head="Download Downlo" src=Downloader/DownloadA log=Downloader thread="main" }|"""
14 |
15 | engine.onLine(rawLine)
16 | val snapshot = engine.snapshot()
17 | assertTrue(snapshot.traces.isNotEmpty(), "trace should be parsed")
18 | val record = snapshot.traces.first().spans.first().records.first()
19 | assertEquals("Download DownloadA progress 33% (jobId=-123)", record.message)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/Help.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.cli.ansi.AnsiPalette
4 | import dev.goquick.kmpertrace.cli.ansi.maybeColor
5 |
6 | internal fun renderHelp(colorize: Boolean): String {
7 | val sb = StringBuilder()
8 | sb.appendLine(maybeColor("KmperTrace TUI — Help", AnsiPalette.header, colorize))
9 | sb.appendLine()
10 | sb.appendLine("Keys (when stdin is not the log source):")
11 | sb.appendLine(" ? : toggle this help")
12 | sb.appendLine(" c : clear buffer (resets parsed records)")
13 | sb.appendLine(" a : toggle span attributes (off → on)")
14 | sb.appendLine(" r : cycle raw logs (off → all → debug → info → warn → error → off)")
15 | sb.appendLine(" s : cycle structured min level (all → debug → info → error)")
16 | sb.appendLine(" / : set search focus (hides items not matching term; empty clears)")
17 | sb.appendLine(" q : quit")
18 | sb.appendLine(" If raw mode is unavailable, type the letter and press Enter.")
19 | return sb.toString().trimEnd()
20 | }
21 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/TuiInputMappingTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class TuiInputMappingTest {
7 | @Test
8 | fun key_mapping_matches_expected_behavior() {
9 | assertEquals(listOf(UiEvent.ToggleHelp), uiEventsForKey('?'))
10 | assertEquals(listOf(UiEvent.DismissHelp), uiEventsForKey('x'))
11 | assertEquals(listOf(UiEvent.DismissHelp, UiEvent.Clear), uiEventsForKey('c'))
12 | assertEquals(listOf(UiEvent.DismissHelp, UiEvent.PromptSearch), uiEventsForKey('/'))
13 | }
14 |
15 | @Test
16 | fun command_mapping_matches_expected_behavior() {
17 | assertEquals(listOf(UiEvent.ToggleHelp), uiEventsForCommand("?"))
18 | assertEquals(listOf(UiEvent.DismissHelp), uiEventsForCommand("unknown"))
19 | assertEquals(listOf(UiEvent.DismissHelp, UiEvent.Clear), uiEventsForCommand("/clear"))
20 | assertEquals(listOf(UiEvent.DismissHelp, UiEvent.CycleStructured), uiEventsForCommand("struct-logs"))
21 | }
22 | }
23 |
24 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidMain/kotlin/dev/goquick/kmpertrace/trace/android/HandlerTrace.android.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace.android
2 |
3 | import android.os.Handler
4 | import dev.goquick.kmpertrace.trace.TraceSnapshot
5 | import dev.goquick.kmpertrace.trace.captureTraceSnapshot
6 |
7 | /**
8 | * Post a callback that runs with [snapshot] installed for the duration of the block, so logs bind to the
9 | * originating trace/span when crossing non-coroutine async boundaries (Handler/Looper).
10 | */
11 | fun Handler.postWithTrace(snapshot: TraceSnapshot = captureTraceSnapshot(), block: () -> Unit): Boolean =
12 | post { snapshot.withTraceSnapshot(block) }
13 |
14 | /**
15 | * Post a delayed callback that runs with [snapshot] installed for the duration of the block, so logs bind to the
16 | * originating trace/span when crossing non-coroutine async boundaries (Handler/Looper).
17 | */
18 | fun Handler.postDelayedWithTrace(
19 | delayMillis: Long,
20 | snapshot: TraceSnapshot = captureTraceSnapshot(),
21 | block: () -> Unit
22 | ): Boolean = postDelayed({ snapshot.withTraceSnapshot(block) }, delayMillis)
23 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/log/KmperTrace.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.log
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.platform.PlatformLogSink
5 |
6 | /**
7 | * Entry point for configuring KmperTrace logging.
8 | */
9 | object KmperTrace {
10 |
11 | /**
12 | * Configure logging filters, metadata, and backends.
13 | */
14 | fun configure(
15 | minLevel: Level = Level.INFO,
16 | serviceName: String? = null,
17 | environment: String? = null,
18 | sinks: List = listOf(PlatformLogSink),
19 | filter: (LogRecord) -> Boolean = { true },
20 | renderGlyphs: Boolean = true,
21 | emitDebugAttributes: Boolean = false
22 | ) {
23 | LoggerConfig.minLevel = minLevel
24 | LoggerConfig.serviceName = serviceName
25 | LoggerConfig.environment = environment
26 | LoggerConfig.sinks = sinks
27 | LoggerConfig.filter = filter
28 | LoggerConfig.renderGlyphs = renderGlyphs
29 | LoggerConfig.emitDebugAttributes = emitDebugAttributes
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/test/kotlin/dev/goquick/kmpertrace/analysis/StructuredSuffixFramerTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 | import kotlin.test.assertFalse
6 | import kotlin.test.assertTrue
7 |
8 | class StructuredSuffixFramerTest {
9 | @Test
10 | fun frames_multiline_structured_suffix_and_keeps_trailing() {
11 | val framer = StructuredSuffixFramer()
12 | assertFalse(framer.isOpenStructured())
13 |
14 | val part1 = """prefix |{ ts=2025-01-01T00:00:00Z lvl=info head="a""""
15 | val out1 = framer.feed(part1)
16 | assertEquals(emptyList(), out1)
17 | assertTrue(framer.isOpenStructured())
18 |
19 | val part2 = """b" }|trailing"""
20 | val out2 = framer.feed(part2)
21 | assertEquals(1, out2.size)
22 | assertEquals(
23 | """$part1
24 | $part2""".substringBefore("trailing"),
25 | out2.single()
26 | )
27 |
28 | // trailing should remain buffered
29 | val out3 = framer.flush()
30 | assertEquals(listOf("trailing"), out3)
31 | }
32 | }
33 |
34 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/trace/LoggingBinding.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | /**
4 | * Internal toggle describing whether log records should bind to the current span.
5 | */
6 | internal enum class LoggingBinding {
7 | Unbound,
8 | BindToSpan
9 | }
10 |
11 | /**
12 | * Platform storage for the current logging binding mode.
13 | *
14 | * This mirrors [TraceContextStorage] so we can decide at emission time whether to attach
15 | * trace/span IDs to log records. Each platform chooses the same propagation strategy as
16 | * TraceContextStorage (ThreadLocal + ThreadContextElement, or coroutine-context storage).
17 | */
18 | internal expect object LoggingBindingStorage {
19 | /**
20 | * Current binding mode in this execution context.
21 | */
22 | fun get(): LoggingBinding
23 |
24 | /**
25 | * Set the binding mode for subsequent log records.
26 | */
27 | fun set(value: LoggingBinding)
28 |
29 | /**
30 | * Coroutine context element that propagates [LoggingBinding] across suspending boundaries.
31 | */
32 | fun element(value: LoggingBinding): kotlin.coroutines.CoroutineContext
33 | }
34 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/core/StructuredLogRecord.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.core
2 |
3 | import kotlin.time.Instant
4 |
5 | /**
6 | * Structured representation of a KmperTrace log record.
7 | */
8 | internal data class StructuredLogRecord(
9 | val timestamp: Instant,
10 | val level: Level,
11 | val loggerName: String,
12 | val message: String,
13 |
14 | val traceId: String? = null,
15 | val spanId: String? = null,
16 | val parentSpanId: String? = null,
17 | val logRecordKind: LogRecordKind = LogRecordKind.LOG,
18 | val spanName: String? = null,
19 | val durationMs: Long? = null,
20 |
21 | val threadName: String? = null,
22 |
23 | val serviceName: String? = null,
24 | val environment: String? = null,
25 |
26 | // source metadata
27 | val sourceComponent: String? = null,
28 | val sourceOperation: String? = null,
29 | val sourceLocationHint: String? = null,
30 | val sourceFile: String? = null,
31 | val sourceLine: Int? = null,
32 | val sourceFunction: String? = null,
33 |
34 | val attributes: Map = emptyMap(),
35 | val throwable: Throwable? = null
36 | )
37 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidMain/kotlin/dev/goquick/kmpertrace/platform/PlatformLogSink.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import android.util.Log as AndroidLog
4 | import dev.goquick.kmpertrace.core.Level
5 | import dev.goquick.kmpertrace.log.LogRecord
6 | import dev.goquick.kmpertrace.log.LogSink
7 |
8 | actual object PlatformLogSink : LogSink {
9 | actual override fun emit(record: LogRecord) {
10 | // Logcat already provides timestamp/level; keep a short human prefix for readability.
11 | val tag = record.tag.ifBlank { "KmperTrace" }
12 | val human = buildString {
13 | append(tag)
14 | if (record.message.isNotBlank()) {
15 | append(": ").append(record.message)
16 | }
17 | }
18 | val line = "$human ${record.structuredSuffix}"
19 |
20 | when (record.level) {
21 | Level.VERBOSE -> AndroidLog.v(tag, line, null)
22 | Level.DEBUG -> AndroidLog.d(tag, line, null)
23 | Level.INFO -> AndroidLog.i(tag, line, null)
24 | Level.WARN -> AndroidLog.w(tag, line, null)
25 | Level.ERROR -> AndroidLog.e(tag, line, null)
26 | Level.ASSERT -> AndroidLog.wtf(tag, line, null)
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/sample-app/src/commonMain/kotlin/dev/goquick/kmpertrace/sampleapp/model/Models.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp.model
2 |
3 | data class Profile(
4 | val id: String,
5 | val name: String,
6 | val title: String,
7 | val email: String,
8 | val phone: String,
9 | val city: String
10 | )
11 |
12 | data class Contact(
13 | val id: String,
14 | val name: String,
15 | val relationship: String,
16 | val email: String
17 | )
18 |
19 | data class ActivityEvent(
20 | val id: String,
21 | val label: String,
22 | val timestamp: String,
23 | val description: String
24 | )
25 |
26 | data class DownloadState(
27 | val id: String,
28 | val label: String,
29 | val progressPercent: Int,
30 | val status: String
31 | )
32 |
33 | sealed interface LoadState {
34 | data object Loading : LoadState
35 | data class Success(val value: T) : LoadState
36 | data class Error(val message: String) : LoadState
37 | }
38 |
39 | data class ProfileScreenState(
40 | val profile: LoadState = LoadState.Loading,
41 | val contacts: LoadState> = LoadState.Loading,
42 | val activity: LoadState> = LoadState.Loading,
43 | val lastRefreshed: String? = null,
44 | val downloads: List = emptyList()
45 | )
46 |
--------------------------------------------------------------------------------
/kmpertrace-cli/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | application
4 | }
5 |
6 | repositories {
7 | mavenCentral()
8 | }
9 |
10 | dependencies {
11 | implementation(libs.clikt)
12 | implementation(project(":kmpertrace-parse"))
13 | implementation(project(":kmpertrace-analysis"))
14 | implementation(libs.mordantJvm)
15 | testImplementation(kotlin("test"))
16 | }
17 |
18 | application {
19 | mainClass.set("dev.goquick.kmpertrace.cli.MainKt")
20 | }
21 |
22 | kotlin {
23 | jvmToolchain(17)
24 | }
25 |
26 | val generateCliBuildInfo by tasks.registering {
27 | val outputDir = layout.buildDirectory.dir("generated/buildInfo").get().asFile
28 | outputs.dir(outputDir)
29 | doLast {
30 | val version = project.version.toString()
31 | val pkgDir = outputDir.resolve("dev/goquick/kmpertrace/cli")
32 | pkgDir.mkdirs()
33 | val file = pkgDir.resolve("BuildInfo.kt")
34 | file.writeText(
35 | """
36 | package dev.goquick.kmpertrace.cli
37 |
38 | internal object BuildInfo {
39 | const val VERSION: String = "$version"
40 | }
41 | """.trimIndent()
42 | )
43 | }
44 | }
45 |
46 | sourceSets {
47 | named("main") {
48 | kotlin.srcDir(layout.buildDirectory.dir("generated/buildInfo"))
49 | }
50 | }
51 |
52 | tasks.named("compileKotlin") {
53 | dependsOn(generateCliBuildInfo)
54 | }
55 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/TuiInputMapping.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | internal fun uiEventsForKey(key: Char): List = when (key) {
4 | 'c', 'C' -> listOf(UiEvent.DismissHelp, UiEvent.Clear)
5 | 'a', 'A' -> listOf(UiEvent.DismissHelp, UiEvent.ToggleAttrs)
6 | 'r', 'R' -> listOf(UiEvent.DismissHelp, UiEvent.CycleRaw)
7 | 's', 'S' -> listOf(UiEvent.DismissHelp, UiEvent.CycleStructured)
8 | '/' -> listOf(UiEvent.DismissHelp, UiEvent.PromptSearch)
9 | '?' -> listOf(UiEvent.ToggleHelp)
10 | 'q', 'Q' -> listOf(UiEvent.DismissHelp, UiEvent.Quit)
11 | else -> listOf(UiEvent.DismissHelp)
12 | }
13 |
14 | internal fun uiEventsForCommand(line: String): List {
15 | val normalized = line.trim().lowercase()
16 | return when (normalized) {
17 | "c", "clear", ":clear", "/clear" -> listOf(UiEvent.DismissHelp, UiEvent.Clear)
18 | "a", "attrs", ":attrs" -> listOf(UiEvent.DismissHelp, UiEvent.ToggleAttrs)
19 | "r", "raw" -> listOf(UiEvent.DismissHelp, UiEvent.CycleRaw)
20 | "s", "struct", "struct-logs" -> listOf(UiEvent.DismissHelp, UiEvent.CycleStructured)
21 | "/", ":search", "search" -> listOf(UiEvent.DismissHelp, UiEvent.PromptSearch)
22 | "?", "help", ":help" -> listOf(UiEvent.ToggleHelp)
23 | "q", "quit", ":q" -> listOf(UiEvent.DismissHelp, UiEvent.Quit)
24 | else -> listOf(UiEvent.DismissHelp)
25 | }
26 | }
27 |
28 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosTest/kotlin/dev/goquick/kmpertrace/trace/ComponentInheritanceIosTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.log.Log
5 | import dev.goquick.kmpertrace.log.KmperTrace
6 | import dev.goquick.kmpertrace.testutil.parseStructuredSuffix
7 | import kotlin.test.AfterTest
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 |
11 | class ComponentInheritanceIosTest {
12 | private val sink = IosCollectingSink()
13 |
14 | @AfterTest
15 | fun tearDown() {
16 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = emptyList())
17 | sink.records.clear()
18 | }
19 |
20 | @Test
21 | fun nested_span_inherits_component_and_operation() = kotlinx.coroutines.test.runTest {
22 | KmperTrace.configure(sinks = listOf(sink))
23 |
24 | traceSpan(component = "Downloader", operation = "DownloadProfile") {
25 | traceSpan("FetchHttp") {
26 | Log.i { "inside child" }
27 | }
28 | }
29 |
30 | val fields = sink.records.map { parseStructuredSuffix(it.structuredSuffix) }
31 | val childStart = fields.first { it["kind"] == "SPAN_START" && it["name"] == "FetchHttp" }
32 | val childLog = fields.first { it["kind"] == null && it["head"] == "inside child" }
33 |
34 | assertEquals("Downloader/DownloadProfile", childStart["src"])
35 | assertEquals("Downloader/DownloadProfile", childLog["src"])
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/jvmMain/kotlin/dev/goquick/kmpertrace/trace/LoggingBindingStorage.jvm.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import kotlin.coroutines.CoroutineContext
4 | import kotlinx.coroutines.ThreadContextElement
5 |
6 | private val bindingLocal: ThreadLocal = ThreadLocal.withInitial { LoggingBinding.Unbound } // per-thread flag for binding logs to spans
7 |
8 | // ThreadContextElement moves the binding flag across coroutine resumption by saving/restoring our ThreadLocal.
9 | private class LoggingBindingElement(private val value: LoggingBinding) : ThreadContextElement, CoroutineContext.Element {
10 | companion object Key : CoroutineContext.Key
11 | override val key: CoroutineContext.Key<*> get() = Key
12 |
13 | override fun updateThreadContext(context: CoroutineContext): LoggingBinding {
14 | val previous = bindingLocal.get()
15 | bindingLocal.set(value) // install span-binding flag before coroutine resumes on this thread
16 | return previous
17 | }
18 |
19 | override fun restoreThreadContext(context: CoroutineContext, oldState: LoggingBinding) {
20 | bindingLocal.set(oldState) // put back whatever binding was there before
21 | }
22 | }
23 |
24 | internal actual object LoggingBindingStorage {
25 | actual fun get(): LoggingBinding = bindingLocal.get()
26 | actual fun set(value: LoggingBinding) {
27 | bindingLocal.set(value)
28 | }
29 |
30 | actual fun element(value: LoggingBinding): CoroutineContext = LoggingBindingElement(value)
31 | }
32 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/ansi/Ansi.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli.ansi
2 |
3 | /**
4 | * Controls whether ANSI color codes should be used in CLI output.
5 | */
6 | enum class AnsiMode { AUTO, ON, OFF }
7 |
8 | internal fun AnsiMode.shouldColorize(): Boolean =
9 | when (this) {
10 | AnsiMode.ON -> true
11 | AnsiMode.OFF -> false
12 | AnsiMode.AUTO -> System.console() != null
13 | }
14 |
15 | internal object AnsiPalette {
16 | const val header = "\u001B[1;36m" // bold cyan
17 | const val span = "\u001B[1m" // bold
18 | const val timestamp = "\u001B[2m" // dim
19 | const val logger = "\u001B[36m" // cyan
20 | const val source = "\u001B[35m" // magenta
21 | const val location = "\u001B[2m" // dim
22 | const val error = "\u001B[31m" // red
23 | const val warn = "\u001B[33m" // yellow
24 | const val statusBg = "\u001B[48;5;254m" // light gray background
25 | const val statusFg = "\u001B[30m" // black foreground for status line
26 | const val statusTrace = "\u001B[34m" // blue for trace count
27 | const val reset = "\u001B[0m"
28 | }
29 |
30 | internal fun maybeColor(text: String, code: String, colorize: Boolean): String =
31 | if (colorize) "$code$text${AnsiPalette.reset}" else text
32 |
33 | internal fun maybeColorBold(text: String, colorize: Boolean): String =
34 | if (colorize) "${AnsiPalette.span}$text${AnsiPalette.reset}" else text
35 |
36 | internal fun stripAnsi(text: String): String = text.replace(ansiRegex, "")
37 |
38 | private val ansiRegex = "\u001B\\[[;\\d]*m".toRegex()
39 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/main/kotlin/dev/goquick/kmpertrace/analysis/ChunkAssembler.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | /**
4 | * Reassembles chunked log lines marked with `(chunkId:kmpert...)` or `(chunkId:kmpert!)`
5 | * at the very end of the line. Returns complete reconstructed lines ready for parsing.
6 | */
7 | class ChunkAssembler {
8 | private val buffers = mutableMapOf()
9 | private val markerRegex =
10 | Regex("""\(([\da-fA-F]{4,32}):kmpert(\.\.\.|!)\)$""")
11 |
12 | /**
13 | * Feed one raw line. May return zero or more completed lines.
14 | */
15 | fun feed(line: String): List {
16 | val match = markerRegex.find(line.trimEnd())
17 | if (match == null) {
18 | // Not chunked
19 | return listOf(line)
20 | }
21 | val chunkId = match.groupValues[1]
22 | val kind = match.groupValues[2] // "..." or "!"
23 | val content = line.removeSuffix(match.value)
24 | return when (kind) {
25 | "..." -> {
26 | val buf = buffers.getOrPut(chunkId) { StringBuilder() }
27 | buf.append(content)
28 | emptyList()
29 | }
30 | "!" -> {
31 | val buf = buffers[chunkId] ?: return emptyList()
32 | buf.append(content)
33 | buffers.remove(chunkId)
34 | listOf(buf.toString().trimEnd())
35 | }
36 | else -> emptyList()
37 | }
38 | }
39 |
40 | fun reset() {
41 | buffers.clear()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidUnitTest/kotlin/dev/goquick/kmpertrace/trace/ComponentInheritanceAndroidTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.log.Log
5 | import dev.goquick.kmpertrace.log.KmperTrace
6 | import dev.goquick.kmpertrace.testutil.parseStructuredSuffix
7 | import kotlin.test.AfterTest
8 | import kotlin.test.Test
9 | import kotlin.test.assertEquals
10 |
11 | class ComponentInheritanceAndroidTest {
12 | private val sink = AndroidCollectingSink()
13 |
14 | @AfterTest
15 | fun tearDown() {
16 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = emptyList())
17 | sink.records.clear()
18 | }
19 |
20 | @Test
21 | fun nested_span_inherits_component_and_operation() = kotlinx.coroutines.test.runTest {
22 | KmperTrace.configure(sinks = listOf(sink))
23 |
24 | traceSpan(component = "Downloader", operation = "DownloadProfile") {
25 | traceSpan("FetchHttp") {
26 | Log.i { "inside child" }
27 | }
28 | }
29 |
30 | val childStart = sink.records
31 | .map { parseStructuredSuffix(it.structuredSuffix) }
32 | .first { it["kind"] == "SPAN_START" && it["name"] == "FetchHttp" }
33 | val childLog = sink.records
34 | .map { parseStructuredSuffix(it.structuredSuffix) }
35 | .first { it["kind"] == null && it["head"] == "inside child" }
36 |
37 | assertEquals("Downloader/DownloadProfile", childStart["src"])
38 | assertEquals("Downloader/DownloadProfile", childLog["src"])
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/trace/TraceDispatchers.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.TraceContext
4 | import kotlinx.coroutines.CoroutineDispatcher
5 | import kotlinx.coroutines.currentCoroutineContext
6 | import kotlinx.coroutines.withContext
7 | import kotlin.coroutines.ContinuationInterceptor
8 | import kotlin.coroutines.EmptyCoroutineContext
9 |
10 | /**
11 | * Propagate the current trace/binding when hopping to a different dispatcher inside a span.
12 | */
13 | suspend fun withTraceContext(
14 | dispatcher: CoroutineDispatcher,
15 | block: suspend () -> T
16 | ): T {
17 | val ctx = currentCoroutineContext()
18 | val trace: TraceContext? = ctx[TraceContext] ?: TraceContextStorage.get()
19 | val downstreamInterceptor = dispatcher as? ContinuationInterceptor
20 |
21 | val propagationContext =
22 | (trace?.let { TraceContextStorage.element(it, downstreamInterceptor) } ?: EmptyCoroutineContext) +
23 | LoggingBindingStorage.element(LoggingBinding.BindToSpan)
24 |
25 | return withContext(dispatcher + propagationContext) {
26 | val previousTrace = TraceContextStorage.get()
27 | val previousBinding = LoggingBindingStorage.get()
28 |
29 | if (trace != null) {
30 | TraceContextStorage.set(trace)
31 | LoggingBindingStorage.set(LoggingBinding.BindToSpan)
32 | }
33 |
34 | try {
35 | block()
36 | } finally {
37 | TraceContextStorage.set(previousTrace)
38 | LoggingBindingStorage.set(previousBinding)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/sample-app/src/commonMain/composeResources/drawable/compose-multiplatform.xml:
--------------------------------------------------------------------------------
1 |
8 |
11 |
14 |
16 |
17 |
22 |
23 |
24 |
25 |
26 |
27 |
29 |
30 |
36 |
37 |
38 |
39 |
40 |
41 |
44 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosMain/kotlin/dev/goquick/kmpertrace/swift/KmperTraceSnapshot.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.swift
2 |
3 | import dev.goquick.kmpertrace.trace.TraceSnapshot
4 |
5 | /**
6 | * Swift-facing wrapper around KmperTrace's internal [TraceSnapshot].
7 | *
8 | * Why this exists:
9 | * - Swift projects shouldn't have to reference KMP-internal types like `dev.goquick.kmpertrace.trace.TraceSnapshot`.
10 | * - Kotlin/Native interop treats Kotlin function-type parameters as escaping closures in Swift; this wrapper makes
11 | * it explicit that blocks passed here are allowed to escape.
12 | *
13 | * Use [KmperTraceSwift.captureSnapshot] while a span is active, then use [with] in Swift callbacks to re-install
14 | * the trace/span binding when crossing async boundaries (delegates, GCD, third-party SDK callbacks).
15 | */
16 | class KmperTraceSnapshot internal constructor(
17 | internal val raw: TraceSnapshot
18 | ) {
19 | /**
20 | * Run [block] with this snapshot installed for the duration of the block, then restore previous state.
21 | *
22 | * This does not create a span; it only affects which trace/span IDs and binding mode are visible to log
23 | * emission while [block] executes.
24 | *
25 | * Note for Swift: [block] is effectively treated as an escaping closure by Kotlin/Native interop.
26 | */
27 | fun with(block: () -> Unit) {
28 | raw.withTraceSnapshot(block)
29 | }
30 |
31 | /**
32 | * Same as [with], but returns the block result.
33 | *
34 | * Note for Swift: generics are erased across Kotlin/Native interop, so Swift will typically see `Any?`.
35 | */
36 | fun withResult(block: () -> T): T = raw.withTraceSnapshot(block)
37 | }
38 |
39 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/log/LoggerConfig.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.log
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 |
5 | /**
6 | * Internal runtime configuration for KmperTrace logging/tracing.
7 | *
8 | * Public configuration should go through [KmperTrace].
9 | */
10 | internal object LoggerConfig {
11 |
12 | /**
13 | * Minimum level allowed through filtering.
14 | */
15 | var minLevel: Level = Level.DEBUG
16 |
17 | /**
18 | * Service name recorded on each log record when provided.
19 | */
20 | var serviceName: String? = null
21 |
22 | /**
23 | * Environment name recorded on each log record when provided.
24 | */
25 | var environment: String? = null
26 |
27 | /**
28 | * Active sinks; empty list disables emission.
29 | */
30 | var sinks: List = emptyList()
31 |
32 | /**
33 | * Whether platform sinks should render glyph icons (e.g., ℹ️/⚠️/❌) before lines.
34 | */
35 | var renderGlyphs: Boolean = true
36 |
37 | /**
38 | * Whether debug span attributes should be emitted into log lines.
39 | *
40 | * This is intended for dev-only or sensitive fields you don't want written to logs in release builds.
41 | * Public APIs mark debug attributes with a leading `?` in the key, and they are emitted on `SPAN_END`
42 | * as fields whose keys start with `d:`.
43 | *
44 | * When disabled, any span attributes whose keys start with `d:` are dropped before emission.
45 | */
46 | var emitDebugAttributes: Boolean = false
47 |
48 | /**
49 | * Additional filter predicate evaluated before emitting to sinks.
50 | */
51 | var filter: (LogRecord) -> Boolean = { true }
52 | }
53 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidMain/kotlin/dev/goquick/kmpertrace/trace/LoggingBindingStorage.android.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
2 |
3 | package dev.goquick.kmpertrace.trace
4 |
5 | import kotlin.coroutines.CoroutineContext
6 | import kotlinx.coroutines.ThreadContextElement
7 |
8 | // per-thread flag for binding logs to spans
9 | private val bindingLocal: ThreadLocal =
10 | ThreadLocal.withInitial { LoggingBinding.Unbound }
11 |
12 | // ThreadContextElement moves the binding flag across coroutine resumption
13 | // by saving/restoring our ThreadLocal.
14 | @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
15 | private class LoggingBindingElement(private val value: LoggingBinding) :
16 | ThreadContextElement, CoroutineContext.Element {
17 | companion object Key : CoroutineContext.Key
18 |
19 | override val key: CoroutineContext.Key<*> get() = Key
20 |
21 | override fun updateThreadContext(context: CoroutineContext): LoggingBinding {
22 | val previous = bindingLocal.get()
23 | bindingLocal.set(value) // install span-binding flag before coroutine resumes on this thread
24 | return previous
25 | }
26 |
27 | override fun restoreThreadContext(context: CoroutineContext, oldState: LoggingBinding) {
28 | bindingLocal.set(oldState) // put back whatever binding was there before
29 | }
30 | }
31 |
32 | internal actual object LoggingBindingStorage {
33 | actual fun get(): LoggingBinding = bindingLocal.get()
34 |
35 | actual fun set(value: LoggingBinding) {
36 | bindingLocal.set(value)
37 | }
38 |
39 | actual fun element(value: LoggingBinding): CoroutineContext = LoggingBindingElement(value)
40 | }
41 |
--------------------------------------------------------------------------------
/docs/IOS-XCFramework.md:
--------------------------------------------------------------------------------
1 | # KmperTrace iOS XCFramework (pure iOS/Swift)
2 |
3 | Two ways to link the runtime without KMP in your Xcode project:
4 |
5 | If you have a KMP app with Swift host code, prefer exporting KmperTrace from your KMP-produced
6 | framework instead: see `docs/IOS-KMP-Swift.md`.
7 |
8 | ## 1) Manual drag & drop
9 | 1. Grab `KmperTraceRuntime.xcframework.zip` from the GitHub Release assets.
10 | 2. Unzip, drag `KmperTraceRuntime.xcframework` into Xcode, select **Embed & Sign**.
11 | 3. In Swift/ObjC, `import KmperTraceRuntime`.
12 |
13 | ## 2) Swift Package Manager (binary target)
14 | 1. From the same Release, download the accompanying `Package.swift` (generated per release).
15 | 2. In Xcode > Add Package Dependency, point to the repo URL and pick the tag matching the release.
16 | - The `Package.swift` binary target references the release asset URL and checksum.
17 |
18 | Notes:
19 | - Built from `iosArm64` + `iosSimulatorArm64`, static by default.
20 | - CI builds on macOS using `./gradlew :kmpertrace-runtime:assembleKmperTraceRuntimeReleaseXCFramework`; the zipped XCFramework + Package.swift are attached to releases automatically.
21 |
22 | ## Swift-friendly logging + trace binding
23 |
24 | Kotlin-style logging APIs use message lambdas (`Log.d { ... }`), which are awkward to call from Swift.
25 | The runtime ships a small Swift-facing facade:
26 |
27 | - `dev.goquick.kmpertrace.swift.KmperTraceSwift` — string-based logging (`d/i/w/e`) and snapshot helpers.
28 |
29 | Recommended pattern for non-coroutine callbacks:
30 |
31 | 1) Capture a `KmperTraceSnapshot` while a span is active.
32 | 2) In Swift callbacks (GCD/delegate/SDK callbacks), re-install it with `snapshot.with { ... }` (or `KmperTraceSwift.withSnapshot(...)`) while emitting logs via `KmperTraceSwift`.
33 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/wasmJsTest/kotlin/dev/goquick/kmpertrace/trace/ComponentInheritanceWasmTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.log.Log
5 | import dev.goquick.kmpertrace.log.LogRecord
6 | import dev.goquick.kmpertrace.log.LogSink
7 | import dev.goquick.kmpertrace.log.KmperTrace
8 | import dev.goquick.kmpertrace.testutil.parseStructuredSuffix
9 | import kotlin.test.AfterTest
10 | import kotlin.test.Test
11 | import kotlin.test.assertEquals
12 |
13 | private class CollectingSink : LogSink {
14 | val records = mutableListOf()
15 | override fun emit(record: LogRecord) {
16 | records += record
17 | }
18 | }
19 |
20 | class ComponentInheritanceWasmTest {
21 | private val sink = CollectingSink()
22 |
23 | @AfterTest
24 | fun tearDown() {
25 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = emptyList())
26 | sink.records.clear()
27 | }
28 |
29 | @Test
30 | fun nested_span_inherits_component_and_operation() = kotlinx.coroutines.test.runTest {
31 | KmperTrace.configure(sinks = listOf(sink))
32 |
33 | traceSpan(component = "Downloader", operation = "DownloadProfile") {
34 | traceSpan("FetchHttp") {
35 | Log.i { "inside child" }
36 | }
37 | }
38 |
39 | val fields = sink.records.map { parseStructuredSuffix(it.structuredSuffix) }
40 | val childStart = fields.first { it["kind"] == "SPAN_START" && it["name"] == "FetchHttp" }
41 | val childLog = fields.first { it["kind"] == null && it["head"] == "inside child" }
42 |
43 | assertEquals("Downloader/DownloadProfile", childStart["src"])
44 | assertEquals("Downloader/DownloadProfile", childLog["src"])
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/sample-app/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/CliParsing.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.cli.ansi.AnsiMode
4 |
5 | internal fun parseAnsiMode(color: String?): AnsiMode =
6 | when (color?.lowercase()) {
7 | "on", "true", "yes" -> AnsiMode.ON
8 | "off", "false", "no" -> AnsiMode.OFF
9 | null, "auto" -> AnsiMode.AUTO
10 | else -> usageError("Invalid --color value: $color (use auto|on|off)")
11 | }
12 |
13 | internal fun parseTimeFormat(timeFormatOpt: String?): TimeFormat =
14 | when (timeFormatOpt?.lowercase()) {
15 | null, "time-only", "time" -> TimeFormat.TIME_ONLY
16 | "full" -> TimeFormat.FULL
17 | else -> usageError("Invalid --time-format value: $timeFormatOpt (use full|time-only)")
18 | }
19 |
20 | /**
21 | * Returns (maxLineWidth, autoWidth). `autoByDefault` controls the null case:
22 | * - Print: false (manual width unless user asked for auto)
23 | * - TUI: true (auto width when user omits the flag)
24 | */
25 | internal fun resolveWidth(maxLineWidthOpt: String?, autoByDefault: Boolean): Pair =
26 | when {
27 | maxLineWidthOpt == null -> null to autoByDefault
28 | maxLineWidthOpt.equals("auto", ignoreCase = true) -> null to true
29 | maxLineWidthOpt.equals(
30 | "unlimited",
31 | ignoreCase = true
32 | ) || maxLineWidthOpt == "0" -> null to false
33 |
34 | else -> maxLineWidthOpt.toInt() to false
35 | }
36 |
37 | internal fun validateMaxWidth(value: String) {
38 | require(
39 | value.equals("auto", ignoreCase = true) ||
40 | value.equals("unlimited", ignoreCase = true) ||
41 | value == "0" ||
42 | value.toIntOrNull()?.let { n -> n > 0 } == true
43 | ) { "max-line-width must be a positive integer, 'auto', or 'unlimited'/0" }
44 | }
45 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.13.2"
3 | kotlin = "2.2.21"
4 | compose = "1.9.3"
5 | androidx-activity-compose = "1.12.1"
6 | androidx-lifecycle = "2.10.0"
7 | kotlinx-coroutines = "1.10.2"
8 | kotlinx-datetime = "0.7.1"
9 | kotlinx-coroutines-test = "1.10.2"
10 | clikt = "5.0.3"
11 |
12 | androidMinSdk = "26"
13 | androidCompileSdk = "36"
14 | androidTargetSdk = "36"
15 | mordantJvm = "3.0.2"
16 |
17 | [libraries]
18 | androidxActivityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
19 | androidxLifecycleViewmodelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
20 | androidxLifecycleRuntimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
21 | kotlinxCoroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
22 | kotlinxCoroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
23 | kotlinxCoroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" }
24 | kotlinxDatetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
25 | clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
26 | mordantJvm = { module = "com.github.ajalt.mordant:mordant-jvm", version.ref = "mordantJvm" }
27 |
28 | [plugins]
29 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
30 | androidApplication = { id = "com.android.application", version.ref = "agp" }
31 | androidLibrary = { id = "com.android.library", version.ref = "agp" }
32 | composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
33 | composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
34 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosMain/kotlin/dev/goquick/kmpertrace/swift/KmperTraceSwift.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.swift
2 |
3 | import dev.goquick.kmpertrace.trace.captureTraceSnapshot
4 |
5 | /**
6 | * Swift-facing facade for emitting KmperTrace logs and bridging trace/span binding across
7 | * non-coroutine async boundaries.
8 | *
9 | * Notes:
10 | * - Swift cannot ergonomically call Kotlin `() -> String` message lambdas, so these APIs accept plain Strings.
11 | * - Use [captureSnapshot] while a span is active, then call [withSnapshot] in Swift callbacks to re-install
12 | * the trace/span binding for the duration of the block.
13 | */
14 | object KmperTraceSwift {
15 | /**
16 | * Capture the current trace/span + log binding state into an immutable [KmperTraceSnapshot].
17 | */
18 | fun captureSnapshot(): KmperTraceSnapshot = KmperTraceSnapshot(captureTraceSnapshot())
19 |
20 | /**
21 | * Run [block] with [snapshot] installed for the duration of the block, then restore previous state.
22 | *
23 | * This does not create a span; it only makes KmperTrace logging bind to the captured span context.
24 | */
25 | fun withSnapshot(snapshot: KmperTraceSnapshot?, block: () -> Unit) {
26 | if (snapshot == null) {
27 | block()
28 | } else {
29 | snapshot.raw.withTraceSnapshot(block)
30 | }
31 | }
32 |
33 | /**
34 | * Build a component-bound logger to avoid repeating `component` at each callsite.
35 | */
36 | fun logger(component: String): KmperLogger = KmperLogger(component = component)
37 |
38 | /**
39 | * Build a component-bound logger that automatically installs [snapshot] (when present) for each log call.
40 | */
41 | fun snapshotLogger(component: String, snapshot: KmperTraceSnapshot? = null): KmperSnapshotLogger =
42 | KmperSnapshotLogger(logger = KmperLogger(component = component), snapshot = snapshot)
43 | }
44 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosTest/kotlin/dev/goquick/kmpertrace/platform/ChunkingSmokeTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import dev.goquick.kmpertrace.core.LogRecordKind
4 | import dev.goquick.kmpertrace.core.Level
5 | import dev.goquick.kmpertrace.core.StructuredLogRecord
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 | import kotlin.test.assertTrue
9 | import kotlin.time.Instant
10 |
11 | class ChunkingSmokeTest {
12 | @Test
13 | fun very_long_log_is_split_into_chunk_marked_lines() {
14 | val longMsg = buildString {
15 | append("prefix ")
16 | repeat(120) { append("0123456789") }
17 | }
18 | val record = StructuredLogRecord(
19 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
20 | level = Level.INFO,
21 | loggerName = "IosLogger",
22 | message = longMsg,
23 | logRecordKind = LogRecordKind.LOG
24 | )
25 | val base = "IosLogger: $longMsg |{ ts=${record.timestamp} lvl=info head=\"prefix 01234567\" log=IosLogger }|"
26 |
27 | val chunkId = "deadbeef"
28 | val chunks = PlatformLogSink.frameIosChunks(
29 | line = base,
30 | maxLogLineChars = 200,
31 | chunkSize = 80,
32 | chunkId = chunkId
33 | )
34 |
35 | assertTrue(chunks.size > 1, "expected chunking for long line")
36 | chunks.dropLast(1).forEach { chunk ->
37 | assertTrue(chunk.endsWith("($chunkId:kmpert...)"), "expected continuation marker at end, got: $chunk")
38 | }
39 | assertTrue(chunks.last().endsWith("($chunkId:kmpert!)"), "expected final marker at end, got: ${chunks.last()}")
40 |
41 | val reconstructed = chunks.joinToString("") { chunk ->
42 | chunk
43 | .replace(Regex("""\s*\($chunkId:kmpert(?:\.\.\.|!)\)$"""), "")
44 | }
45 | assertEquals(base, reconstructed)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/Logfmt.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | internal fun parseLogfmt(input: String): Map {
4 | val result = linkedMapOf()
5 | var index = 0
6 | val length = input.length
7 |
8 | fun skipSpaces() {
9 | while (index < length && input[index].isWhitespace()) index++
10 | }
11 |
12 | while (index < length) {
13 | skipSpaces()
14 | if (index >= length) break
15 |
16 | val keyStart = index
17 | while (index < length && input[index] != '=' && !input[index].isWhitespace()) {
18 | index++
19 | }
20 | if (index >= length || input[index] != '=') {
21 | while (index < length && !input[index].isWhitespace()) index++
22 | continue
23 | }
24 | val key = input.substring(keyStart, index)
25 | index++ // '='
26 | if (index >= length) {
27 | result[key] = ""
28 | break
29 | }
30 |
31 | val value = if (input[index] == '"') {
32 | index++
33 | val sb = StringBuilder()
34 | while (index < length) {
35 | val ch = input[index]
36 | if (ch == '\\' && index + 1 < length) {
37 | sb.append(input[index + 1])
38 | index += 2
39 | continue
40 | }
41 | if (ch == '"') {
42 | index++
43 | break
44 | }
45 | sb.append(ch)
46 | index++
47 | }
48 | sb.toString()
49 | } else {
50 | val valueStart = index
51 | while (index < length && !input[index].isWhitespace()) index++
52 | input.substring(valueStart, index)
53 | }
54 |
55 | result[key] = value
56 | }
57 |
58 | return result
59 | }
60 |
61 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/main/kotlin/dev/goquick/kmpertrace/analysis/FilterState.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | import dev.goquick.kmpertrace.parse.ParsedLogRecord
4 |
5 | /**
6 | * Mutable filter state to be shared between analysis core and UI.
7 | */
8 | data class FilterState(
9 | val minLevel: String? = null,
10 | val traceId: String? = null,
11 | val component: String? = null,
12 | val operation: String? = null,
13 | val text: String? = null,
14 | val excludeUntraced: Boolean = false
15 | ) {
16 | fun predicate(): (ParsedLogRecord) -> Boolean = { evt ->
17 | when {
18 | excludeUntraced && evt.traceId == "0" -> false
19 | traceId != null && evt.traceId != traceId -> false
20 | component != null && evt.rawFields["src_comp"] != component -> false
21 | operation != null && evt.rawFields["src_op"] != operation -> false
22 | minLevel != null -> {
23 | val levelOrder = levelOrdinal(evt.rawFields["lvl"])
24 | val minOrder = levelOrdinal(minLevel)
25 | levelOrder >= minOrder
26 | }
27 | text != null -> {
28 | val haystacks = listOfNotNull(evt.message, evt.rawFields["stack_trace"]).joinToString("\n")
29 | haystacks.contains(text, ignoreCase = true)
30 | }
31 | else -> true
32 | }
33 | }
34 |
35 | fun describe(): String = buildList {
36 | minLevel?.let { add("lvl>=${it.uppercase()}") }
37 | traceId?.let { add("trace=$it") }
38 | component?.let { add("comp=$it") }
39 | operation?.let { add("op=$it") }
40 | if (text != null) add("text=$text")
41 | if (excludeUntraced) add("untraced=off")
42 | }.joinToString(",")
43 | }
44 |
45 | private fun levelOrdinal(lvl: String?): Int = when (lvl?.lowercase()) {
46 | "verbose", "v" -> 0
47 | "debug", "d" -> 1
48 | "info", "i" -> 2
49 | "warn", "w" -> 3
50 | "error", "e" -> 4
51 | "assert", "a" -> 5
52 | else -> 0
53 | }
54 |
--------------------------------------------------------------------------------
/plugin/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm") version "2.2.21"
3 | `java-gradle-plugin`
4 | // id("com.vanniktech.maven.publish") version "0.35.0"
5 | }
6 |
7 | repositories {
8 | mavenCentral()
9 | gradlePluginPortal()
10 | }
11 |
12 | java {
13 | toolchain.languageVersion.set(JavaLanguageVersion.of(17))
14 | }
15 |
16 | dependencies {
17 | implementation(gradleApi())
18 | implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.21")
19 | testImplementation(kotlin("test"))
20 | }
21 |
22 | gradlePlugin {
23 | plugins {
24 | create("kmperTracePlugin") {
25 | id = "dev.goquick.kmpertrace.gradle"
26 | implementationClass = "dev.goquick.kmpertrace.KmperTracePlugin"
27 | }
28 | }
29 | }
30 |
31 | tasks.test {
32 | useJUnitPlatform()
33 | }
34 |
35 | kotlin {
36 | jvmToolchain(17)
37 | }
38 |
39 | /*
40 | mavenPublishing {
41 | coordinates("dev.goquick.kmpertrace", "plugin", version.toString())
42 | publishToMavenCentral()
43 | signAllPublications()
44 |
45 | // TODO update with your own metadata (repository, license, etc.)
46 | pom {
47 | name.set("KMP Gradle Builder Template Plugin")
48 | description.set("Gradle plugin that demonstrates generating shared Kotlin sources for KMP projects")
49 | url.set("https://github.com/goquick/kmpertrace")
50 | licenses {
51 | license {
52 | name.set("Apache License 2.0")
53 | url.set("https://www.apache.org/licenses/LICENSE-2.0")
54 | }
55 | }
56 | developers {
57 | developer {
58 | id.set("goquick")
59 | name.set("GoQuick")
60 | email.set("oss@goquick.dev")
61 | }
62 | }
63 | scm {
64 | connection.set("scm:git:https://github.com/mobiletoly/kmpertrace.git")
65 | developerConnection.set("scm:git:ssh://git@github.com/mobiletoly/kmpertrace.git")
66 | url.set("https://github.com/mobiletoly/kmpertrace")
67 | }
68 | }
69 | }
70 | */
71 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/TuiStateMachineTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 | import kotlin.test.assertFalse
6 | import kotlin.test.assertTrue
7 |
8 | class TuiStateMachineTest {
9 |
10 | @Test
11 | fun hides_help_on_any_non_help_event() {
12 | val start = UiState(helpVisible = true, spanAttrsMode = SpanAttrsMode.OFF)
13 | val res = reduceUi(start, UiEvent.ToggleAttrs)
14 | assertFalse(res.state.helpVisible)
15 | assertEquals(SpanAttrsMode.ON, res.state.spanAttrsMode)
16 | assertTrue(res.requestRender)
17 | }
18 |
19 | @Test
20 | fun dismiss_help_is_noop_when_help_is_not_visible() {
21 | val start = UiState(helpVisible = false)
22 | val res = reduceUi(start, UiEvent.DismissHelp)
23 | assertEquals(start, res.state)
24 | assertFalse(res.requestRender)
25 | assertTrue(res.effects.isEmpty())
26 | }
27 |
28 | @Test
29 | fun cycle_structured_emits_filter_update_effect() {
30 | val start = UiState(minLevel = "all")
31 | val res = reduceUi(start, UiEvent.CycleStructured)
32 | assertEquals("debug", res.state.minLevel)
33 | assertTrue(res.effects.contains(UiEffect.UpdateMinLevelFilter("debug")))
34 | assertTrue(res.requestRender)
35 | }
36 |
37 | @Test
38 | fun prompt_search_emits_effect_and_set_search_updates_state() {
39 | val start = UiState(search = null)
40 | val prompt = reduceUi(start, UiEvent.PromptSearch)
41 | assertTrue(prompt.effects.contains(UiEffect.PromptSearch))
42 |
43 | val set = reduceUi(start, UiEvent.SetSearch("foo"))
44 | assertEquals("foo", set.state.search)
45 | assertTrue(set.requestRender)
46 | }
47 |
48 | @Test
49 | fun clear_emits_clear_buffers_effect() {
50 | val start = UiState()
51 | val res = reduceUi(start, UiEvent.Clear)
52 | assertTrue(res.effects.contains(UiEffect.ClearBuffers))
53 | assertTrue(res.requestRender)
54 | }
55 | }
56 |
57 |
--------------------------------------------------------------------------------
/plugin/src/main/kotlin/dev/goquick/kmpertrace/GenerateKmperTraceTask.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace
2 |
3 | import org.gradle.api.DefaultTask
4 | import org.gradle.api.file.DirectoryProperty
5 | import org.gradle.api.provider.Property
6 | import org.gradle.api.tasks.Input
7 | import org.gradle.api.tasks.OutputDirectory
8 | import org.gradle.api.tasks.TaskAction
9 |
10 | /**
11 | * Task that writes a generated Kotlin source file based on the [KmperTraceExtension] settings.
12 | */
13 | abstract class GenerateKmperTraceTask : DefaultTask() {
14 |
15 | @get:Input
16 | /**
17 | * Package name used for the generated source.
18 | */
19 | abstract val packageName: Property
20 |
21 | @get:Input
22 | /**
23 | * Class name used for the generated source.
24 | */
25 | abstract val className: Property
26 |
27 | @get:Input
28 | /**
29 | * Message assigned to the generated `MESSAGE` constant.
30 | */
31 | abstract val message: Property
32 |
33 | @get:OutputDirectory
34 | /**
35 | * Destination directory where generated sources are written.
36 | */
37 | abstract val outputDirectory: DirectoryProperty
38 |
39 | @TaskAction
40 | /**
41 | * Generate the Kotlin source file into [outputDirectory].
42 | */
43 | fun generate() {
44 | val pkg = packageName.get()
45 | val cls = className.get()
46 | val msg = message.get()
47 |
48 | val targetDir = outputDirectory.get().dir(pkg.replace('.', '/')).asFile
49 | targetDir.mkdirs()
50 |
51 | val file = targetDir.resolve("$cls.kt")
52 | val escapedMessage = msg.replace("\"", "\\\"")
53 | file.writeText(
54 | """
55 | |// Generated by dev.goquick.kmpertrace at ${java.time.Instant.now()}
56 | |package $pkg
57 | |
58 | |class $cls {
59 | | companion object {
60 | | const val MESSAGE: String = "$escapedMessage"
61 | | }
62 | |}
63 | |
64 | """.trimMargin()
65 | )
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/main/kotlin/dev/goquick/kmpertrace/analysis/IosUnifiedLogMultiline.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | /**
4 | * `log stream` / Xcode console prints a *single* unified-log entry that contains embedded newlines
5 | * as multiple physical lines: the first line has the timestamp/process header, the following lines
6 | * are continuations without that header.
7 | *
8 | * We need to reassemble those physical lines back into the original entry string so that downstream
9 | * chunk reassembly (kmpert markers) and structured framing can operate on whole entries.
10 | */
11 | internal class IosUnifiedLogMultilineGrouper {
12 | private val buffer = StringBuilder()
13 | private var hasEntry = false
14 |
15 | // Examples covered:
16 | // - 2025-12-08 23:18:36.143963-0500 localhost powerd[333]: ...
17 | // - 12:34:56.789 MyApp[123:4567] ...
18 | private val syslogHead = Regex(
19 | "^\\s*\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2}\\.\\d+(?:[+-]\\d{4})?\\s+\\S+\\s+[^\\[]+\\[\\d+(?::\\d+)?\\]:\\s+.*$"
20 | )
21 | private val compactHead = Regex(
22 | "^\\s*\\d{2}:\\d{2}:\\d{2}\\.\\d+\\s+[^\\[]+\\[\\d+(?::\\d+)?\\]\\s+.*$"
23 | )
24 |
25 | fun feed(line: String): List {
26 | val isHeader = syslogHead.matches(line) || compactHead.matches(line)
27 | if (isHeader) {
28 | val out = flush()
29 | buffer.clear()
30 | buffer.append(line)
31 | hasEntry = true
32 | return out
33 | }
34 |
35 | if (!hasEntry) {
36 | // Not a unified-log stream (or we started mid-entry); pass through.
37 | return listOf(line)
38 | }
39 |
40 | buffer.append('\n').append(line)
41 | return emptyList()
42 | }
43 |
44 | fun flush(): List {
45 | if (!hasEntry) return emptyList()
46 | val out = buffer.toString()
47 | buffer.clear()
48 | hasEntry = false
49 | return listOf(out)
50 | }
51 |
52 | fun reset() {
53 | buffer.clear()
54 | hasEntry = false
55 | }
56 | }
57 |
58 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/test/kotlin/dev/goquick/kmpertrace/analysis/IosUnifiedLogChunkingRegressionTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 | import kotlin.test.assertNotNull
6 | import kotlin.test.assertTrue
7 |
8 | class IosUnifiedLogChunkingRegressionTest {
9 | @Test
10 | fun `reassembles chunk markers even when marker is on continuation line`() {
11 | val engine = AnalysisEngine()
12 |
13 | // Simulate `log stream` output for a *chunked* entry whose message contains embedded newlines.
14 | // The chunk marker only appears at the end of the OS log entry (the last physical line).
15 | val id = "6a099778"
16 | val chunk1Line1 =
17 | """2025-12-14 17:28:22.590356+0000 SampleApp[123:456] Downloader: boom | |{ ts=2025-12-14T17:28:22.590356Z lvl=error trace=t span=s kind=SPAN_END name="X" dur=1 head="boom" log=Downloader stack_trace="line1"""
18 | val chunk1Line2 = """line2 ($id:kmpert...)"""
19 |
20 | val chunk2Line1 = """2025-12-14 17:28:22.590357+0000 SampleApp[123:456] continued"""
21 | val chunk2Line2 = """line3" }| ($id:kmpert!)"""
22 |
23 | // Feed as physical lines.
24 | listOf(chunk1Line1, chunk1Line2, chunk2Line1, chunk2Line2).forEach { engine.ingest(it) }
25 | engine.flush()
26 |
27 | val snapshot = engine.snapshot()
28 | assertTrue(snapshot.traces.isNotEmpty(), "expected parsed trace from reassembled chunks")
29 | val record = snapshot.traces.first().spans.first().records.first()
30 |
31 | val stack = record.rawFields["stack_trace"]
32 | assertNotNull(stack, "expected stack_trace to be parsed")
33 | assertTrue("(kmpert" !in stack, "chunk markers should be removed from stack_trace")
34 | assertEquals(true, stack.contains("line1"), "expected reconstructed stack to contain line1")
35 | assertEquals(true, stack.contains("line2"), "expected reconstructed stack to contain line2")
36 | assertEquals(true, stack.contains("line3"), "expected reconstructed stack to contain line3")
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/CliParsingTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import com.github.ajalt.clikt.core.UsageError
4 | import dev.goquick.kmpertrace.cli.ansi.AnsiMode
5 | import kotlin.test.Test
6 | import kotlin.test.assertEquals
7 | import kotlin.test.assertFailsWith
8 |
9 | class CliParsingTest {
10 | @Test
11 | fun parseAnsiMode_accepts_on_off_auto() {
12 | assertEquals(AnsiMode.ON, parseAnsiMode("on"))
13 | assertEquals(AnsiMode.OFF, parseAnsiMode("off"))
14 | assertEquals(AnsiMode.AUTO, parseAnsiMode(null))
15 | }
16 |
17 | @Test
18 | fun parseAnsiMode_rejects_invalid() {
19 | assertFailsWith { parseAnsiMode("maybe") }
20 | }
21 |
22 | @Test
23 | fun parseTimeFormat_accepts_full_and_time_only() {
24 | assertEquals(TimeFormat.FULL, parseTimeFormat("full"))
25 | assertEquals(TimeFormat.TIME_ONLY, parseTimeFormat("time"))
26 | assertEquals(TimeFormat.TIME_ONLY, parseTimeFormat(null))
27 | }
28 |
29 | @Test
30 | fun parseTimeFormat_rejects_invalid() {
31 | assertFailsWith { parseTimeFormat("nope") }
32 | }
33 |
34 | @Test
35 | fun resolveWidth_handles_auto_defaults_and_numbers() {
36 | assertEquals(null to true, resolveWidth(null, autoByDefault = true))
37 | assertEquals(null to false, resolveWidth(null, autoByDefault = false))
38 | assertEquals(null to true, resolveWidth("auto", autoByDefault = false))
39 | assertEquals(null to false, resolveWidth("unlimited", autoByDefault = false))
40 | assertEquals(null to false, resolveWidth("0", autoByDefault = false))
41 | assertEquals(80 to false, resolveWidth("80", autoByDefault = false))
42 | }
43 |
44 | @Test
45 | fun validateMaxWidth_accepts_known_values_and_rejects_bad() {
46 | validateMaxWidth("auto")
47 | validateMaxWidth("unlimited")
48 | validateMaxWidth("0")
49 | validateMaxWidth("10")
50 | assertFailsWith { validateMaxWidth("-1") }
51 | assertFailsWith { validateMaxWidth("bad") }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/StatusLineTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.analysis.AnalysisEngine
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 |
7 | class StatusLineTest {
8 |
9 | @Test
10 | fun `error count captures span end with status error`() {
11 | val line = """2025-01-01T00:00:00Z ERROR Downloader X |{| ts=2025-01-01T00:00:00Z lvl=error log=Downloader trace=trace123 span=span1 parent_span=- kind=SPAN_END name="Downloader.DownloadA" dur=100 thread="main" status="ERROR" err_type="IllegalStateException" err_msg="boom" stack_trace="java.lang.IllegalStateException: boom" }|"""
12 | val engine = AnalysisEngine()
13 | engine.onLine(line)
14 | val snapshot = engine.snapshot()
15 | assertEquals(1, errorCount(snapshot))
16 | }
17 |
18 | @Test
19 | fun `error count includes nested child span errors`() {
20 | val parentStart = """2025-01-01T00:00:00Z INFO Vm X |{| ts=2025-01-01T00:00:00Z lvl=info log=Vm trace=trace123 span=root parent_span=- kind=SPAN_START name="Root" dur=0 }|"""
21 | val childEnd = """2025-01-01T00:00:01Z ERROR Downloader X |{| ts=2025-01-01T00:00:01Z lvl=error log=Downloader trace=trace123 span=child parent_span=root kind=SPAN_END name="Downloader.Child" dur=100 thread="main" status="ERROR" err_type="IllegalStateException" err_msg="boom" stack_trace="java.lang.IllegalStateException: boom" }|"""
22 | val engine = AnalysisEngine()
23 | engine.onLine(parentStart)
24 | engine.onLine(childEnd)
25 | val snapshot = engine.snapshot()
26 | assertEquals(1, errorCount(snapshot))
27 | }
28 |
29 | @Test
30 | fun `error count stays zero for non-error log`() {
31 | val line = """2025-01-01T00:00:00Z INFO Downloader X |{| ts=2025-01-01T00:00:00Z lvl=info log=Downloader trace=trace123 span=span1 parent_span=- kind=LOG name="Downloader.DownloadA" dur=0 thread="main" head="hello" }|"""
32 | val engine = AnalysisEngine()
33 | engine.onLine(line)
34 | val snapshot = engine.snapshot()
35 | assertEquals(0, errorCount(snapshot))
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/wasmJsTest/kotlin/dev/goquick/kmpertrace/platform/PlatformLogBackendWasmTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import dev.goquick.kmpertrace.core.LogRecordKind
4 | import dev.goquick.kmpertrace.core.Level
5 | import dev.goquick.kmpertrace.core.StructuredLogRecord
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 | import kotlin.test.assertTrue
9 | import kotlin.time.Instant
10 |
11 | class PlatformLogBackendWasmTest {
12 |
13 | @Test
14 | fun formatLogLine_includes_error_fields_and_stack() {
15 | val record = StructuredLogRecord(
16 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
17 | level = Level.ERROR,
18 | loggerName = "WasmLogger",
19 | message = "failed",
20 | traceId = "trace-wasm",
21 | spanId = "span-wasm",
22 | logRecordKind = LogRecordKind.SPAN_END,
23 | spanName = "op",
24 | durationMs = 1,
25 | attributes = mapOf(
26 | "status" to "ERROR",
27 | "err_type" to "IllegalStateException",
28 | "err_msg" to "boom"
29 | ),
30 | throwable = IllegalStateException("boom")
31 | )
32 |
33 | val rendered = formatLogLine(record)
34 | assertTrue(rendered.contains("""status="ERROR""""))
35 | assertTrue(rendered.contains("""err_type="IllegalStateException""""))
36 | assertTrue(rendered.contains("""err_msg="boom""""))
37 | assertTrue(rendered.contains("""stack_trace="""))
38 | }
39 |
40 | @Test
41 | fun structured_suffix_has_single_pipe_separator() {
42 | val record = StructuredLogRecord(
43 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
44 | level = Level.INFO,
45 | loggerName = "WasmLogger",
46 | message = "hello",
47 | traceId = null,
48 | spanId = null,
49 | logRecordKind = LogRecordKind.LOG
50 | )
51 | val rendered = formatLogLine(record)
52 | assertEquals(1, rendered.windowed(size = 2, step = 1).count { it == "|{" })
53 | assertTrue(!rendered.contains(" | |{"), "double pipe separator should not appear: $rendered")
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/FieldNormalization.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | internal fun normalizeFields(fields: MutableMap): MutableMap {
4 | val src = fields["src"]
5 | val hasSrcComp = fields.containsKey("src_comp")
6 | val hasSrcOp = fields.containsKey("src_op")
7 | if (src != null && !hasSrcComp && !hasSrcOp) {
8 | val parts = src.split('/', limit = 2)
9 | if (parts.size == 2) {
10 | fields["src_comp"] = parts[0]
11 | fields["src_op"] = parts[1]
12 | } else {
13 | fields["src_comp"] = src
14 | }
15 | if (!fields.containsKey("src_hint")) {
16 | fields["src_hint"] = src
17 | }
18 | }
19 | if (src != null && !fields.containsKey("src_hint")) {
20 | fields["src_hint"] = src
21 | }
22 |
23 | fields["stack_trace"]?.let { raw ->
24 | fields["stack_trace"] = cleanStackTrace(raw)
25 | }
26 |
27 | return fields
28 | }
29 |
30 | // Strip common logcat prefixes (epoch, threadtime, brief/tag) that can appear on stack trace lines
31 | // when adb splits multi-line messages. Leave lines intact if they don't match a known header.
32 | private fun cleanStackTrace(raw: String): String =
33 | raw.split('\n').joinToString("\n") { stripLogcatPrefix(it.trimEnd()) }
34 |
35 | private val logcatPrefixMatchers = listOf(
36 | Regex("^\\s*\\d{10}\\.\\d{3}\\s+\\d+\\s+\\d+\\s+[VDIWEF]\\s+[^:]+:\\s*"),
37 | Regex("^\\s*\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2}\\.\\d+\\s+\\d+\\s+\\d+\\s+[VDIWEF]/?\\s*[^:]*:\\s*"),
38 | Regex("^\\s*[VDIWEF]/[^:]+:\\s*")
39 | )
40 |
41 | private fun stripLogcatPrefix(line: String): String {
42 | for (regex in logcatPrefixMatchers) {
43 | val match = regex.find(line)
44 | if (match != null && match.range.first == 0) {
45 | val rest = line.substring(match.value.length)
46 | return normalizeStackLine(rest)
47 | }
48 | }
49 | val simple = line.indexOf(": ")
50 | if (simple >= 0) return normalizeStackLine(line.substring(simple + 2))
51 | return line
52 | }
53 |
54 | private fun normalizeStackLine(rest: String): String =
55 | if (rest.startsWith("at ")) " $rest" else rest
56 |
57 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonTest/kotlin/dev/goquick/kmpertrace/testutil/StructuredSuffixParsing.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.testutil
2 |
3 | /**
4 | * Minimal logfmt-ish parser for KmperTrace's structured suffix (`|{ ... }|`), used in tests.
5 | *
6 | * Supports:
7 | * - unquoted values: `k=v`
8 | * - quoted values with escaped quotes: `k="a b \\\"c\\\""`
9 | * - quoted values with real newlines (e.g. `stack_trace`)
10 | */
11 | internal fun parseStructuredSuffix(structuredSuffix: String): Map {
12 | val start = structuredSuffix.indexOf("|{")
13 | val end = structuredSuffix.lastIndexOf("}|")
14 | require(start >= 0 && end >= 0 && end > start) { "Not a structured suffix: $structuredSuffix" }
15 |
16 | val inner = structuredSuffix.substring(start + 2, end).trim()
17 | val result = LinkedHashMap()
18 |
19 | var i = 0
20 | fun skipSpaces() {
21 | while (i < inner.length && inner[i] == ' ') i++
22 | }
23 |
24 | skipSpaces()
25 | while (i < inner.length) {
26 | val keyStart = i
27 | while (i < inner.length && inner[i] != '=' && inner[i] != ' ') i++
28 | if (i >= inner.length || inner[i] != '=') break
29 | val key = inner.substring(keyStart, i)
30 | i++ // '='
31 |
32 | val value: String =
33 | if (i < inner.length && inner[i] == '"') {
34 | i++ // opening quote
35 | val sb = StringBuilder()
36 | while (i < inner.length) {
37 | val ch = inner[i++]
38 | when (ch) {
39 | '"' -> break
40 | '\\' -> {
41 | if (i < inner.length) {
42 | val next = inner[i++]
43 | sb.append(next)
44 | }
45 | }
46 |
47 | else -> sb.append(ch)
48 | }
49 | }
50 | sb.toString()
51 | } else {
52 | val valueStart = i
53 | while (i < inner.length && inner[i] != ' ') i++
54 | inner.substring(valueStart, i)
55 | }
56 |
57 | result[key] = value
58 | skipSpaces()
59 | }
60 |
61 | return result
62 | }
63 |
64 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/test/kotlin/dev/goquick/kmpertrace/analysis/AnalysisEngineUnescapeTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 | import kotlin.test.assertTrue
6 |
7 | class AnalysisEngineUnescapeTest {
8 |
9 | @Test
10 | fun `unescape percents before parse`() {
11 | val engine = AnalysisEngine()
12 | val rawLine =
13 | """INFO Logger: fatal at 66%% | |{ ts=2025-12-07T00:00:00.000Z lvl=info trace=abc span=1 parent=0 kind=LOG head="fatal at 66%%" log=Logger thread="main" }|"""
14 |
15 | engine.onLine(rawLine)
16 | val snapshot = engine.snapshot()
17 | assertTrue(snapshot.traces.isNotEmpty(), "trace should be parsed")
18 | val record = snapshot.traces.first().spans.first().records.first()
19 | assertEquals("fatal at 66%", record.message)
20 | }
21 |
22 | @Test
23 | fun `chunk markers are stripped even if they slip through`() {
24 | val engine = AnalysisEngine()
25 | val raw =
26 | """INFO Logger: oops (abcd1234:kmpert...) still here | |{ ts=2025-12-07T00:00:00.000Z lvl=info trace=abc span=1 parent=0 kind=LOG head="oops (abcd1234:kmpert...) still here" log=Logger thread="main" }|"""
27 | engine.onLine(raw)
28 | val record = engine.snapshot().traces.first().spans.first().records.first()
29 | assertEquals("""oops still here""", record.message)
30 | }
31 |
32 | @Test
33 | fun `sanitizes structured fields as well`() {
34 | val engine = AnalysisEngine()
35 | val raw =
36 | """ERROR Logger: fail | |{ ts=2025-12-07T00:00:00.000Z lvl=error trace=abc span=1 parent=0 kind=SPAN_END name="Download" dur=10 thread="main" status="ERROR" err_msg="Fatal at 50%% (dead)" stack_trace="line1 (abcd1234:kmpert...)\nline2 (abcd1234:kmpert!)" log=Logger }|"""
37 | engine.onLine(raw)
38 | val record = engine.snapshot().traces.first().spans.first().records.first()
39 | assertEquals("Fatal at 50% (dead)", record.rawFields["err_msg"])
40 | val cleanedStack = record.rawFields["stack_trace"] ?: error("stack_trace missing")
41 | assertTrue("(kmpert" !in cleanedStack, "chunk markers should be removed from stack")
42 | assertTrue("%%" !in cleanedStack, "percents should be unescaped in stack")
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/StructuredSuffixFramer.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | /**
4 | * Incrementally frames "human prefix + structured suffix" log entries.
5 | *
6 | * Input is assumed to arrive as lines, but structured entries may span multiple reads (e.g.
7 | * multiline `stack_trace` stored inside quotes with real newlines).
8 | *
9 | * This framer buffers until it sees a closing `}|` that occurs after the last `|{`, then emits
10 | * the buffered chunk up to and including `}|`. Any trailing text after the close marker is retained
11 | * and may form the start of the next entry.
12 | */
13 | internal class StructuredSuffixFramer(
14 | private val maxBufferChars: Int = 50_000
15 | ) {
16 | private val buffer = StringBuilder()
17 |
18 | fun clear() {
19 | buffer.clear()
20 | }
21 |
22 | fun feed(line: String): List {
23 | if (buffer.isNotEmpty()) buffer.append('\n')
24 | buffer.append(line)
25 | return drainCompleted()
26 | }
27 |
28 | fun flush(): List {
29 | if (buffer.isEmpty()) return emptyList()
30 | return listOf(buffer.toString()).also { buffer.clear() }
31 | }
32 |
33 | private fun drainCompleted(): List {
34 | val out = mutableListOf()
35 | while (true) {
36 | val buffered = buffer.toString()
37 | val lastOpen = buffered.lastIndexOf("|{")
38 | val lastClose = buffered.lastIndexOf("}|")
39 | if (lastOpen == -1 || lastClose == -1 || lastClose <= lastOpen) break
40 |
41 | val candidate = buffered.substring(0, lastClose + 2)
42 | val trailing = buffered.substring(lastClose + 2).trimStart('\n')
43 | out += candidate
44 |
45 | buffer.clear()
46 | if (trailing.isNotEmpty()) {
47 | buffer.append(trailing)
48 | continue
49 | }
50 | break
51 | }
52 |
53 | if (buffer.length > maxBufferChars) {
54 | buffer.clear()
55 | }
56 | return out
57 | }
58 | }
59 |
60 | internal fun frameStructuredSuffixEntries(lines: Sequence): Sequence = sequence {
61 | val framer = StructuredSuffixFramer()
62 | for (line in lines) {
63 | framer.feed(line).forEach { yield(it) }
64 | }
65 | framer.flush().forEach { yield(it) }
66 | }
67 |
68 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/FollowModeTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.parse.ParsedLogRecord
4 | import dev.goquick.kmpertrace.parse.parseLine
5 | import java.io.BufferedReader
6 | import java.io.StringReader
7 | import kotlin.test.Test
8 | import kotlin.test.assertTrue
9 |
10 | class FollowModeTest {
11 | @Test
12 | fun streaming_input_emits_span_end_with_stack_trace() {
13 | // Simulate logcat splitting a multiline span end across multiple readLine() calls.
14 | val chunked = listOf(
15 | """prefix |{ ts=2025-01-01T00:00:00Z lvl=info log=Api trace=trace-1 span=s1 parent_span=- kind=SPAN_START name="root" dur=0 head="start" }|""",
16 | """prefix |{ ts=2025-01-01T00:00:01Z lvl=error log=Api trace=trace-1 span=s1 parent_span=- kind=SPAN_END name="root" dur=10 head="boom" status="ERROR" stack_trace="java.lang.IllegalStateException: boom""",
17 | """ at A.foo(A.kt:10)""",
18 | """ at B.bar(B.kt:20)""",
19 | """ " }|"""
20 | )
21 |
22 | val reader = BufferedReader(StringReader(chunked.joinToString("\n")))
23 | val records = ArrayDeque()
24 | val pending = StringBuilder()
25 |
26 | while (true) {
27 | val line = reader.readLine() ?: break
28 | if (pending.isNotEmpty()) pending.append('\n')
29 | pending.append(line)
30 |
31 | val buffered = pending.toString()
32 | val lastOpen = buffered.lastIndexOf("|{")
33 | val lastClose = buffered.lastIndexOf("}|")
34 | if (lastOpen != -1 && lastClose != -1 && lastClose > lastOpen) {
35 | val candidate = buffered.substring(lastOpen, lastClose + 2)
36 | parseLine(candidate)?.let { records.add(it) }
37 | // If there is trailing data after the last closing brace, keep it.
38 | val trailing = buffered.substring(lastClose + 2).trimStart('\n')
39 | pending.clear()
40 | if (trailing.isNotEmpty()) pending.append(trailing)
41 | }
42 | }
43 |
44 | // Ensure we captured a span end with stack trace content.
45 | val end = records.firstOrNull { it.logRecordKind == dev.goquick.kmpertrace.parse.LogRecordKind.SPAN_END }
46 | assertTrue(end != null)
47 | val stack = end.rawFields["stack_trace"] ?: ""
48 | assertTrue(stack.isNotBlank())
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/wasmJsMain/kotlin/dev/goquick/kmpertrace/trace/TraceContextStorage.wasm.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.TraceContext
4 | import kotlin.coroutines.ContinuationInterceptor
5 | import kotlin.coroutines.CoroutineContext
6 |
7 | // JS/Wasm cannot rely on ThreadLocal; we intercept continuations to re-install the captured trace/binding on resume.
8 | private class TraceContextElement(
9 | private val traceValue: TraceContext?,
10 | private val downstream: ContinuationInterceptor?
11 | ) : CoroutineContext.Element, ContinuationInterceptor {
12 | override val key: CoroutineContext.Key<*> get() = ContinuationInterceptor
13 |
14 | override fun interceptContinuation(continuation: kotlin.coroutines.Continuation): kotlin.coroutines.Continuation =
15 | downstream?.interceptContinuation(TraceContinuation(continuation, traceValue))
16 | ?: TraceContinuation(continuation, traceValue)
17 |
18 | override fun releaseInterceptedContinuation(continuation: kotlin.coroutines.Continuation<*>) {
19 | downstream?.releaseInterceptedContinuation(continuation)
20 | }
21 | }
22 |
23 | private class TraceContinuation(
24 | private val delegate: kotlin.coroutines.Continuation,
25 | private val traceValue: TraceContext?
26 | ) : kotlin.coroutines.Continuation {
27 | override val context: CoroutineContext get() = delegate.context
28 |
29 | override fun resumeWith(result: Result) {
30 | val previousTrace = currentTraceContext
31 | val previousBinding = LoggingBindingStorage.get()
32 |
33 | currentTraceContext = traceValue
34 | LoggingBindingStorage.set(LoggingBinding.BindToSpan)
35 | try {
36 | delegate.resumeWith(result)
37 | } finally {
38 | currentTraceContext = previousTrace
39 | LoggingBindingStorage.set(previousBinding)
40 | }
41 | }
42 | }
43 |
44 | private var currentTraceContext: TraceContext? = null // JS/Wasm is single-threaded, so a top-level var is enough
45 |
46 | actual object TraceContextStorage {
47 | actual fun get(): TraceContext? = currentTraceContext
48 | actual fun set(value: TraceContext?) {
49 | currentTraceContext = value
50 | }
51 |
52 | actual fun element(
53 | value: TraceContext?,
54 | downstream: ContinuationInterceptor?
55 | ): CoroutineContext = TraceContextElement(value, downstream) // capture value and re-install it on continuation resume
56 | }
57 |
--------------------------------------------------------------------------------
/.github/workflows/compute-spm-checksum.yml:
--------------------------------------------------------------------------------
1 | name: Compute SPM XCFramework checksum
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | kmpertrace_version:
7 | description: "Version hint (e.g. 0.1.6) — used only for logging a suggested URL"
8 | required: false
9 | default: ""
10 |
11 | jobs:
12 | compute-checksum:
13 | runs-on: macOS-latest
14 | steps:
15 | - name: Check out code
16 | uses: actions/checkout@v4
17 |
18 | - name: Set up JDK 17
19 | uses: actions/setup-java@v4
20 | with:
21 | distribution: temurin
22 | java-version: 17
23 |
24 | - name: Cache Gradle
25 | uses: actions/cache@v4
26 | with:
27 | path: |
28 | ~/.gradle/caches
29 | ~/.gradle/wrapper
30 | ~/.konan
31 | key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/settings.gradle*', '**/build.gradle*') }}
32 | restore-keys: |
33 | gradle-${{ runner.os }}-
34 |
35 | - name: Build XCFramework (Release)
36 | run: ./gradlew :kmpertrace-runtime:assembleKmperTraceRuntimeReleaseXCFramework
37 |
38 | - name: Zip XCFramework
39 | run: |
40 | cd kmpertrace-runtime/build/XCFrameworks/release
41 | zip -r -X KmperTraceRuntime.xcframework.zip KmperTraceRuntime.xcframework
42 |
43 | - name: Compute SwiftPM checksum
44 | id: checksum
45 | run: |
46 | set -euo pipefail
47 | ZIP_PATH="kmpertrace-runtime/build/XCFrameworks/release/KmperTraceRuntime.xcframework.zip"
48 | CHECKSUM=$(swift package compute-checksum "$ZIP_PATH")
49 | echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT"
50 |
51 | echo ""
52 | echo "================ SPM CHECKSUM ================"
53 | echo "$CHECKSUM"
54 | echo "============================================="
55 | echo ""
56 |
57 | if [ -n "${{ github.event.inputs.kmpertrace_version }}" ]; then
58 | echo "Suggested binaryTarget URL:"
59 | echo " https://github.com/mobiletoly/kmpertrace/releases/download/v${{ github.event.inputs.kmpertrace_version }}/KmperTraceRuntime.xcframework.zip"
60 | echo ""
61 | fi
62 |
63 | - name: Upload XCFramework zip as artifact
64 | uses: actions/upload-artifact@v4
65 | with:
66 | name: KmperTraceRuntime.xcframework
67 | path: kmpertrace-runtime/build/XCFrameworks/release/KmperTraceRuntime.xcframework.zip
68 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/main/kotlin/dev/goquick/kmpertrace/analysis/StructuredFraming.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | /**
4 | * Returns true when the current buffered text contains an opening structured marker `|{`
5 | * that is not yet closed by a matching `}|`.
6 | */
7 | internal fun hasOpenStructuredSuffix(buffer: CharSequence): Boolean {
8 | val open = buffer.lastIndexOf("|{")
9 | val close = buffer.lastIndexOf("}|")
10 | return open != -1 && open > close
11 | }
12 |
13 | /**
14 | * Incrementally frames "human prefix + structured suffix" log entries.
15 | *
16 | * Input is assumed to arrive as lines (e.g. from `readLine()`), but structured records may be split
17 | * across multiple reads (e.g. multiline stack traces). This framer buffers until it sees a closing
18 | * `}|` that occurs after the last `|{`, then emits the buffered chunk up to and including `}|`.
19 | *
20 | * Any trailing text after the close marker is retained and may form the start of the next record.
21 | */
22 | internal class StructuredSuffixFramer(
23 | private val maxBufferChars: Int = 50_000
24 | ) {
25 | private val buffer = StringBuilder()
26 |
27 | fun isOpenStructured(): Boolean = hasOpenStructuredSuffix(buffer)
28 |
29 | fun clear() {
30 | buffer.clear()
31 | }
32 |
33 | fun feed(line: String): List {
34 | if (buffer.isNotEmpty()) buffer.append('\n')
35 | buffer.append(line)
36 | return drainCompleted()
37 | }
38 |
39 | fun flush(): List {
40 | if (buffer.isEmpty()) return emptyList()
41 | return listOf(buffer.toString()).also { buffer.clear() }
42 | }
43 |
44 | private fun drainCompleted(): List {
45 | val out = mutableListOf()
46 | while (true) {
47 | val buffered = buffer.toString()
48 | val lastOpen = buffered.lastIndexOf("|{")
49 | val lastClose = buffered.lastIndexOf("}|")
50 | if (lastOpen == -1 || lastClose == -1 || lastClose <= lastOpen) break
51 |
52 | val candidate = buffered.substring(0, lastClose + 2)
53 | val trailing = buffered.substring(lastClose + 2).trimStart('\n')
54 | out += candidate
55 |
56 | buffer.clear()
57 | if (trailing.isNotEmpty()) {
58 | buffer.append(trailing)
59 | continue
60 | }
61 | break
62 | }
63 |
64 | if (buffer.length > maxBufferChars) {
65 | buffer.clear()
66 | }
67 | return out
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/kmpertrace-cli/README.md:
--------------------------------------------------------------------------------
1 | # KmperTrace CLI
2 |
3 | Small JVM CLI that renders structured KmperTrace logs into ASCII trace trees with level glyphs and
4 | optional source metadata.
5 |
6 | ## Install / Run
7 |
8 | From the repo root:
9 |
10 | ```bash
11 | ./gradlew :kmpertrace-cli:installDist
12 | ./kmpertrace-cli/build/install/kmpertrace-cli/bin/kmpertrace-cli --help
13 | ```
14 |
15 | ## Commands
16 |
17 | ### `print`
18 |
19 | Render trace trees from structured logs (expects lines with the `|{ ts=... }|` suffix emitted by
20 | KmperTrace).
21 |
22 | Options:
23 |
24 | - `--file`, `-f `: Read from a file (defaults to stdin).
25 | - `--hide-source`: Hide source component/operation/location metadata.
26 | - `--max-line-width `: Wrap output lines at N characters (unlimited when omitted).
27 | - `--color {auto|on|off}`: ANSI color output (default: auto).
28 | - `--time-format {time-only|full}`: Show timestamps as time-only (default) or full ISO.
29 | - `--raw-logs {off|all|verbose|debug|info|warn|error|assert}`: Include non-KmperTrace raw lines (default: off).
30 | - `--span-attrs {off|on}`: Show span attributes next to span names (default: off).
31 | - `--help`: Show usage.
32 |
33 | ### `tui`
34 |
35 | Interactive TUI that streams logs from a source (adb/ios/file/stdin) and live-refreshes.
36 |
37 | See `docs/CLI-UserGuide.md` for current flags and keys.
38 |
39 | iOS notes:
40 | - Simulator logs are streamed via `xcrun simctl ... log stream`.
41 | - Real device logs require `idevicesyslog` (libimobiledevice).
42 |
43 | ## Colors
44 |
45 | - Default `--color=auto` only emits ANSI when stdout is a TTY. Gradle’s `:run` captures stdout, so colors are off unless forced.
46 | - Force colors under Gradle: `./gradlew --console=rich :kmpertrace-cli:run --args="print --file /path/to.log --color=on"`.
47 | - Or run the installed binary directly in a TTY: `./build/install/kmpertrace-cli/bin/kmpertrace-cli print --file /path/to.log --color=on`.
48 | - Disable colors explicitly with `--color=off`.
49 |
50 | ## Examples
51 |
52 | Render from a file:
53 |
54 | ```bash
55 | kmpertrace-cli print --file /path/to/results.log
56 | ```
57 |
58 | Render from `adb logcat` (Android):
59 |
60 | ```bash
61 | adb logcat -v brief | kmpertrace-cli print
62 | ```
63 |
64 | Wrap long lines to 80 chars:
65 |
66 | ```bash
67 | kmpertrace-cli print --file results.log --max-line-width 80
68 | ```
69 |
70 | Hide source metadata:
71 |
72 | ```bash
73 | kmpertrace-cli print --file results.log --hide-source
74 | ```
75 | Untraced log records (`trace=0`) are interleaved by timestamp alongside trace output (e.g., a record before a trace will show before the trace header).
76 |
--------------------------------------------------------------------------------
/sample-app/src/commonMain/kotlin/dev/goquick/kmpertrace/sampleapp/data/ProfileRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp.data
2 |
3 | import dev.goquick.kmpertrace.log.Log
4 | import dev.goquick.kmpertrace.sampleapp.model.ActivityEvent
5 | import dev.goquick.kmpertrace.sampleapp.model.Contact
6 | import dev.goquick.kmpertrace.sampleapp.model.Profile
7 | import dev.goquick.kmpertrace.trace.traceSpan
8 |
9 | class ProfileRepository(
10 | private val network: FakeNetworkService,
11 | private val database: FakeDatabase
12 | ) {
13 |
14 | private val log = Log.forComponent("ProfileRepository")
15 |
16 | suspend fun loadProfile(userId: String): Profile =
17 | traceSpan(component = "ProfileRepository", operation = "loadProfile", attributes = mapOf("userId" to userId)) {
18 | log.withOperation("loadProfile").d { "loadProfile begin for $userId" }
19 | database.loadProfile(userId)?.let {
20 | log.withOperation("loadProfile").d { "Profile cache hit" }
21 | return@traceSpan it
22 | }
23 | val fresh = network.fetchProfile(userId)
24 | database.saveProfile(fresh)
25 | log.withOperation("loadProfile").i { "Profile fetched from network" }
26 | log.withOperation("loadProfile").d { "loadProfile finished for $userId" }
27 | fresh
28 | }
29 |
30 | suspend fun loadContacts(userId: String): List =
31 | traceSpan(component = "ProfileRepository", operation = "loadContacts", attributes = mapOf("userId" to userId)) {
32 | log.withOperation("loadContacts").d { "loadContacts begin for $userId" }
33 | database.loadContacts(userId)?.let {
34 | log.withOperation("loadContacts").d { "Contacts cache hit" }
35 | return@traceSpan it
36 | }
37 | val fresh = network.fetchContacts(userId)
38 | database.saveContacts(userId, fresh)
39 | log.withOperation("loadContacts").d { "loadContacts finished for $userId" }
40 | fresh
41 | }
42 |
43 | suspend fun loadActivity(userId: String): List =
44 | traceSpan(component = "ProfileRepository", operation = "loadActivity", attributes = mapOf("userId" to userId)) {
45 | log.withOperation("loadActivity").d { "loadActivity begin for $userId" }
46 | database.loadActivity(userId)?.let {
47 | log.withOperation("loadActivity").d { "Activity cache hit" }
48 | return@traceSpan it
49 | }
50 | val events = network.fetchActivity(userId)
51 | database.saveActivity(userId, events)
52 | log.withOperation("loadActivity").d { "loadActivity finished for $userId" }
53 | events
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/docs/RELEASE.md:
--------------------------------------------------------------------------------
1 | # KmperTrace Release Checklist (Maven + SwiftPM)
2 |
3 | This repo publishes:
4 | - Maven Central: `kmpertrace-runtime` (and other JVM/KMP artifacts) via `publish.yml` on GitHub Release.
5 | - SwiftPM binary: XCFramework zip attached to the GitHub Release, with `Package.swift` in the repo root pointing to that zip.
6 |
7 | ## Release steps (SwiftPM + Maven)
8 | 1) Bump version
9 | - Edit `gradle.properties` `kmpertraceVersion=...` (e.g., `0.1.5` -> `0.1.6`).
10 | - Commit & push to main.
11 |
12 | 2) Compute SwiftPM checksum for the XCFramework zip
13 | - GitHub → Actions → **Compute SPM XCFramework checksum** → Run workflow.
14 | - Run it on the commit that contains the version bump.
15 | - CI builds the XCFramework, zips it, prints the checksum and suggested URL.
16 |
17 | 3) Update `Package.swift`
18 | - From helper logs, copy checksum + suggested URL (`.../releases/download/v/KmperTraceRuntime.xcframework.zip`).
19 | - Edit root `Package.swift` with that URL and checksum.
20 | - Commit & push.
21 |
22 | 4) Tag
23 | - Ensure HEAD contains the updated `Package.swift`.
24 | - Tag the commit: `git tag v`; `git push origin v`.
25 |
26 | 5) Create GitHub Release (manual)
27 | - In GitHub UI, create a release for tag `v`.
28 |
29 | 6) What CI does on the release event
30 | - publish.yml:
31 | - Publishes Maven artifacts.
32 | - Builds the iOS XCFramework zip from the tag commit.
33 | - Verifies `swift package compute-checksum` on that zip matches `Package.swift`.
34 | - Uploads the zip to the GitHub Release.
35 | - Fails if checksum mismatches.
36 |
37 | ## SwiftPM consumers
38 | - Add package dependency:
39 | ```swift
40 | .package(url: "https://github.com/mobiletoly/kmpertrace.git", from: "")
41 | ```
42 | - SwiftPM resolves the tag, reads `Package.swift` from that tag, downloads the zip from the release asset, and verifies the checksum.
43 |
44 | ## Notes / Gotchas
45 | - The commit the tag points to must already contain the final `Package.swift`; SwiftPM ignores later commits.
46 | - The helper workflow exists to help you update `Package.swift` before tagging; publish.yml still verifies the checksum at release time.
47 | - If checksum verification fails in publish.yml, update `Package.swift` and retag with a new version.
48 | - `Package.swift` must live in the repo root and be correct per tag; SwiftPM ignores Package.swift uploaded as a release asset.
49 | - `Package.swift` can be formatted either as multi-line or single-line; CI parses `checksum: "..."` anywhere in the file.
50 | - Avoid snapshot versions when tagging.
51 | - Deprecated Gradle warnings about `exec` are harmless but can be cleaned up later.
52 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/HumanPrefix.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | internal data class HumanInfo(val logger: String?, val message: String?)
4 |
5 | private val logcatThreadTimeRegex =
6 | Regex("""^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+\d+\s+\d+\s+[VDIWEF]/?\s*(\S+):\s*(.*)$""")
7 |
8 | private val logcatBriefRegex =
9 | Regex("""^[VDIWEF]/\s*(\S+):\s*(.*)$""")
10 |
11 | internal fun parseHumanPrefix(human: String): HumanInfo {
12 | if (human.isBlank()) return HumanInfo(null, null)
13 | var text = human.trim()
14 | val glyphs = listOf("▫️", "🔍", "ℹ️", "⚠️", "❌", "💥")
15 | glyphs.forEach { if (text.startsWith(it)) text = text.removePrefix(it).trimStart() }
16 |
17 | logcatThreadTimeRegex.matchEntire(text)?.let { m ->
18 | val logger = m.groupValues[1].takeIf { it.isNotEmpty() }
19 | val msg = m.groupValues[2].takeIf { it.isNotEmpty() }
20 | return HumanInfo(logger, msg)
21 | }
22 | logcatBriefRegex.matchEntire(text)?.let { m ->
23 | val logger = m.groupValues[1].takeIf { it.isNotEmpty() }
24 | val msg = m.groupValues[2].takeIf { it.isNotEmpty() }
25 | return HumanInfo(logger, msg)
26 | }
27 |
28 | if (text.startsWith("+++ ") || text.startsWith("--- ")) {
29 | return HumanInfo(null, text)
30 | }
31 |
32 | val colon = text.indexOf(':')
33 | if (colon > 0) {
34 | val logger = text.substring(0, colon).trim().takeIf { it.isNotEmpty() }
35 | val msg = text.substring(colon + 1).trim().takeIf { it.isNotEmpty() }
36 | return HumanInfo(logger, msg)
37 | }
38 |
39 | return HumanInfo(null, null)
40 | }
41 |
42 | internal fun resolveMessage(logger: String?, head: String?, humanPrefix: String, human: HumanInfo): String? {
43 | val trimmedHuman = humanPrefix.trim()
44 | if (!head.isNullOrBlank()) {
45 | if (!logger.isNullOrBlank()) {
46 | val anchor = "$logger: $head"
47 | val idxAnchor = trimmedHuman.indexOf(anchor)
48 | if (idxAnchor >= 0) {
49 | val idxHead = trimmedHuman.indexOf(head, idxAnchor)
50 | if (idxHead >= 0) {
51 | val full = trimmedHuman.substring(idxHead).trim()
52 | if (full.isNotEmpty()) return full
53 | }
54 | }
55 | }
56 | val idxHead = trimmedHuman.indexOf(head)
57 | if (idxHead >= 0) {
58 | val full = trimmedHuman.substring(idxHead).trim()
59 | if (full.isNotEmpty()) return full
60 | }
61 | if (head.isNotBlank()) return head
62 | }
63 | val humanMessage = human.message?.takeIf { it.isNotBlank() }
64 | if (humanMessage != null) return humanMessage
65 | return if (trimmedHuman.isNotEmpty()) trimmedHuman else null
66 | }
67 |
--------------------------------------------------------------------------------
/sample-app/src/commonMain/kotlin/dev/goquick/kmpertrace/sampleapp/data/FakeDownloader.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.sampleapp.data
2 |
3 | import dev.goquick.kmpertrace.log.Log
4 | import dev.goquick.kmpertrace.trace.traceSpan
5 | import kotlinx.coroutines.delay
6 | import kotlin.random.Random
7 |
8 | class FakeDownloader {
9 | private val log = Log.forComponent("Downloader")
10 |
11 | suspend fun download(
12 | label: String,
13 | totalChunks: Int = 10,
14 | chunkDelayMs: Long = 500L,
15 | onProgress: (Int) -> Unit = {}
16 | ) {
17 | val jobId = (Random.nextInt() % 100000).toString()
18 | val simulateFailure = label == "DownloadA"
19 | var failureInjected = false
20 | traceSpan(
21 | component = "Downloader",
22 | operation = label,
23 | attributes = mapOf("jobId" to jobId, "chunks" to totalChunks.toString())
24 | ) {
25 | log.withOperation(label).i { "Download $label starting (jobId=$jobId)" }
26 | repeat(totalChunks) { idx ->
27 | delay(chunkDelayMs)
28 | val percent = ((idx + 1) * 100) / totalChunks
29 | onProgress(percent)
30 | log.withOperation(label).d { "Download $label progress $percent% (jobId=$jobId)" }
31 | if (simulateFailure && !failureInjected && percent >= 50) {
32 | failureInjected = true
33 | val ex = IllegalStateException("Fatal network error on \"$label\" at ${percent}% (jobId=$jobId)")
34 | log.withOperation(label).e { "Download $label failed (jobId=$jobId)" }
35 | throw ex
36 | }
37 | storeChunk(jobId = jobId, label = label, chunkIndex = idx, totalChunks = totalChunks)
38 | }
39 | log.withOperation(label).i { "Download $label finished (jobId=$jobId)" }
40 | }
41 | }
42 |
43 | private suspend fun storeChunk(jobId: String, label: String, chunkIndex: Int, totalChunks: Int) {
44 | val bytes = 64 * 1024 // 64KB fake chunk
45 | traceSpan(
46 | component = "Downloader",
47 | operation = "storeChunk",
48 | attributes = mapOf(
49 | "jobId" to jobId,
50 | "label" to label,
51 | "chunkIndex" to chunkIndex.toString(),
52 | "totalChunks" to totalChunks.toString(),
53 | "bytes" to bytes.toString()
54 | )
55 | ) {
56 | log.withOperation("storeChunk").d { "Storing chunk ${chunkIndex + 1}/$totalChunks for $label ($bytes bytes, jobId=$jobId)" }
57 | delay(150) // simulate disk write
58 | log.withOperation("storeChunk").d { "Chunk ${chunkIndex + 1}/$totalChunks stored for $label" }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosMain/kotlin/dev/goquick/kmpertrace/trace/TraceContextStorage.ios.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.TraceContext
4 | import kotlin.coroutines.Continuation
5 | import kotlin.coroutines.ContinuationInterceptor
6 | import kotlin.coroutines.CoroutineContext
7 | import kotlin.native.concurrent.ThreadLocal
8 |
9 | private class TraceContextElement(
10 | private val traceValue: TraceContext?,
11 | private val downstream: ContinuationInterceptor?
12 | ) : CoroutineContext.Element, ContinuationInterceptor {
13 | override val key: CoroutineContext.Key<*> get() = ContinuationInterceptor
14 |
15 | // Wrap the continuation so that when it resumes on a native thread we re-install the captured trace context.
16 | override fun interceptContinuation(continuation: Continuation): Continuation =
17 | // Install our wrapper first so downstream dispatchers resume with trace applied.
18 | downstream?.interceptContinuation(TraceContinuation(continuation, traceValue))
19 | ?: TraceContinuation(continuation, traceValue)
20 |
21 | override fun releaseInterceptedContinuation(continuation: Continuation<*>) {
22 | downstream?.releaseInterceptedContinuation(continuation)
23 | }
24 | }
25 |
26 | private class TraceContinuation(
27 | private val delegate: Continuation,
28 | private val traceValue: TraceContext?
29 | ) : Continuation {
30 | override val context: CoroutineContext get() = delegate.context
31 |
32 | override fun resumeWith(result: Result) {
33 | val previousTrace = currentTraceContext
34 | val previousBinding = LoggingBindingStorage.get()
35 |
36 | // Install the captured trace context for the duration of this continuation resume.
37 | currentTraceContext = traceValue
38 | // Always bind logs to the current span while this continuation runs.
39 | LoggingBindingStorage.set(LoggingBinding.BindToSpan)
40 | try {
41 | delegate.resumeWith(result)
42 | } finally {
43 | // Restore whatever was active before we hopped back to this thread.
44 | currentTraceContext = previousTrace
45 | LoggingBindingStorage.set(previousBinding)
46 | }
47 | }
48 | }
49 |
50 | @ThreadLocal
51 | private var currentTraceContext: TraceContext? = null // keep one TraceContext per native thread
52 |
53 | actual object TraceContextStorage {
54 | actual fun get(): TraceContext? = currentTraceContext
55 | actual fun set(value: TraceContext?) {
56 | currentTraceContext = value
57 | }
58 |
59 | actual fun element(
60 | value: TraceContext?,
61 | downstream: ContinuationInterceptor?
62 | ): CoroutineContext = TraceContextElement(value, downstream) // capture current context + downstream interceptor chain
63 | }
64 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/TuiControllerTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.analysis.AnalysisEngine
4 | import dev.goquick.kmpertrace.analysis.FilterState
5 | import kotlin.test.Test
6 | import kotlin.test.assertEquals
7 | import kotlin.test.assertFalse
8 | import kotlin.test.assertTrue
9 |
10 | class TuiControllerTest {
11 | @Test
12 | fun prompt_search_updates_state_via_effect() {
13 | val engine = AnalysisEngine(filterState = FilterState(), maxRecords = 100)
14 | val controller = TuiController(
15 | engine = engine,
16 | filters = FilterState(),
17 | maxRecords = 100,
18 | promptSearch = { "foo" }
19 | )
20 | controller.setInitialModes(rawLogsLevel = RawLogLevel.OFF, spanAttrsMode = SpanAttrsMode.OFF)
21 |
22 | val update = controller.handleUiEvent(UiEvent.PromptSearch, allowInput = true)
23 | assertTrue(update.forceRender, "prompt should trigger a render")
24 | assertEquals("foo", controller.state.search)
25 | }
26 |
27 | @Test
28 | fun clear_resets_engine_snapshot() {
29 | val engine = AnalysisEngine(filterState = FilterState(), maxRecords = 100)
30 | val controller = TuiController(
31 | engine = engine,
32 | filters = FilterState(),
33 | maxRecords = 100,
34 | promptSearch = { null }
35 | )
36 | controller.setInitialModes(rawLogsLevel = RawLogLevel.OFF, spanAttrsMode = SpanAttrsMode.OFF)
37 |
38 | controller.handleLine(
39 | """t INFO Api |{ ts=2025-01-01T00:00:00Z lvl=info log=Api trace=trace-1 span=root parent=- kind=LOG name="root" dur=0 head="hello" }|"""
40 | )
41 | assertTrue(controller.snapshot().traces.isNotEmpty(), "expected trace to be present after ingestion")
42 |
43 | val cleared = controller.handleUiEvent(UiEvent.Clear, allowInput = true)
44 | assertTrue(cleared.forceRender)
45 | assertTrue(controller.snapshot().traces.isEmpty(), "expected traces to be cleared")
46 | }
47 |
48 | @Test
49 | fun raw_dirty_is_set_only_when_raw_enabled() {
50 | val engine = AnalysisEngine(filterState = FilterState(), maxRecords = 100)
51 | val controller = TuiController(
52 | engine = engine,
53 | filters = FilterState(),
54 | maxRecords = 100,
55 | promptSearch = { null }
56 | )
57 |
58 | controller.setInitialModes(rawLogsLevel = RawLogLevel.OFF, spanAttrsMode = SpanAttrsMode.OFF)
59 | val off = controller.handleLine("D/Foo: hello")
60 | assertFalse(off.rawDirty)
61 |
62 | controller.setInitialModes(rawLogsLevel = RawLogLevel.ALL, spanAttrsMode = SpanAttrsMode.OFF)
63 | val on = controller.handleLine("D/Foo: hello")
64 | assertTrue(on.rawDirty)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosTest/kotlin/dev/goquick/kmpertrace/trace/LoggingBindingIsolationIosTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.log.Log
5 | import dev.goquick.kmpertrace.log.KmperTrace
6 | import kotlinx.coroutines.async
7 | import kotlinx.coroutines.coroutineScope
8 | import kotlinx.coroutines.runBlocking
9 | import kotlin.test.AfterTest
10 | import kotlin.test.Test
11 | import kotlin.test.assertEquals
12 | import kotlin.test.assertNotEquals
13 | import kotlin.test.assertNotNull
14 | import kotlin.test.assertNull
15 | import dev.goquick.kmpertrace.testutil.parseStructuredSuffix
16 |
17 | class LoggingBindingIsolationIosTest {
18 | private val sink = IosCollectingSink()
19 |
20 | @AfterTest
21 | fun tearDown() {
22 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = emptyList())
23 | sink.records.clear()
24 | }
25 |
26 | @Test
27 | fun unbound_logs_after_span_are_not_attached() = runBlocking {
28 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = listOf(sink))
29 |
30 | traceSpan("isolation-span") {
31 | Log.d { "span-log" }
32 | }
33 |
34 | Log.d { "outside-span-log" }
35 |
36 | val spanLog = sink.records.first { it.message == "span-log" }
37 | val outsideLog = sink.records.first { it.message == "outside-span-log" }
38 | val spanFields = parseStructuredSuffix(spanLog.structuredSuffix)
39 | val outsideFields = parseStructuredSuffix(outsideLog.structuredSuffix)
40 |
41 | assertNotNull(spanFields["trace"])
42 | assertNotNull(spanFields["span"])
43 | assertNull(outsideFields["trace"], "outside log should not carry span trace id")
44 | assertNull(outsideFields["span"], "outside log should not carry span id")
45 | assertNull(outsideFields["parent"], "outside log should not carry parent span id")
46 | }
47 |
48 | @Test
49 | fun parallel_spans_keep_traceIds_separate() = runBlocking {
50 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = listOf(sink))
51 |
52 | coroutineScope {
53 | val a = async {
54 | traceSpan("span-A") { Log.d { "from-A" } }
55 | }
56 | val b = async {
57 | traceSpan("span-B") { Log.d { "from-B" } }
58 | }
59 | a.await()
60 | b.await()
61 | }
62 |
63 | val logA = sink.records.first { it.message == "from-A" }
64 | val logB = sink.records.first { it.message == "from-B" }
65 | val fieldsA = parseStructuredSuffix(logA.structuredSuffix)
66 | val fieldsB = parseStructuredSuffix(logB.structuredSuffix)
67 |
68 | assertNotNull(fieldsA["trace"])
69 | assertNotNull(fieldsB["trace"])
70 | assertNotEquals(fieldsA["trace"], fieldsB["trace"], "separate spans should have different trace ids")
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/source/SourceCommands.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli.source
2 |
3 | import dev.goquick.kmpertrace.cli.usageError
4 |
5 | internal object SourceCommands {
6 | private val safeAndroidPackageRegex = Regex("^[A-Za-z0-9_.]+$")
7 | private val safeProcessNameRegex = Regex("^[A-Za-z0-9_.-]+$")
8 | private val safeUdidRegex = Regex("^[0-9A-Fa-f-]{8,64}$")
9 |
10 | fun buildAdbCommand(adbCmd: String?, adbPkg: String?): String {
11 | adbCmd?.let { return it }
12 | if (adbPkg == null) usageError("--adb-cmd or --adb-pkg is required when --source=adb")
13 | if (!safeAndroidPackageRegex.matches(adbPkg)) {
14 | usageError("--adb-pkg must match ${safeAndroidPackageRegex.pattern} (got: $adbPkg)")
15 | }
16 | // Wait for the process to appear and reattach if it restarts.
17 | return """
18 | while true; do
19 | pid=""
20 | while [ -z "${'$'}pid" ]; do
21 | pid=$(adb shell pidof -s $adbPkg | tr -d '\r')
22 | if [ -z "${'$'}pid" ]; then
23 | echo "waiting for $adbPkg to start..." >&2
24 | sleep 1
25 | fi
26 | done
27 | echo "streaming logcat for pid=${'$'}pid ($adbPkg)" >&2
28 | adb logcat -v epoch --pid=${'$'}pid &
29 | logcat_pid=${'$'}!
30 | while true; do
31 | sleep 1
32 | cur=$(adb shell pidof -s $adbPkg | tr -d '\r')
33 | if [ -z "${'$'}cur" ] || [ "${'$'}cur" != "${'$'}pid" ]; then
34 | echo "pid ${'$'}pid exited; restarting when app returns..." >&2
35 | kill ${'$'}logcat_pid 2>/dev/null
36 | wait ${'$'}logcat_pid 2>/dev/null
37 | break
38 | fi
39 | done
40 | done
41 | """.trimIndent()
42 | }
43 |
44 | fun buildIosSimCommand(iosProc: String, simUdid: String): String {
45 | if (!safeProcessNameRegex.matches(iosProc)) {
46 | usageError("--ios-proc must match ${safeProcessNameRegex.pattern} (got: $iosProc)")
47 | }
48 | if (!safeUdidRegex.matches(simUdid)) {
49 | usageError("--ios-udid must match ${safeUdidRegex.pattern} (got: $simUdid)")
50 | }
51 | return """exec xcrun simctl spawn $simUdid log stream --style syslog --predicate 'process == "$iosProc"'"""
52 | }
53 |
54 | fun buildIosDeviceCommand(deviceUdid: String, iosProc: String): String {
55 | if (!safeUdidRegex.matches(deviceUdid)) {
56 | usageError("--ios-udid must match ${safeUdidRegex.pattern} (got: $deviceUdid)")
57 | }
58 | if (!safeProcessNameRegex.matches(iosProc)) {
59 | usageError("--ios-proc must match ${safeProcessNameRegex.pattern} (got: $iosProc)")
60 | }
61 | return "exec idevicesyslog -u $deviceUdid -p $iosProc --no-colors"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/source/IosDiscovery.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli.source
2 |
3 | import dev.goquick.kmpertrace.cli.cliError
4 | import dev.goquick.kmpertrace.cli.usageError
5 | import java.io.BufferedReader
6 |
7 | internal object IosDiscovery {
8 | enum class IosSourceKind { SIMULATOR, DEVICE }
9 |
10 | data class ResolvedIosSource(val kind: IosSourceKind, val command: String)
11 |
12 | fun resolveIosSource(
13 | iosCmd: String?,
14 | iosProc: String?,
15 | iosTarget: String?,
16 | iosUdid: String?
17 | ): ResolvedIosSource {
18 | iosCmd?.let { return ResolvedIosSource(IosSourceKind.SIMULATOR, iosCmd) }
19 | if (iosProc == null) {
20 | usageError("--ios-proc is required for iOS sources (unless --ios-cmd is set)")
21 | }
22 |
23 | val bootedSims = listBootedSimulators().map { IosTargetResolver.BootedSimulator(it.name, it.udid) }
24 | val deviceUdids = listConnectedDeviceUdids()
25 | return when (val sel = IosTargetResolver.resolve(iosTarget, iosUdid, iosProc, bootedSims, deviceUdids)) {
26 | is IosTargetResolver.Selection.Simulator -> {
27 | ResolvedIosSource(
28 | kind = IosSourceKind.SIMULATOR,
29 | command = SourceCommands.buildIosSimCommand(iosProc = iosProc, simUdid = sel.udid)
30 | )
31 | }
32 |
33 | is IosTargetResolver.Selection.Device ->
34 | ResolvedIosSource(
35 | kind = IosSourceKind.DEVICE,
36 | command = SourceCommands.buildIosDeviceCommand(deviceUdid = sel.udid, iosProc = iosProc)
37 | )
38 | }
39 | }
40 |
41 | private fun listBootedSimulators(): List {
42 | val out = runAndCollectLines("xcrun simctl list devices booted")
43 | val regex = Regex("""^\s*(.+?)\s+\(([0-9A-Fa-f-]{36})\)\s+\(Booted\)\s*$""")
44 | return out.mapNotNull { line ->
45 | val m = regex.matchEntire(line) ?: return@mapNotNull null
46 | BootedSimulator(name = m.groupValues[1], udid = m.groupValues[2])
47 | }
48 | }
49 |
50 | private data class BootedSimulator(val name: String, val udid: String)
51 |
52 | private fun listConnectedDeviceUdids(): List {
53 | val out = runAndCollectLines("idevice_id -l")
54 | return out.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() } }
55 | }
56 |
57 | private fun runAndCollectLines(command: String): List {
58 | val process = ProcessBuilder(listOf("sh", "-c", command))
59 | .redirectErrorStream(true)
60 | .start()
61 | val lines = process.inputStream.bufferedReader().use(BufferedReader::readLines)
62 | val exit = process.waitFor()
63 | if (exit != 0) {
64 | cliError("Command failed ($exit): $command\n${lines.joinToString("\n")}")
65 | }
66 | return lines
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/TuiRawFilterTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.analysis.FilterState
4 | import dev.goquick.kmpertrace.analysis.AnalysisEngine
5 | import dev.goquick.kmpertrace.parse.ParsedLogRecord
6 | import dev.goquick.kmpertrace.parse.LogRecordKind
7 | import kotlin.test.Test
8 | import kotlin.test.assertEquals
9 | import kotlin.test.assertTrue
10 |
11 | class TuiRawFilterTest {
12 | @Test
13 | fun raw_respects_search_and_filters() {
14 | val engine = AnalysisEngine(filterState = FilterState())
15 | val rawLines = ArrayDeque()
16 |
17 | // Structured record that matches "foo"
18 | engine.onLine("t INFO Api |{ ts=2025-01-01T00:00:00Z lvl=info log=Api trace=trace-1 span=root parent=- kind=LOG name=\"-\" dur=0 head=\"foo match\" }|")
19 |
20 | // Raw event that matches search term
21 | rawLines += ParsedLogRecord(
22 | traceId = "0",
23 | spanId = "0",
24 | parentSpanId = null,
25 | logRecordKind = LogRecordKind.LOG,
26 | spanName = "-",
27 | durationMs = null,
28 | loggerName = "Raw",
29 | timestamp = "2025-01-01T00:00:01Z",
30 | message = "foo raw line",
31 | sourceComponent = null,
32 | sourceOperation = null,
33 | sourceLocationHint = null,
34 | sourceFile = null,
35 | sourceLine = null,
36 | sourceFunction = null,
37 | rawFields = mutableMapOf("lvl" to "info", "raw" to "true")
38 | )
39 |
40 | // Raw event that should be filtered out
41 | rawLines += ParsedLogRecord(
42 | traceId = "0",
43 | spanId = "0",
44 | parentSpanId = null,
45 | logRecordKind = LogRecordKind.LOG,
46 | spanName = "-",
47 | durationMs = null,
48 | loggerName = "Raw",
49 | timestamp = "2025-01-01T00:00:02Z",
50 | message = "other line",
51 | sourceComponent = null,
52 | sourceOperation = null,
53 | sourceLocationHint = null,
54 | sourceFile = null,
55 | sourceLine = null,
56 | sourceFunction = null,
57 | rawFields = mutableMapOf("lvl" to "info", "raw" to "true")
58 | )
59 |
60 | val searchTerm = "foo"
61 | val filteredSnapshot = applySearchFilter(engine.snapshot(), searchTerm)
62 | val filteredRaw = rawLines.filter { evt ->
63 | rawLevelAllows(evt, RawLogLevel.ALL) && (searchTerm.isBlank() || matchesRecord(evt, searchTerm))
64 | }
65 |
66 | // Only the matching raw event remains
67 | assertEquals(1, filteredRaw.size)
68 | assertTrue(filteredRaw.first().message?.contains("foo") == true)
69 |
70 | // Structured side still filtered as before
71 | assertEquals(1, filteredSnapshot.traces.flatMap { it.spans }.flatMap { it.records }.size)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidUnitTest/kotlin/dev/goquick/kmpertrace/trace/LoggingBindingIsolationTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.log.Log
5 | import dev.goquick.kmpertrace.log.KmperTrace
6 | import kotlinx.coroutines.async
7 | import kotlinx.coroutines.coroutineScope
8 | import kotlinx.coroutines.runBlocking
9 | import kotlin.test.AfterTest
10 | import kotlin.test.Test
11 | import kotlin.test.assertEquals
12 | import kotlin.test.assertNotEquals
13 | import kotlin.test.assertNotNull
14 | import kotlin.test.assertNull
15 | import dev.goquick.kmpertrace.testutil.parseStructuredSuffix
16 |
17 | class LoggingBindingIsolationTest {
18 | private val sink = AndroidCollectingSink()
19 |
20 | @AfterTest
21 | fun tearDown() {
22 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = emptyList())
23 | sink.records.clear()
24 | }
25 |
26 | @Test
27 | fun unbound_logs_after_span_are_not_attached() = runBlocking {
28 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = listOf(sink))
29 |
30 | traceSpan("isolation-span") {
31 | Log.d { "span-log" }
32 | }
33 |
34 | Log.d { "outside-span-log" }
35 |
36 | val spanLog = sink.records.first { it.message == "span-log" }
37 | val outsideLog = sink.records.first { it.message == "outside-span-log" }
38 | val spanFields = parseStructuredSuffix(spanLog.structuredSuffix)
39 | val outsideFields = parseStructuredSuffix(outsideLog.structuredSuffix)
40 |
41 | assertNotNull(spanFields["trace"])
42 | assertNotNull(spanFields["span"])
43 | assertNull(outsideFields["trace"], "outside log should not carry span trace id")
44 | assertNull(outsideFields["span"], "outside log should not carry span id")
45 | assertNull(outsideFields["parent"], "outside log should not carry parent span id")
46 | }
47 |
48 | @Test
49 | fun parallel_spans_keep_traceIds_separate() = runBlocking {
50 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = listOf(sink))
51 |
52 | coroutineScope {
53 | val a = async {
54 | traceSpan("span-A") { Log.d { "from-A" } }
55 | }
56 | val b = async {
57 | traceSpan("span-B") { Log.d { "from-B" } }
58 | }
59 | a.await()
60 | b.await()
61 | }
62 |
63 | val logA = sink.records.first { it.message == "from-A" }
64 | val logB = sink.records.first { it.message == "from-B" }
65 | val fieldsA = parseStructuredSuffix(logA.structuredSuffix)
66 | val fieldsB = parseStructuredSuffix(logB.structuredSuffix)
67 |
68 | assertNotNull(fieldsA["trace"])
69 | assertNotNull(fieldsB["trace"])
70 | assertNotEquals(fieldsA["trace"], fieldsB["trace"], "separate spans should have different trace ids")
71 | assertEquals("span-A", parseStructuredSuffix(sink.records.first { it.message == "+++ span-A" }.structuredSuffix)["name"])
72 | assertEquals("span-B", parseStructuredSuffix(sink.records.first { it.message == "+++ span-B" }.structuredSuffix)["name"])
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/plugin/src/main/kotlin/dev/goquick/kmpertrace/KmperTracePlugin.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace
2 |
3 | import org.gradle.api.Plugin
4 | import org.gradle.api.Project
5 | import org.gradle.api.model.ObjectFactory
6 | import org.gradle.api.provider.Property
7 | import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
8 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
9 | import javax.inject.Inject
10 |
11 | /**
12 | * Extension for configuring the KmperTrace source generator.
13 | */
14 | abstract class KmperTraceExtension @Inject constructor(objects: ObjectFactory) {
15 | /**
16 | * Package name for the generated Kotlin source.
17 | */
18 | val packageName: Property = objects.property(String::class.java)
19 |
20 | /**
21 | * Class name for the generated Kotlin source.
22 | */
23 | val className: Property = objects.property(String::class.java)
24 |
25 | /**
26 | * Message content stored in the generated `MESSAGE` constant.
27 | */
28 | val message: Property = objects.property(String::class.java)
29 | }
30 |
31 | /**
32 | * Gradle plugin that generates a simple shared Kotlin source and wires it into KMP builds.
33 | */
34 | class KmperTracePlugin : Plugin {
35 | override fun apply(project: Project) {
36 | val extension = project.extensions.create("kmperTrace", KmperTraceExtension::class.java).apply {
37 | packageName.convention("dev.goquick.kmpertrace.generated")
38 | className.convention("GeneratedGreeting")
39 | message.convention("Hello from KmperTrace plugin!")
40 | }
41 |
42 | val outputDir = project.layout.buildDirectory.dir("generated/src/commonMain/kotlin")
43 | val generateTask = project.tasks.register(
44 | "generateKmperTraceSources",
45 | GenerateKmperTraceTask::class.java
46 | ) { task ->
47 | task.group = "code generation"
48 | task.description = "Generates shared Kotlin sources configured via the kmperTrace extension."
49 | task.packageName.set(extension.packageName)
50 | task.className.set(extension.className)
51 | task.message.set(extension.message)
52 | task.outputDirectory.set(outputDir)
53 | }
54 |
55 | project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
56 | val kotlinExt = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
57 | kotlinExt.sourceSets.named("commonMain").configure { sourceSet ->
58 | sourceSet.kotlin.srcDir(generateTask.flatMap { it.outputDirectory })
59 | }
60 |
61 | project.tasks.withType(KotlinCompilationTask::class.java).configureEach { compileTask ->
62 | compileTask.dependsOn(generateTask)
63 | }
64 | }
65 |
66 | project.tasks.register("kmperTraceDoctor") { task ->
67 | task.group = "verification"
68 | task.description = "Prints a short diagnostic about the current Gradle project."
69 | task.doLast {
70 | project.logger.lifecycle(
71 | "[kmpertrace] package=${extension.packageName.get()}, class=${extension.className.get()}, message=${extension.message.get()}"
72 | )
73 | }
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosMain/kotlin/dev/goquick/kmpertrace/platform/PlatformLogSink.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.log.LogRecord
5 | import dev.goquick.kmpertrace.log.LogSink
6 | import dev.goquick.kmpertrace.log.LoggerConfig
7 | import kotlinx.cinterop.BetaInteropApi
8 | import kotlinx.cinterop.autoreleasepool
9 | import platform.Foundation.NSLog
10 |
11 | actual object PlatformLogSink : LogSink {
12 | actual override fun emit(record: LogRecord) {
13 | val formatted = formatForIos(record)
14 | emitPossiblyChunked(formatted)
15 | }
16 |
17 | private fun levelIcon(level: Level): String = when (level) {
18 | Level.VERBOSE -> "▫️"
19 | Level.DEBUG -> "🔍"
20 | Level.INFO -> "ℹ️"
21 | Level.WARN -> "⚠️"
22 | Level.ERROR -> "❌"
23 | Level.ASSERT -> "💥"
24 | }
25 |
26 | private fun formatForIos(record: LogRecord): String {
27 | val icon = if (LoggerConfig.renderGlyphs) levelIcon(record.level) + " " else ""
28 | // Keep a short human prefix (logger + message) without duplicating ts/lvl.
29 | val human = buildString {
30 | append(record.tag.ifBlank { "KmperTrace" })
31 | if (record.message.isNotBlank()) {
32 | append(": ").append(record.message)
33 | }
34 | }
35 | return "$icon$human ${record.structuredSuffix}"
36 | }
37 |
38 | internal fun emitPossiblyChunked(line: String) {
39 | // Preserve real newlines so stack traces stay readable in Xcode console.
40 | frameIosChunks(line).forEach { chunk ->
41 | nsLogEscaped(chunk)
42 | }
43 | }
44 |
45 | private fun randomChunkId(): String =
46 | List(8) { CHARS.random() }.joinToString("")
47 |
48 | private const val MAX_LOG_LINE_CHARS = 900
49 | private const val CHUNK_SIZE = 700
50 | private val CHARS = "0123456789abcdef".toCharArray()
51 |
52 | internal fun frameIosChunks(
53 | line: String,
54 | maxLogLineChars: Int = MAX_LOG_LINE_CHARS,
55 | chunkSize: Int = CHUNK_SIZE,
56 | chunkId: String = randomChunkId()
57 | ): List {
58 | if (line.length <= maxLogLineChars) return listOf(line)
59 |
60 | val parts = line.chunked(chunkSize)
61 | return parts.mapIndexed { idx, part ->
62 | val marker = when (idx) {
63 | parts.lastIndex -> "($chunkId:kmpert!)"
64 | else -> "($chunkId:kmpert...)"
65 | }
66 | // Marker must be at the very end of the emitted line; aggregator relies on that.
67 | "$part $marker"
68 | }
69 | }
70 | }
71 |
72 | internal fun escapeForNsLogFormat(line: String): String =
73 | line.replace("%", "%%")
74 |
75 | @OptIn(BetaInteropApi::class)
76 | private fun nsLogEscaped(line: String) {
77 | // Kotlin/Native can leak autoreleased Objective-C objects in non-runloop contexts (like tests);
78 | // be explicit so emitting lots of logs doesn't destabilize the process.
79 | autoreleasepool {
80 | // iOS unified logging treats the first argument as a printf-style format string.
81 | // Escape percent signs so message content doesn't get interpreted as formatting directives.
82 | NSLog(escapeForNsLogFormat(line))
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/androidUnitTest/kotlin/dev/goquick/kmpertrace/platform/AndroidPlatformBackendTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import dev.goquick.kmpertrace.core.LogRecordKind
4 | import dev.goquick.kmpertrace.core.Level
5 | import dev.goquick.kmpertrace.core.StructuredLogRecord
6 | import dev.goquick.kmpertrace.log.KmperTrace
7 | import kotlin.test.AfterTest
8 | import kotlin.test.Test
9 | import kotlin.test.assertContains
10 | import kotlin.test.assertEquals
11 | import kotlin.time.Instant
12 |
13 | class AndroidPlatformBackendTest {
14 |
15 | @AfterTest
16 | fun resetConfig() {
17 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = emptyList())
18 | }
19 |
20 | @Test
21 | fun platform_backend_formats_and_prints() {
22 | val record = sampleEvent()
23 | val expectedStructured = formatLogLine(record, includeHumanPrefix = false)
24 | val expectedPrefix = "AndroidLogger: android hello"
25 | val expected = "$expectedPrefix $expectedStructured"
26 |
27 | // We don't invoke AndroidLog in unit tests (no Android runtime); just assert the composed line.
28 | val human = buildString {
29 | append(record.loggerName)
30 | if (record.message.isNotBlank()) append(": ").append(record.message)
31 | }
32 | val composed = "$human ${formatLogLine(record, includeHumanPrefix = false)}"
33 |
34 | assertEquals(expected, composed)
35 | }
36 |
37 | @Test
38 | fun log_includes_current_thread_name() {
39 | val currentThread = Thread.currentThread().name
40 | val record = StructuredLogRecord(
41 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
42 | level = Level.INFO,
43 | loggerName = "ThreadTest",
44 | message = "message",
45 | threadName = currentThread
46 | )
47 |
48 | val rendered = formatLogLine(record, includeHumanPrefix = false)
49 | assertContains(rendered, """thread="$currentThread"""")
50 | assertContains(rendered, "log=ThreadTest")
51 | // human prefix expectation
52 | val human = "ThreadTest: message"
53 | assertContains("$human $rendered", human)
54 | }
55 |
56 | @Test
57 | fun formatLogLine_includes_error_fields_and_stack() {
58 | val record = sampleEvent().copy(
59 | attributes = mapOf("status" to "ERROR", "err_type" to "IllegalStateException", "err_msg" to "boom"),
60 | throwable = IllegalStateException("boom")
61 | )
62 | val rendered = formatLogLine(record, includeHumanPrefix = false)
63 | assertContains(rendered, "status=\"ERROR\"")
64 | assertContains(rendered, "err_type=\"IllegalStateException\"")
65 | assertContains(rendered, "err_msg=\"boom\"")
66 | assertContains(rendered, "stack_trace=")
67 | }
68 |
69 | private fun sampleEvent(): StructuredLogRecord = StructuredLogRecord(
70 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
71 | level = Level.INFO,
72 | loggerName = "AndroidLogger",
73 | message = "android hello",
74 | traceId = "traceA",
75 | spanId = "spanA",
76 | parentSpanId = "-",
77 | logRecordKind = LogRecordKind.SPAN_END,
78 | spanName = "op",
79 | durationMs = 77,
80 | threadName = "main",
81 | serviceName = "svc",
82 | environment = "dev",
83 | attributes = mapOf("k" to "v")
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosMain/kotlin/dev/goquick/kmpertrace/swift/KmperLogger.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.swift
2 |
3 | import dev.goquick.kmpertrace.log.Log
4 | import dev.goquick.kmpertrace.log.LogContext
5 |
6 | /**
7 | * Swift-friendly, component-bound logger.
8 | *
9 | * This removes boilerplate at Swift call sites (no Kotlin lambdas; no repeated component strings).
10 | */
11 | class KmperLogger internal constructor(
12 | private val component: String,
13 | private val operation: String? = null
14 | ) {
15 | /**
16 | * Returns a new logger with the same component and the provided [operation].
17 | */
18 | fun withOperation(operation: String): KmperLogger = KmperLogger(component = component, operation = operation)
19 |
20 | fun v(message: String) = context().v { message }
21 | fun v(operation: String, message: String) = context(operation).v { message }
22 |
23 | fun d(message: String) = context().d { message }
24 | fun d(operation: String, message: String) = context(operation).d { message }
25 |
26 | fun i(message: String) = context().i { message }
27 | fun i(operation: String, message: String) = context(operation).i { message }
28 |
29 | fun w(message: String) = context().w { message }
30 | fun w(operation: String, message: String) = context(operation).w { message }
31 |
32 | fun e(message: String) = context().e { message }
33 | fun e(operation: String, message: String) = context(operation).e { message }
34 |
35 | private fun context(overrideOperation: String? = null): LogContext {
36 | val base = Log.forComponent(component)
37 | val op = overrideOperation ?: operation
38 | return if (op.isNullOrBlank()) base else base.withOperation(op)
39 | }
40 | }
41 |
42 | /**
43 | * Swift-friendly logger that automatically installs a [KmperTraceSnapshot] (when present) for each log call.
44 | *
45 | * Useful when Swift code runs in callbacks/queues where trace context would otherwise be missing.
46 | */
47 | class KmperSnapshotLogger internal constructor(
48 | private val logger: KmperLogger,
49 | var snapshot: KmperTraceSnapshot? = null
50 | ) {
51 | fun withOperation(operation: String): KmperSnapshotLogger =
52 | KmperSnapshotLogger(logger = logger.withOperation(operation), snapshot = snapshot)
53 |
54 | fun bind(snapshot: KmperTraceSnapshot?): KmperSnapshotLogger {
55 | this.snapshot = snapshot
56 | return this
57 | }
58 |
59 | fun v(message: String) = withSnapshot { logger.v(message) }
60 | fun v(operation: String, message: String) = withSnapshot { logger.v(operation, message) }
61 |
62 | fun d(message: String) = withSnapshot { logger.d(message) }
63 | fun d(operation: String, message: String) = withSnapshot { logger.d(operation, message) }
64 |
65 | fun i(message: String) = withSnapshot { logger.i(message) }
66 | fun i(operation: String, message: String) = withSnapshot { logger.i(operation, message) }
67 |
68 | fun w(message: String) = withSnapshot { logger.w(message) }
69 | fun w(operation: String, message: String) = withSnapshot { logger.w(operation, message) }
70 |
71 | fun e(message: String) = withSnapshot { logger.e(message) }
72 | fun e(operation: String, message: String) = withSnapshot { logger.e(operation, message) }
73 |
74 | private fun withSnapshot(block: () -> Unit) {
75 | val snap = snapshot
76 | if (snap == null) {
77 | block()
78 | } else {
79 | snap.with(block)
80 | }
81 | }
82 | }
83 |
84 |
--------------------------------------------------------------------------------
/kmpertrace-parse/src/commonMain/kotlin/dev/goquick/kmpertrace/parse/ParseApi.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.parse
2 |
3 | /**
4 | * Parse a single log entry that contains a structured KmperTrace suffix (`|{ ... }|`).
5 | *
6 | * Notes:
7 | * - `kind` is optional; when missing, the log record kind defaults to [LogRecordKind.LOG].
8 | * - `trace`/`span` are optional; when missing, they default to `"0"` (untraced).
9 | *
10 | * Returns null when the input does not contain a valid structured suffix or is missing required
11 | * base fields (currently `ts` and `lvl`).
12 | */
13 | fun parseLine(line: String): ParsedLogRecord? {
14 | val trimmed = line.trimEnd()
15 | val start = trimmed.lastIndexOf("|{")
16 | val end = trimmed.lastIndexOf("}|")
17 | if (start == -1 || end == -1 || end <= start + 2) return null
18 | val humanPrefix = trimmed.substring(0, start).trimEnd()
19 |
20 | val structured = trimmed.substring(start + 2, end).trim()
21 | if (structured.isEmpty()) return null
22 |
23 | val fields = normalizeFields(parseLogfmt(structured).toMutableMap())
24 | if (!(fields.containsKey("ts") && fields.containsKey("lvl"))) {
25 | return null
26 | }
27 |
28 | val traceId = fields["trace"] ?: "0"
29 | val kindStr = fields["kind"]
30 | val logRecordKind = if (kindStr == null) {
31 | LogRecordKind.LOG
32 | } else {
33 | runCatching { LogRecordKind.valueOf(kindStr) }.getOrNull() ?: return null
34 | }
35 |
36 | val spanId = fields["span"] ?: "0"
37 | val parentSpanIdRaw = fields["parent"]
38 | val parentSpanId = parentSpanIdRaw?.takeUnless { it == "0" || it == "-" }
39 | val spanName = fields["name"]
40 | val durationMs = fields["dur"]?.toLongOrNull()
41 | val human = parseHumanPrefix(humanPrefix)
42 | val loggerName = fields["log"] ?: fields["src_comp"] ?: fields["src_hint"] ?: human.logger
43 | val timestamp = fields["ts"]
44 | val head = fields["head"]
45 | val message = resolveMessage(loggerName, head, humanPrefix, human)
46 | val sourceComponent = fields["src_comp"]
47 | val sourceOperation = fields["src_op"]
48 | val sourceLocationHint = fields["src_hint"]
49 | val sourceFile = fields["file"]
50 | val sourceLine = fields["line"]?.toIntOrNull()
51 | val sourceFunction = fields["fn"]
52 |
53 | return ParsedLogRecord(
54 | traceId = traceId,
55 | spanId = spanId,
56 | parentSpanId = parentSpanId,
57 | logRecordKind = logRecordKind,
58 | spanName = spanName,
59 | durationMs = durationMs,
60 | loggerName = loggerName,
61 | timestamp = timestamp,
62 | message = message,
63 | sourceComponent = sourceComponent,
64 | sourceOperation = sourceOperation,
65 | sourceLocationHint = sourceLocationHint,
66 | sourceFile = sourceFile,
67 | sourceLine = sourceLine,
68 | sourceFunction = sourceFunction,
69 | rawFields = fields
70 | )
71 | }
72 |
73 | /**
74 | * Parse multiple entries; drops lines that cannot be parsed.
75 | *
76 | * The input is assumed to arrive as lines (e.g. `readLine()` / file iteration), but structured
77 | * entries may span multiple lines due to embedded stack traces. This function frames entries by
78 | * buffering until it sees a closing `}|` that matches an earlier `|{`.
79 | */
80 | fun parseLines(lines: Sequence): List =
81 | frameStructuredSuffixEntries(lines).mapNotNull(::parseLine).toList()
82 |
83 | /**
84 | * Parse multiple lines provided via [Iterable]; drops lines that cannot be parsed.
85 | */
86 | fun parseLines(lines: Iterable): List =
87 | parseLines(lines.asSequence())
88 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/SourcesTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.cli.source.Sources
4 | import dev.goquick.kmpertrace.cli.source.SourceCommands
5 | import com.github.ajalt.clikt.core.UsageError
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 | import kotlin.test.assertTrue
9 |
10 | class SourcesTest {
11 |
12 | @Test
13 | fun resolve_prefers_explicit_source() {
14 | val src =
15 | Sources.resolve(
16 | "file",
17 | filePresent = true,
18 | adbCmd = null,
19 | adbPkg = null,
20 | iosCmd = null,
21 | iosProc = null,
22 | iosTarget = null,
23 | iosUdid = null
24 | )
25 | assertEquals("file", src)
26 | }
27 |
28 | @Test
29 | fun resolve_infers_adb_when_pkg_present() {
30 | val src =
31 | Sources.resolve(
32 | null,
33 | filePresent = false,
34 | adbCmd = null,
35 | adbPkg = "pkg",
36 | iosCmd = null,
37 | iosProc = null,
38 | iosTarget = null,
39 | iosUdid = null
40 | )
41 | assertEquals("adb", src)
42 | }
43 |
44 | @Test
45 | fun build_adb_command_waits_for_pid_and_reattaches() {
46 | val cmd = SourceCommands.buildAdbCommand(adbCmd = null, adbPkg = "com.example.app")
47 | assertTrue(cmd.contains("pidof -s com.example.app"))
48 | assertTrue(cmd.contains("waiting for com.example.app to start"))
49 | assertTrue(cmd.contains("streaming logcat for pid="))
50 | assertTrue(cmd.contains("pid ${'$'}pid exited; restarting when app returns"))
51 | }
52 |
53 | @Test
54 | fun build_adb_command_rejects_unsafe_pkg() {
55 | try {
56 | SourceCommands.buildAdbCommand(adbCmd = null, adbPkg = "com.example.app; rm -rf /")
57 | throw AssertionError("expected unsafe --adb-pkg to be rejected")
58 | } catch (_: UsageError) {
59 | // ok
60 | }
61 | }
62 |
63 | @Test
64 | fun build_ios_command_rejects_unsafe_process_name() {
65 | try {
66 | SourceCommands.buildIosSimCommand(
67 | iosProc = "MyApp\"; rm -rf /",
68 | simUdid = "00000000-0000-0000-0000-000000000000"
69 | )
70 | throw AssertionError("expected unsafe --ios-proc to be rejected")
71 | } catch (_: UsageError) {
72 | // ok
73 | }
74 | }
75 |
76 | @Test
77 | fun build_ios_device_command_includes_proc_filter_and_no_colors() {
78 | val cmd =
79 | SourceCommands.buildIosDeviceCommand(
80 | deviceUdid = "00008120-001819660E93C01E",
81 | iosProc = "SampleApp"
82 | )
83 | assertTrue(cmd.contains("exec idevicesyslog"))
84 | assertTrue(cmd.contains("-u 00008120-001819660E93C01E"))
85 | assertTrue(cmd.contains("-p SampleApp"))
86 | assertTrue(cmd.contains("--no-colors"))
87 | }
88 |
89 | @Test
90 | fun build_ios_device_command_rejects_unsafe_process_name() {
91 | try {
92 | SourceCommands.buildIosDeviceCommand(
93 | deviceUdid = "00008120-001819660E93C01E",
94 | iosProc = "SampleApp\"; rm -rf /"
95 | )
96 | throw AssertionError("expected unsafe --ios-proc to be rejected")
97 | } catch (_: UsageError) {
98 | // ok
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/commonMain/kotlin/dev/goquick/kmpertrace/trace/TraceSnapshot.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.TraceContext
4 | import kotlin.coroutines.ContinuationInterceptor
5 | import kotlin.coroutines.CoroutineContext
6 | import kotlin.coroutines.EmptyCoroutineContext
7 |
8 | /**
9 | * Opaque snapshot of the current trace context and log binding.
10 | *
11 | * This is intended for bridging non-coroutine async boundaries (e.g. callbacks, handlers, executors)
12 | * so logs emitted later can still attach to the originating span.
13 | */
14 | class TraceSnapshot internal constructor(
15 | private val traceContext: TraceContext?,
16 | private val loggingBinding: LoggingBinding
17 | ) {
18 | /**
19 | * Temporarily installs this snapshot into the current execution context, runs [block],
20 | * and then restores whatever was active before.
21 | *
22 | * This does not create a span; it only affects which trace/span IDs and binding mode
23 | * are visible to log emission while [block] executes.
24 | */
25 | fun withTraceSnapshot(block: () -> T): T {
26 | val previousTrace = TraceContextStorage.get()
27 | val previousBinding = LoggingBindingStorage.get()
28 |
29 | TraceContextStorage.set(traceContext)
30 | LoggingBindingStorage.set(loggingBinding)
31 | return try {
32 | block()
33 | } finally {
34 | TraceContextStorage.set(previousTrace)
35 | LoggingBindingStorage.set(previousBinding)
36 | }
37 | }
38 |
39 | /**
40 | * Build a [CoroutineContext] that propagates this snapshot across suspending boundaries.
41 | *
42 | * This is useful when you *can* stay in coroutines (e.g., `withContext(snapshot.asCoroutineContext()) { ... }`)
43 | * but want to make the binding explicit when crossing into a scope that otherwise has no trace context.
44 | *
45 | * [downstream] should be the existing interceptor/dispatcher chain when you need to preserve it.
46 | */
47 | fun asCoroutineContext(downstream: ContinuationInterceptor? = null): CoroutineContext {
48 | val tracePropagation =
49 | traceContext?.let { TraceContextStorage.element(it, downstream) } ?: EmptyCoroutineContext
50 |
51 | return (traceContext ?: EmptyCoroutineContext) +
52 | tracePropagation +
53 | LoggingBindingStorage.element(loggingBinding)
54 | }
55 | }
56 |
57 | /**
58 | * Capture the current trace/span + log binding state into an immutable [TraceSnapshot].
59 | */
60 | fun captureTraceSnapshot(): TraceSnapshot =
61 | TraceSnapshot(
62 | traceContext = TraceContextStorage.get(),
63 | loggingBinding = LoggingBindingStorage.get()
64 | )
65 |
66 | /**
67 | * Wrap a callback so that when it runs, logs remain attached to [snapshot]'s trace/span (if any).
68 | */
69 | fun (() -> Unit).withTrace(snapshot: TraceSnapshot = captureTraceSnapshot()): () -> Unit {
70 | val original = this
71 | return { snapshot.withTraceSnapshot { original() } }
72 | }
73 |
74 | /**
75 | * Wrap a callback so that when it runs, logs remain attached to [snapshot]'s trace/span (if any).
76 | */
77 | fun ((A) -> Unit).withTrace(snapshot: TraceSnapshot = captureTraceSnapshot()): (A) -> Unit {
78 | val original = this
79 | return { a -> snapshot.withTraceSnapshot { original(a) } }
80 | }
81 |
82 | /**
83 | * Wrap a callback so that when it runs, logs remain attached to [snapshot]'s trace/span (if any).
84 | */
85 | fun ((A, B) -> Unit).withTrace(snapshot: TraceSnapshot = captureTraceSnapshot()): (A, B) -> Unit {
86 | val original = this
87 | return { a, b -> snapshot.withTraceSnapshot { original(a, b) } }
88 | }
89 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/main/kotlin/dev/goquick/kmpertrace/cli/TuiStateMachine.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | internal data class UiState(
4 | val helpVisible: Boolean = false,
5 | val search: String? = null,
6 | val minLevel: String? = null,
7 | val rawEnabled: Boolean = false,
8 | val rawLevel: RawLogLevel = RawLogLevel.OFF,
9 | val spanAttrsMode: SpanAttrsMode = SpanAttrsMode.OFF
10 | )
11 |
12 | internal sealed interface UiEvent {
13 | data object Clear : UiEvent
14 | data object DismissHelp : UiEvent
15 | data object ToggleHelp : UiEvent
16 | data object ToggleAttrs : UiEvent
17 | data object CycleRaw : UiEvent
18 | data object CycleStructured : UiEvent
19 | data object PromptSearch : UiEvent
20 | data class SetSearch(val term: String?) : UiEvent
21 | data object Quit : UiEvent
22 | }
23 |
24 | internal sealed interface UiEffect {
25 | data object ClearBuffers : UiEffect
26 | data class UpdateMinLevelFilter(val minLevel: String?) : UiEffect
27 | data object PromptSearch : UiEffect
28 | data object Quit : UiEffect
29 | }
30 |
31 | internal data class ReduceResult(
32 | val state: UiState,
33 | val effects: List = emptyList(),
34 | val requestRender: Boolean = false
35 | )
36 |
37 | internal fun reduceUi(state: UiState, event: UiEvent): ReduceResult {
38 | var nextState = state
39 | val effects = mutableListOf()
40 | var requestRender = false
41 |
42 | // Safety: any interaction closes help (even if a caller forgot to emit DismissHelp explicitly).
43 | if (state.helpVisible && event != UiEvent.ToggleHelp && event != UiEvent.DismissHelp) {
44 | nextState = nextState.copy(helpVisible = false)
45 | requestRender = true
46 | }
47 |
48 | when (event) {
49 | UiEvent.Clear -> {
50 | effects += UiEffect.ClearBuffers
51 | requestRender = true
52 | }
53 |
54 | UiEvent.DismissHelp -> {
55 | if (nextState.helpVisible) {
56 | nextState = nextState.copy(helpVisible = false)
57 | requestRender = true
58 | }
59 | }
60 |
61 | UiEvent.ToggleHelp -> {
62 | nextState = nextState.copy(helpVisible = !nextState.helpVisible)
63 | requestRender = true
64 | }
65 |
66 | UiEvent.ToggleAttrs -> {
67 | nextState = nextState.copy(spanAttrsMode = nextSpanAttrsMode(nextState.spanAttrsMode))
68 | requestRender = true
69 | }
70 |
71 | UiEvent.CycleRaw -> {
72 | val next = nextRawLevel(nextState.rawEnabled, nextState.rawLevel)
73 | nextState = nextState.copy(rawEnabled = next != RawLogLevel.OFF, rawLevel = next)
74 | requestRender = true
75 | }
76 |
77 | UiEvent.CycleStructured -> {
78 | val next = nextStructuredLevel(nextState.minLevel)
79 | nextState = nextState.copy(minLevel = next)
80 | effects += UiEffect.UpdateMinLevelFilter(next)
81 | requestRender = true
82 | }
83 |
84 | UiEvent.PromptSearch -> {
85 | effects += UiEffect.PromptSearch
86 | }
87 |
88 | is UiEvent.SetSearch -> {
89 | nextState = nextState.copy(search = event.term)
90 | requestRender = true
91 | }
92 |
93 | UiEvent.Quit -> {
94 | effects += UiEffect.Quit
95 | }
96 | }
97 |
98 | return ReduceResult(nextState, effects, requestRender)
99 | }
100 |
101 | internal fun nextStructuredLevel(current: String?): String? = when (current) {
102 | null, "all" -> "debug"
103 | "debug" -> "info"
104 | "info" -> "error"
105 | "error" -> "all"
106 | else -> "debug"
107 | }
108 |
109 |
--------------------------------------------------------------------------------
/sample-app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat
2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
3 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
4 |
5 | plugins {
6 | alias(libs.plugins.kotlinMultiplatform)
7 | alias(libs.plugins.androidApplication)
8 | alias(libs.plugins.composeMultiplatform)
9 | alias(libs.plugins.composeCompiler)
10 | id("dev.goquick.kmpertrace.gradle")
11 | }
12 |
13 | kmperTrace {
14 | packageName.set("dev.goquick.kmpertrace.sampleapp.generated")
15 | className.set("HelloFromPlugin")
16 | message.set("Hello from the custom KMP plugin!")
17 | }
18 |
19 | @OptIn(ExperimentalWasmDsl::class)
20 | kotlin {
21 | compilerOptions {
22 | freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
23 | }
24 |
25 | androidTarget {
26 | compilerOptions {
27 | jvmTarget.set(JvmTarget.JVM_17)
28 | }
29 | }
30 |
31 | listOf(
32 | iosArm64(),
33 | iosSimulatorArm64()
34 | ).forEach { iosTarget ->
35 | iosTarget.binaries.framework {
36 | baseName = "ComposeApp"
37 | isStatic = true
38 | // Export the runtime so Swift sees its symbols and bundled dependencies.
39 | export(project(":kmpertrace-runtime"))
40 | export(libs.kotlinxDatetime)
41 | export(libs.kotlinxCoroutinesCore)
42 | }
43 | }
44 |
45 | wasmJs {
46 | browser()
47 | binaries.executable()
48 | }
49 |
50 | jvm()
51 |
52 | sourceSets {
53 | androidMain.dependencies {
54 | implementation(compose.preview)
55 | implementation(libs.androidxActivityCompose)
56 | }
57 | commonMain.dependencies {
58 | implementation(compose.runtime)
59 | implementation(compose.foundation)
60 | implementation(compose.material3)
61 | implementation(compose.ui)
62 | api(project(":kmpertrace-runtime"))
63 | api(libs.kotlinxCoroutinesCore)
64 | api(libs.kotlinxDatetime)
65 | }
66 | commonTest.dependencies {
67 | implementation(kotlin("test"))
68 | }
69 | jvmMain.dependencies {
70 | implementation(compose.desktop.currentOs)
71 | implementation(libs.kotlinxCoroutinesSwing)
72 | }
73 | wasmJsMain.dependencies {
74 | implementation(npm("@js-joda/core", "5.5.2"))
75 | }
76 | }
77 | }
78 |
79 | android {
80 | namespace = "dev.goquick.kmpertrace.sampleapp"
81 | compileSdk = libs.versions.androidCompileSdk.get().toInt()
82 |
83 | defaultConfig {
84 | applicationId = "dev.goquick.kmpertrace.sampleapp"
85 | minSdk = libs.versions.androidMinSdk.get().toInt()
86 | targetSdk = libs.versions.androidTargetSdk.get().toInt()
87 | versionCode = 1
88 | versionName = "1.0"
89 | }
90 | packaging {
91 | resources {
92 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
93 | }
94 | }
95 | buildTypes {
96 | getByName("release") {
97 | isMinifyEnabled = false
98 | }
99 | }
100 | compileOptions {
101 | sourceCompatibility = JavaVersion.VERSION_17
102 | targetCompatibility = JavaVersion.VERSION_17
103 | }
104 | }
105 |
106 | dependencies {
107 | debugImplementation(compose.uiTooling)
108 | }
109 |
110 | compose.desktop {
111 | application {
112 | mainClass = "dev.goquick.kmpertrace.sampleapp.MainKt"
113 |
114 | nativeDistributions {
115 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
116 | packageName = "dev.goquick.kmpertrace.sampleapp"
117 | packageVersion = "1.0.0"
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/jvmTest/kotlin/dev/goquick/kmpertrace/trace/TraceSnapshotJvmTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.trace
2 |
3 | import dev.goquick.kmpertrace.core.Level
4 | import dev.goquick.kmpertrace.log.Log
5 | import dev.goquick.kmpertrace.log.LogRecord
6 | import dev.goquick.kmpertrace.log.LogSink
7 | import dev.goquick.kmpertrace.log.KmperTrace
8 | import dev.goquick.kmpertrace.testutil.parseStructuredSuffix
9 | import java.util.concurrent.CountDownLatch
10 | import java.util.concurrent.Executors
11 | import java.util.concurrent.TimeUnit
12 | import kotlin.test.AfterTest
13 | import kotlin.test.Test
14 | import kotlin.test.assertEquals
15 | import kotlin.test.assertNotNull
16 | import kotlin.test.assertNull
17 | import kotlinx.coroutines.runBlocking
18 | import kotlinx.coroutines.withTimeout
19 |
20 | private class CollectingSink : LogSink {
21 | val records = mutableListOf()
22 | override fun emit(record: LogRecord) {
23 | records += record
24 | }
25 | }
26 |
27 | class TraceSnapshotJvmTest {
28 | private val sink = CollectingSink()
29 |
30 | @AfterTest
31 | fun tearDown() {
32 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = emptyList())
33 | sink.records.clear()
34 | }
35 |
36 | @Test
37 | fun executor_callbacks_can_bind_to_origin_span_and_restore_thread_state() = runBlocking {
38 | KmperTrace.configure(minLevel = Level.DEBUG, sinks = listOf(sink))
39 |
40 | val executor = Executors.newSingleThreadExecutor()
41 | try {
42 | traceSpan(component = "Snapshot", operation = "Root") {
43 | val snapshot = captureTraceSnapshot()
44 |
45 | val latch = CountDownLatch(3)
46 | executor.executeWithTrace(snapshot) {
47 | Log.d { "bound-executor" }
48 | latch.countDown()
49 | }
50 |
51 | executor.execute(
52 | Runnable {
53 | Log.d { "bound-runnable" }
54 | latch.countDown()
55 | }.withTrace(snapshot)
56 | )
57 |
58 | executor.execute(
59 | Runnable {
60 | Log.d { "unbound" }
61 | latch.countDown()
62 | }
63 | )
64 |
65 | withTimeout(5_000) {
66 | while (!latch.await(10, TimeUnit.MILLISECONDS)) Unit
67 | }
68 | }
69 | } finally {
70 | executor.shutdownNow()
71 | }
72 |
73 | val spanStartFields =
74 | sink.records
75 | .map { parseStructuredSuffix(it.structuredSuffix) }
76 | .first { it["kind"] == "SPAN_START" && it["name"] == "Snapshot.Root" }
77 |
78 | val traceId = spanStartFields["trace"]
79 | val spanId = spanStartFields["span"]
80 | assertNotNull(traceId, "SPAN_START missing trace id")
81 | assertNotNull(spanId, "SPAN_START missing span id")
82 |
83 | val boundExecutorFields = parseStructuredSuffix(sink.records.first { it.message == "bound-executor" }.structuredSuffix)
84 | assertEquals(traceId, boundExecutorFields["trace"])
85 | assertEquals(spanId, boundExecutorFields["span"])
86 |
87 | val boundRunnableFields = parseStructuredSuffix(sink.records.first { it.message == "bound-runnable" }.structuredSuffix)
88 | assertEquals(traceId, boundRunnableFields["trace"])
89 | assertEquals(spanId, boundRunnableFields["span"])
90 |
91 | val unboundFields = parseStructuredSuffix(sink.records.first { it.message == "unbound" }.structuredSuffix)
92 | assertNull(unboundFields["trace"], "unbound log should not carry trace id")
93 | assertNull(unboundFields["span"], "unbound log should not carry span id")
94 | assertNull(unboundFields["parent"], "unbound log should not carry parent span id")
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/kmpertrace-analysis/src/test/kotlin/dev/goquick/kmpertrace/analysis/AndroidMultilineGrouperTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.analysis
2 |
3 | import dev.goquick.kmpertrace.parse.parseLine
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 | import kotlin.test.assertNotNull
7 | import kotlin.test.assertTrue
8 |
9 | class AndroidMultilineGrouperTest {
10 | @Test
11 | fun `collapses multi-line adb entry into single structured log record`() {
12 | val lines = sequenceOf(
13 | " 1765253114.749 17097 17144 D AppRootView: AppRootView: SafeSQLiteConnection.execSQL: CREATE TABLE app_settings_record",
14 | " 1765253114.749 17097 17144 D AppRootView: AppRootView: (",
15 | " 1765253114.749 17097 17144 D AppRootView: AppRootView: id TEXT NOT NULL PRIMARY KEY,",
16 | " 1765253114.749 17097 17144 D AppRootView: AppRootView: ); |{ ts=2025-12-09T22:19:52.747879Z lvl=debug trace=495667e4535b7beb span=72d7309dc852583a parent=8567601fae276cf1 head=\"SafeSQLiteConne\" src=AppRootView/openDatabase log=AppRootView svc=sample-app thread=\"DefaultDispatcher-worker-3\" }|"
17 | )
18 |
19 | val grouper = AndroidMultilineGrouper()
20 | val out = buildList {
21 | lines.forEach { addAll(grouper.feed(it)) }
22 | addAll(grouper.flush())
23 | }
24 |
25 | assertEquals(1, out.size, "expected fragments to collapse into one line")
26 |
27 | val parsed = parseLine(out.first())
28 | assertNotNull(parsed, "collapsed structured line should parse")
29 | val msg = parsed.message ?: ""
30 | assertTrue(msg.contains("CREATE TABLE app_settings_record"), "message should include full SQL body")
31 | assertTrue(msg.lines().size >= 3, "message should preserve embedded newlines, got: $msg")
32 | }
33 |
34 | @Test
35 | fun `flushes buffered entry when header changes`() {
36 | val lines = sequenceOf(
37 | " 1765253114.749 17097 17144 D TagOne: TagOne: first part",
38 | " 1765253114.749 17097 17144 D TagOne: TagOne: second part",
39 | " 1765253114.750 17097 17144 D TagTwo: other message"
40 | )
41 | val grouper = AndroidMultilineGrouper()
42 | val out = buildList {
43 | lines.forEach { addAll(grouper.feed(it)) }
44 | addAll(grouper.flush())
45 | }
46 | assertEquals(2, out.size)
47 | assertTrue(out[0].contains("TagOne: first part\nTagOne: second part"))
48 | assertTrue(out[1].contains("TagTwo"))
49 | }
50 |
51 | @Test
52 | fun `does not merge distinct same-ms entries without continuation markers`() {
53 | val lines = sequenceOf(
54 | " 1765666872.209 3886 3886 W ziparchive: Unable to open '/a': No such file",
55 | " 1765666872.209 3886 3886 W ziparchive: Unable to open '/b': No such file"
56 | )
57 | val grouper = AndroidMultilineGrouper()
58 | val out = buildList {
59 | lines.forEach { addAll(grouper.feed(it)) }
60 | addAll(grouper.flush())
61 | }
62 | assertEquals(2, out.size)
63 | assertTrue(out[0].contains("Unable to open '/a'"))
64 | assertTrue(out[1].contains("Unable to open '/b'"))
65 | }
66 |
67 | @Test
68 | fun `structured lines are not coalesced`() {
69 | val lines = sequenceOf(
70 | " 1765253114.749 17097 17144 D AppRootView: message one |{ ts=2025-12-09T22:19:52.747Z lvl=debug head=\"one\" log=AppRootView }|",
71 | " 1765253114.749 17097 17144 D AppRootView: message two |{ ts=2025-12-09T22:19:52.748Z lvl=debug head=\"two\" log=AppRootView }|"
72 | )
73 | val grouper = AndroidMultilineGrouper()
74 | val out = buildList {
75 | lines.forEach { addAll(grouper.feed(it)) }
76 | addAll(grouper.flush())
77 | }
78 | assertEquals(2, out.size, "structured entries must remain separate")
79 | assertEquals("two", parseLine(out[1])?.rawFields?.get("head"))
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/kmpertrace-runtime/src/iosTest/kotlin/dev/goquick/kmpertrace/platform/IosPlatformBackendTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.platform
2 |
3 | import dev.goquick.kmpertrace.core.LogRecordKind
4 | import dev.goquick.kmpertrace.core.Level
5 | import dev.goquick.kmpertrace.core.StructuredLogRecord
6 | import dev.goquick.kmpertrace.log.currentThreadNameOrNull
7 | import kotlin.time.Instant
8 | import kotlin.test.Test
9 | import kotlin.test.assertNull
10 | import kotlin.test.assertTrue
11 |
12 | class IosPlatformBackendTest {
13 |
14 | @Test
15 | fun current_thread_name_is_null_on_ios() {
16 | assertNull(currentThreadNameOrNull())
17 | }
18 |
19 | @Test
20 | fun formatLogLine_defaults_unknown_thread_when_none() {
21 | val record = StructuredLogRecord(
22 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
23 | level = Level.INFO,
24 | loggerName = "IosLogger",
25 | message = "msg",
26 | traceId = null,
27 | spanId = null,
28 | parentSpanId = null,
29 | logRecordKind = LogRecordKind.LOG,
30 | spanName = null,
31 | durationMs = null,
32 | threadName = null,
33 | serviceName = null,
34 | environment = null
35 | )
36 |
37 | val rendered = formatLogLine(record)
38 | assertTrue(rendered.contains("|{ ts=2025-01-02T03:04:05Z"))
39 | assertTrue(rendered.contains("""head="msg""""))
40 | assertTrue(rendered.contains("log=IosLogger"))
41 | }
42 |
43 | @Test
44 | fun platform_backend_prints_human_prefix() {
45 | val record = StructuredLogRecord(
46 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
47 | level = Level.INFO,
48 | loggerName = "IosLogger",
49 | message = "hello",
50 | logRecordKind = LogRecordKind.LOG
51 | )
52 |
53 | // On iOS tests, just ensure formatting returns the structured suffix.
54 | val rendered = formatLogLine(record)
55 | assertTrue(rendered.contains("|{ ts="))
56 | }
57 |
58 | @Test
59 | fun formatLogLine_includes_error_fields_and_stack() {
60 | val record = StructuredLogRecord(
61 | timestamp = Instant.parse("2025-01-02T03:04:05Z"),
62 | level = Level.ERROR,
63 | loggerName = "IosLogger",
64 | message = "failed",
65 | traceId = "trace-ios",
66 | spanId = "span-ios",
67 | logRecordKind = LogRecordKind.SPAN_END,
68 | spanName = "op",
69 | durationMs = 1,
70 | attributes = mapOf(
71 | "status" to "ERROR",
72 | "err_type" to "IllegalStateException",
73 | "err_msg" to "boom"
74 | ),
75 | throwable = IllegalStateException("boom")
76 | )
77 |
78 | val rendered = formatLogLine(record)
79 | assertTrue(rendered.contains("""status="ERROR""""))
80 | assertTrue(rendered.contains("""err_type="IllegalStateException""""))
81 | assertTrue(rendered.contains("""err_msg="boom""""))
82 | assertTrue(rendered.contains("""stack_trace="""))
83 | }
84 |
85 | @Test
86 | fun platform_backend_escapes_percent_signs_for_nslog_format_string() {
87 | val raw = "progress 50% and 100% (boom for 100%)"
88 | val escaped = escapeForNsLogFormat(raw)
89 | assertTrue(
90 | !containsBarePercent(escaped),
91 | "expected no bare % in escaped string, got: $escaped"
92 | )
93 | }
94 |
95 | private fun containsBarePercent(text: String): Boolean {
96 | var idx = 0
97 | while (idx < text.length) {
98 | if (text[idx] == '%') {
99 | val next = text.getOrNull(idx + 1)
100 | if (next != '%') return true
101 | idx += 2
102 | continue
103 | }
104 | idx++
105 | }
106 | return false
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/IosTargetResolverTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.cli.source.IosTargetResolver
4 | import com.github.ajalt.clikt.core.UsageError
5 | import kotlin.test.Test
6 | import kotlin.test.assertEquals
7 |
8 | class IosTargetResolverTest {
9 |
10 | @Test
11 | fun auto_errors_when_both_sim_and_device_present() {
12 | try {
13 | IosTargetResolver.resolve(
14 | iosTarget = "auto",
15 | iosUdid = null,
16 | iosProc = "SampleApp",
17 | bootedSims = listOf(IosTargetResolver.BootedSimulator("iPhone", "00000000-0000-0000-0000-000000000000")),
18 | deviceUdids = listOf("DEVICEUDID")
19 | )
20 | throw AssertionError("expected auto target to require explicit --ios-target when both sources exist")
21 | } catch (_: UsageError) {
22 | // ok (error(...) throws IllegalStateException)
23 | }
24 | }
25 |
26 | @Test
27 | fun sim_requires_ios_proc() {
28 | try {
29 | IosTargetResolver.resolve(
30 | iosTarget = "sim",
31 | iosUdid = null,
32 | iosProc = null,
33 | bootedSims = listOf(IosTargetResolver.BootedSimulator("iPhone", "00000000-0000-0000-0000-000000000000")),
34 | deviceUdids = emptyList()
35 | )
36 | throw AssertionError("expected --ios-proc to be required for simulator")
37 | } catch (_: UsageError) {
38 | // ok
39 | }
40 | }
41 |
42 | @Test
43 | fun sim_picks_only_booted_simulator_when_single() {
44 | val res =
45 | IosTargetResolver.resolve(
46 | iosTarget = "auto",
47 | iosUdid = null,
48 | iosProc = "SampleApp",
49 | bootedSims = listOf(IosTargetResolver.BootedSimulator("iPhone", "00000000-0000-0000-0000-000000000000")),
50 | deviceUdids = emptyList()
51 | )
52 | assertEquals(IosTargetResolver.Selection.Simulator("00000000-0000-0000-0000-000000000000"), res)
53 | }
54 |
55 | @Test
56 | fun device_picks_only_connected_device_when_single() {
57 | val res =
58 | IosTargetResolver.resolve(
59 | iosTarget = "auto",
60 | iosUdid = null,
61 | iosProc = "SampleApp",
62 | bootedSims = emptyList(),
63 | deviceUdids = listOf("DEVICEUDID")
64 | )
65 | assertEquals(IosTargetResolver.Selection.Device("DEVICEUDID"), res)
66 | }
67 |
68 | @Test
69 | fun device_requires_ios_proc() {
70 | try {
71 | IosTargetResolver.resolve(
72 | iosTarget = "device",
73 | iosUdid = null,
74 | iosProc = null,
75 | bootedSims = emptyList(),
76 | deviceUdids = listOf("DEVICEUDID")
77 | )
78 | throw AssertionError("expected --ios-proc to be required for device")
79 | } catch (_: UsageError) {
80 | // ok
81 | }
82 | }
83 |
84 | @Test
85 | fun device_errors_when_no_devices_connected() {
86 | try {
87 | IosTargetResolver.resolve(
88 | iosTarget = "device",
89 | iosUdid = null,
90 | iosProc = "SampleApp",
91 | bootedSims = emptyList(),
92 | deviceUdids = emptyList()
93 | )
94 | throw AssertionError("expected a clear 'no devices' error")
95 | } catch (_: UsageError) {
96 | // ok
97 | }
98 | }
99 |
100 | @Test
101 | fun sim_errors_when_no_booted_simulators() {
102 | try {
103 | IosTargetResolver.resolve(
104 | iosTarget = "sim",
105 | iosUdid = null,
106 | iosProc = "SampleApp",
107 | bootedSims = emptyList(),
108 | deviceUdids = emptyList()
109 | )
110 | throw AssertionError("expected a clear 'no simulators' error")
111 | } catch (_: UsageError) {
112 | // ok
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/kmpertrace-cli/src/test/kotlin/dev/goquick/kmpertrace/cli/IdeviceSyslogLineProcessorTest.kt:
--------------------------------------------------------------------------------
1 | package dev.goquick.kmpertrace.cli
2 |
3 | import dev.goquick.kmpertrace.cli.source.IdeviceSyslogLineProcessor
4 | import dev.goquick.kmpertrace.cli.source.IdeviceSyslogPrefixStripper
5 | import dev.goquick.kmpertrace.parse.parseLines
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 | import kotlin.test.assertNotNull
9 | import kotlin.test.assertTrue
10 |
11 | class IdeviceSyslogLineProcessorTest {
12 |
13 | @Test
14 | fun normalizes_idevicesyslog_prefix_to_syslog_style() {
15 | val line =
16 | """Dec 15 01:39:46.970278 SampleApp(SampleApp.debug.dylib)[10612] : 🔍 Hello |{ ts=2025-12-15T06:39:46.970278Z lvl=debug }|"""
17 | val normalized = IdeviceSyslogPrefixStripper.normalizeToSyslogStyle(line)
18 | assertTrue(normalized.contains("01:39:46.970278"))
19 | assertTrue(normalized.contains("SampleApp[10612]:"))
20 | assertTrue(normalized.contains("[SampleApp.debug.dylib]"))
21 | assertTrue(normalized.contains(""))
22 | assertTrue(normalized.endsWith("""🔍 Hello |{ ts=2025-12-15T06:39:46.970278Z lvl=debug }|"""))
23 | }
24 |
25 | @Test
26 | fun keeps_multiline_structured_records_in_device_mode_when_raw_logs_disabled() {
27 | val processor = IdeviceSyslogLineProcessor(iosProc = "SampleApp")
28 |
29 | val lines = listOf(
30 | // Start of structured suffix, stack trace opens but doesn't close.
31 | """Dec 15 01:39:46.970278 SampleApp(SampleApp.debug.dylib)[10612] : ❌ Downloader: --- Downloader.DownloadA |{ ts=2025-12-15T06:39:46.919486Z lvl=error trace=0f89d69311de9211 span=783c26a493db2329 parent=fa23c337562453a8 kind=SPAN_END name="Downloader.DownloadA" stack_trace="first""",
32 | // Continuation line without `|{` (must be retained).
33 | """Dec 15 01:39:46.970407 SampleApp(SampleApp.debug.dylib)[10612] : second""",
34 | // Close the quoted stack trace and structured suffix.
35 | """Dec 15 01:39:46.970468 SampleApp(SampleApp.debug.dylib)[10612] : third"}|"""
36 | )
37 |
38 | val processed = lines.mapNotNull(processor::process)
39 | assertEquals(3, processed.size)
40 | assertTrue(processed[0].contains("❌ Downloader: --- Downloader.DownloadA |{"))
41 | assertTrue(processed[1].contains("second"))
42 | assertTrue(processed[2].contains("""third"}|"""))
43 |
44 | val parsed = parseLines(processed)
45 | assertEquals(1, parsed.size)
46 | val record = parsed.single()
47 |
48 | val stack = record.rawFields["stack_trace"]
49 | assertNotNull(stack)
50 | assertTrue(stack.contains("first\nsecond\nthird"))
51 | }
52 |
53 | @Test
54 | fun keeps_prefixless_continuation_lines_inside_open_structured_frame() {
55 | val processor = IdeviceSyslogLineProcessor(iosProc = "SampleApp")
56 |
57 | val processed =
58 | listOf(
59 | """Dec 15 01:39:46.970278 SampleApp(SampleApp.debug.dylib)[10612] : ❌ Downloader: --- Downloader.DownloadA |{ ts=2025-12-15T06:39:46.919486Z lvl=error trace=0f89d69311de9211 span=783c26a493db2329 parent=fa23c337562453a8 kind=SPAN_END name="Downloader.DownloadA" stack_trace="first""",
60 | // Some transports emit stack continuations without the process header; we must keep them.
61 | """second""",
62 | """third"}|"""
63 | ).mapNotNull(processor::process)
64 |
65 | val parsed = parseLines(processed)
66 | assertEquals(1, parsed.size)
67 | val stack = parsed.single().rawFields["stack_trace"]
68 | assertNotNull(stack)
69 | assertTrue(stack.contains("first\nsecond\nthird"))
70 | }
71 |
72 | @Test
73 | fun keeps_timestamps_for_non_structured_device_lines() {
74 | val processor = IdeviceSyslogLineProcessor(iosProc = "SampleApp")
75 | val out =
76 | processor.process(
77 | "Dec 15 11:48:28.972604 SampleApp(UIKitCore)[11710] : Sending UIEvent type: 0; subtype: 0; to windows: 1"
78 | )
79 | assertNotNull(out)
80 | assertTrue(out.contains("11:48:28.972604"))
81 | assertTrue(out.contains("SampleApp[11710]:"))
82 | assertTrue(out.contains("[UIKitCore]"))
83 | assertTrue(out.contains(""))
84 | assertTrue(out.endsWith("Sending UIEvent type: 0; subtype: 0; to windows: 1"))
85 | }
86 | }
87 |
--------------------------------------------------------------------------------