From 4f3b2f721d63b66f8896e6a0afdd725121fd3812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Tue, 5 May 2026 09:57:39 -0300 Subject: [PATCH 1/7] Update `Scroll.seal` to snapshot data and support multiple emissions - Modify `Scroll.seal` to create a `SealedScroll` using a snapshot of the current data via `this.toMap()`. - Update documentation in `README.md` and `docs/` to clarify that `seal(...)` can be called multiple times on the same `Scroll`, with each call emitting a separate snapshot. - Update `ScribeDataSerializationTest` to verify that mutating a `Scroll` after calling `seal` does not affect the data in the previously emitted snapshot. - Rename and update lifecycle and concurrency tests to reflect that `seal` is no longer idempotent and intentionally emits an event for every call. --- README.md | 2 ++ docs/api-concepts.md | 6 ++++-- docs/getting-started.md | 1 + docs/lifecycle-and-delivery.md | 4 +++- scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt | 4 +++- .../com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt | 3 +-- .../com/rafambn/scribe/ScribeDataSerializationTest.kt | 4 ++-- .../kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt | 2 +- 8 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a4cab61..f489155 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ scroll.writeBoolean("retry", false) scroll.seal(success = true) ``` +Each `seal(...)` call emits a separate `SealedScroll` snapshot, so sealing the same scroll more than once is allowed when you need multiple terminal records. + Choose the saver that matches your output flow: ```kotlin diff --git a/docs/api-concepts.md b/docs/api-concepts.md index d2c970e..5c519a1 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -5,7 +5,7 @@ Scribe models logging with two event shapes: - `Note`: a single standalone event -- `SealedScroll`: the finalized result of a multi-step `Scroll` +- `SealedScroll`: a sealed snapshot result of a multi-step `Scroll` Both implement the sealed `Entry` interface, which is what `EntrySaver` receives. @@ -13,7 +13,7 @@ Both implement the sealed `Entry` interface, which is what `EntrySaver` receives - `note(...)`: suspending call for a single log entry - `newScroll(...)`: starts a contextual logging session -- `seal(...)`: finalizes a scroll and emits a `SealedScroll` +- `seal(...)`: snapshots the current scroll data and emits a `SealedScroll` - `Margin`: hook for writing fields at open/close boundaries - `hire(channel = ..., onSaver = ...)`: starts delivery over your channel configuration @@ -56,6 +56,8 @@ val removed = scroll.remove("retryable") `scroll.id` reads the generated/custom `scroll_id` field. +Calling `seal(...)` more than once is allowed. Each call emits a separate `SealedScroll` with the current `success` value and a snapshot of the data at that point. + ## `Margin` `Margin` enriches a scroll at beginning and end. diff --git a/docs/getting-started.md b/docs/getting-started.md index a21c9c9..f1711db 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,6 +57,7 @@ With the saver above, the log output looks like this: ## Track a Flow with `Scroll` `Scroll` is a mutable map (`MutableMap`) that you seal into one wide event. +Each `seal(...)` call emits a new `SealedScroll` using a snapshot of the scroll data at that moment. ```kotlin import kotlinx.serialization.json.Json diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 67c9113..5dc9d5c 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -27,10 +27,12 @@ Scribe.hire( Current emission calls are suspending: - `note(...)` sends a `Note` -- `seal(...)` sends a `SealedScroll` +- `seal(...)` snapshots the current `Scroll` data and sends a `SealedScroll` There are no separate best-effort APIs in this runtime shape. +Multiple calls to `seal(...)` on the same `Scroll` are intentional. Each call emits a separate `SealedScroll`, so a flow can record more than one terminal snapshot when that is useful. + ## Shared Context with `imprint` `imprint` adds fields to every new `Scroll` created by the same `Scribe`. diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt index 8907f6c..08fdd76 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt @@ -17,10 +17,12 @@ val Scroll.id: String /** * Seals this scroll and suspends until its [SealedScroll] is enqueued. + * + * Every call emits a new [SealedScroll] with a snapshot of the current data. */ suspend fun Scroll.seal(success: Boolean = true): SealedScroll { Scribe.config?.margins?.footer(this) - val result = SealedScroll(success = success, data = this) + val result = SealedScroll(success = success, data = this.toMap()) Scribe.enqueue(result) return result } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt index b2c2a6c..7ac8493 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt @@ -36,7 +36,7 @@ class ScribeConcurrencyAndScrollTest { } @Test - fun scroll_double_seal_is_idempotent_and_emits_only_once() { + fun scroll_double_seal_emits_one_event_per_seal_call() { runSuspend { val shelf = RecordingShelf() val scribe = scribeWithScrollShelves(shelf) @@ -51,7 +51,6 @@ class ScribeConcurrencyAndScrollTest { assertEquals(false, first.success) assertEquals(JsonPrimitive("initial"), first.data["state"]) - // Current behavior emits one SealedScroll per seal call. assertEquals(true, second.success) assertEquals(JsonPrimitive("initial"), second.data["state"]) assertEquals(2, shelf.events.size) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt index a03b9e6..ca87e1b 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt @@ -86,7 +86,7 @@ class ScribeDataSerializationTest { } @Test - fun erase_after_seal_mutates_same_backing_map() { + fun erase_after_seal_does_not_mutate_sealed_event_snapshot() { runSuspend { val shelf = RecordingShelf() val scribe = scribeWithScrollShelves(shelf) @@ -99,7 +99,7 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertNull(event.data["key"]) + assertEquals(JsonPrimitive("value"), event.data["key"]) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt index 16b832d..49bc9c6 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt @@ -28,7 +28,7 @@ class ScribeScrollLifecycleTest { } @Test - fun seal_is_idempotent_and_writes_once() { + fun each_seal_call_emits_a_new_event() { runSuspend { val shelf = RecordingShelf() val scribe = scribeWithScrollShelves(shelf) From bdb5c87b31bb2ad2610aedb70b4cfd3387cdfead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Tue, 5 May 2026 10:37:54 -0300 Subject: [PATCH 2/7] Improve delivery error handling and ignore `CancellationException` in `onSaver` - Update `Scribe.kt` and `ScribeTestFixtures.kt` to explicitly rethrow `CancellationException`, ensuring it is not handled as a standard saver error or reported to the `onSaver` callback. - Wrap the `onSaver` callback execution in a try-catch block to prevent internal callback failures from interrupting the delivery loop. - Add unit tests in `ScribeDeliveryRetireTest.kt` to verify that delivery continues when the error callback fails and that cancellations are not reported. - Remove unused `Channel` and `BufferOverflow` imports from the code example in `getting-started.md`. --- docs/getting-started.md | 3 -- .../kotlin/com/rafambn/scribe/Scribe.kt | 9 +++- .../scribe/ScribeDeliveryRetireTest.kt | 43 +++++++++++++++++++ .../com/rafambn/scribe/ScribeTestFixtures.kt | 9 +++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index f1711db..42fa510 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,9 +19,6 @@ kotlin { Initialize once with one or more savers, then hire the runtime with a `Channel`. ```kotlin -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel - Scribe.inscribe { shelves = listOf(NoteSaver { note -> println("[${note.level}] ${note.tag}: ${note.message}") diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 25143bf..29d2c0e 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch @@ -63,8 +64,14 @@ object Scribe { is ScrollSaver if entry is SealedScroll -> saver.write(entry) is NoteSaver if entry is Note -> saver.write(entry) } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - onSaver?.invoke(saver, entry, e) + try { + onSaver?.invoke(saver, entry, e) + } catch (_: Throwable) { + // Ignore callback failures to keep delivery alive. + } } } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt index 7ba9e38..62ce260 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt @@ -1,6 +1,7 @@ package com.rafambn.scribe import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -231,4 +232,46 @@ class ScribeDeliveryRetireTest { assertEquals("boom", errors.single().message) } } + + @Test + fun onSaverError_callback_failure_does_not_stop_delivery() { + runSuspend { + val failingSaver = EntrySaver { throw IllegalStateException("boom") } + val recordingSaver = RecordingEntrySaver() + val scribe = scribeWithSavers( + shelves = listOf(failingSaver, recordingSaver), + onSaver = { _, _, _ -> + throw IllegalStateException("callback-failed") + }, + ) + + scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 10L) + scribe.note(tag = "payments", message = "continued", level = Urgency.INFO, timestamp = 11L) + recordingSaver.awaitEvents(2) + scribe.retire() + + assertEquals(2, recordingSaver.events.size) + } + } + + @Test + fun saver_cancellation_is_not_reported_to_onSaver() { + runSuspend { + val reportedErrors = mutableListOf() + val cancelingSaver = EntrySaver { throw CancellationException("cancel-delivery") } + val scribe = scribeWithSavers( + shelves = listOf(cancelingSaver), + channel = Channel(capacity = 16), + onSaver = { _, _, error -> + reportedErrors += error + }, + ) + + scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 12L) + delay(50) + scribe.retire() + + assertTrue(reportedErrors.isEmpty()) + } + } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index ccffe3e..b5bd9f8 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -1,6 +1,7 @@ package com.rafambn.scribe import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking @@ -40,8 +41,14 @@ private val delegatingEntrySaver = EntrySaver { entry -> is ScrollSaver if entry is SealedScroll -> saver.write(entry) is NoteSaver if entry is Note -> saver.write(entry) } + } catch (error: CancellationException) { + throw error } catch (error: Throwable) { - onSaverErrorCallback(saver, entry, error) + try { + onSaverErrorCallback(saver, entry, error) + } catch (_: Throwable) { + // Keep test delivery path alive when callback fails. + } } } } From f50919287f5e4036bcb8a8219e30d26bea4b6ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Tue, 5 May 2026 10:53:14 -0300 Subject: [PATCH 3/7] Ensure previous exception handlers are called even if the custom handler fails - Wrap the custom exception handler execution in a `try-finally` block across Android, JVM, and iOS implementations. - Ensure that the `previous` uncaught exception handler is always invoked, preventing the custom handler from suppressing exceptions if it fails. - Refactor the iOS `setUnhandledExceptionHook` logic to properly chain hooks within a single assignment. - Add a unit test in `GlobalExceptionHandlerJvmTest` to verify that the previous handler is executed even when the installed handler throws an exception. --- .../scribe/GlobalExceptionHandler.android.kt | 7 ++-- .../scribe/GlobalExceptionHandler.ios.kt | 14 ++++---- .../scribe/GlobalExceptionHandler.jvm.kt | 7 ++-- .../scribe/GlobalExceptionHandlerJvmTest.kt | 32 +++++++++++++++++++ 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt b/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt index 553fe73..d63277f 100644 --- a/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt +++ b/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt @@ -3,7 +3,10 @@ package com.rafambn.scribe internal actual fun installUncaughtExceptionHandler(handler: (Throwable) -> Unit) { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - handler(throwable) - previous?.uncaughtException(thread, throwable) + try { + handler(throwable) + } finally { + previous?.uncaughtException(thread, throwable) + } } } diff --git a/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt b/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt index 998b0db..b604969 100644 --- a/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt +++ b/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt @@ -2,12 +2,12 @@ package com.rafambn.scribe @OptIn(kotlin.experimental.ExperimentalNativeApi::class) internal actual fun installUncaughtExceptionHandler(handler: (Throwable) -> Unit) { - val previous = setUnhandledExceptionHook { throwable -> - handler(throwable) - } - // Re-install, wrapping previous hook - setUnhandledExceptionHook { throwable -> - handler(throwable) - previous?.invoke(throwable) + var previous: ((Throwable) -> Unit)? = null + previous = setUnhandledExceptionHook { throwable -> + try { + handler(throwable) + } finally { + previous?.invoke(throwable) + } } } diff --git a/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt b/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt index 553fe73..d63277f 100644 --- a/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt +++ b/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt @@ -3,7 +3,10 @@ package com.rafambn.scribe internal actual fun installUncaughtExceptionHandler(handler: (Throwable) -> Unit) { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - handler(throwable) - previous?.uncaughtException(thread, throwable) + try { + handler(throwable) + } finally { + previous?.uncaughtException(thread, throwable) + } } } diff --git a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt index 6846e1f..c10a217 100644 --- a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt +++ b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt @@ -3,6 +3,7 @@ package com.rafambn.scribe import java.util.Collections import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class GlobalExceptionHandlerJvmTest { @Test @@ -33,4 +34,35 @@ class GlobalExceptionHandlerJvmTest { Thread.setDefaultUncaughtExceptionHandler(original) } } + + @Test + fun installUncaughtExceptionHandler_calls_previous_even_if_installed_handler_throws() { + val original = Thread.getDefaultUncaughtExceptionHandler() + val invocations = Collections.synchronizedList(mutableListOf()) + + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + invocations += "previous:${throwable.message}" + } + + try { + installUncaughtExceptionHandler { throwable -> + invocations += "installed:${throwable.message}" + throw IllegalStateException("installed-failed") + } + + val thread = Thread { + throw IllegalStateException("boom") + } + thread.start() + thread.join(2_000) + + assertEquals( + listOf("installed:boom", "previous:boom"), + invocations.toList(), + ) + assertTrue(!thread.isAlive) + } finally { + Thread.setDefaultUncaughtExceptionHandler(original) + } + } } From d03272463621ada69b05064a5c046ade72d8da7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Tue, 5 May 2026 11:16:08 -0300 Subject: [PATCH 4/7] Update Scroll API to indexed accessors and remove non-suspending variants - Remove `flingNote` and `looseSeal` variants from the features list in `README.md`. - Replace type-specific `Scroll` write methods (e.g., `writeString`, `writeNumber`) with indexed accessors (`scroll["key"] = value`) in documentation examples. - Update `Scribe.hire()` examples to include an explicit `Channel` configuration. - Refactor `ScribeTestFixtures` to support dynamic `imprint` configuration and improve initialization state management. - Update `ScribeContextMarginTest` expectations to reflect changes in default test metadata. - Remove unnecessary serialization imports from `getting-started.md`. --- README.md | 11 +++++------ docs/getting-started.md | 3 --- .../rafambn/scribe/ScribeContextMarginTest.kt | 8 ++++---- .../com/rafambn/scribe/ScribeTestFixtures.kt | 18 ++++++++---------- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f489155..3bb444e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ - Story-driven logging primitives instead of flat logger calls - Single-event logging with `note(...)` and contextual logging with `newScroll(...)` -- Best-effort non-suspending variants with `flingNote(...)` (returns `Boolean` acceptance) and `looseSeal(...)` - Delivery hooks through `NoteSaver`, `ScrollSaver`, and `EntrySaver` - Scroll lifecycle enrichment through `Margin` @@ -62,7 +61,7 @@ Scribe.inscribe { } ) } -Scribe.hire() +Scribe.hire(channel = Channel(capacity = 256)) Scribe.note( tag = "payments", @@ -83,12 +82,12 @@ Scribe.inscribe { "environment" to JsonPrimitive("production"), ) } -Scribe.hire() +Scribe.hire(channel = Channel(capacity = 256)) val scroll = Scribe.newScroll(id = "checkout-42") -scroll.writeString("gateway", "stripe") -scroll.writeNumber("attempt", 1) -scroll.writeBoolean("retry", false) +scroll["gateway"] = JsonPrimitive("stripe") +scroll["attempt"] = JsonPrimitive(1) +scroll["retry"] = JsonPrimitive(false) scroll.seal(success = true) ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 42fa510..e2ae826 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,9 +57,6 @@ With the saver above, the log output looks like this: Each `seal(...)` call emits a new `SealedScroll` using a snapshot of the scroll data at that moment. ```kotlin -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive - val scroll = Scribe.newScroll(id = "checkout-42") scroll["gateway"] = JsonPrimitive("stripe") scroll["attempt"] = JsonPrimitive(1) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt index 3d183ed..b138494 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt @@ -23,8 +23,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("test-service"), event.data["service"]) - assertEquals(JsonPrimitive("test"), event.data["environment"]) + assertEquals(JsonPrimitive("mobile-app"), event.data["service"]) + assertEquals(JsonPrimitive("production"), event.data["environment"]) } } @@ -45,8 +45,8 @@ class ScribeContextMarginTest { assertNotNull(firstEvent) assertNotNull(secondEvent) - assertEquals(JsonPrimitive("test-region"), firstEvent.data["region"]) - assertEquals(JsonPrimitive("test-region"), secondEvent.data["region"]) + assertEquals(JsonPrimitive("us-east"), firstEvent.data["region"]) + assertEquals(JsonPrimitive("us-east"), secondEvent.data["region"]) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index b5bd9f8..1747a9e 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -12,13 +12,6 @@ import kotlinx.serialization.json.JsonPrimitive internal val UUID_REGEX = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") -private val defaultTestImprint = mapOf( - "service" to JsonPrimitive("test-service"), - "environment" to JsonPrimitive("test"), - "region" to JsonPrimitive("test-region"), -) - -private var initialized = false private var activeDelegatedSavers: List> = emptyList() private var activeMargin: Margin? = null private var onSaverErrorCallback: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> } @@ -53,14 +46,16 @@ private val delegatingEntrySaver = EntrySaver { entry -> } } +private var isInitialized = false + private fun ensureScribeInitialized() { - if (initialized) return + if (isInitialized) return Scribe.inscribe { shelves = listOf(delegatingEntrySaver) - imprint = defaultTestImprint + imprint = emptyMap() margins = delegatingMargin } - initialized = true + isInitialized = true } internal fun scribeWithScrollShelves( @@ -72,6 +67,7 @@ internal fun scribeWithScrollShelves( ): Scribe { ensureScribeInitialized() runBlocking { Scribe.retire() } + Scribe.config!!.imprint = imprint activeDelegatedSavers = shelves.toList() activeMargin = margins onSaverErrorCallback = onSaver @@ -81,12 +77,14 @@ internal fun scribeWithScrollShelves( internal fun scribeWithSavers( shelves: List>, + imprint: Map = emptyMap(), margins: Margin? = null, channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), onSaver: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, ): Scribe { ensureScribeInitialized() runBlocking { Scribe.retire() } + Scribe.config!!.imprint = imprint activeDelegatedSavers = shelves activeMargin = margins onSaverErrorCallback = onSaver From 938281486dcd221b4831ccbfddd36ba749fbc675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Tue, 5 May 2026 12:20:23 -0300 Subject: [PATCH 5/7] Update KDoc for `hire` and `retire` to clarify channel ownership - Update `Scribe.hire` documentation to specify that the provided channel becomes disposable and ownership is transferred to Scribe, which handles its closure. - Update `Scribe.retire` documentation to clarify that the channel is closed upon calling and cannot be reused for subsequent `hire` calls. --- docs/lifecycle-and-delivery.md | 4 ++-- .../src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 5dc9d5c..2c2e363 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -2,7 +2,7 @@ ## Delivery Pipeline -`Scribe` delivers entries through the `Channel` you provide to `hire(...)`. +`Scribe` delivers entries through the `Channel` you provide to `hire(...)`. The channel is disposable and transfers ownership to Scribe, which closes it on processor completion or `retire()`. Create a fresh channel for each `hire(...)` call. ```kotlin Scribe.inscribe { @@ -82,7 +82,7 @@ Use `retire()` to stop intake and wait until queued delivery work is finished. Scribe.retire() ``` -After `retire()`, you can call `hire(...)` again (with a new channel) to restart runtime delivery. +After `retire()`, the previous channel is closed and cannot be reused. Call `hire(...)` with a new channel to restart runtime delivery. ## Uncaught Exceptions diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 29d2c0e..b6db077 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -45,6 +45,10 @@ object Scribe { /** * Starts the delivery runtime using previously initialized parameters. + * + * The provided [channel] becomes disposable and transfers ownership to Scribe. + * Scribe closes the channel when the processor completes or when [retire] is called. + * Create a fresh channel for each call to this method. */ fun hire( scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), @@ -103,7 +107,10 @@ object Scribe { } /** - * Stops accepting entries and waits for queued events to finish delivery. + * Stops accepting entries, closes the delivery channel, and waits for queued events to finish delivery. + * + * The channel passed to [hire] is closed and must not be reused. + * After this call completes, you may call [hire] again with a fresh channel. */ suspend fun retire() { val queue = activeQueue ?: return From c8670d25807a1c3679df4a8d57c5c302a9f1fc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Tue, 5 May 2026 14:10:08 -0300 Subject: [PATCH 6/7] Update documentation for Scroll extensions and delivery lifecycle - Add documentation and code examples for `Scroll.extend` and `Scroll.append` extension functions to support merging and nesting data. - Document the optional `scope` parameter in `Scribe.hire`, allowing users to provide a custom `CoroutineScope` for delivery. - Add an "Urgency Levels" section to the API concepts documentation defining the `Urgency` enum. - Update the "Getting Started" guide and OpenObserve showcase instructions to include the new extension functions and demo steps. - Add code snippets demonstrating `scroll.id` usage and `CoroutineScope` configuration with `SupervisorJob`. --- docs/api-concepts.md | 62 ++++++++++++++++++++++++++++++++-- docs/getting-started.md | 11 ++++++ docs/lifecycle-and-delivery.md | 11 ++++++ docs/openobserve-showcase.md | 4 +++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/api-concepts.md b/docs/api-concepts.md index 5c519a1..711bc60 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -14,8 +14,10 @@ Both implement the sealed `Entry` interface, which is what `EntrySaver` receives - `note(...)`: suspending call for a single log entry - `newScroll(...)`: starts a contextual logging session - `seal(...)`: snapshots the current scroll data and emits a `SealedScroll` +- `extend(scroll)`: copies missing keys from another scroll into this one +- `append(key, scroll)`: nests a scroll as a JSON object under the given key - `Margin`: hook for writing fields at open/close boundaries -- `hire(channel = ..., onSaver = ...)`: starts delivery over your channel configuration +- `hire(channel = ..., scope = ..., onSaver = ...)`: starts delivery over your channel configuration ## `Scribe` @@ -54,10 +56,41 @@ val phase = scroll["phase"] val removed = scroll.remove("retryable") ``` -`scroll.id` reads the generated/custom `scroll_id` field. +`scroll.id` reads the generated/custom `scroll_id` field: + +```kotlin +val scroll = Scribe.newScroll(id = "checkout-42") +println(scroll.id) // "checkout-42" +``` Calling `seal(...)` more than once is allowed. Each call emits a separate `SealedScroll` with the current `success` value and a snapshot of the data at that point. +## `Scroll` Extensions + +Beyond direct map writes, `Scroll` has two extension functions: + +### `extend(scroll)` +Copies only missing keys from another scroll into this one: + +```kotlin +val base = Scribe.newScroll(id = "base") +base["gateway"] = JsonPrimitive("stripe") + +val checkout = Scribe.newScroll(id = "checkout-42") +checkout["attempt"] = JsonPrimitive(1) +checkout.extend(base) // only copies "gateway" if not already present +``` + +### `append(key, scroll)` +Nests another scroll as a `JsonObject` under the given key: + +```kotlin +val meta = mutableMapOf() +meta["item_count"] = JsonPrimitive(3) +checkout.append("cart", meta) +// Result: checkout["cart"] = {"item_count": 3} +``` + ## `Margin` `Margin` enriches a scroll at beginning and end. @@ -94,6 +127,17 @@ Scribe.hire( ) ``` +You can optionally provide a custom `CoroutineScope` to control the lifecycle of the delivery coroutine: + +```kotlin +val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + +Scribe.hire( + scope = customScope, + channel = Channel(capacity = 256), +) +``` + ## Event Shapes ```kotlin @@ -115,6 +159,20 @@ SealedScroll( ) ``` +## Urgency Levels + +`Urgency` is used by `Note` to indicate severity: + +```kotlin +enum class Urgency { + VERBOSE, + DEBUG, + INFO, + WARN, + ERROR +} +``` + ## Failure Handling ```kotlin diff --git a/docs/getting-started.md b/docs/getting-started.md index e2ae826..1f79aaf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,6 +56,17 @@ With the saver above, the log output looks like this: `Scroll` is a mutable map (`MutableMap`) that you seal into one wide event. Each `seal(...)` call emits a new `SealedScroll` using a snapshot of the scroll data at that moment. +You can also merge other scrolls or nest them: + +```kotlin +val base = Scribe.newScroll() +base["gateway"] = JsonPrimitive("stripe") + +val checkout = Scribe.newScroll(id = "checkout-42") +checkout.extend(base) // copies missing keys from base +checkout.append("meta", mapOf("items" to JsonPrimitive(3))) +``` + ```kotlin val scroll = Scribe.newScroll(id = "checkout-42") scroll["gateway"] = JsonPrimitive("stripe") diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 2c2e363..1515907 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -4,6 +4,17 @@ `Scribe` delivers entries through the `Channel` you provide to `hire(...)`. The channel is disposable and transfers ownership to Scribe, which closes it on processor completion or `retire()`. Create a fresh channel for each `hire(...)` call. +You can optionally provide a custom `CoroutineScope` to control the delivery coroutine lifecycle: + +```kotlin +val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + +Scribe.hire( + scope = customScope, + channel = Channel(capacity = 256), +) +``` + ```kotlin Scribe.inscribe { shelves = listOf(EntrySaver { entry -> diff --git a/docs/openobserve-showcase.md b/docs/openobserve-showcase.md index 8762c45..f71a1fe 100644 --- a/docs/openobserve-showcase.md +++ b/docs/openobserve-showcase.md @@ -9,6 +9,8 @@ The app covers the current public runtime features of Scribe: - `note(...)` - `newScroll(...)` with generated and custom IDs - direct `Scroll` map writes (`scroll["field"] = ...`) +- `extend(scroll)` to copy missing keys from another scroll +- `append(key, scroll)` to nest a scroll as a JSON object - map read/remove operations - `seal(...)` with success and failure outcomes - `Margin` @@ -83,6 +85,8 @@ or: 7. Run `Overflow demo` and confirm a burst can be trimmed by `DROP_OLDEST` under pressure. 8. Run `Saver failure demo` and observe that `onSaver` reports the injected failure while delivery continues. 9. Compare `retire() (light queue)` with `retire() with backlog` in the in-app timeline. +10. Run `Extend scroll` to verify keys are copied only when missing from the target scroll. +11. Run `Append scroll` to validate nested JSON object creation in OpenObserve. ## Querying In OpenObserve From f4b879d82a8a593bf83088db27e9cd734f7e580d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a?= Date: Tue, 5 May 2026 15:58:55 -0300 Subject: [PATCH 7/7] Allow Scribe re-initialization and prevent deadlocks in `retire` - Update `inscribe` to allow it to be called multiple times, provided `retire` is called in between. - Refactor `isProcessorFamily` to traverse the job hierarchy upwards via `parent` instead of searching descendants, improving efficiency. - Prevent deadlocks in `retire` by ensuring the function returns immediately if called from within the processor's own coroutine tree. - Simplify `ScribeTestFixtures.kt` by centralizing the `retire` logic within `ensureScribeInitialized` and removing redundant calls in test helper functions. - Add `@OptIn(ExperimentalCoroutinesApi::class)` to support parent job traversal. --- .../kotlin/com/rafambn/scribe/Scribe.kt | 44 +++++++++---------- .../com/rafambn/scribe/ScribeTestFixtures.kt | 6 +-- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index b6db077..812c789 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch @@ -29,10 +30,9 @@ object Scribe { /** * Initializes the singleton with immutable parameters. * - * This function can be called only once per process lifetime. + * This function can be called again after [retire] has been called. */ fun inscribe(block: Inscribe.() -> Unit) { - check(config == null) { "Scribe is already initialized and cannot be initialized again." } val dsl = Inscribe().apply(block) val configuredShelves = dsl.shelves require(configuredShelves.isNotEmpty()) { "At least one shelf is required." } @@ -111,6 +111,9 @@ object Scribe { * * The channel passed to [hire] is closed and must not be reused. * After this call completes, you may call [hire] again with a fresh channel. + * + * If called from within the processor coroutine (e.g., from a saver), + * this function returns immediately without waiting to avoid deadlocks. */ suspend fun retire() { val queue = activeQueue ?: return @@ -120,12 +123,24 @@ object Scribe { val callerJob = currentCoroutineContext()[Job] if (runningProcessor != null && !isProcessorFamily(runningProcessor, callerJob)) { runningProcessor.join() - if (processorJob === runningProcessor) { - processorJob = null - } } } + /** + * Checks if the caller job is the processor itself or a descendant. + * Traverses from caller up through parents to handle any nesting depth. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun isProcessorFamily(root: Job, target: Job?): Boolean { + if (target == null) return false + var current: Job? = target + while (current != null) { + if (current === root) return true + current = current.parent + } + return false + } + /** * Emits a [Note] and suspends until it is enqueued. * @@ -155,23 +170,4 @@ object Scribe { internal suspend fun enqueue(entry: Entry) { requireActiveQueue().send(entry) } - - private fun isProcessorFamily(root: Job, target: Job?): Boolean { - if (target == null) return false - if (target === root) return true - return containsDescendant(root, target) - } - - private fun containsDescendant(root: Job, target: Job): Boolean { - val queue = ArrayDeque() - queue.add(root) - while (queue.isNotEmpty()) { - val current = queue.removeFirst() - current.children.forEach { child -> - if (child === target) return true - queue.addLast(child) - } - } - return false - } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index 1747a9e..3a3db0f 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -49,7 +49,9 @@ private val delegatingEntrySaver = EntrySaver { entry -> private var isInitialized = false private fun ensureScribeInitialized() { - if (isInitialized) return + if (isInitialized) { + runBlocking { Scribe.retire() } + } Scribe.inscribe { shelves = listOf(delegatingEntrySaver) imprint = emptyMap() @@ -66,7 +68,6 @@ internal fun scribeWithScrollShelves( margins: Margin? = null, ): Scribe { ensureScribeInitialized() - runBlocking { Scribe.retire() } Scribe.config!!.imprint = imprint activeDelegatedSavers = shelves.toList() activeMargin = margins @@ -83,7 +84,6 @@ internal fun scribeWithSavers( onSaver: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, ): Scribe { ensureScribeInitialized() - runBlocking { Scribe.retire() } Scribe.config!!.imprint = imprint activeDelegatedSavers = shelves activeMargin = margins