├── 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 | --------------------------------------------------------------------------------