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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -62,7 +61,7 @@ Scribe.inscribe {
}
)
}
Scribe.hire()
Scribe.hire(channel = Channel(capacity = 256))

Scribe.note(
tag = "payments",
Expand All @@ -83,15 +82,17 @@ 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)
```

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
Expand Down
68 changes: 64 additions & 4 deletions docs/api-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
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.

## Terminology

- `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`
- `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`

Expand Down Expand Up @@ -54,7 +56,40 @@ 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<String, JsonElement>()
meta["item_count"] = JsonPrimitive(3)
checkout.append("cart", meta)
// Result: checkout["cart"] = {"item_count": 3}
```

## `Margin`

Expand Down Expand Up @@ -92,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
Expand All @@ -113,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
Expand Down
16 changes: 11 additions & 5 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ kotlin {
Initialize once with one or more savers, then hire the runtime with a `Channel<Entry>`.

```kotlin
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel

Scribe.inscribe {
shelves = listOf(NoteSaver { note ->
println("[${note.level}] ${note.tag}: ${note.message}")
Expand Down Expand Up @@ -57,11 +54,20 @@ With the saver above, the log output looks like this:
## Track a Flow with `Scroll`

`Scroll` is a mutable map (`MutableMap<String, JsonElement>`) 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
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
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")
scroll["attempt"] = JsonPrimitive(1)
Expand Down
19 changes: 16 additions & 3 deletions docs/lifecycle-and-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@

## Delivery Pipeline

`Scribe` delivers entries through the `Channel<Entry>` you provide to `hire(...)`.
`Scribe` delivers entries through the `Channel<Entry>` 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 {
Expand All @@ -27,10 +38,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`.
Expand Down Expand Up @@ -80,7 +93,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

Expand Down
4 changes: 4 additions & 0 deletions docs/openobserve-showcase.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
62 changes: 36 additions & 26 deletions scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import kotlinx.coroutines.CoroutineScope
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
Expand All @@ -28,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." }
Expand All @@ -44,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),
Expand All @@ -63,8 +68,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.
}
}
}
}
Expand Down Expand Up @@ -96,7 +107,13 @@ 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.
*
* 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
Expand All @@ -106,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.
*
Expand Down Expand Up @@ -141,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<Job>()
queue.add(root)
while (queue.isNotEmpty()) {
val current = queue.removeFirst()
current.children.forEach { child ->
if (child === target) return true
queue.addLast(child)
}
}
return false
}
}
4 changes: 3 additions & 1 deletion scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading