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
2 changes: 2 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
include:
- target: jvmTest
os: ubuntu-latest
- target: :scribe-slf4j:test
os: ubuntu-latest
- target: iosArm64TestKlibrary
os: macos-latest
- target: testAndroidHostTest
Expand Down
81 changes: 59 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@
## Features:

- Story-driven logging primitives instead of flat logger calls
- Single-event logging with `note(...)` and contextual logging with `newScroll(...)`
- Delivery hooks through `NoteSaver`, `ScrollSaver`, and `EntrySaver`
- Contextual logging with `newScroll(...)` and immediate-seal one-shot scrolls
- Delivery hooks through `Archivist` instances receiving `Entry` snapshots
- Scroll lifecycle enrichment through `Margin`
- Independent `Scribe` objects for applications and imported libraries
- A JVM SLF4J 2.x provider backed by the same structured logging pipeline

## Setup

Expand All @@ -44,60 +45,96 @@ Add Scribe to your `commonMain` dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.rafambn:scribe:0.4.0")
implementation("com.rafambn:scribe:0.6.0")
}
}
}
```

## Usage

Create a `Scribe` object, hire its runtime, and emit a note:
Create a `Scribe` object, start processing its private buffer, and emit a scroll:

```kotlin
object AppScribe : Scribe() {
override val shelves: List<Saver<*>> = listOf(
NoteSaver { note ->
println("[${note.level}] ${note.tag}: ${note.message}")
override val archivists: List<Archivist> = listOf(
Archivist { entry ->
println(entry)
}
)
}
AppScribe.hire(channel = Channel(capacity = 256))
AppScribe.hire()

AppScribe.note(
tag = "payments",
message = "starting checkout",
level = Urgency.INFO,
)
val scroll = AppScribe.newScroll()
scroll["tag"] = JsonPrimitive("payments")
scroll["message"] = JsonPrimitive("starting checkout")
scroll["level"] = JsonPrimitive("INFO")
scroll.seal(AppScribe)
```

Use a scroll when you need shared context for a longer flow:

```kotlin
object BillingScribe : Scribe() {
override val shelves: List<Saver<*>> = listOf(
ScrollSaver { scroll -> println(scroll) }
override val archivists: List<Archivist> = listOf(
Archivist { entry -> println(entry) }
)
override val imprint = mapOf(
"service" to JsonPrimitive("billing"),
"environment" to JsonPrimitive("production"),
)
}
BillingScribe.hire(channel = Channel(capacity = 256))
BillingScribe.hire()

val scroll = BillingScribe.newScroll(id = "checkout-42")
scroll["gateway"] = JsonPrimitive("stripe")
scroll["attempt"] = JsonPrimitive(1)
scroll["retry"] = JsonPrimitive(false)
scroll.seal(BillingScribe, success = true)
scroll.seal(BillingScribe)
```

Each `Scribe` object has independent configuration and delivery lifecycle. A `Scroll` is a mutable JSON-element map initialized by `newScroll(...)`; pass the runtime that should enrich and deliver it to `scroll.seal(scribe, ...)`. Each `seal(...)` call emits a separate `SealedScroll` snapshot.
Each `Scribe` object has independent configuration and delivery lifecycle. A `Scroll` is a mutable JSON-element map initialized by `newScroll(...)`; pass the runtime that should enrich and deliver it to `scroll.seal(scribe)`. Each `seal(...)` call emits a separate snapshot of the scroll data.

Choose the saver that matches your output flow:
## SLF4J

For JVM applications, add the SLF4J provider:

```kotlin
val noteSaver = NoteSaver { note -> println(note) }
val scrollSaver = ScrollSaver { scroll -> println(scroll) }
val entrySaver = EntrySaver { record -> println(record) }
dependencies {
implementation("com.rafambn:scribe-slf4j:0.6.0")
}
```

Select exactly one application-wide backend with `@ScribeBackend`:

```kotlin
@ScribeBackend
object AppScribe : Slf4jScribe() {
override val bufferCapacity = 1_024
override val bufferOverflow = BufferOverflow.DROP_OLDEST
override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null
override val archivists = listOf(
Archivist { entry -> println(entry) },
)

override fun isEnabled(
loggerName: String,
level: Level,
marker: Marker?,
): Boolean = level.toInt() >= Level.INFO.toInt()
}
```

The provider discovers the annotated backend once on the first SLF4J access and registers a JVM shutdown hook. Intake starts open, so early calls accumulate in the private buffer; the application calls `AppScribe.hire()` when processing should begin. The shutdown hook retires the Scribe and drains accepted entries automatically. Initialization fails with a descriptive error when no backend is present, multiple backends are annotated, or the annotation is not placed on a Kotlin object extending `Slf4jScribe`.

`scribe-slf4j` is a standalone SLF4J provider. Do not include another provider such as `logback-classic` in the same runtime classpath.

See the [full documentation](https://scribe.rafambn.com/) for lifecycle controls, overflow behavior, margins, and SLF4J field mapping.

## Performance

Scribe is designed for high-throughput and thread-safe concurrent logging.

The repository includes JVM throughput tests for concurrent in-memory ingestion and serialized file
writing. Results depend on the machine, runtime, buffer configuration, and archivist implementation;
run the tests in your target environment before using them for capacity planning.
129 changes: 52 additions & 77 deletions docs/api-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,31 @@

## Core Types

Scribe models logging with two event shapes:
Scribe models logging with structured scroll events:

- `Note`: a single standalone event
- `SealedScroll`: a sealed snapshot result of a multi-step `Scroll`

Both implement the sealed `Entry` interface, which is what `EntrySaver` receives.
- `Scroll`: a mutable JSON-map you build up and then pass to `seal(...)`
- `Entry`: typealias for `Map<String, JsonElement>`, the read-only snapshot produced by sealing a `Scroll` and delivered through a runtime's archivists

## Terminology

- `note(...)`: emits a single log entry through the active runtime
- `newScroll(...)`: starts a contextual logging session
- `seal(scribe, ...)`: applies the supplied runtime's footer, snapshots the
current scroll data, and emits a `SealedScroll`
- `seal(scribe)`: applies the supplied runtime's footer, snapshots the
current scroll data, attempts a non-blocking enqueue, and returns the `Entry`
- `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 = ..., scope = ..., onSaver = ...)`: starts delivery over your channel configuration
- `hire()`: starts or resumes processing of the private buffer
- `openIntake()` / `closeIntake()`: independently control whether new entries are accepted
- `dismiss()`: requests a cooperative job pause while preserving buffered entries
- `retire()`: permanently closes intake and drains the buffer

## `Scribe`

`Scribe` is an abstract runtime base class. A user creates one or more objects
that extend it. Each object owns:

- one or more savers (`shelves`)
- zero or more configured archivists (at least one is required when `hire()` is called)
- a private buffer with a capacity and overflow policy
- an optional shared `imprint`
- optional lifecycle hooks through `Margin`
- optional uncaught exception wiring through `onIgnition` (the installed
Expand All @@ -35,15 +36,19 @@ Define runtime configuration with overridden properties:

```kotlin
object CheckoutScribe : Scribe() {
override val shelves: List<Saver<*>> = listOf(entrySaver)
override val bufferCapacity = 256
override val bufferOverflow = BufferOverflow.DROP_OLDEST
override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null
override val archivists: List<Archivist> = listOf(Archivist { entry -> println(entry) })
override val imprint = mapOf("service" to JsonPrimitive("checkout"))
override val margins = timingMargin
}
```

Delivery is started with `CheckoutScribe.hire(...)` and stopped with
`CheckoutScribe.retire()`. Different objects can run concurrently without
sharing queues, savers, or lifecycle.
Intake starts open and the job starts dismissed. Delivery is started with
`CheckoutScribe.hire()`, paused with `CheckoutScribe.dismiss()`, and permanently
ended with `CheckoutScribe.retire()`. Different objects have independent private
buffers, archivists, and lifecycle controls.

## `Scroll`

Expand Down Expand Up @@ -82,9 +87,8 @@ val scroll = CheckoutScribe.newScroll(id = "checkout-42")
println(scroll.id) // "checkout-42"
```

Calling `seal(...)` more than once is allowed. Each call emits a separate
`SealedScroll` through the `Scribe` passed to that call, with the current
`success` value and a snapshot of the data at that point.
Calling `seal(...)` more than once is allowed. Each call applies the footer again, creates and
returns a separate `Entry` snapshot, and attempts delivery through the supplied `Scribe`.

## `Scroll` Operations

Expand Down Expand Up @@ -117,7 +121,7 @@ checkout.append("cart", meta)
`Margin` enriches a scroll at beginning and end.

```kotlin
val timingMargin = object : Margin {
val margin = object : Margin {
override fun header(scroll: Scroll) {
scroll["started_at"] = JsonPrimitive(1000)
}
Expand All @@ -130,86 +134,57 @@ val timingMargin = object : Margin {

## Delivery Configuration

Configure queue behavior through the `Channel<Entry>` passed to an instance's
`hire(...)`.

```kotlin
CheckoutScribe.hire(
channel = Channel(
capacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
),
onSaver = { saver, entry, error ->
println("Saver $saver failed for $entry: $error")
},
)
```

You can optionally provide a custom `CoroutineScope` to control the lifecycle of the delivery coroutine:
Configure private-buffer behavior when creating the `Scribe`.

```kotlin
val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
object CheckoutScribe : Scribe() {
override val bufferCapacity = 256
override val bufferOverflow = BufferOverflow.DROP_OLDEST
override val onArchiveFailure = { archivist: Archivist, entry: Entry, error: Throwable ->
println("Archivist $archivist failed for $entry: $error")
}
override val archivists = listOf(Archivist { entry -> println(entry) })
}

CheckoutScribe.hire(
scope = customScope,
channel = Channel(capacity = 256),
)
CheckoutScribe.hire()
```

The delivery coroutine is owned by the `Scribe` instance so it can remain alive while processing
is paused and resume on a later `hire()`. The configuration properties have defaults; only
`archivists` normally needs to be overridden for a minimal implementation.

## Event Shapes

```kotlin
Note(
tag = "payments",
message = "starting checkout",
level = Urgency.INFO,
timestamp = 1710000000000L,
)
```
The standard delivered event is a sealed `Scroll` snapshot. Fields written to
the scroll via normal map operations appear directly in the delivered `Entry`,
which is a `Map<String, JsonElement>`:

```kotlin
SealedScroll(
success = true,
data = mapOf(
"scroll_id" to JsonPrimitive("checkout-42"),
"gateway" to JsonPrimitive("stripe"),
),
mapOf(
"scroll_id" to JsonPrimitive("checkout-42"),
"gateway" to JsonPrimitive("stripe"),
)
```

## Urgency Levels

`Urgency` is used by `Note` to indicate severity:

```kotlin
enum class Urgency {
VERBOSE,
DEBUG,
INFO,
WARN,
ERROR
}
```

## Failure Handling

```kotlin
object ApplicationScribe : Scribe() {
override val shelves: List<Saver<*>> = listOf(entrySaver)
override val bufferCapacity = 256
override val bufferOverflow = BufferOverflow.DROP_OLDEST
override val onArchiveFailure = { archivist: Archivist, entry: Entry, error: Throwable ->
println("Archivist $archivist failed for $entry: ${error.message}")
}
override val archivists: List<Archivist> = listOf(Archivist { entry -> println(entry) })
override val onIgnition: ((Throwable) -> Unit)? = { throwable ->
println("Uncaught exception: ${throwable.message}")
}
}

ApplicationScribe.hire(
channel = Channel(capacity = 256),
onSaver = { saver, entry, error ->
println("Saver $saver failed for $entry: ${error.message}")
},
)
ApplicationScribe.hire()
```

`onIgnition` is read when that runtime is first hired, but handles uncaught
`onIgnition` is read when processing is first hired, but handles uncaught
exceptions at the platform level. Multiple runtimes should not independently
claim this application-global hook. Saver failures are reported by the
`onSaver` callback passed to `hire(...)`.
claim this application-global hook. Archivist failures are reported by the
`onArchiveFailure` property defined by the implementation.
43 changes: 0 additions & 43 deletions docs/console-showcase.md

This file was deleted.

Loading
Loading