diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 247547c..28afa57 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -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 diff --git a/README.md b/README.md index 69a33e3..e21003e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -44,7 +45,7 @@ Add Scribe to your `commonMain` dependencies: kotlin { sourceSets { commonMain.dependencies { - implementation("com.rafambn:scribe:0.4.0") + implementation("com.rafambn:scribe:0.6.0") } } } @@ -52,52 +53,88 @@ kotlin { ## 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> = listOf( - NoteSaver { note -> - println("[${note.level}] ${note.tag}: ${note.message}") + override val archivists: List = 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> = listOf( - ScrollSaver { scroll -> println(scroll) } + override val archivists: List = 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. diff --git a/docs/api-concepts.md b/docs/api-concepts.md index 2b5ac50..b43dc36 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -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`, 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 @@ -35,15 +36,19 @@ Define runtime configuration with overridden properties: ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(entrySaver) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = 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` @@ -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 @@ -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) } @@ -130,86 +134,57 @@ val timingMargin = object : Margin { ## Delivery Configuration -Configure queue behavior through the `Channel` 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`: ```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> = 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 = 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. diff --git a/docs/console-showcase.md b/docs/console-showcase.md deleted file mode 100644 index 462e9ae..0000000 --- a/docs/console-showcase.md +++ /dev/null @@ -1,43 +0,0 @@ -# Console Showcase - -The `testApp` module is the runnable Scribe demo. It uses an application-owned -`Scribe` object and prints each delivered record as formatted JSON to the -application console. There is no external service to configure. - -## What The Showcase Demonstrates - -- `note(...)` -- `newScroll(...)` with generated and custom IDs -- Direct `Scroll` map-like writes and explicit delivery runtime selection -- `extend(scroll)` and `append(key, scroll)` -- Map read/remove operations -- `seal(...)` success and failure outcomes -- `Margin` -- `EntrySaver` -- Channel overflow behavior through `DROP_OLDEST` -- Saver error callbacks -- `retire()` and runtime re-hire -- Safe `onIgnition` wiring - -## Run It - -```bash -./gradlew :testApp:jvmApp:run -``` - -The desktop app writes records to the launching terminal. Android and iOS -records are available through their platform run consoles. - -## Suggested Experiments - -1. Run `Checkout flow` and inspect a wide-event JSON record. -2. Run `Map read/remove` to observe mutation before sealing. -3. Run `Margins + seal(failure)` and verify timing fields plus `success = false`. -4. Run `JSON object serialization` to inspect a nested payload. -5. Run `String template message` to inspect the `message` and `order_id` fields. -6. Run `EntrySaver mixed flow` to print a note and a scroll through one saver. -7. Run `Overflow demo` and observe that a burst can be trimmed under pressure. -8. Run `Saver failure demo` and observe the printed saver error while delivery continues. -9. Compare `retire() (light queue)` with `retire() with backlog`. - -The in-app timeline mirrors delivered console records for convenient inspection. diff --git a/docs/getting-started.md b/docs/getting-started.md index 6d16b27..b1fcd38 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,7 +8,7 @@ Use the library from shared code in your Kotlin Multiplatform module: kotlin { sourceSets { commonMain.dependencies { - implementation("com.rafambn:scribe:0.4.0") + implementation("com.rafambn:scribe:0.6.0") } } } @@ -16,48 +16,43 @@ kotlin { ## Create a Minimal `Scribe` -Create an object that extends `Scribe`, override its savers, then hire that -object's runtime with a `Channel`. +Create an object that extends `Scribe`, configure its private buffer, then hire its processor. ```kotlin object AppScribe : Scribe() { - override val shelves: List> = listOf(NoteSaver { note -> - println("[${note.level}] ${note.tag}: ${note.message}") + override val archivists: List = listOf(Archivist { entry -> + println(entry) }) } -AppScribe.hire( - channel = Channel( - capacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ), -) +AppScribe.hire() ``` ## Emit a Single Event -Use `note(...)` for standalone events: +Every event is a scroll. For a standalone event, build a scroll and seal it +immediately: ```kotlin -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) ``` -With the saver above, the log output looks like this: +With the archivist above, the log output looks like this: ```text -[INFO] payments: starting checkout +{scroll_id=..., tag=payments, message=starting checkout, level=INFO} ``` ## Track a Flow with `Scroll` `Scroll` is a mutable map of JSON elements initialized by `newScroll(...)`. When sealing it, supply the `Scribe` runtime that should apply its footer -margin and deliver the event. Each `seal(...)` call emits a new -`SealedScroll` using a snapshot of the scroll data at that moment. +margin and deliver the event. Each `seal(...)` call emits a new snapshot of +the scroll data at that moment. You can also merge other scrolls or nest them: @@ -82,7 +77,7 @@ scroll["cart"] = Json.encodeToJsonElement( CheckoutMeta.serializer(), CheckoutMeta(itemCount = 3, subtotalCents = 249_900, featureFlag = "wide-events"), ) -scroll.seal(AppScribe, success = true) +scroll.seal(AppScribe) ``` ## Use Multiple Runtimes @@ -92,51 +87,53 @@ application may supply a configured object to a component. ```kotlin object PaymentsScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { sendPaymentsRecord(it) }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf(Archivist { sendPaymentsRecord(it) }) } object AnalyticsScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { sendAnalyticsRecord(it) }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists: List = listOf(Archivist { sendAnalyticsRecord(it) }) } -PaymentsScribe.hire(channel = Channel(256)) -AnalyticsScribe.hire(channel = Channel(256)) +PaymentsScribe.hire() +AnalyticsScribe.hire() ``` -Retiring `PaymentsScribe` does not stop `AnalyticsScribe`. +Dismissing `PaymentsScribe` pauses only its job and does not stop `AnalyticsScribe`. -The emitted `SealedScroll` shape: +The emitted event shape is the scroll map itself: ```json { - "success": true, - "data": { - "scroll_id": "checkout-42", - "gateway": "stripe", - "attempt": 1, - "retry": false, - "cart": { - "item_count": 3, - "subtotal_cents": 249900, - "feature_flag": "wide-events" - } + "scroll_id": "checkout-42", + "gateway": "stripe", + "attempt": 1, + "retry": false, + "cart": { + "item_count": 3, + "subtotal_cents": 249900, + "feature_flag": "wide-events" } } ``` -## Choose the Right Saver +## Choose the Right Archivist ```kotlin -val noteSaver = NoteSaver { note -> println(note) } -val scrollSaver = ScrollSaver { scroll -> println(scroll) } -val entrySaver = EntrySaver { entry -> println(entry) } +val scrollArchivist = Archivist { scroll -> println(scroll) } ``` -- `NoteSaver` handles only `Note` -- `ScrollSaver` handles only `SealedScroll` -- `EntrySaver` handles both +- Every archivist receives `Entry` snapshots +- `Archivist` is a functional interface: `Archivist { entry -> ... }` is all you need +- Add multiple archivists to a `Scribe` object to fan out to several outputs ## What to Read Next - [API Concepts](api-concepts.md) for the core types and terminology -- [Lifecycle and Delivery](lifecycle-and-delivery.md) for channel behavior, margins, shutdown, and saver error callbacks +- [Lifecycle and Delivery](lifecycle-and-delivery.md) for intake, processing, retirement, and archivist error callbacks +- [SLF4J Provider](slf4j.md) for using Scribe as a JVM SLF4J 2.x provider diff --git a/docs/index.md b/docs/index.md index 3389dac..9618dee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,10 +26,9 @@ A Kotlin Multiplatform logging library for structured events and long-lived cont Modern systems do not fail in one place. A single user action can cross services, queues, retries, feature flags, and third-party calls. Traditional logging turns that into dozens of partial lines, each easy to write and hard to query. -Scribe is built around the argument from [loggingsucks.com](https://loggingsucks.com/): useful logs are not just structured, they are context-rich. Instead of scattering breadcrumbs everywhere, Scribe gives you primitives for both shapes you actually need: +Scribe is built around the argument from [loggingsucks.com](https://loggingsucks.com/): useful logs are not just structured, they are context-rich. Instead of scattering breadcrumbs everywhere, Scribe gives you one primitive for the shape you actually need: -- `Note` for one-off events that stand on their own (Old way, if you still insist) -- `Scroll` for building a wide event over the lifetime of an operation, then sealing it as a `SealedScroll` +- `Scroll` for building a wide event over the lifetime of an operation, then sealing it as a delivered `Entry` snapshot That pushes logging toward "what happened to this request or workflow?" instead of "what line of code ran next?". The result is fewer events, better context, and logs that are easier to filter, correlate, and analyze. @@ -37,5 +36,5 @@ That pushes logging toward "what happened to this request or workflow?" instead - [Getting Started](getting-started.md) for setup and first usage - [API Concepts](api-concepts.md) for terminology and data model -- [Lifecycle and Delivery](lifecycle-and-delivery.md) for buffering, margins, and shutdown -- [Console Showcase](console-showcase.md) for the runnable demo app and printed event records +- [Lifecycle and Delivery](lifecycle-and-delivery.md) for buffering, intake, processing, and retirement +- [SLF4J Provider](slf4j.md) for routing JVM SLF4J 2.x calls through Scribe diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 93e0c63..904cd84 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -1,124 +1,88 @@ # Lifecycle and Delivery -## Delivery Pipeline +## Private Buffer -A `Scribe` object delivers entries through the `Channel` provided to -`hire(...)`. The channel is disposable and transfers ownership to that object, -which closes it on processor completion or `retire()`. Different `Scribe` -objects may be hired concurrently with independent channels. - -You can optionally provide a custom `CoroutineScope` to control the delivery coroutine lifecycle: - -```kotlin -val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - -CheckoutScribe.hire( - scope = customScope, - channel = Channel(capacity = 256), -) -``` +Each `Scribe` owns its delivery buffer. Callers configure its capacity and overflow policy, +but never own or close the underlying `Channel`: ```kotlin object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { entry -> - println(entry) - }) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + override val archivists = listOf(Archivist { entry -> println(entry) }) } - -CheckoutScribe.hire( - channel = Channel( - capacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ), - onSaver = { saver, entry, error -> - println("Saver $saver failed for $entry: ${error.message}") - }, -) ``` -## Emission APIs +Intake starts open and the job starts dismissed. Entries sealed before `hire()` remain in the +buffer according to the configured overflow policy. -Current emission calls are non-suspending: +## Independent Controls -- `note(...)` sends a `Note` -- `seal(scribe, ...)` applies that runtime's footer margin, snapshots the - current `Scroll` data, and sends a `SealedScroll` +Intake and the processing job are independent: -Both calls attempt an immediate channel send and block the calling thread if a -channel configured with `BufferOverflow.SUSPEND` is full. `Saver.write(...)` -and `retire()` are the suspending parts of the API. There are no separate -best-effort emission APIs in this runtime shape. - -Multiple calls to `seal(...)` on the same `Scroll` are intentional. Each call -emits a separate `SealedScroll` through the `Scribe` passed to that call. - -## Shared Context with `imprint` - -`imprint` adds fields to every new `Scroll` created by the same `Scribe` object. +| Intake | Job | Behavior | +|---|---|---| +| open | dismissed | new entries accumulate in the buffer | +| open | hired | new and buffered entries are delivered | +| closed | hired | no new entries are accepted; the backlog keeps draining | +| closed | dismissed | no intake or delivery occurs; the backlog is preserved | ```kotlin -object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(ScrollSaver { println(it) }) - override val imprint = mapOf( - "app" to JsonPrimitive("checkout"), - "region" to JsonPrimitive("us-east-1"), - ) -} - -CheckoutScribe.hire(channel = Channel(capacity = 256)) +CheckoutScribe.hire() // start or resume the job +CheckoutScribe.dismiss() // request a cooperative pause immediately +CheckoutScribe.closeIntake() +CheckoutScribe.openIntake() ``` -These values are inserted into the scroll map and then appear in `SealedScroll.data`. +Archivist failure handling is configured by the implementation's `onArchiveFailure` property. +Calling `hire()` +while processing is active has no effect. `dismiss()` is reversible, returns immediately, and does +not close intake. An entry already received by the worker may finish or remain held at the pause +gate; all other entries stay in the private buffer until the next `hire()`. -## Open and Close Hooks with `Margin` +Archivists process each entry concurrently. The processor waits for every archivist to finish +before consuming the next entry, preserving entry order for each archivist while preventing one +archivist from delaying the start of its peers. -Use `Margin` when scrolls need standard fields at creation and sealing time. +## Emission -```kotlin -val timingMargin = object : Margin { - override fun header(scroll: Scroll) { - scroll["started_at"] = JsonPrimitive(1000) - } - - override fun footer(scroll: Scroll) { - scroll["sealed_at"] = JsonPrimitive(2000) - } -} +`seal(scribe)` applies the footer margin, snapshots the current `Scroll`, and attempts to place the +resulting `Entry` in the private buffer. It is non-suspending and never blocks waiting for buffer +space. Entries rejected because intake is closed, the buffer is full with `SUSPEND`, or retirement +has begun are not delivered. Prefer `DROP_OLDEST` or `DROP_LATEST` for synchronous logging. -object CheckoutScribe : Scribe() { - override val shelves: List> = listOf(ScrollSaver { println(it) }) - override val margins = timingMargin -} +`seal(...)` always returns the created snapshot; its return value does not indicate whether the +buffer accepted it. With `DROP_LATEST`, the channel can report a successful send while discarding +the new entry according to that overflow policy. -CheckoutScribe.hire(channel = Channel(capacity = 256)) -``` +Multiple calls to `seal(...)` on the same `Scroll` intentionally create separate snapshots. -## Graceful Shutdown +## Terminal Retirement -Use `retire()` to stop intake and wait until queued delivery work is finished. +`retire()` is distinct from the reversible `dismiss()`: ```kotlin CheckoutScribe.retire() ``` -After `retire()`, that object's previous channel is closed and cannot be -reused. Call `hire(...)` with a new channel to restart its delivery. Other -active `Scribe` objects are unaffected. +It closes intake and the private buffer, finishes the active archivist call, drains all accepted +entries, and releases the internally owned scope. Intake and processing cannot restart +afterward. -## Uncaught Exceptions +Concurrent or repeated callers wait for the same retirement operation. Cancelling one waiting +caller does not cancel the drain, and a later `retire()` call can still await its completion. +Calling `retire()` from an archivist or one of its child coroutines throws an +`IllegalStateException` to avoid waiting on the processor from within its own job tree; request +retirement from the application lifecycle owner instead. -Override `onIgnition` on an application-owned `Scribe` object to install the -platform uncaught exception hook when that object is first hired: +The JVM SLF4J provider registers a shutdown hook that calls `retire()` automatically. It does not +call `hire()`: the application chooses when processing begins, while earlier SLF4J calls accumulate +in the backend's private buffer. -```kotlin -object ApplicationScribe : Scribe() { - override val shelves: List> = listOf(EntrySaver { println(it) }) - override val onIgnition: ((Throwable) -> Unit)? = { throwable -> - println("Uncaught exception: ${throwable.message}") - } -} -``` +## Uncaught Exceptions -This hook is platform-global even though the property is declared by one -runtime object. Saver-level failures are handled separately by `onSaver` passed -to `hire(...)`. +Override `onIgnition` on an application-owned `Scribe` to install the platform uncaught exception +hook when processing is first hired. The hook is platform-global even though it is configured on +one instance. Archivist failures are handled separately by the implementation's +`onArchiveFailure` property. diff --git a/docs/scribe-logo.svg b/docs/scribe-logo.svg index 7204393..80e84fd 100644 --- a/docs/scribe-logo.svg +++ b/docs/scribe-logo.svg @@ -1,6 +1,6 @@ + xmlns="http://www.w3.org/2000/svg"> diff --git a/docs/slf4j.md b/docs/slf4j.md new file mode 100644 index 0000000..636e9e7 --- /dev/null +++ b/docs/slf4j.md @@ -0,0 +1,105 @@ +# SLF4J Provider + +The `scribe-slf4j` module is a JVM SLF4J 2.x provider. It converts conventional SLF4J calls into +Scribe `Entry` snapshots and sends them through one application-wide `Slf4jScribe` backend. + +## Install + +Add the provider to a JVM application's runtime dependencies: + +```kotlin +dependencies { + implementation("com.rafambn:scribe-slf4j:0.6.0") +} +``` + +The module exposes the SLF4J API and the core `scribe` module transitively. It is itself an SLF4J +provider, so do not put another provider such as `logback-classic` on the same runtime classpath. + +## Define the Backend + +Annotate exactly one Kotlin `object` that extends `Slf4jScribe`: + +```kotlin +@ScribeBackend +object AppScribe : Slf4jScribe() { + override val bufferCapacity = 1_024 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val archivists = listOf( + Archivist { entry -> println(entry) }, + ) + + override val onArchiveFailure = + { _: Archivist, _: Entry, error: Throwable -> error.printStackTrace() } + + override fun isEnabled( + loggerName: String, + level: Level, + marker: Marker?, + ): Boolean = level.toInt() >= Level.INFO.toInt() +} +``` + +The provider scans the runtime classpath on its first initialization. Initialization fails if it +finds no annotated backend, more than one, a class that does not extend `Slf4jScribe`, or an +annotated class that is not a Kotlin object. + +## Start Processing + +SLF4J calls are accepted as soon as the provider is initialized, but processing starts dismissed. +Call `hire()` during application startup: + +```kotlin +fun main() { + AppScribe.hire() + + val logger = LoggerFactory.getLogger("checkout") + logger.info("Starting order {}", 42) +} +``` + +Calls made before `hire()` accumulate according to the backend's capacity and overflow policy. +The provider registers a JVM shutdown hook that calls `retire()` and drains accepted entries. You +may also retire explicitly from your lifecycle owner; repeated calls await the same terminal +retirement operation. + +## Default Entry Mapping + +The default `handleNormalizedLoggingCall(...)` uses SLF4J's message formatter and writes these +fields: + +| Field | When present | Value | +|---|---|---| +| `scroll_id` | always | generated Scribe scroll ID | +| `level` | always | SLF4J level name | +| `logger` | always | requested logger name | +| `message` | always | formatted message | +| `marker` | when supplied | marker name | +| `exception` | when supplied | exception stack trace | + +The current MDC map is copied into the entry. MDC keys cannot replace the reserved fields above, +and they also do not replace fields already supplied by the backend's imprint or header margin. + +`isEnabled(loggerName, level, marker)` runs before a scroll is allocated. It can filter by logger, +level, and marker. All levels are disabled automatically while intake is closed or retirement is +in progress. + +## Customize the Mapping + +Override `handleNormalizedLoggingCall` when the default field names or message formatting do not +fit your schema. The call contains the logger name, level, marker, raw message pattern, arguments, +throwable, and an MDC snapshot: + +```kotlin +override fun handleNormalizedLoggingCall(call: ScribeLoggingCall) { + val scroll = newScroll() + scroll["severity"] = JsonPrimitive(call.level.name) + scroll["source"] = JsonPrimitive(call.loggerName) + scroll["template"] = JsonPrimitive(call.messagePattern.orEmpty()) + call.mdc["requestId"]?.let { scroll["request_id"] = JsonPrimitive(it) } + scroll.seal(this) +} +``` + +The override owns the mapping and must seal a scroll itself to deliver an entry. Call +`super.handleNormalizedLoggingCall(call)` instead when the default mapping is desired. diff --git a/gradle.properties b/gradle.properties index 19aa1d4..68613dd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,7 @@ org.gradle.configuration-cache=true kotlin.code.style=official #MPP kotlin.mpp.enableCInteropCommonization=true +kotlin.native.ignoreDisabledTargets=true #Android android.useAndroidX=true android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 161cfec..f3fba36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,27 @@ [versions] -agp = "9.2.1" -kotlin = "2.3.21" -compose-multiplatform = "1.10.3" +agp = "9.3.1" +kotlin = "2.4.10" +compose-multiplatform = "1.11.1" android-minSdk = "24" android-compileSdk = "37" -vanniktechMavenPublish = "0.36.0" +material3 = "1.9.0" +vanniktechMavenPublish = "0.37.0" kotlinxSerialization = "1.11.0" -kotlinxCoroutines = "1.10.2" -androidx-activity-compose = "1.9.3" +kotlinxCoroutines = "1.11.0" +androidx-activity-compose = "1.13.0" +slf4j = "2.0.18" +classgraph = "4.8.181" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } -scribe = { module = "com.rafambn:scribe", version = "0.4.0" } +material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } +scribe = { module = "com.rafambn:scribe", version = "0.5.0" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" } [plugins] android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1a70468..1e922f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/mkdocs.yml b/mkdocs.yml index 9015af9..a0e5abf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,7 +28,7 @@ nav: - Getting Started: getting-started.md - API Concepts: api-concepts.md - Lifecycle and Delivery: lifecycle-and-delivery.md - - Console Showcase: console-showcase.md + - SLF4J Provider: slf4j.md markdown_extensions: - admonition diff --git a/scribe-logo.svg b/scribe-logo.svg index 7204393..80e84fd 100644 --- a/scribe-logo.svg +++ b/scribe-logo.svg @@ -1,6 +1,6 @@ + xmlns="http://www.w3.org/2000/svg"> diff --git a/scribe-slf4j/build.gradle.kts b/scribe-slf4j/build.gradle.kts new file mode 100644 index 0000000..e66871a --- /dev/null +++ b/scribe-slf4j/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + kotlin("jvm") + alias(libs.plugins.vanniktech.mavenPublish) +} + +group = "com.rafambn" +version = "0.6.0" + +kotlin { + jvmToolchain(11) +} + +dependencies { + api(project(":scribe")) + api(libs.slf4j.api) + implementation(libs.classgraph) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.kotlin.test) +} + +mavenPublishing { + coordinates( + groupId = group.toString(), + artifactId = "scribe-slf4j", + version = version.toString() + ) + + pom { + name.set("Scribe SLF4J") + description.set("SLF4J binding for Scribe structured logging.") + url.set("https://scribe.rafambn.com") + licenses { + license { + name.set("Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + } + } + developers { + developer { + id.set("rafambn") + name.set("Rafael Mendonca") + email.set("rafambn@gmail.com") + url.set("https://rafambn.com") + } + } + scm { + url.set("https://github.com/rafambn/Scribe") + } + } + + publishToMavenCentral(automaticRelease = false) + signAllPublications() +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt new file mode 100644 index 0000000..6576613 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/NamedScribeLogger.kt @@ -0,0 +1,54 @@ +package com.rafambn.scribe.slf4j + +import org.slf4j.MDC +import org.slf4j.Marker +import org.slf4j.event.Level +import org.slf4j.helpers.AbstractLogger + +/** Lightweight SLF4J adapter that preserves the name requested from LoggerFactory. */ +internal class NamedScribeLogger( + private val loggerName: String, + private val scribe: Slf4jScribe, +) : AbstractLogger() { + override fun getName(): String = loggerName + + override fun isTraceEnabled(): Boolean = isEnabled(Level.TRACE, null) + override fun isTraceEnabled(marker: Marker?): Boolean = isEnabled(Level.TRACE, marker) + + override fun isDebugEnabled(): Boolean = isEnabled(Level.DEBUG, null) + override fun isDebugEnabled(marker: Marker?): Boolean = isEnabled(Level.DEBUG, marker) + + override fun isInfoEnabled(): Boolean = isEnabled(Level.INFO, null) + override fun isInfoEnabled(marker: Marker?): Boolean = isEnabled(Level.INFO, marker) + + override fun isWarnEnabled(): Boolean = isEnabled(Level.WARN, null) + override fun isWarnEnabled(marker: Marker?): Boolean = isEnabled(Level.WARN, marker) + + override fun isErrorEnabled(): Boolean = isEnabled(Level.ERROR, null) + override fun isErrorEnabled(marker: Marker?): Boolean = isEnabled(Level.ERROR, marker) + + private fun isEnabled(level: Level, marker: Marker?): Boolean = + scribe.isLoggingEnabled(loggerName, level, marker) + + override fun handleNormalizedLoggingCall( + level: Level, + marker: Marker?, + messagePattern: String?, + arguments: Array?, + throwable: Throwable?, + ) { + scribe.dispatch( + ScribeLoggingCall( + loggerName = loggerName, + level = level, + marker = marker, + messagePattern = messagePattern, + arguments = arguments, + throwable = throwable, + mdc = MDC.getCopyOfContextMap()?.toMap().orEmpty(), + ), + ) + } + + override fun getFullyQualifiedCallerName(): String = javaClass.name +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeBackend.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeBackend.kt new file mode 100644 index 0000000..fa204a4 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeBackend.kt @@ -0,0 +1,6 @@ +package com.rafambn.scribe.slf4j + +/** Selects the single [Slf4jScribe] object that will back SLF4J in this application. */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ScribeBackend diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggerFactory.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggerFactory.kt new file mode 100644 index 0000000..a42b615 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggerFactory.kt @@ -0,0 +1,13 @@ +package com.rafambn.scribe.slf4j + +import org.slf4j.ILoggerFactory +import org.slf4j.Logger +import java.util.concurrent.ConcurrentHashMap + +internal class ScribeLoggerFactory(private val scribe: Slf4jScribe) : ILoggerFactory { + private val loggers = ConcurrentHashMap() + + override fun getLogger(name: String): Logger { + return loggers.computeIfAbsent(name) { NamedScribeLogger(it, scribe) } + } +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggingCall.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggingCall.kt new file mode 100644 index 0000000..6fda19e --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeLoggingCall.kt @@ -0,0 +1,43 @@ +package com.rafambn.scribe.slf4j + +import org.slf4j.Marker +import org.slf4j.event.Level + +/** Complete snapshot of a normalized SLF4J call routed to a [Slf4jScribe]. */ +data class ScribeLoggingCall( + val loggerName: String, + val level: Level, + val marker: Marker?, + val messagePattern: String?, + val arguments: Array?, + val throwable: Throwable?, + val mdc: Map, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ScribeLoggingCall + + if (loggerName != other.loggerName) return false + if (level != other.level) return false + if (marker != other.marker) return false + if (messagePattern != other.messagePattern) return false + if (!arguments.contentEquals(other.arguments)) return false + if (throwable != other.throwable) return false + if (mdc != other.mdc) return false + + return true + } + + override fun hashCode(): Int { + var result = loggerName.hashCode() + result = 31 * result + level.hashCode() + result = 31 * result + (marker?.hashCode() ?: 0) + result = 31 * result + (messagePattern?.hashCode() ?: 0) + result = 31 * result + (arguments?.contentHashCode() ?: 0) + result = 31 * result + (throwable?.hashCode() ?: 0) + result = 31 * result + mdc.hashCode() + return result + } +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt new file mode 100644 index 0000000..f4a6344 --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/ScribeServiceProvider.kt @@ -0,0 +1,85 @@ +package com.rafambn.scribe.slf4j + +import io.github.classgraph.ClassGraph +import kotlinx.coroutines.runBlocking +import org.slf4j.ILoggerFactory +import org.slf4j.IMarkerFactory +import org.slf4j.helpers.BasicMDCAdapter +import org.slf4j.helpers.BasicMarkerFactory +import org.slf4j.spi.MDCAdapter +import org.slf4j.spi.SLF4JServiceProvider + +/** SLF4J provider backed by the single [Slf4jScribe] object annotated with [ScribeBackend]. */ +class ScribeServiceProvider : SLF4JServiceProvider { + private val markerFactory: IMarkerFactory = BasicMarkerFactory() + private val mdcAdapter: MDCAdapter = BasicMDCAdapter() + private lateinit var scribe: Slf4jScribe + private lateinit var loggerFactory: ILoggerFactory + + override fun initialize() { + check(!::scribe.isInitialized) { "Scribe SLF4J provider is already initialized." } + val createdScribe = discoverScribe() + try { + registerShutdownHook(createdScribe) + } catch (error: Throwable) { + runCatching { runBlocking { createdScribe.retire() } } + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error + } + scribe = createdScribe + loggerFactory = ScribeLoggerFactory(createdScribe) + } + + override fun getLoggerFactory(): ILoggerFactory { + check(::loggerFactory.isInitialized) { "Scribe SLF4J provider has not been initialized." } + return loggerFactory + } + + override fun getMarkerFactory(): IMarkerFactory = markerFactory + + override fun getMDCAdapter(): MDCAdapter = mdcAdapter + + override fun getRequestedApiVersion(): String = "2.0.99" + + private fun discoverScribe(): Slf4jScribe { + val backendClasses = ClassGraph() + .enableAnnotationInfo() + .scan() + .use { scan -> + scan.getClassesWithAnnotation(ScribeBackend::class.java.name) + .map { it.loadClass() } + } + + check(backendClasses.isNotEmpty()) { + "No Scribe SLF4J backend was found. Annotate exactly one Kotlin object extending " + + "Slf4jScribe with @ScribeBackend." + } + check(backendClasses.size == 1) { + "Multiple Scribe SLF4J backends were found: ${backendClasses.joinToString { it.name }}. " + + "Annotate exactly one Kotlin object with @ScribeBackend." + } + + val backendClass = backendClasses.single() + check(Slf4jScribe::class.java.isAssignableFrom(backendClass)) { + "@ScribeBackend class ${backendClass.name} must extend Slf4jScribe." + } + + val instance = runCatching { backendClass.getField("INSTANCE").get(null) } + .getOrElse { error -> + throw IllegalStateException( + "@ScribeBackend class ${backendClass.name} must be a Kotlin object.", + error, + ) + } + return instance as Slf4jScribe + } + + private fun registerShutdownHook(scribe: Slf4jScribe) { + val hook = Thread( + { runBlocking { scribe.retire() } }, + "scribe-slf4j-shutdown", + ) + Runtime.getRuntime().addShutdownHook(hook) + } +} diff --git a/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/Slf4jScribe.kt b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/Slf4jScribe.kt new file mode 100644 index 0000000..888f76d --- /dev/null +++ b/scribe-slf4j/src/main/kotlin/com/rafambn/scribe/slf4j/Slf4jScribe.kt @@ -0,0 +1,54 @@ +package com.rafambn.scribe.slf4j + +import com.rafambn.scribe.Scribe +import com.rafambn.scribe.seal +import kotlinx.serialization.json.JsonPrimitive +import org.slf4j.Marker +import org.slf4j.event.Level +import org.slf4j.helpers.MessageFormatter + +/** Process-wide Scribe backend selected at runtime with [ScribeBackend]. */ +abstract class Slf4jScribe : Scribe() { + + /** Returns whether a call should be accepted before a scroll is allocated. */ + open fun isEnabled(loggerName: String, level: Level, marker: Marker?): Boolean = true + + /** Converts a normalized SLF4J call into a scroll owned by this Scribe. */ + open fun handleNormalizedLoggingCall(call: ScribeLoggingCall) { + val message = MessageFormatter.arrayFormat(call.messagePattern, call.arguments).message + val scroll = newScroll() + + scroll["level"] = JsonPrimitive(call.level.name) + scroll["logger"] = JsonPrimitive(call.loggerName) + scroll["message"] = JsonPrimitive(message) + + call.marker?.let { + scroll["marker"] = JsonPrimitive(it.name) + } + + call.throwable?.let { + scroll["exception"] = JsonPrimitive(it.stackTraceToString()) + } + + call.mdc.forEach { (key, value) -> + if (key !in RESERVED_FIELDS && !scroll.containsKey(key)) { + scroll[key] = JsonPrimitive(value) + } + } + + scroll.seal(this) + } + + internal fun isLoggingEnabled(loggerName: String, level: Level, marker: Marker?): Boolean = + isIntakeOpen && isEnabled(loggerName, level, marker) + + internal fun dispatch(call: ScribeLoggingCall) { + if (isIntakeOpen) { + handleNormalizedLoggingCall(call) + } + } + + private companion object { + val RESERVED_FIELDS = setOf("scroll_id", "level", "logger", "message", "marker", "exception") + } +} diff --git a/scribe-slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider b/scribe-slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider new file mode 100644 index 0000000..9a18c5c --- /dev/null +++ b/scribe-slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider @@ -0,0 +1 @@ +com.rafambn.scribe.slf4j.ScribeServiceProvider diff --git a/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt b/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt new file mode 100644 index 0000000..a20f3a4 --- /dev/null +++ b/scribe-slf4j/src/test/kotlin/com/rafambn/scribe/slf4j/ScribeSLF4JTest.kt @@ -0,0 +1,175 @@ +package com.rafambn.scribe.slf4j + +import com.rafambn.scribe.Archivist +import com.rafambn.scribe.Entry +import com.rafambn.scribe.seal +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonPrimitive +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.slf4j.Marker +import org.slf4j.MarkerFactory +import org.slf4j.event.Level +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@ScribeBackend +internal object TestScribeBackend : Slf4jScribe() { + val captured = CopyOnWriteArrayList() + + override val archivists = listOf(Archivist { entry -> captured.add(entry) }) + override val bufferCapacity: Int = 256 + override val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + + override fun isEnabled(loggerName: String, level: Level, marker: Marker?): Boolean { + return if (loggerName == "CustomLogger") { + level == Level.ERROR && marker?.name == "CUSTOM" + } else { + true + } + } + + override fun handleNormalizedLoggingCall(call: ScribeLoggingCall) { + if (call.loggerName != "CustomLogger") { + super.handleNormalizedLoggingCall(call) + return + } + + val scroll = newScroll() + scroll["source"] = JsonPrimitive(call.loggerName) + scroll["severity"] = JsonPrimitive(call.level.name) + scroll["template"] = JsonPrimitive(call.messagePattern.orEmpty()) + scroll["argument"] = JsonPrimitive(call.arguments?.firstOrNull().toString()) + scroll["marker"] = JsonPrimitive(call.marker?.name.orEmpty()) + scroll["failure"] = JsonPrimitive(call.throwable?.message.orEmpty()) + scroll["request"] = JsonPrimitive(call.mdc.getValue("requestId")) + scroll.seal(this) + } +} + +class ScribeSLF4JTest { + @BeforeTest + fun resetCapturedEntries() { + MDC.clear() + TestScribeBackend.captured.clear() + TestScribeBackend.hire() + } + + @Test + fun `logging buffers until the user hires processing`() = runBlocking { + val captured = CopyOnWriteArrayList() + val backend = object : Slf4jScribe() { + override val archivists = listOf(Archivist { entry -> captured.add(entry) }) + override val bufferCapacity: Int = 256 + override val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + } + + backend.dispatch( + ScribeLoggingCall( + loggerName = "BufferedLogger", + level = Level.INFO, + marker = null, + messagePattern = "before hire", + arguments = null, + throwable = null, + mdc = emptyMap(), + ), + ) + delay(50) + assertTrue(captured.isEmpty()) + + backend.hire() + withTimeout(5.seconds) { + while (captured.isEmpty()) delay(10) + } + + assertEquals("before hire", (captured.single()["message"] as JsonPrimitive).content) + backend.retire() + } + + @Test + fun `provider discovers annotated backend and user starts processing`() = runBlocking { + val logger = LoggerFactory.getLogger("TestLogger") + + assertTrue(logger.isInfoEnabled) + logger.info("Hello SLF4J!") + awaitCapturedEntries(1) + + val entry = TestScribeBackend.captured.single() + assertEquals("INFO", (entry["level"] as JsonPrimitive).content) + assertEquals("TestLogger", (entry["logger"] as JsonPrimitive).content) + assertEquals("Hello SLF4J!", (entry["message"] as JsonPrimitive).content) + } + + @Test + fun `mdc context is included without replacing reserved log fields`() = runBlocking { + val logger = LoggerFactory.getLogger("MdcLogger") + + try { + MDC.put("requestId", "req-123") + MDC.put("message", "must not replace the log message") + MDC.put("scroll_id", "must not replace the scroll id") + logger.info("Processing request") + + MDC.clear() + logger.info("Context cleared") + awaitCapturedEntries(2) + } finally { + MDC.clear() + } + + assertEquals("req-123", (TestScribeBackend.captured[0]["requestId"] as JsonPrimitive).content) + assertEquals("Processing request", (TestScribeBackend.captured[0]["message"] as JsonPrimitive).content) + assertFalse( + (TestScribeBackend.captured[0]["scroll_id"] as JsonPrimitive).content.startsWith("must not"), + ) + assertTrue("requestId" !in TestScribeBackend.captured[1]) + } + + @Test + fun `annotated scribe controls levels and normalized call mapping`() = runBlocking { + val logger = LoggerFactory.getLogger("CustomLogger") + val marker = MarkerFactory.getMarker("CUSTOM") + + try { + assertFalse(logger.isDebugEnabled) + assertFalse(logger.isErrorEnabled) + assertTrue(logger.isErrorEnabled(marker)) + logger.info("Ignored") + logger.error("Also ignored") + + MDC.put("requestId", "req-custom") + logger.error(marker, "Failure {}", 42, IllegalStateException("boom")) + awaitCapturedEntries(1) + } finally { + MDC.clear() + } + + val entry = TestScribeBackend.captured.single() + assertEquals("CustomLogger", (entry["source"] as JsonPrimitive).content) + assertEquals("ERROR", (entry["severity"] as JsonPrimitive).content) + assertEquals("Failure {}", (entry["template"] as JsonPrimitive).content) + assertEquals("42", (entry["argument"] as JsonPrimitive).content) + assertEquals("CUSTOM", (entry["marker"] as JsonPrimitive).content) + assertEquals("boom", (entry["failure"] as JsonPrimitive).content) + assertEquals("req-custom", (entry["request"] as JsonPrimitive).content) + } + + private suspend fun awaitCapturedEntries(count: Int) { + withTimeout(5.seconds) { + while (TestScribeBackend.captured.size < count) { + delay(10) + } + } + } +} diff --git a/scribe/build.gradle.kts b/scribe/build.gradle.kts index a988607..963d6c2 100644 --- a/scribe/build.gradle.kts +++ b/scribe/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } group = "com.rafambn" -version = "0.5.0" +version = "0.6.0" kotlin { jvm { diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt new file mode 100644 index 0000000..21ec79a --- /dev/null +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Archivist.kt @@ -0,0 +1,11 @@ +package com.rafambn.scribe + +/** + * Contract for persisting [Entry] structured logs produced by [Scribe]. + */ +fun interface Archivist { + /** + * Handles an emitted structured log. + */ + suspend fun write(event: Entry) +} diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Events.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Events.kt deleted file mode 100644 index 8701253..0000000 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Events.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.rafambn.scribe -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -/** - * Base type for all events emitted by [Scribe]. - */ -@Serializable -sealed interface Entry - -/** - * Final representation of a scroll once it has been sealed. - */ -@Serializable -data class SealedScroll( - val success: Boolean, - val data: Map, -): Entry - -/** - * Lightweight standalone log message emitted through a [Scribe] instance. - */ -@Serializable -data class Note( - val tag: String, - val message: String, - val level: Urgency, - val timestamp: Long, -): Entry - -/** - * Severity level used by [Note]. - */ -@Serializable -enum class Urgency { - VERBOSE, - DEBUG, - INFO, - WARN, - ERROR -} diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 3bf9fd7..f3b9e96 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -1,34 +1,55 @@ package com.rafambn.scribe import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi /** - * Independent event writer that creates [Scroll]s and dispatches [Entry] objects to configured savers. + * Independent structured log writer that creates [Scroll]s and dispatches [Entry] snapshots to configured archivists. * * Create an object that extends this type and override its configuration: * * ``` * object AppScribe : Scribe() { - * override val shelves = listOf(EntrySaver { entry -> println(entry) }) + * override val bufferCapacity = 256 + * override val bufferOverflow = BufferOverflow.DROP_OLDEST + * override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit)? = null + * override val archivists = listOf(Archivist { entry -> println(entry) }) * } * ``` */ +@OptIn(ExperimentalAtomicApi::class) abstract class Scribe { /** - * Savers receiving entries emitted by this instance. + * Archivists receiving structured logs emitted by this instance. */ - protected abstract val shelves: List> + protected open val archivists: List = emptyList() + + /** Maximum number of entries retained by this instance's private buffer. */ + protected open val bufferCapacity: Int = 256 + + /** Overflow behavior used when this instance's private buffer is full. */ + protected open val bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST + + /** Callback invoked when an archivist fails to write an entry. */ + protected open val onArchiveFailure: ((archivist: Archivist, entry: Entry, error: Throwable) -> Unit)? = null /** * Fields copied into every [Scroll] created by this instance. @@ -48,62 +69,73 @@ abstract class Scribe { */ protected open val onIgnition: ((Throwable) -> Unit)? = null - private var activeQueue: Channel? = null - private var processorJob: Job? = null - private var ignitionInstalled: Boolean = false + private val queue: Channel by lazy { + Channel( + capacity = bufferCapacity, + onBufferOverflow = bufferOverflow, + ) + } + private val ownedScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val intakeOpen = AtomicBoolean(true) + private val processingEnabled = MutableStateFlow(false) + private val retiring = AtomicBoolean(false) + private val ignitionInstalled = AtomicBoolean(false) + private val retirementCompleted = CompletableDeferred() + private val processorJob: Job by lazy { + ownedScope.launch { + try { + for (entry in queue) { + processingEnabled.first { it } + archive(archivists, entry) + } + } finally { + processingEnabled.value = false + } + } + } + + /** Whether new entries are currently accepted into the private buffer. */ + val isIntakeOpen: Boolean + get() = intakeOpen.load() && !retiring.load() + + /** Whether buffered entries are currently being processed. */ + val isProcessing: Boolean + get() = processingEnabled.value /** - * Starts delivery for this runtime instance. + * Starts or resumes delivery from this instance's private buffer. * - * The provided [channel] becomes disposable and transfers ownership to this instance. - * This instance closes the channel when the processor completes or when [retire] is called. - * Create a fresh channel for each call to this method. + * Entries can be accepted before this method is called. Calling this method while processing + * is already active has no effect. After [dismiss], this method resumes processing. */ - fun hire( - scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), - channel: Channel, - onSaver: ((saver: Saver<*>, entry: Entry, error: Throwable) -> Unit)? = null, - ) { - val configuredShelves = shelves - require(configuredShelves.isNotEmpty()) { "At least one shelf is required." } - check(activeQueue == null) { "Scribe runtime is already active. Call retire() first." } - check(processorJob?.isActive != true) { "Scribe is still retiring. Wait for pending delivery to finish." } + fun hire() { + val configuredArchivists = archivists + require(configuredArchivists.isNotEmpty()) { "At least one archivist is required." } + check(!retiring.load()) { "This Scribe has been retired." } val exceptionHandler = onIgnition - if (!ignitionInstalled && exceptionHandler != null) { + if (exceptionHandler != null && ignitionInstalled.compareAndSet(expectedValue = false, newValue = true)) { installUncaughtExceptionHandler(exceptionHandler) - ignitionInstalled = true } - activeQueue = channel - val createdProcessor = scope.launch { - for (entry in channel) { - configuredShelves.forEach { saver -> - try { - when (saver) { - is EntrySaver -> saver.write(entry) - 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) { - try { - onSaver?.invoke(saver, entry, e) - } catch (_: Throwable) { - // Ignore callback failures to keep delivery alive. - } - } - } - } - } - processorJob = createdProcessor - createdProcessor.invokeOnCompletion { - channel.close() - if (processorJob === createdProcessor) { - processorJob = null - } + + val processor = processorJob + processingEnabled.value = true + if (processor.isCompleted) { + processingEnabled.value = false + error("The Scribe processor has terminated and cannot be restarted.") } } + /** Allows new entries to be accepted into the private buffer. */ + fun openIntake() { + check(!retiring.load()) { "This Scribe has been retired." } + intakeOpen.store(true) + } + + /** Stops accepting new entries without changing processing of entries already buffered. */ + fun closeIntake() { + intakeOpen.store(false) + } + /** * Creates a new scroll, optionally with a custom unique [id]. * @@ -121,24 +153,43 @@ abstract class Scribe { } /** - * 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. + * Cooperatively pauses delivery, preserving queued entries. + * Intake remains independently controlled by [openIntake] and [closeIntake]. Call [hire] to + * resume processing. + */ + fun dismiss() { + processingEnabled.value = false + } + + /** + * Permanently closes intake, drains every accepted entry, and releases the owned runtime. + * This is the terminal lifecycle operation; neither intake nor processing can restart afterward. */ suspend fun retire() { - val queue = activeQueue - val runningProcessor = processorJob - if (queue == null && runningProcessor == null) return - activeQueue = null - queue?.close() val callerJob = currentCoroutineContext()[Job] - if (runningProcessor != null && !isProcessorFamily(runningProcessor, callerJob)) { - runningProcessor.join() + check(!isProcessorFamily(processorJob, callerJob)) { + "retire() cannot be called from an archivist; request it from the lifecycle owner." + } + + if (retiring.compareAndSet(expectedValue = false, newValue = true)) { + intakeOpen.store(false) + processingEnabled.value = true + queue.close() + + val processor = processorJob + ownedScope.launch { + try { + processor.join() + retirementCompleted.complete(Unit) + } catch (error: Throwable) { + retirementCompleted.completeExceptionally(error) + } finally { + ownedScope.cancel() + } + } } + + retirementCompleted.await() } /** @@ -156,42 +207,33 @@ abstract class Scribe { return false } - /** - * Emits a [Note] immediately, blocking only when the channel buffer is full under [BufferOverflow.SUSPEND][kotlinx.coroutines.channels.BufferOverflow.SUSPEND]. - * - * @param tag logical source/category for the note. - * @param message note text payload. - * @param level severity level for the note. - * @param timestamp epoch milliseconds associated with the note. - */ - fun note(tag: String, message: String, level: Urgency = Urgency.INFO, timestamp: Long = nowEpochMs()) { - requireActiveQueue().trySendBlocking( - Note( - tag = tag, - message = message, - level = level, - timestamp = timestamp, - ), - ) - } - internal fun applyFooter(scroll: Scroll) { margins?.footer(scroll) } - private fun requireActiveQueue(): Channel { - return activeQueue ?: throw IllegalStateException("This Scribe runtime is not active. Call hire(...) first.") + internal fun enqueue(entry: Entry): Boolean { + if (!isIntakeOpen) return false + return queue.trySend(entry).isSuccess } - internal fun enqueue(entry: Entry) { - requireActiveQueue().trySendBlocking(entry) - } - - private fun Channel.trySendBlocking(element: E) { - val result = trySend(element) - if (result.isSuccess) return - runBlocking { - runCatching { send(element) } + private suspend fun archive( + configuredArchivists: List, + entry: Entry, + ) = coroutineScope { + configuredArchivists.forEach { archivist -> + launch { + try { + archivist.write(entry) + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + } catch (e: Throwable) { + try { + onArchiveFailure?.invoke(archivist, entry, e) + } catch (_: Throwable) { + // Ignore callback failures to keep delivery alive. + } + } + } } } } diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt index 1f62cd7..7099bf5 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt @@ -3,15 +3,23 @@ package com.rafambn.scribe import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid typealias Scroll = MutableMap +/** Immutable structured log emitted when a [Scroll] is sealed. */ +typealias Entry = Map + val Scroll.id: String get() = this["scroll_id"]?.let { (it as? JsonPrimitive)?.content } ?: error("Invalid scroll id metadata.") -fun Scroll.seal(scribe: Scribe, success: Boolean = true): SealedScroll { +@OptIn(ExperimentalUuidApi::class) +internal fun newScrollId(): String = Uuid.random().toString() + +fun Scroll.seal(scribe: Scribe): Entry { scribe.applyFooter(this) - val result = SealedScroll(success = success, data = this.toMap()) + val result: Entry = toMap() scribe.enqueue(result) return result } diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt deleted file mode 100644 index 7ed83ec..0000000 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Shelf.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.rafambn.scribe - -/** - * Contract for persisting [Entry] instances produced by [Scribe]. - */ -fun interface Saver { - /** - * Handles an emitted event. - */ - suspend fun write(event: T) -} - -/** - * Saver specialized for [Note] events. - */ -fun interface NoteSaver : Saver - -/** - * Saver specialized for [SealedScroll] events. - */ -fun interface ScrollSaver : Saver - -/** - * Saver that receives all entry types. - */ -fun interface EntrySaver : Saver diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Utils.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Utils.kt deleted file mode 100644 index 6f2e0f6..0000000 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Utils.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.rafambn.scribe - -import kotlin.time.Clock -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class) -internal fun newScrollId(): String = Uuid.random().toString() - -internal fun nowEpochMs(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt index 8cd9731..eeefe6e 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt @@ -1,41 +1,44 @@ package com.rafambn.scribe import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals class ScribeConcurrencyAndScrollTest { @Test - fun note_supports_high_throughput_concurrent_writes() { + fun scroll_seal_supports_high_throughput_concurrent_writes() { runSuspend { - val saver = RecordingNoteSaver() - val scribe = scribeWithSavers( - shelves = listOf(saver), - channel = Channel(Channel.UNLIMITED), + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves( + shelf, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, ) coroutineScope { repeat(1_000) { index -> launch(Dispatchers.Default) { - scribe.note( - tag = "stress", - message = "msg-$index", - level = Urgency.INFO, - timestamp = index.toLong(), - ) + scribe.newScroll().apply { + this["msg"] = JsonPrimitive("msg-$index") + }.seal(scribe) } } } - saver.awaitEvents(1_000) + shelf.awaitEvents(1_000) scribe.retire() - assertEquals(1_000, saver.events.size) - assertEquals(1_000, saver.events.map { it.message }.toSet().size) + assertEquals(1_000, shelf.events.size) + assertEquals( + 1_000, + shelf.events.map { it["msg"]?.jsonPrimitive?.content }.toSet().size, + ) } } @@ -47,17 +50,16 @@ class ScribeConcurrencyAndScrollTest { val scroll = scribe.newScroll(id = "scroll-id") scroll["state"] = JsonPrimitive("initial") - val first = scroll.seal(scribe, success = false) - val second = scroll.seal(scribe, success = true) + scroll.seal(scribe) + scroll.seal(scribe) shelf.awaitEvents(2) scribe.retire() - assertEquals(false, first.success) - assertEquals(JsonPrimitive("initial"), first.data["state"]) - - assertEquals(true, second.success) - assertEquals(JsonPrimitive("initial"), second.data["state"]) assertEquals(2, shelf.events.size) + shelf.events.forEach { event -> + assertEquals(JsonPrimitive("initial"), event["state"]) + assertEquals("scroll-id", (event["scroll_id"] as? JsonPrimitive)?.content) + } } } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt index 7e7a860..28817e0 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("mobile-app"), event.data["service"]) - assertEquals(JsonPrimitive("production"), event.data["environment"]) + assertEquals(JsonPrimitive("mobile-app"), event["service"]) + assertEquals(JsonPrimitive("production"), event["environment"]) } } @@ -40,13 +40,13 @@ class ScribeContextMarginTest { shelf.awaitEvents(2) scribe.retire() - val firstEvent = shelf.events.firstOrNull { it.data["scroll_id"]?.jsonPrimitive?.content == "first" } - val secondEvent = shelf.events.firstOrNull { it.data["scroll_id"]?.jsonPrimitive?.content == "second" } + val firstEvent = shelf.events.firstOrNull { it["scroll_id"]?.jsonPrimitive?.content == "first" } + val secondEvent = shelf.events.firstOrNull { it["scroll_id"]?.jsonPrimitive?.content == "second" } assertNotNull(firstEvent) assertNotNull(secondEvent) - assertEquals(JsonPrimitive("us-east"), firstEvent.data["region"]) - assertEquals(JsonPrimitive("us-east"), secondEvent.data["region"]) + assertEquals(JsonPrimitive("us-east"), firstEvent["region"]) + assertEquals(JsonPrimitive("us-east"), secondEvent["region"]) } } @@ -64,8 +64,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("ap-south"), event.data["region"]) - assertEquals(JsonPrimitive("ap-south"), event.data["region"]) + assertEquals(JsonPrimitive("ap-south"), event["region"]) + assertEquals(JsonPrimitive("ap-south"), event["region"]) } } @@ -89,8 +89,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive(1000L), event.data["startedAtEpochMs"]) - assertEquals(JsonPrimitive(2000L), event.data["sealedAtEpochMs"]) + assertEquals(JsonPrimitive(1000L), event["startedAtEpochMs"]) + assertEquals(JsonPrimitive(2000L), event["sealedAtEpochMs"]) } } @@ -105,8 +105,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertFalse(event.data.containsKey("startedAtEpochMs")) - assertFalse(event.data.containsKey("sealedAtEpochMs")) + assertFalse(event.containsKey("startedAtEpochMs")) + assertFalse(event.containsKey("sealedAtEpochMs")) } } @@ -133,8 +133,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive(500L), event.data["elapsedMs"]) - assertFalse(event.data.containsKey("_startTime")) + assertEquals(JsonPrimitive(500L), event["elapsedMs"]) + assertFalse(event.containsKey("_startTime")) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt index 864853a..db7ded2 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt @@ -21,7 +21,7 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonObject(mapOf("retries" to JsonPrimitive(2))), event.data["meta"]) + assertEquals(JsonObject(mapOf("retries" to JsonPrimitive(2))), event["meta"]) } } @@ -41,9 +41,9 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("accepted"), event.data["message"]) - assertEquals(JsonPrimitive(3), event.data["attempt"]) - assertEquals(JsonPrimitive(false), event.data["retry"]) + assertEquals(JsonPrimitive("accepted"), event["message"]) + assertEquals(JsonPrimitive(3), event["attempt"]) + assertEquals(JsonPrimitive(false), event["retry"]) } } @@ -99,7 +99,7 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("value"), event.data["key"]) + assertEquals(JsonPrimitive("value"), event["key"]) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt index b10e2e3..7adf7e3 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt @@ -5,18 +5,19 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds class ScribeDeliveryRetireTest { @Test @@ -41,7 +42,7 @@ class ScribeDeliveryRetireTest { assertEquals(2, secondShelf.events.size) assertEquals( "second-still-active", - secondShelf.events.last().data["scroll_id"]?.jsonPrimitive?.content, + secondShelf.events.last()["scroll_id"]?.jsonPrimitive?.content, ) } } @@ -64,24 +65,52 @@ class ScribeDeliveryRetireTest { } @Test - fun routes_can_select_notes_scrolls_or_both() { + fun archivists_process_the_same_entry_in_parallel() { + runSuspend { + val release = CompletableDeferred() + val firstStarted = CompletableDeferred() + val secondStarted = CompletableDeferred() + val first = Archivist { + firstStarted.complete(Unit) + release.await() + } + val second = Archivist { + secondStarted.complete(Unit) + release.await() + } + val scribe = scribeWithArchivists(listOf(first, second)) + + scribe.newScroll(id = "parallel").seal(scribe) + withTimeout(2_000.milliseconds) { + firstStarted.await() + secondStarted.await() + } + + release.complete(Unit) + scribe.retire() + } + } + + @Test + fun scroll_events_reach_all_configured_archivists() { runSuspend { val scrollShelf = RecordingShelf() - val noteSaver = RecordingNoteSaver() - val allSaver = RecordingEntrySaver() - val scribe = scribeWithSavers( - shelves = listOf(scrollShelf, noteSaver, allSaver), + val secondSearcher = RecordingShelf() + val scribe = scribeWithArchivists( + shelves = listOf(scrollShelf, secondSearcher), ) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 100L) scribe.newScroll(id = "scroll-1").seal(scribe) scrollShelf.awaitEvents(1) - noteSaver.awaitEvents(1) - allSaver.awaitEvents(2) + secondSearcher.awaitEvents(1) scribe.retire() - assertTrue(allSaver.events.any { it is Note }) - assertTrue(allSaver.events.any { it is SealedScroll }) + assertEquals(1, scrollShelf.events.size) + assertEquals( + "scroll-1", + scrollShelf.events.single()["scroll_id"]?.jsonPrimitive?.content, + ) + assertEquals("scroll-1", secondSearcher.events.single()["scroll_id"]?.jsonPrimitive?.content) } } @@ -92,7 +121,8 @@ class ScribeDeliveryRetireTest { val shelf = BlockingShelf(gate) val scribe = scribeWithScrollShelves( shelf, - channel = Channel(capacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST), + bufferCapacity = 4, + bufferOverflow = BufferOverflow.DROP_OLDEST, ) scribe.newScroll(id = "slow").seal(scribe) @@ -112,7 +142,7 @@ class ScribeDeliveryRetireTest { val scribe = scribeWithScrollShelves(shelf) scribe.newScroll(id = "first").seal(scribe) - delay(500) + delay(500.milliseconds) scribe.newScroll(id = "second").seal(scribe) shelf.awaitEvents(2) scribe.retire() @@ -136,12 +166,12 @@ class ScribeDeliveryRetireTest { shelf.awaitEvents(1) scribe.retire() - assertEquals("scoped", shelf.events.single().data["scroll_id"]?.jsonPrimitive?.content) + assertEquals("scoped", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) } } @Test - fun retire_waits_for_inflight_delivery() { + fun dismiss_returns_while_inflight_delivery_finishes_cooperatively() { runSuspend { val gate = CompletableDeferred() val firstWriteStarted = CompletableDeferred() @@ -150,26 +180,33 @@ class ScribeDeliveryRetireTest { scribe.newScroll(id = "in-flight").seal(scribe) firstWriteStarted.await() - val retireScope = CoroutineScope(Dispatchers.Default) - val retireJob = retireScope.launch { scribe.retire() } - delay(50) - assertFalse(retireJob.isCompleted) + scribe.dismiss() + assertFalse(scribe.isProcessing) + assertTrue(shelf.events.isEmpty()) + gate.complete(Unit) - withTimeout(2_000) { retireJob.join() } - retireScope.cancel() - assertEquals("in-flight", shelf.events.single().data["scroll_id"]?.jsonPrimitive?.content) + shelf.awaitEvents(1) + assertEquals("in-flight", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) + scribe.retire() } } @Test - fun note_throws_after_retire() { + fun dismiss_keeps_intake_open_and_rehire_processes_buffered_entries() { runSuspend { - val scribe = scribeWithScrollShelves(RecordingShelf()) + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf) + + scribe.dismiss() + assertFalse(scribe.isProcessing) + scribe.newScroll(id = "after").seal(scribe) + delay(50.milliseconds) + assertTrue(shelf.events.isEmpty()) + scribe.hire() + shelf.awaitEvents(1) + assertEquals("after", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) scribe.retire() - assertFailsWith { - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 123L) - } } } @@ -186,116 +223,151 @@ class ScribeDeliveryRetireTest { val retireScope = CoroutineScope(Dispatchers.Default) val retireJob = retireScope.launch { scribe.retire() } - delay(50) + delay(50.milliseconds) assertFalse(retireJob.isCompleted) gate.complete(Unit) - withTimeout(2_000) { retireJob.join() } + withTimeout(2_000.milliseconds) { retireJob.join() } retireScope.cancel() - assertEquals("flush-me", shelf.events.single().data["scroll_id"]?.jsonPrimitive?.content) + assertEquals("flush-me", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) + } + } + + @Test + fun cancelling_retire_caller_does_not_cancel_retirement() { + runSuspend { + coroutineScope { + val gate = CompletableDeferred() + val firstWriteStarted = CompletableDeferred() + val shelf = BlockingShelf(gate, firstWriteStarted) + val scribe = scribeWithScrollShelves(shelf) + + scribe.newScroll(id = "survives-cancellation").seal(scribe) + firstWriteStarted.await() + + val retireJob = launch { scribe.retire() } + withTimeout(2_000.milliseconds) { + while (scribe.isIntakeOpen) yield() + } + withTimeout(2_000.milliseconds) { retireJob.cancelAndJoin() } + assertTrue(retireJob.isCancelled) + + gate.complete(Unit) + withTimeout(2_000.milliseconds) { scribe.retire() } + + assertEquals( + "survives-cancellation", + shelf.events.single()["scroll_id"]?.jsonPrimitive?.content, + ) + } } } @Test - fun retire_called_from_saver_does_not_deadlock() { + fun dismiss_called_from_archivist_does_not_deadlock() { runSuspend { val retired = CompletableDeferred() lateinit var scribe: Scribe - val saver = EntrySaver { - scribe.retire() + val archivist = Archivist { + scribe.dismiss() retired.complete(Unit) } - scribe = scribeWithSavers(shelves = listOf(saver)) + scribe = scribeWithArchivists(shelves = listOf(archivist)) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 1L) - withTimeout(2_000) { retired.await() } + scribe.newScroll(id = "retire-1").seal(scribe) + withTimeout(2_000.milliseconds) { retired.await() } } } @Test - fun retire_called_from_saver_child_coroutine_does_not_deadlock() { + fun dismiss_called_from_archivist_child_coroutine_does_not_deadlock() { runSuspend { val retired = CompletableDeferred() lateinit var scribe: Scribe - val saver = EntrySaver { + val archivist = Archivist { coroutineScope { launch { - scribe.retire() + scribe.dismiss() retired.complete(Unit) } } } - scribe = scribeWithSavers(shelves = listOf(saver)) + scribe = scribeWithArchivists(shelves = listOf(archivist)) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 2L) - withTimeout(2_000) { retired.await() } + scribe.newScroll(id = "retire-2").seal(scribe) + withTimeout(2_000.milliseconds) { retired.await() } } } @Test - fun onSaverError_is_called_and_other_savers_continue() { + fun onArchivistError_is_called_and_other_archivists_continue() { runSuspend { val events = mutableListOf() val errors = mutableListOf() - val failingSaver = EntrySaver { throw IllegalStateException("boom") } - val recordingSaver = RecordingEntrySaver() - val scribe = scribeWithSavers( - shelves = listOf(failingSaver, recordingSaver), - onSaver = { _, entry, error -> + val failureReported = CompletableDeferred() + val failingArchivist = Archivist { throw IllegalStateException("boom") } + val recordingArchivist = RecordingShelf() + val scribe = scribeWithArchivists( + shelves = listOf(failingArchivist, recordingArchivist), + onArchivist = { _, entry, error -> events += entry errors += error + failureReported.complete(Unit) }, ) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 42L) - recordingSaver.awaitEvents(1) + scribe.newScroll(id = "error-1").seal(scribe) + recordingArchivist.awaitEvents(1) + failureReported.await() scribe.retire() - assertEquals(1, recordingSaver.events.size) + assertEquals(1, recordingArchivist.events.size) assertEquals(1, events.size) assertEquals(1, errors.size) - assertTrue(events.single() is Note) + val failedEntry = events.single() + assertEquals("error-1", failedEntry["scroll_id"]?.jsonPrimitive?.content) assertEquals("boom", errors.single().message) } } @Test - fun onSaverError_callback_failure_does_not_stop_delivery() { + fun onArchivistError_callback_failure_does_not_stop_delivery() { runSuspend { - val failingSaver = EntrySaver { throw IllegalStateException("boom") } - val recordingSaver = RecordingEntrySaver() - val scribe = scribeWithSavers( - shelves = listOf(failingSaver, recordingSaver), - onSaver = { _, _, _ -> + val failingArchivist = Archivist { throw IllegalStateException("boom") } + val recordingArchivist = RecordingShelf() + val scribe = scribeWithArchivists( + shelves = listOf(failingArchivist, recordingArchivist), + onArchivist = { _, _, _ -> 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.newScroll(id = "first").seal(scribe) + scribe.newScroll(id = "second").seal(scribe) + recordingArchivist.awaitEvents(2) scribe.retire() - assertEquals(2, recordingSaver.events.size) + assertEquals(2, recordingArchivist.events.size) } } @Test - fun saver_cancellation_is_not_reported_to_onSaver() { + fun archivist_cancellation_is_not_reported_to_onArchivist() { runSuspend { val reportedErrors = mutableListOf() - val cancelingSaver = EntrySaver { throw CancellationException("cancel-delivery") } - val scribe = scribeWithSavers( - shelves = listOf(cancelingSaver), - channel = Channel(capacity = 16), - onSaver = { _, _, error -> + val cancelingArchivist = Archivist { throw CancellationException("cancel-delivery") } + val scribe = scribeWithArchivists( + shelves = listOf(cancelingArchivist), + bufferCapacity = 16, + bufferOverflow = BufferOverflow.SUSPEND, + onArchivist = { _, _, error -> reportedErrors += error }, ) - scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 12L) - delay(50) + scribe.newScroll(id = "cancellation-probe").seal(scribe) + delay(50.milliseconds) scribe.retire() assertTrue(reportedErrors.isEmpty()) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeIndependentLifecycleTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeIndependentLifecycleTest.kt new file mode 100644 index 0000000..bfe3341 --- /dev/null +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeIndependentLifecycleTest.kt @@ -0,0 +1,66 @@ +package com.rafambn.scribe + +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +class ScribeIndependentLifecycleTest { + @Test + fun input_starts_enabled_while_processing_starts_paused() = runSuspend { + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf, startProcessing = false) + + assertTrue(scribe.isIntakeOpen) + assertFalse(scribe.isProcessing) + + scribe.newScroll(id = "buffered-before-hire").seal(scribe) + delay(50.milliseconds) + assertTrue(shelf.events.isEmpty()) + + scribe.hire() + shelf.awaitEvents(1) + assertEquals( + "buffered-before-hire", + shelf.events.single()["scroll_id"]?.jsonPrimitive?.content, + ) + scribe.retire() + } + + @Test + fun input_can_be_disabled_and_enabled_without_stopping_processing() = runSuspend { + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf) + + scribe.closeIntake() + assertFalse(scribe.isIntakeOpen) + assertTrue(scribe.isProcessing) + scribe.newScroll(id = "rejected").seal(scribe) + + scribe.openIntake() + scribe.newScroll(id = "accepted").seal(scribe) + shelf.awaitEvents(1) + + assertEquals("accepted", shelf.events.single()["scroll_id"]?.jsonPrimitive?.content) + scribe.retire() + } + + @Test + fun retire_drains_entries_even_when_job_is_dismissed() = runSuspend { + val shelf = RecordingShelf() + val scribe = scribeWithScrollShelves(shelf, startProcessing = false) + + scribe.newScroll(id = "first").seal(scribe) + scribe.newScroll(id = "second").seal(scribe) + + withTimeout(2_000.milliseconds) { scribe.retire() } + + assertEquals(listOf("first", "second"), shelf.events.map { it["scroll_id"]?.jsonPrimitive?.content }) + assertFalse(scribe.isIntakeOpen) + assertFalse(scribe.isProcessing) + } +} diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt index 3787cf2..2667fb4 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt @@ -4,7 +4,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -16,14 +15,13 @@ class ScribeScrollLifecycleTest { val scribe = scribeWithScrollShelves(shelf) val scroll = scribe.newScroll() scroll["method"] = JsonPrimitive("card") - scroll.seal(scribe, success = true) + scroll.seal(scribe) shelf.awaitEvents(1) scribe.retire() val event = shelf.events.single() - assertEquals(scroll.id, (event.data["scroll_id"] as? JsonPrimitive)?.content) - assertEquals(JsonPrimitive("card"), event.data["method"]) - assertTrue(event.success) + assertEquals(scroll.id, (event["scroll_id"] as? JsonPrimitive)?.content) + assertEquals(JsonPrimitive("card"), event["method"]) } } @@ -36,15 +34,14 @@ class ScribeScrollLifecycleTest { scroll["gateway"] = JsonPrimitive("stripe") - scroll.seal(scribe, success = false) - scroll.seal(scribe, success = true) + scroll.seal(scribe) + scroll.seal(scribe) shelf.awaitEvents(2) scribe.retire() val firstEvent = shelf.events.first() val secondEvent = shelf.events.last() - assertFalse(firstEvent.success) - assertTrue(secondEvent.success) - assertEquals(JsonPrimitive("stripe"), firstEvent.data["gateway"]) + assertEquals(JsonPrimitive("stripe"), firstEvent["gateway"]) + assertEquals(JsonPrimitive("stripe"), secondEvent["gateway"]) } } @@ -62,18 +59,17 @@ class ScribeScrollLifecycleTest { assertFailsWith { paymentService.pay("order2", scroll2, scribe) } - scroll1.seal(scribe, success = true) - scroll2.seal(scribe, success = true) - shelf.awaitEvents(2) + scroll1.seal(scribe) + scroll2.seal(scribe) + shelf.awaitEvents(3) scribe.retire() - val successEvent = shelf.events.firstOrNull { (it.data["scroll_id"] as? JsonPrimitive)?.content == scroll1.id } - val failureEvent = shelf.events.firstOrNull { (it.data["scroll_id"] as? JsonPrimitive)?.content == scroll2.id } + val successEvent = shelf.events.firstOrNull { (it["scroll_id"] as? JsonPrimitive)?.content == scroll1.id } + val failureEvent = shelf.events.firstOrNull { it["error_stage"] != null } assertNotNull(successEvent) assertNotNull(failureEvent) - assertTrue(successEvent.success) - assertFalse(failureEvent.success) + assertEquals(JsonPrimitive("gateway_call"), failureEvent["error_stage"]) } } @@ -103,7 +99,7 @@ class ScribeScrollLifecycleTest { val event = shelf.events.single() assertEquals("session-42", scroll.id) - assertEquals("session-42", (event.data["scroll_id"] as? JsonPrimitive)?.content) + assertEquals("session-42", (event["scroll_id"] as? JsonPrimitive)?.content) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index b5480f5..27138f3 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -12,46 +12,60 @@ 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}$") internal fun scribeWithScrollShelves( - vararg shelves: ScrollSaver, + vararg shelves: Archivist, imprint: Map = emptyMap(), - channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), - onSaver: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, + bufferCapacity: Int = 256, + bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST, + onArchivist: (archivist: Archivist, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, margins: Margin? = null, + startProcessing: Boolean = true, ): Scribe { val configuredShelves = shelves.toList() val configuredImprint = imprint val configuredMargins = margins + val configuredBufferCapacity = bufferCapacity + val configuredBufferOverflow = bufferOverflow return object : Scribe() { - override val shelves: List> = configuredShelves + override val archivists: List = configuredShelves + override val bufferCapacity: Int = configuredBufferCapacity + override val bufferOverflow: BufferOverflow = configuredBufferOverflow + override val onArchiveFailure = onArchivist override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { - it.hire(channel = channel, onSaver = onSaver) + if (startProcessing) it.hire() } } -internal fun scribeWithSavers( - shelves: List>, +internal fun scribeWithArchivists( + 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 = { _, _, _ -> }, + bufferCapacity: Int = 256, + bufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST, + onArchivist: (archivist: Archivist, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, + startProcessing: Boolean = true, ): Scribe { val configuredShelves = shelves val configuredImprint = imprint val configuredMargins = margins + val configuredBufferCapacity = bufferCapacity + val configuredBufferOverflow = bufferOverflow return object : Scribe() { - override val shelves: List> = configuredShelves + override val archivists: List = configuredShelves + override val bufferCapacity: Int = configuredBufferCapacity + override val bufferOverflow: BufferOverflow = configuredBufferOverflow + override val onArchiveFailure = onArchivist override val imprint: Map = configuredImprint override val margins: Margin? = configuredMargins }.also { - it.hire(channel = channel, onSaver = onSaver) + if (startProcessing) it.hire() } } internal fun runSuspend(block: suspend () -> T): T = runBlocking { block() } -internal fun createScribeInHelperAndEmit(shelf: ScrollSaver): Scribe { +internal fun createScribeInHelperAndEmit(shelf: Archivist): Scribe { val scribe = scribeWithScrollShelves(shelf) scribe.newScroll(id = "scoped").seal(scribe) return scribe @@ -67,7 +81,7 @@ internal class PaymentService { scroll["gateway"] = JsonPrimitive("stripe") } catch (t: Throwable) { scroll["error_stage"] = JsonPrimitive("gateway_call") - scroll.seal(scribe, success = false) + scroll.seal(scribe) throw t } } @@ -76,13 +90,11 @@ internal class PaymentService { @Serializable internal data class GatewayMeta(val retries: Int) -internal data class NonSerializableMeta(val retries: Int) - -internal class RecordingShelf : ScrollSaver { - val events = mutableListOf() +internal class RecordingShelf : Archivist { + val events = mutableListOf() private val writes = Channel(Channel.UNLIMITED) - override suspend fun write(event: SealedScroll) { + override suspend fun write(event: Entry) { events += event writes.trySend(Unit) } @@ -97,45 +109,13 @@ internal class RecordingShelf : ScrollSaver { internal class BlockingShelf( private val gate: CompletableDeferred, private val firstWriteStarted: CompletableDeferred? = null, -) : ScrollSaver { - val events = mutableListOf() - private val writes = Channel(Channel.UNLIMITED) - - override suspend fun write(event: SealedScroll) { - firstWriteStarted?.complete(Unit) - gate.await() - events += event - writes.trySend(Unit) - } - - suspend fun awaitEvents(count: Int) { - repeat(count) { - writes.receive() - } - } -} - -internal class RecordingNoteSaver : NoteSaver { - val events = mutableListOf() - private val writes = Channel(Channel.UNLIMITED) - - override suspend fun write(event: Note) { - events += event - writes.trySend(Unit) - } - - suspend fun awaitEvents(count: Int) { - repeat(count) { - writes.receive() - } - } -} - -internal class RecordingEntrySaver : EntrySaver { +) : Archivist { val events = mutableListOf() private val writes = Channel(Channel.UNLIMITED) override suspend fun write(event: Entry) { + firstWriteStarted?.complete(Unit) + gate.await() events += event writes.trySend(Unit) } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt new file mode 100644 index 0000000..0a78e0a --- /dev/null +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeThroughputTest.kt @@ -0,0 +1,88 @@ +package com.rafambn.scribe + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.TimeSource + +class ScribeThroughputTest { + + private class NoOpArchivist : Archivist { + var count = 0 + override suspend fun write(event: Entry) { + count++ + } + } + + @Test + fun measure_sequential_throughput() = runSuspend { + val archivist = NoOpArchivist() + val scribe = scribeWithScrollShelves( + archivist, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, + ) + val iterations = 100_000 + + val timeSource = TimeSource.Monotonic + val start = timeSource.markNow() + + repeat(iterations) { + val scroll = scribe.newScroll() + scroll["index"] = JsonPrimitive(it) + scroll.seal(scribe) + } + + scribe.retire() + val duration = start.elapsedNow() + + println("Sequential Throughput:") + printResults(iterations, duration) + } + + @Test + fun measure_concurrent_throughput() = runSuspend { + val archivist = NoOpArchivist() + val scribe = scribeWithScrollShelves( + archivist, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, + ) + val iterations = 100_000 + val coroutines = 10 + + val timeSource = TimeSource.Monotonic + val start = timeSource.markNow() + + coroutineScope { + repeat(coroutines) { c -> + launch(Dispatchers.Default) { + repeat(iterations / coroutines) { i -> + val scroll = scribe.newScroll() + scroll["c"] = JsonPrimitive(c) + scroll["i"] = JsonPrimitive(i) + scroll.seal(scribe) + } + } + } + } + + scribe.retire() + val duration = start.elapsedNow() + + println("Concurrent Throughput ($coroutines coroutines):") + printResults(iterations, duration) + } + + private fun printResults(iterations: Int, duration: Duration) { + val seconds = duration.inWholeMicroseconds / 1_000_000.0 + val opsPerSec = iterations / seconds + println(" Completed $iterations ops in $duration") + println(" Throughput: ${opsPerSec.toInt()} ops/sec") + } +} diff --git a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt new file mode 100644 index 0000000..786c0c9 --- /dev/null +++ b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/ScribeFileThroughputTest.kt @@ -0,0 +1,109 @@ +package com.rafambn.scribe + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import java.io.File +import java.io.FileWriter +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.TimeSource + +class ScribeFileThroughputTest { + + private lateinit var testFile: File + private val json = Json { encodeDefaults = true } + + private class FileArchivist(file: File, private val json: Json) : Archivist { + private val writer = FileWriter(file).buffered() + + override suspend fun write(event: Entry) { + val line = json.encodeToString(event) + writer.write(line) + writer.newLine() + } + + fun close() { + writer.flush() + writer.close() + } + } + + @BeforeTest + fun setup() { + testFile = File.createTempFile("scribe-throughput", ".log") + } + + @AfterTest + fun cleanup() { + if (testFile.exists()) { + testFile.delete() + } + } + + @Test + fun measure_concurrent_file_throughput() = runBlocking { + val archivist = FileArchivist(testFile, json) + val scribe = scribeWithScrollShelves( + archivist, + bufferCapacity = Channel.UNLIMITED, + bufferOverflow = BufferOverflow.SUSPEND, + ) + val iterations = 50_000 + val coroutines = 10 + + println("Starting File Load Test: writing $iterations entries to ${testFile.absolutePath} using $coroutines coroutines...") + + val timeSource = TimeSource.Monotonic + val start = timeSource.markNow() + + coroutineScope { + repeat(coroutines) { c -> + launch(Dispatchers.Default) { + repeat(iterations / coroutines) { i -> + val scroll = scribe.newScroll() + scroll["coroutine"] = JsonPrimitive(c) + scroll["index"] = JsonPrimitive(i) + scroll["payload"] = JsonPrimitive("Some repetitive logging payload to simulate load " + i) + scroll.seal(scribe) + } + } + } + } + + scribe.retire() + archivist.close() + + val duration = start.elapsedNow() + + // Verification + val lines = testFile.readLines() + assertEquals(iterations, lines.size, "Line count mismatch. Possible data loss.") + + // Check for corruption (ensure each line is a valid JSON and belongs to Scribe) + lines.forEach { line -> + assertTrue(line.startsWith("{") && line.endsWith("}"), "Interleaved or corrupt line: $line") + assertTrue(line.contains("scroll_id"), "Metadata missing in line: $line") + } + + println("File Throughput Results:") + printResults(iterations, duration) + } + + private fun printResults(iterations: Int, duration: Duration) { + val seconds = duration.inWholeMicroseconds / 1_000_000.0 + val opsPerSec = iterations / seconds + println(" Completed $iterations file writes in $duration") + println(" Throughput: ${opsPerSec.toInt()} ops/sec") + println(" Final file size: ${testFile.length() / 1024} KB") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d6628b7..0d6c783 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,8 @@ rootProject.name = "Scribe" include(":scribe") -include(":testApp:shared") -include(":testApp:androidApp") -include(":testApp:jvmApp") +include(":scribe-slf4j") +include(":testServer") pluginManagement { repositories { diff --git a/testApp/README.md b/testApp/README.md deleted file mode 100644 index 21b0ba8..0000000 --- a/testApp/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# testApp Scribe Showcase - -This app is a guided demo for the current Scribe API. It runs on JVM desktop, -Android, and iOS, and writes each delivered record to the application console. -No server or local observability stack is required. - -## What This Demo Covers - -- An application-owned object extending `Scribe` -- `note(...)` -- `newScroll(...)` with generated and custom IDs -- Direct map-like writes on `Scroll` and explicit delivery runtime selection -- Map reads/removals before sealing -- `seal(...)` with success and failure outcomes -- `Margin.header(...)` and `Margin.footer(...)` -- `EntrySaver` -- Channel overflow behavior through `DROP_OLDEST` -- Saver failure reporting through `hire(onSaver = ...)` -- `retire()` and runtime re-hire -- `onIgnition` wiring without intentionally crashing the app - -## Run The App - -From the repository root: - -```bash -./gradlew :testApp:jvmApp:run -./gradlew :testApp:androidApp:installDebug -``` - -The UI contains demo actions for notes, scrolls, JSON serialization, queue -delivery, saver failures, and runtime shutdown. Each delivered `Entry` is -rendered as JSON and printed to stdout, while the most recent records remain -visible in the in-app timeline. - -Example console output: - -```json -{ - "event_kind": "scroll", - "demo_name": "checkout_scroll", - "scroll_id": "checkout-42", - "success": true, - "gateway": "stripe" -} -``` - -## Inspect Output - -For the JVM app, records appear in the terminal where `./gradlew :testApp:jvmApp:run` -was started. For Android, view application stdout in Logcat or the run console. -The iOS run console likewise displays the records. - -Useful fields include: - -- `event_kind` -- `demo_name` -- `platform` -- `app_version` -- `saver_type` -- `tag`, `message`, `level` -- `scroll_id`, `success` -- Scroll fields such as `gateway`, `order_id`, `order_snapshot`, and `elapsed_ms` - -The overflow scenario intentionally slows the console saver while using a small -dropping channel; fewer printed records than attempted notes demonstrates the -configured overflow behavior. diff --git a/testApp/androidApp/build.gradle.kts b/testApp/androidApp/build.gradle.kts deleted file mode 100644 index 4cb2346..0000000 --- a/testApp/androidApp/build.gradle.kts +++ /dev/null @@ -1,27 +0,0 @@ -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlinComposeCompiler) - alias(libs.plugins.compose.multiplatform) -} - -android { - namespace = "scribe.demo.android" - compileSdk = libs.versions.android.compileSdk.get().toInt() - - defaultConfig { - applicationId = "scribe.demo.android" - minSdk = libs.versions.android.minSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } -} - -dependencies { - implementation(project(":testApp:shared")) - implementation(libs.androidx.activity.compose) -} diff --git a/testApp/androidApp/src/main/AndroidManifest.xml b/testApp/androidApp/src/main/AndroidManifest.xml deleted file mode 100644 index 203d0d5..0000000 --- a/testApp/androidApp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/testApp/androidApp/src/main/kotlin/com/rafambn/scribe/MainActivity.kt b/testApp/androidApp/src/main/kotlin/com/rafambn/scribe/MainActivity.kt deleted file mode 100644 index 83f3b7e..0000000 --- a/testApp/androidApp/src/main/kotlin/com/rafambn/scribe/MainActivity.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.rafambn.scribe - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import scribe.demo.ui.HomeScreen - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - HomeScreen() - } - } -} \ No newline at end of file diff --git a/testApp/androidApp/src/main/proguard-rules.pro b/testApp/androidApp/src/main/proguard-rules.pro deleted file mode 100644 index fb164d6..0000000 --- a/testApp/androidApp/src/main/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ -# Add project specific ProGuard rules here. diff --git a/testApp/iosApp/src/ViewController.kt b/testApp/iosApp/src/ViewController.kt deleted file mode 100644 index 125b6ed..0000000 --- a/testApp/iosApp/src/ViewController.kt +++ /dev/null @@ -1,4 +0,0 @@ -import androidx.compose.ui.window.ComposeUIViewController -import scribe.demo.ui.Screen - -fun ViewController() = ComposeUIViewController { Screen() } diff --git a/testApp/iosApp/src/iosApp.swift b/testApp/iosApp/src/iosApp.swift deleted file mode 100644 index 358047c..0000000 --- a/testApp/iosApp/src/iosApp.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI -import KotlinModules - -struct ComposeView: UIViewControllerRepresentable { - func makeUIViewController(context: Context) -> some UIViewController { - ViewControllerKt.ViewController() - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} -} - -struct ContentView: View { - var body: some View { - ComposeView().ignoresSafeArea(.all, edges: .bottom) - } -} - -@main -struct iosApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/testApp/jvmApp/build.gradle.kts b/testApp/jvmApp/build.gradle.kts deleted file mode 100644 index c8dafa6..0000000 --- a/testApp/jvmApp/build.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - kotlin("jvm") - alias(libs.plugins.kotlinComposeCompiler) - alias(libs.plugins.compose.multiplatform) -} - -kotlin { - jvmToolchain(11) -} - -dependencies { - implementation(project(":testApp:shared")) - implementation(compose.desktop.currentOs) -} - -compose.desktop { - application { - mainClass = "MainKt" - } -} diff --git a/testApp/jvmApp/src/main/kotlin/main.kt b/testApp/jvmApp/src/main/kotlin/main.kt deleted file mode 100644 index 6301cd0..0000000 --- a/testApp/jvmApp/src/main/kotlin/main.kt +++ /dev/null @@ -1,9 +0,0 @@ -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import scribe.demo.ui.HomeScreen - -fun main() = application { - Window(onCloseRequest = ::exitApplication) { - HomeScreen() - } -} diff --git a/testApp/shared/build.gradle.kts b/testApp/shared/build.gradle.kts deleted file mode 100644 index 3d1ee59..0000000 --- a/testApp/shared/build.gradle.kts +++ /dev/null @@ -1,29 +0,0 @@ -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.kotlinSerialization) - alias(libs.plugins.android.kotlin.multiplatform.library) - alias(libs.plugins.kotlinComposeCompiler) - alias(libs.plugins.compose.multiplatform) -} - -kotlin { - jvm() - android { - namespace = "scribe.demo.shared" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() - } - iosX64() - iosArm64() - iosSimulatorArm64() - - sourceSets { - commonMain.dependencies { - api(compose.foundation) - implementation(compose.material3) - implementation(project(":scribe")) - implementation(libs.kotlinx.serialization.json) - implementation(libs.kotlinx.coroutines.core) - } - } -} diff --git a/testApp/shared/src/androidMain/kotlin/scribe/demo/WorldAndroid.kt b/testApp/shared/src/androidMain/kotlin/scribe/demo/WorldAndroid.kt deleted file mode 100644 index e51e159..0000000 --- a/testApp/shared/src/androidMain/kotlin/scribe/demo/WorldAndroid.kt +++ /dev/null @@ -1,5 +0,0 @@ -package scribe.demo - -actual fun platformName() = "Android" - -actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/Platform.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/Platform.kt deleted file mode 100644 index ad0d0f7..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/Platform.kt +++ /dev/null @@ -1,5 +0,0 @@ -package scribe.demo - -expect fun platformName(): String - -expect fun currentEpochMillis(): Long diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt deleted file mode 100644 index 69f40d9..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/ConsoleRecord.kt +++ /dev/null @@ -1,85 +0,0 @@ -package scribe.demo.data - -import com.rafambn.scribe.Entry -import com.rafambn.scribe.Note -import com.rafambn.scribe.SealedScroll -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive - -typealias ConsoleRecord = Map - -fun consoleRecordFromEntry( - entry: Entry, - demoName: String, - platform: String, - saverType: String, - appVersion: String, - recordedAt: Long, -): ConsoleRecord = - when (entry) { - is Note -> linkedMapOf( - "_timestamp" to JsonPrimitive(recordedAt), - "event_kind" to JsonPrimitive("note"), - "demo_name" to JsonPrimitive(demoName), - "platform" to JsonPrimitive(platform), - "app_version" to JsonPrimitive(appVersion), - "saver_type" to JsonPrimitive(saverType), - "tag" to JsonPrimitive(entry.tag), - "message" to JsonPrimitive(entry.message), - "level" to JsonPrimitive(entry.level.name), - "note_timestamp" to JsonPrimitive(entry.timestamp), - ) - - is SealedScroll -> { - val payload = linkedMapOf() - payload["_timestamp"] = JsonPrimitive(recordedAt) - payload["event_kind"] = JsonPrimitive("scroll") - payload["demo_name"] = JsonPrimitive(stringField(entry.data, "demo_name") ?: demoName) - payload["platform"] = JsonPrimitive(platform) - payload["app_version"] = JsonPrimitive(appVersion) - payload["saver_type"] = JsonPrimitive(saverType) - payload["scroll_id"] = JsonPrimitive(stringField(entry.data, "scroll_id") ?: "missing-scroll-id") - payload["success"] = JsonPrimitive(entry.success) - stringField(entry.data, "message")?.let { payload["message"] = JsonPrimitive(it) } - entry.data["order_id"]?.let { payload["order_id"] = it } - ?: entry.data["ordemId"]?.let { payload["order_id"] = it } - entry.data.forEach { (key, value) -> - if (key !in payload) { - payload[key] = value - } - } - payload - } - } - -fun recordSummary(record: ConsoleRecord): String = - when (payloadEventKind(record)) { - "note" -> "${record.tag ?: "note"} ${record.level ?: ""}".trim() - else -> "${record.scroll_id ?: "scroll"} success=${record.success}" - } - -fun payloadEventKind(record: ConsoleRecord): String = - record["event_kind"]?.jsonPrimitive?.contentOrNull ?: "unknown" - -private fun stringField(data: Map, key: String): String? = - data[key]?.jsonPrimitive?.contentOrNull - -val ConsoleRecord.tag: String? - get() = this["tag"]?.jsonPrimitive?.contentOrNull - -val ConsoleRecord.level: String? - get() = this["level"]?.jsonPrimitive?.contentOrNull - -val ConsoleRecord.scroll_id: String? - get() = this["scroll_id"]?.jsonPrimitive?.contentOrNull - -val ConsoleRecord.success: String? - get() = this["success"]?.jsonPrimitive?.contentOrNull - -fun sampleImprint(platform: String): Map = mapOf( - "service" to JsonPrimitive("scribe-showcase"), - "environment" to JsonPrimitive("local"), - "platform" to JsonPrimitive(platform), -) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/DemoSchemas.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/DemoSchemas.kt deleted file mode 100644 index d83cef6..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/DemoSchemas.kt +++ /dev/null @@ -1,48 +0,0 @@ -package scribe.demo.data - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class CheckoutMeta( - @SerialName("item_count") - val itemCount: Int, - @SerialName("subtotal_cents") - val subtotalCents: Int, - @SerialName("feature_flag") - val featureFlag: String, -) - -@Serializable -data class SerializationBuyer( - val id: String, - val tier: String, - val email: String, -) - -@Serializable -data class SerializationLineItem( - val sku: String, - val quantity: Int, - @SerialName("unit_price_cents") - val unitPriceCents: Int, -) - -@Serializable -data class SerializationPayment( - val method: String, - val installments: Int, - val currency: String, -) - -@Serializable -data class SerializationOrderSnapshot( - @SerialName("order_id") - val orderId: String, - val buyer: SerializationBuyer, - @SerialName("line_items") - val lineItems: List, - val payment: SerializationPayment, - val tags: List, - val metadata: Map, -) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/TimelineItem.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/data/TimelineItem.kt deleted file mode 100644 index 75e6f5b..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/data/TimelineItem.kt +++ /dev/null @@ -1,8 +0,0 @@ -package scribe.demo.data - -data class TimelineItem( - val title: String, - val detail: String, - val payload: String, - val success: Boolean, -) diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt deleted file mode 100644 index c524d45..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/scribe/AppScribe.kt +++ /dev/null @@ -1,64 +0,0 @@ -package scribe.demo.scribe - -import com.rafambn.scribe.Entry -import com.rafambn.scribe.EntrySaver -import com.rafambn.scribe.Margin -import com.rafambn.scribe.Note -import com.rafambn.scribe.Scribe -import com.rafambn.scribe.Scroll -import com.rafambn.scribe.Urgency -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull -import scribe.demo.currentEpochMillis -import scribe.demo.data.sampleImprint -import scribe.demo.platformName - -class AppScribe(onRecord: (Entry) -> Unit) : Scribe() { - - var overflowDelay: Boolean = false - - override val shelves = listOf( - EntrySaver { entry -> - if (entry is Note && entry.tag == "saver_failure") { - error("Intentional saver failure from showcase demo") - } - }, - EntrySaver { entry -> - if (overflowDelay) delay(220) - onRecord(entry) - }, - ) - - override val imprint = sampleImprint(platformName()) + mapOf( - "output" to JsonPrimitive("console"), - "session_kind" to JsonPrimitive("persistent-demo"), - ) - - override val margins = object : Margin { - override fun header(scroll: Scroll) { - scroll["started_at"] = JsonPrimitive(currentEpochMillis()) - scroll["platform_session"] = JsonPrimitive(platformName()) - } - - override fun footer(scroll: Scroll) { - val startedAt = scroll["started_at"]?.jsonPrimitive?.longOrNull ?: return - val completedAt = currentEpochMillis() - scroll["completed_at"] = JsonPrimitive(completedAt) - scroll["elapsed_ms"] = JsonPrimitive(completedAt - startedAt) - } - } - - override val onIgnition: (Throwable) -> Unit = { throwable -> - println("Scribe onIgnition: ${throwable.message ?: throwable}") - } - - fun close(scope: CoroutineScope) { - scope.launch { - runCatching { retire() } - } - } -} diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt deleted file mode 100644 index 8f57240..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeContent.kt +++ /dev/null @@ -1,308 +0,0 @@ -package scribe.demo.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import scribe.demo.data.TimelineItem -import scribe.demo.platformName - -@Composable -fun HomeContent( - isBusy: Boolean, - busyLabel: String, - outputMessage: String, - statusMessage: String, - isRetired: Boolean, - ignitionMessage: String, - activeScrollIds: List, - saverErrors: List, - lastRecord: String, - timeline: List, - onRunNoteScenario: () -> Unit, - onRunFlingNoteScenario: () -> Unit, - onRunCheckoutScenario: () -> Unit, - onRunInspectionScenario: () -> Unit, - onRunMarginScenario: () -> Unit, - onRunJsonSerializationScenario: () -> Unit, - onRunStringTemplateScenario: () -> Unit, - onRunEntrySaverScenario: () -> Unit, - onRunOverflowScenario: () -> Unit, - onRunSaverFailureScenario: () -> Unit, - onRehireMainScribe: () -> Unit, - onRunRetireScenario: () -> Unit, - onRunPlanRetireScenario: () -> Unit, - onWireIgnitionScenario: () -> Unit, -) { - MaterialTheme { - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - Color(0xFFF6F1E8), - Color(0xFFE8F0EE), - Color(0xFFF8F6F2), - ), - ), - ), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - HeroCard() - StatusCard( - outputMessage = outputMessage, - statusMessage = statusMessage, - isRetired = isRetired, - ignitionMessage = ignitionMessage, - activeScrollIds = activeScrollIds, - saverErrors = saverErrors, - isBusy = isBusy, - busyLabel = busyLabel, - ) - ActionGroup( - title = "Notes", - description = "Standalone events emitted through note(...).", - buttons = listOf( - "Run note(...)" to onRunNoteScenario, - "Run second note(...)" to onRunFlingNoteScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Scrolls", - description = "Wide-event flows with generated/custom IDs, direct map writes, and margins.", - buttons = listOf( - "Checkout flow" to onRunCheckoutScenario, - "Map read/remove" to onRunInspectionScenario, - "Margins + seal(failure)" to onRunMarginScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Console Rendering Checks", - description = "Validate nested JSON object serialization and string-template rendering in console records.", - buttons = listOf( - "JSON object serialization" to onRunJsonSerializationScenario, - "String template message" to onRunStringTemplateScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Savers And Delivery", - description = "Use the three saver types, queue overflow behavior, and saver error handling.", - buttons = listOf( - "EntrySaver mixed flow" to onRunEntrySaverScenario, - "Overflow demo" to onRunOverflowScenario, - "Saver failure demo" to onRunSaverFailureScenario, - ), - enabled = !isBusy, - ) - ActionGroup( - title = "Shutdown And Safety", - description = "Use retire() shutdown flows and wire the onIgnition callback safely.", - buttons = listOf( - "Re-hire Scribe" to onRehireMainScribe, - "retire() (light queue)" to onRunRetireScenario, - "retire() with backlog" to onRunPlanRetireScenario, - "Wire onIgnition" to onWireIgnitionScenario, - ), - enabled = !isBusy, - ) - TimelineCard(lastRecord, timeline) - } - } - } -} - -@Composable -private fun HeroCard() { - Card( - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = Color(0xFF14213D)), - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = "Scribe Console Showcase", - color = Color(0xFFFFF7E6), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = "Guided demos for notes, wide events, margins, queue delivery, and saver behavior. Every delivered record is printed to the console.", - color = Color(0xFFE7ECEF), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = "Platform: ${platformName()}", - color = Color(0xFFFCA311), - style = MaterialTheme.typography.labelLarge, - ) - } - } -} - -@Composable -private fun StatusCard( - outputMessage: String, - statusMessage: String, - isRetired: Boolean, - ignitionMessage: String, - activeScrollIds: List, - saverErrors: List, - isBusy: Boolean, - busyLabel: String, -) { - Card(shape = RoundedCornerShape(24.dp)) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("Console Output", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text(outputMessage, style = MaterialTheme.typography.bodyMedium) - Text("Status: $statusMessage", style = MaterialTheme.typography.bodyMedium) - Text( - "Scribe instance: ${if (isRetired) "retired" else "active"}", - style = MaterialTheme.typography.bodyMedium, - color = if (isRetired) Color(0xFF9C2F2F) else Color(0xFF1D5C63), - ) - Text("Ignition: $ignitionMessage", style = MaterialTheme.typography.bodyMedium) - if (activeScrollIds.isNotEmpty()) { - Text( - "Active scrolls: ${activeScrollIds.joinToString()}", - style = MaterialTheme.typography.bodyMedium, - ) - } - if (saverErrors.isNotEmpty()) { - Text( - "Saver errors: ${saverErrors.joinToString()}", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF9C2F2F), - ) - } - if (isBusy) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) - Text(busyLabel, style = MaterialTheme.typography.bodyMedium) - } - } - } - } -} - -@Composable -private fun ActionGroup( - title: String, - description: String, - buttons: List Unit>>, - enabled: Boolean, -) { - Card(shape = RoundedCornerShape(24.dp)) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text(description, style = MaterialTheme.typography.bodyMedium) - buttons.forEach { (label, action) -> - OutlinedButton( - onClick = action, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - ) { - Text(label) - } - } - } - } -} - -@Composable -private fun TimelineCard(lastRecord: String, timeline: List) { - Card(shape = RoundedCornerShape(24.dp)) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("Timeline", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text("Last console record", style = MaterialTheme.typography.bodyMedium) - if (lastRecord.isNotBlank()) { - Surface( - color = Color(0xFF101820), - shape = RoundedCornerShape(18.dp), - ) { - SelectionContainer { - Text( - text = lastRecord, - modifier = Modifier.padding(14.dp), - color = Color(0xFFE9F1F7), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - timeline.forEachIndexed { index, item -> - if (index > 0) { - HorizontalDivider() - } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - item.title, - style = MaterialTheme.typography.titleMedium, - color = if (item.success) Color(0xFF1D5C63) else Color(0xFF9C2F2F), - ) - Text(item.detail, style = MaterialTheme.typography.bodyMedium) - if (item.payload.isNotBlank()) { - Surface( - color = Color(0xFFF2EFEA), - shape = RoundedCornerShape(14.dp), - ) { - SelectionContainer { - Text( - item.payload, - modifier = Modifier.padding(12.dp), - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - } - } - } -} diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt deleted file mode 100644 index b525b61..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeScreen.kt +++ /dev/null @@ -1,44 +0,0 @@ -package scribe.demo.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember - -@Composable -fun HomeScreen() { - val viewModel = remember { HomeViewModel() } - val state by viewModel.state.collectAsState() - - DisposableEffect(viewModel) { - onDispose { viewModel.close() } - } - - HomeContent( - isBusy = state.isBusy, - busyLabel = state.busyLabel, - outputMessage = state.outputMessage, - statusMessage = state.statusMessage, - isRetired = state.isRetired, - ignitionMessage = state.ignitionMessage, - activeScrollIds = state.activeScrollIds, - saverErrors = state.saverErrors, - lastRecord = state.lastRecord, - timeline = state.timeline, - onRunNoteScenario = viewModel::runNoteScenario, - onRunFlingNoteScenario = viewModel::runFlingNoteScenario, - onRunCheckoutScenario = viewModel::runCheckoutScenario, - onRunInspectionScenario = viewModel::runInspectionScenario, - onRunMarginScenario = viewModel::runMarginScenario, - onRunJsonSerializationScenario = viewModel::runJsonSerializationScenario, - onRunStringTemplateScenario = viewModel::runStringTemplateScenario, - onRunEntrySaverScenario = viewModel::runEntrySaverScenario, - onRunOverflowScenario = viewModel::runOverflowScenario, - onRunSaverFailureScenario = viewModel::runSaverFailureScenario, - onRehireMainScribe = viewModel::rehireMainScribe, - onRunRetireScenario = viewModel::runRetireScenario, - onRunPlanRetireScenario = viewModel::runPlanRetireScenario, - onWireIgnitionScenario = viewModel::wireIgnitionScenario, - ) -} diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt deleted file mode 100644 index e33cc5e..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package scribe.demo.ui - -import scribe.demo.data.TimelineItem - -data class HomeState( - val isBusy: Boolean = false, - val busyLabel: String = "", - val statusMessage: String = "Ready to run demo scenarios.", - val isRetired: Boolean = false, - val activeScrollIds: List = emptyList(), - val lastRecord: String = "", - val outputMessage: String = "Records are written to the application console.", - val saverErrors: List = emptyList(), - val timeline: List = emptyList(), - val ignitionMessage: String = "The onIgnition hook is wired, but the demo does not crash itself to trigger it.", -) \ No newline at end of file diff --git a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt b/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt deleted file mode 100644 index 4a4526a..0000000 --- a/testApp/shared/src/commonMain/kotlin/scribe/demo/ui/HomeViewModel.kt +++ /dev/null @@ -1,438 +0,0 @@ -package scribe.demo.ui - -import com.rafambn.scribe.Entry -import com.rafambn.scribe.Note -import com.rafambn.scribe.Scribe -import com.rafambn.scribe.Scroll -import com.rafambn.scribe.Urgency -import com.rafambn.scribe.id -import com.rafambn.scribe.seal -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import scribe.demo.currentEpochMillis -import scribe.demo.data.CheckoutMeta -import scribe.demo.data.SerializationBuyer -import scribe.demo.data.SerializationLineItem -import scribe.demo.data.SerializationOrderSnapshot -import scribe.demo.data.SerializationPayment -import scribe.demo.data.TimelineItem -import scribe.demo.data.consoleRecordFromEntry -import scribe.demo.data.payloadEventKind -import scribe.demo.data.recordSummary -import scribe.demo.platformName -import scribe.demo.scribe.AppScribe -import kotlin.collections.set - -class HomeViewModel { - private val json = Json { - prettyPrint = true - prettyPrintIndent = " " - encodeDefaults = true - } - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val gate = Mutex() - private val appVersion = "testApp-showcase" - private val platform = platformName() - private val activeScrolls = linkedMapOf() - private var printedEvents = 0 - - private val _state = MutableStateFlow(HomeState()) - val state: StateFlow = _state.asStateFlow() - - private val appScribe = AppScribe { entry -> handleRecord(entry) } - - init { - appScribe.hire( - scope = scope, - channel = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST), - onSaver = { saver, entry, error -> - appendSaverError( - "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind(entry)}: ${error.message ?: error}", - ) - }, - ) - } - - fun runNoteScenario() = launchScenario("Note emission demo") { - appScribe.note( - tag = "checkout", - message = "Started checkout for premium customer", - level = Urgency.INFO, - ) - updateStatus("Ran note(...): a single INFO event was printed through EntrySaver.") - } - - fun runFlingNoteScenario() = launchScenario("Second note demo") { - appScribe.note( - tag = "queue", - message = "Queued retry audit event through note(...)", - level = Urgency.DEBUG, - ) - updateStatus("Ran a second note(...) flow.") - } - - fun runStringTemplateScenario() = launchScenario("String template scroll demo") { - val scroll = openScroll(appScribe, id = "template-render-1") - scroll["demo_name"] = JsonPrimitive("string_template_render") - scroll["message"] = JsonPrimitive("error on order_id=\$order_id") - scroll["order_id"] = JsonPrimitive(555) - sealScroll(scroll, appScribe, success = true) - appendTimeline( - title = "Template message preview", - detail = "Sent scroll with {message: \"error on order_id=\$order_id\", order_id: 555}.", - payload = "", - success = true, - ) - updateStatus("Ran string-template scroll demo; inspect message + order_id in console output.") - } - - fun runCheckoutScenario() = launchScenario("Wide-event scroll demo") { - val scroll = openScroll(appScribe) - scroll["demo_name"] = JsonPrimitive("checkout_scroll") - scroll["order_id"] = JsonPrimitive("order-42") - scroll["gateway"] = JsonPrimitive("stripe") - scroll["attempt"] = JsonPrimitive(1) - scroll["retry"] = JsonPrimitive(false) - scroll["cart"] = json.encodeToJsonElement( - CheckoutMeta.serializer(), - CheckoutMeta( - itemCount = 3, - subtotalCents = 249_900, - featureFlag = "wide-events", - ), - ) - sealScroll(scroll, appScribe, success = true) - updateStatus("Ran newScroll + map writes + seal for a wide checkout event.") - } - - fun runInspectionScenario() = launchScenario("Scroll map inspection demo") { - val scroll = openScroll(appScribe, id = "ops-demo-42") - scroll["demo_name"] = JsonPrimitive("inspection_scroll") - scroll["phase"] = JsonPrimitive("validation") - scroll["retryable"] = JsonPrimitive(true) - scroll["attempt"] = JsonPrimitive(2) - - val visibleIds = activeScrolls.keys.toList() - val phase = scroll["phase"]?.toString() ?: "missing" - val removed = scroll.remove("retryable")?.toString() ?: "null" - - appendTimeline( - title = "Map read/remove", - detail = "Custom scroll id ops-demo-42 visible in ${visibleIds.joinToString()} ; phase=$phase ; removed retryable=$removed.", - payload = "", - success = true, - ) - sealScroll(scroll, appScribe, success = true) - updateStatus("Ran custom-id scroll demo with map reads/removals and local active-scroll tracking.") - } - - fun runMarginScenario() = launchScenario("Margin + seal(failure) demo") { - val scroll = openScroll(appScribe, id = "inventory-sync-1") - scroll["demo_name"] = JsonPrimitive("margin_scroll") - scroll["flow"] = JsonPrimitive("inventory-sync") - scroll["warehouse"] = JsonPrimitive("gru-1") - scroll["cache_hit"] = JsonPrimitive(false) - scroll["failure_reason"] = JsonPrimitive("downstream retry scheduled") - sealScroll(scroll, appScribe, success = false) - delay(250) - updateStatus("Ran Margin header/footer hooks with seal(success = false).") - } - - fun runJsonSerializationScenario() = launchScenario("JSON serialization scroll demo") { - val scroll = openScroll(appScribe, id = "json-serialization-1") - - val snapshot = SerializationOrderSnapshot( - orderId = "order-555", - buyer = SerializationBuyer( - id = "buyer-123", - tier = "gold", - email = "buyer-123@example.com", - ), - lineItems = listOf( - SerializationLineItem(sku = "SKU-CHAIR-42", quantity = 1, unitPriceCents = 129_900), - SerializationLineItem(sku = "SKU-LAMP-10", quantity = 2, unitPriceCents = 24_990), - ), - payment = SerializationPayment( - method = "credit_card", - installments = 3, - currency = "USD", - ), - tags = listOf("console", "serialization-test", "nested-object"), - metadata = mapOf( - "channel" to "stdout", - "experiment" to "console-json-object", - ), - ) - - scroll["demo_name"] = JsonPrimitive("json_serialization") - scroll["order_snapshot"] = json.encodeToJsonElement(SerializationOrderSnapshot.serializer(), snapshot) - scroll["order_id"] = JsonPrimitive(snapshot.orderId) - scroll["buyer_tier"] = JsonPrimitive(snapshot.buyer.tier) - scroll["primary_sku"] = JsonPrimitive(snapshot.lineItems.first().sku) - scroll["channel"] = JsonPrimitive(snapshot.metadata["channel"] ?: "unknown") - scroll["order_item_count"] = JsonPrimitive(snapshot.lineItems.sumOf { it.quantity }) - scroll["order_tag_count"] = JsonPrimitive(snapshot.tags.size) - scroll["expected_render_checks"] = JsonPrimitive( - "order_snapshot.order_id,order_snapshot.buyer.tier,order_snapshot.line_items[0].sku,order_snapshot.metadata.channel,order_id,buyer_tier,primary_sku,channel,order_item_count,order_tag_count", - ) - - sealScroll(scroll, appScribe, success = true) - updateStatus("Ran JSON serialization demo with a nested object payload for console inspection.") - } - - fun runEntrySaverScenario() = launchScenario("Unified EntrySaver demo") { - appScribe.note( - tag = "auth", - message = "Session accepted for staff dashboard", - level = Urgency.INFO, - ) - val scroll = openScroll(appScribe, id = "session-audit") - scroll["demo_name"] = JsonPrimitive("entry_saver_demo") - scroll["role"] = JsonPrimitive("support") - scroll["elevated_access"] = JsonPrimitive(true) - sealScroll(scroll, appScribe, success = true) - updateStatus("Ran a mixed note + scroll demo through one EntrySaver path.") - } - - fun runOverflowScenario() = launchScenario("Overflow demo") { - val baseline = printedEvents - val attempted = 12 - - appScribe.overflowDelay = true - repeat(attempted) { index -> - appScribe.note( - tag = "buffer", - message = "burst event #$index", - level = if (index % 3 == 0) Urgency.WARN else Urgency.INFO, - ) - } - delay(1800) - appScribe.overflowDelay = false - - val delivered = printedEvents - baseline - appendTimeline( - title = "Overflow result", - detail = "Attempted $attempted notes with channel capacity 2 and DROP_OLDEST; delivered $delivered.", - payload = "", - success = delivered < attempted, - ) - updateStatus("Ran overflow demo with Channel(..., onBufferOverflow = DROP_OLDEST).") - } - - fun runSaverFailureScenario() = launchScenario("Saver error demo") { - appScribe.note( - tag = "saver_failure", - message = "Intentional saver failure probe", - level = Urgency.WARN, - ) - updateStatus("Saver failure demo ran; onSaver callback captures the injected failure.") - } - - fun runRetireScenario() = launchScenario("retire() demo") { - appScribe.note("shutdown", "retire() with light queue", Urgency.INFO) - val started = currentEpochMillis() - appScribe.retire() - val elapsed = currentEpochMillis() - started - - activeScrolls.clear() - _state.update { it.copy(isRetired = true) } - refreshActiveScrolls() - appendTimeline( - title = "retire()", - detail = "retire() finished in ${elapsed}ms and retired the shared demo Scribe instance.", - payload = "", - success = true, - ) - updateStatus("The shared demo Scribe is retired. Press Re-hire Scribe before sending more messages.") - } - - fun runPlanRetireScenario() = launchScenario("retire() with backlog demo") { - repeat(6) { index -> - appScribe.note("shutdown", "drain probe #$index", Urgency.INFO) - } - val started = currentEpochMillis() - appScribe.retire() - val elapsed = currentEpochMillis() - started - - activeScrolls.clear() - _state.update { it.copy(isRetired = true) } - refreshActiveScrolls() - appendTimeline( - title = "retire() with backlog", - detail = "retire() took ${elapsed}ms after a small queued backlog.", - payload = "", - success = true, - ) - updateStatus("The shared demo Scribe is retired after draining queued work. Press Re-hire Scribe to continue.") - } - - fun wireIgnitionScenario() = launchScenario("onIgnition wiring") { - appScribe.note( - tag = "ignition", - message = "onIgnition callback is configured; the demo avoids firing an uncaught exception.", - level = Urgency.INFO, - ) - _state.update { - it.copy( - ignitionMessage = "onIgnition is configured in this demo build. Triggering it live would terminate the app, so the showcase documents the hook instead of crashing itself.", - ) - } - updateStatus("Configured onIgnition safely without terminating the showcase process.") - } - - fun rehireMainScribe() = launchScenario("Re-hire Scribe") { - if (!_state.value.isRetired) { - updateStatus("The shared demo Scribe is already active.") - appendTimeline( - title = "Re-hire Scribe", - detail = "The shared demo Scribe was already active, so no recreation was needed.", - payload = "", - success = true, - ) - return@launchScenario - } - - appScribe.hire( - channel = Channel(capacity = 2, onBufferOverflow = BufferOverflow.DROP_OLDEST), - scope = scope, - onSaver = { saver, entry, error -> - appendSaverError( - "Saver failure in ${saver::class.simpleName ?: "Saver"} for ${entryKind(entry)}: ${error.message ?: error}", - ) - }, - ) - _state.update { it.copy(isRetired = false) } - refreshActiveScrolls() - updateStatus("The shared demo Scribe was re-hired and can send messages again.") - appendTimeline( - title = "Re-hire Scribe", - detail = "The shared demo Scribe object was hired again after retirement.", - payload = "", - success = true, - ) - } - - fun close() { - appScribe.close(scope) - } - - private fun launchScenario(label: String, block: suspend () -> Unit) { - scope.launch { - gate.withLock { - _state.update { it.copy(isBusy = true, busyLabel = label) } - try { - block() - } catch (error: Throwable) { - appendTimeline( - title = label, - detail = error.message ?: error.toString(), - payload = "", - success = false, - ) - updateStatus("Scenario failed: ${error.message ?: error}") - } finally { - _state.update { it.copy(isBusy = false, busyLabel = "") } - } - } - } - } - - private fun handleRecord(entry: Entry) { - val record = consoleRecordFromEntry( - entry = entry, - demoName = "shared_session", - platform = platform, - saverType = "EntrySaver", - appVersion = appVersion, - recordedAt = currentEpochMillis(), - ) - - printedEvents += 1 - val payload = json.encodeToString(JsonObject.serializer(), JsonObject(record)) - println(payload) - _state.update { - it.copy( - lastRecord = payload, - outputMessage = "Printed ${payloadEventKind(record)} record to the console.", - ) - } - appendTimeline( - title = "${payloadEventKind(record)} via EntrySaver", - detail = "${recordSummary(record)}. Printed to console.", - payload = payload, - success = true, - ) - } - - private fun appendTimeline(title: String, detail: String, payload: String, success: Boolean) { - _state.update { - it.copy( - timeline = listOf(TimelineItem(title, detail, payload, success)) + it.timeline.take(19), - ) - } - } - - private fun appendSaverError(message: String) { - println(message) - _state.update { - it.copy( - saverErrors = listOf(message) + it.saverErrors.take(5), - ) - } - appendTimeline( - title = "Saver failure captured", - detail = message, - payload = "", - success = false, - ) - } - - private fun updateStatus(message: String) { - _state.update { it.copy(statusMessage = message) } - } - - private fun updateActiveScrolls(ids: List) { - _state.update { it.copy(activeScrollIds = ids) } - } - - private fun refreshActiveScrolls() { - if (_state.value.isRetired) { - updateActiveScrolls(emptyList()) - return - } - updateActiveScrolls(activeScrolls.keys.toList()) - } - - private fun openScroll(scribe: Scribe, id: String? = null): Scroll { - val scroll = scribe.newScroll(id = id) - activeScrolls[scroll.id] = scroll - refreshActiveScrolls() - return scroll - } - - private suspend fun sealScroll(scroll: Scroll, scribe: Scribe, success: Boolean) { - scroll.seal(scribe, success = success) - activeScrolls.remove(scroll.id) - refreshActiveScrolls() - } - - private fun entryKind(entry: Entry): String = - when (entry) { - is Note -> "note" - else -> "scroll" - } -} diff --git a/testApp/shared/src/iosMain/kotlin/scribe/demo/WorldIos.kt b/testApp/shared/src/iosMain/kotlin/scribe/demo/WorldIos.kt deleted file mode 100644 index 9ec5442..0000000 --- a/testApp/shared/src/iosMain/kotlin/scribe/demo/WorldIos.kt +++ /dev/null @@ -1,7 +0,0 @@ -package scribe.demo - -import kotlin.time.Clock - -actual fun platformName() = "iOS" - -actual fun currentEpochMillis(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/testApp/shared/src/jvmMain/kotlin/scribe/demo/WorldJvm.kt b/testApp/shared/src/jvmMain/kotlin/scribe/demo/WorldJvm.kt deleted file mode 100644 index 22afcd9..0000000 --- a/testApp/shared/src/jvmMain/kotlin/scribe/demo/WorldJvm.kt +++ /dev/null @@ -1,5 +0,0 @@ -package scribe.demo - -actual fun platformName() = "JVM" - -actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/testServer/build.gradle.kts b/testServer/build.gradle.kts new file mode 100644 index 0000000..3b21765 --- /dev/null +++ b/testServer/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + kotlin("jvm") + application +} + +kotlin { + jvmToolchain(11) +} + +dependencies { + implementation(project(":scribe-slf4j")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) +} + +application { + mainClass = "com.rafambn.scribe.testserver.MainKt" +} diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt new file mode 100644 index 0000000..f266b13 --- /dev/null +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/HealthHandler.kt @@ -0,0 +1,34 @@ +package com.rafambn.scribe.testserver + +import com.rafambn.scribe.seal +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import kotlinx.serialization.json.JsonPrimitive +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import java.nio.charset.StandardCharsets + +object HealthHandler : HttpHandler { + private val logger = LoggerFactory.getLogger(HealthHandler::class.java) + + override fun handle(exchange: HttpExchange) { + val scroll = TestServerScribe.newScroll() + scroll["normal-scroll"] = JsonPrimitive("normal-scroll") + scroll.seal(TestServerScribe) + try { + MDC.put("http.method", exchange.requestMethod) + MDC.put("http.path", exchange.requestURI.path) + logger.info("Health check requested") + exchange.respond(statusCode = 200, body = "OK\n") + } finally { + MDC.clear() + } + } +} + +private fun HttpExchange.respond(statusCode: Int, body: String) { + val response = body.toByteArray(StandardCharsets.UTF_8) + responseHeaders.set("Content-Type", "text/plain; charset=utf-8") + sendResponseHeaders(statusCode, response.size.toLong()) + responseBody.use { it.write(response) } +} diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt new file mode 100644 index 0000000..ca10def --- /dev/null +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/Main.kt @@ -0,0 +1,28 @@ +package com.rafambn.scribe.testserver + +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import java.net.InetSocketAddress + +private const val SERVER_PORT = 8080 +private val logger = LoggerFactory.getLogger("TestServer") + +fun main() { + TestServerScribe.hire() + + val server = HttpServer.create(InetSocketAddress(SERVER_PORT), 0).apply { + createContext("/health", HealthHandler) + executor = null + start() + } + + Runtime.getRuntime().addShutdownHook( + Thread { + server.stop(0) + runBlocking { TestServerScribe.retire() } + }, + ) + + logger.info("Test server started on http://localhost:{}/health", SERVER_PORT) +} diff --git a/testServer/src/main/kotlin/com/rafambn/scribe/testserver/TestServerScribe.kt b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/TestServerScribe.kt new file mode 100644 index 0000000..1f736f8 --- /dev/null +++ b/testServer/src/main/kotlin/com/rafambn/scribe/testserver/TestServerScribe.kt @@ -0,0 +1,22 @@ +package com.rafambn.scribe.testserver + +import com.rafambn.scribe.Archivist +import com.rafambn.scribe.Entry +import com.rafambn.scribe.slf4j.ScribeBackend +import com.rafambn.scribe.slf4j.Slf4jScribe +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.serialization.json.JsonPrimitive + +@ScribeBackend +object TestServerScribe : Slf4jScribe() { + override val archivists = listOf( + Archivist { entry: Entry -> println(entry) }, + ) + override val bufferCapacity = 256 + override val bufferOverflow = BufferOverflow.DROP_OLDEST + override val onArchiveFailure: ((Archivist, Entry, Throwable) -> Unit) = + { _, _, error -> error.printStackTrace() } + + override val imprint = mapOf("imprint" to JsonPrimitive("imprint value")) + +}